diff --git a/packages/react-grab/e2e/external-communication.spec.ts b/packages/react-grab/e2e/external-communication.spec.ts new file mode 100644 index 000000000..b7893eaa4 --- /dev/null +++ b/packages/react-grab/e2e/external-communication.spec.ts @@ -0,0 +1,162 @@ +import { test, expect } from "@playwright/test"; + +test.describe("External Communication", () => { + test("should skip external requests during initialization when disabled", async ({ + page, + }) => { + const requestedUrls: string[] = []; + + page.on("request", (request) => { + const requestUrl = request.url(); + if ( + requestUrl.startsWith("https://www.react-grab.com/api/version") || + requestUrl.startsWith("https://fonts.googleapis.com/") + ) { + requestedUrls.push(requestUrl); + } + }); + + await page.addInitScript(() => { + ( + window as { + __REACT_GRAB_OPTIONS__?: { + allowExternalCommunication?: boolean; + }; + } + ).__REACT_GRAB_OPTIONS__ = { + allowExternalCommunication: false, + }; + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction( + () => (window as { __REACT_GRAB__?: unknown }).__REACT_GRAB__ !== undefined, + { timeout: 5000 }, + ); + await page.waitForTimeout(300); + + expect(requestedUrls).toEqual([]); + + const hasFontLink = await page.evaluate(() => { + return document.getElementById("react-grab-fonts") !== null; + }); + + expect(hasFontLink).toBe(false); + }); + + test("should not open a remote open-file fallback from the selection label when disabled", async ({ + page, + }) => { + await page.addInitScript(() => { + ( + window as { + __REACT_GRAB_OPTIONS__?: { + allowExternalCommunication?: boolean; + }; + } + ).__REACT_GRAB_OPTIONS__ = { + allowExternalCommunication: false, + }; + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction( + () => (window as { __REACT_GRAB__?: unknown }).__REACT_GRAB__ !== undefined, + { timeout: 5000 }, + ); + + await page.evaluate(() => { + (window as { __OPEN_FILE_URLS__?: string[] }).__OPEN_FILE_URLS__ = []; + + const originalFetch = window.fetch.bind(window); + window.fetch = async (input, init) => { + const requestUrl = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if ( + requestUrl.includes("/__open-in-editor") || + requestUrl.includes("/__nextjs_launch-editor") + ) { + return new Response("", { status: 404 }); + } + return originalFetch(input, init); + }; + + Object.defineProperty(window, "open", { + configurable: true, + value: (url?: string | URL) => { + const openUrls = + (window as { __OPEN_FILE_URLS__?: string[] }).__OPEN_FILE_URLS__ ?? + []; + openUrls.push(typeof url === "string" ? url : String(url ?? "")); + (window as { __OPEN_FILE_URLS__?: string[] }).__OPEN_FILE_URLS__ = + openUrls; + return null; + }, + }); + }); + + await page.evaluate(() => { + const api = ( + window as { + __REACT_GRAB__?: { + activate: () => void; + }; + } + ).__REACT_GRAB__; + api?.activate(); + }); + await page.waitForFunction( + () => + ( + window as { + __REACT_GRAB__?: { + isActive: () => boolean; + }; + } + ).__REACT_GRAB__?.isActive() === true, + { timeout: 5000 }, + ); + + const firstListItem = page.locator("li").first(); + await firstListItem.hover({ force: true }); + + await page.waitForFunction( + () => { + const api = ( + window as { + __REACT_GRAB__?: { + getState: () => { + isSelectionBoxVisible: boolean; + targetElement: unknown; + selectionFilePath: string | null; + }; + }; + } + ).__REACT_GRAB__; + const state = api?.getState(); + return Boolean( + (state?.isSelectionBoxVisible || state?.targetElement) && + state?.selectionFilePath, + ); + }, + { timeout: 5000 }, + ); + + const selectionLabelOpenButton = page.locator( + "[data-react-grab-selection-label] .cursor-pointer", + ); + await expect(selectionLabelOpenButton).toBeVisible(); + await selectionLabelOpenButton.click({ force: true }); + await page.waitForTimeout(200); + + const openUrls = await page.evaluate(() => { + return (window as { __OPEN_FILE_URLS__?: string[] }).__OPEN_FILE_URLS__; + }); + + expect(openUrls ?? []).toEqual([]); + }); +}); diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index 738cec8c4..737e0ae3c 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -6,7 +6,6 @@ import { FROZEN_GLOW_EDGE_PX, Z_INDEX_OVERLAY_CANVAS, } from "../constants.js"; -import { openFile } from "../utils/open-file.js"; import { isElementConnected } from "../utils/is-element-connected.js"; import { OverlayCanvas } from "./overlay-canvas.js"; import { SelectionLabel } from "./selection-label/index.js"; @@ -143,11 +142,7 @@ export const ReactGrabRenderer: Component = (props) => { isPendingDismiss={props.isPendingDismiss} onConfirmDismiss={props.onConfirmDismiss} onCancelDismiss={props.onCancelDismiss} - onOpen={() => { - if (props.selectionFilePath) { - openFile(props.selectionFilePath, props.selectionLineNumber); - } - }} + onOpen={props.onOpenSelectionFile} isContextMenuOpen={props.contextMenuPosition !== null} /> diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index d05b4c85f..bd277eef5 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -182,6 +182,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { activationMode: "toggle", keyHoldDuration: DEFAULT_KEY_HOLD_DURATION_MS, allowActivationInsideInput: true, + allowExternalCommunication: true, maxContextLines: DEFAULT_MAX_CONTEXT_LINES, ...scriptOptions, ...rawOptions, @@ -192,7 +193,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } hasInited = true; - logIntro(); + logIntro(initialOptions.allowExternalCommunication ?? true); // eslint-disable-next-line @typescript-eslint/no-unused-vars -- need to omit enabled from settableOptions to avoid circular dependency const { enabled: _enabled, ...settableOptions } = initialOptions; @@ -2277,31 +2278,38 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return false; }; - const handleOpenFileShortcut = (event: KeyboardEvent): boolean => { - if (event.key?.toLowerCase() !== "o" || isPromptMode()) return false; - if (!isActivated() || !(event.metaKey || event.ctrlKey)) return false; - + const openSelectionFile = (): boolean => { const filePath = store.selectionFilePath; const lineNumber = store.selectionLineNumber; if (!filePath) return false; - event.preventDefault(); - event.stopPropagation(); - const wasHandled = pluginRegistry.hooks.onOpenFile( filePath, lineNumber ?? undefined, ); if (!wasHandled) { - openFile( + void openFile( filePath, lineNumber ?? undefined, pluginRegistry.hooks.transformOpenFileUrl, + pluginRegistry.store.options.allowExternalCommunication, ); } return true; }; + const handleOpenFileShortcut = (event: KeyboardEvent): boolean => { + if (event.key?.toLowerCase() !== "o" || isPromptMode()) return false; + if (!isActivated() || !(event.metaKey || event.ctrlKey)) return false; + + const didOpenSelectionFile = openSelectionFile(); + if (!didOpenSelectionFile) return false; + + event.preventDefault(); + event.stopPropagation(); + return true; + }; + const clearActionCycleIdleTimeout = () => { if (actionCycleIdleTimeoutId !== null) { window.clearTimeout(actionCycleIdleTimeoutId); @@ -3131,7 +3139,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }); const resolvedCssText = typeof cssText === "string" ? cssText : ""; - const rendererRoot = mountRoot(resolvedCssText); + const rendererRoot = mountRoot( + resolvedCssText, + pluginRegistry.store.options.allowExternalCommunication, + ); const isThemeEnabled = createMemo(() => pluginRegistry.store.theme.enabled); const isSelectionBoxThemeEnabled = createMemo( @@ -3500,6 +3511,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { lineNumber, componentName, tagName, + allowExternalCommunication: + pluginRegistry.store.options.allowExternalCommunication, enterPromptMode: customEnterPromptMode ?? defaultEnterPromptMode, copy: copyAction, hooks: { @@ -4036,6 +4049,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { selectionElementsCount={frozenElementsCount()} selectionFilePath={store.selectionFilePath ?? undefined} selectionLineNumber={store.selectionLineNumber ?? undefined} + onOpenSelectionFile={openSelectionFile} selectionTagName={selectionTagName()} selectionComponentName={resolvedComponentName()} selectionLabelVisible={selectionLabelVisible()} diff --git a/packages/react-grab/src/core/log-intro.ts b/packages/react-grab/src/core/log-intro.ts index 4d9165174..80ee05d80 100644 --- a/packages/react-grab/src/core/log-intro.ts +++ b/packages/react-grab/src/core/log-intro.ts @@ -1,7 +1,7 @@ import { LOGO_SVG } from "../constants.js"; import { isExtensionContext } from "../utils/is-extension-context.js"; -export const logIntro = () => { +export const logIntro = (allowExternalCommunication: boolean) => { try { const version = process.env.VERSION; const logoDataUri = `data:image/svg+xml;base64,${btoa(LOGO_SVG)}`; @@ -10,7 +10,12 @@ export const logIntro = () => { `background: #330039; color: #ffffff; border: 1px solid #d75fcb; padding: 4px 4px 4px 24px; border-radius: 4px; background-image: url("${logoDataUri}"); background-size: 16px 16px; background-repeat: no-repeat; background-position: 4px center; display: inline-block; margin-bottom: 4px;`, "", ); - if (navigator.onLine && version && !isExtensionContext()) { + if ( + allowExternalCommunication && + navigator.onLine && + version && + !isExtensionContext() + ) { fetch( `https://www.react-grab.com/api/version?source=browser&t=${Date.now()}`, { diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts index 4f0e9dd15..1b7b4dfd0 100644 --- a/packages/react-grab/src/core/plugin-registry.ts +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -36,6 +36,7 @@ interface OptionsState { activationMode: ActivationMode; keyHoldDuration: number; allowActivationInsideInput: boolean; + allowExternalCommunication: boolean; maxContextLines: number; activationKey: ActivationKey | undefined; getContent: ((elements: Element[]) => Promise | string) | undefined; @@ -46,6 +47,7 @@ const DEFAULT_OPTIONS: OptionsState = { activationMode: "toggle", keyHoldDuration: DEFAULT_KEY_HOLD_DURATION_MS, allowActivationInsideInput: true, + allowExternalCommunication: true, maxContextLines: DEFAULT_MAX_CONTEXT_LINES, activationKey: undefined, getContent: undefined, @@ -128,6 +130,7 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { "activationMode", "keyHoldDuration", "allowActivationInsideInput", + "allowExternalCommunication", "maxContextLines", "activationKey", "getContent", diff --git a/packages/react-grab/src/core/plugins/open.ts b/packages/react-grab/src/core/plugins/open.ts index 90672ac98..3ebd61ef2 100644 --- a/packages/react-grab/src/core/plugins/open.ts +++ b/packages/react-grab/src/core/plugins/open.ts @@ -18,10 +18,11 @@ export const openPlugin: Plugin = { ); if (!wasHandled) { - openFile( + void openFile( context.filePath, context.lineNumber, context.hooks.transformOpenFileUrl, + context.allowExternalCommunication, ); } diff --git a/packages/react-grab/src/index.ts b/packages/react-grab/src/index.ts index 03066ad27..59d71cf29 100644 --- a/packages/react-grab/src/index.ts +++ b/packages/react-grab/src/index.ts @@ -44,12 +44,13 @@ export type { } from "./types.js"; import { init } from "./core/index.js"; -import type { Plugin, ReactGrabAPI } from "./types.js"; +import type { Options, Plugin, ReactGrabAPI } from "./types.js"; declare global { interface Window { __REACT_GRAB__?: ReactGrabAPI; __REACT_GRAB_DISABLED__?: boolean; + __REACT_GRAB_OPTIONS__?: Options; } } @@ -109,7 +110,7 @@ if (typeof window !== "undefined" && !window.__REACT_GRAB_DISABLED__) { if (window.__REACT_GRAB__) { globalApi = window.__REACT_GRAB__; } else { - globalApi = init(); + globalApi = init(window.__REACT_GRAB_OPTIONS__); window.__REACT_GRAB__ = globalApi; } flushPendingPlugins(globalApi); diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 44135b9e6..c67304892 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -225,6 +225,7 @@ export interface ActionContext { lineNumber?: number; componentName?: string; tagName?: string; + allowExternalCommunication: boolean; enterPromptMode?: (agent?: AgentOptions) => void; hooks: ActionContextHooks; performWithFeedback: (action: () => Promise) => Promise; @@ -368,6 +369,13 @@ export interface Options { activationMode?: ActivationMode; keyHoldDuration?: number; allowActivationInsideInput?: boolean; + /** + * Whether React Grab can make remote network requests or load remote assets. + * When disabled, React Grab skips version checks, remote font loading, and + * remote open-file fallbacks. + * @default true + */ + allowExternalCommunication?: boolean; maxContextLines?: number; activationKey?: ActivationKey; getContent?: (elements: Element[]) => Promise | string; @@ -485,6 +493,7 @@ export interface ReactGrabRendererProps { selectionElementsCount?: number; selectionFilePath?: string; selectionLineNumber?: number; + onOpenSelectionFile?: () => void; selectionTagName?: string; selectionComponentName?: string; selectionLabelVisible?: boolean; diff --git a/packages/react-grab/src/utils/get-script-options.ts b/packages/react-grab/src/utils/get-script-options.ts index 6345a9726..4c707c076 100644 --- a/packages/react-grab/src/utils/get-script-options.ts +++ b/packages/react-grab/src/utils/get-script-options.ts @@ -28,6 +28,10 @@ const parseOptionsFromJson = (rawValue: unknown): Partial | null => { parsedOptions.allowActivationInsideInput = rawValue.allowActivationInsideInput; } + if (typeof rawValue.allowExternalCommunication === "boolean") { + parsedOptions.allowExternalCommunication = + rawValue.allowExternalCommunication; + } if ( typeof rawValue.maxContextLines === "number" && Number.isFinite(rawValue.maxContextLines) diff --git a/packages/react-grab/src/utils/mount-root.ts b/packages/react-grab/src/utils/mount-root.ts index e710ed6ea..05027220e 100644 --- a/packages/react-grab/src/utils/mount-root.ts +++ b/packages/react-grab/src/utils/mount-root.ts @@ -18,8 +18,13 @@ const loadFonts = () => { document.head.appendChild(link); }; -export const mountRoot = (cssText?: string) => { - loadFonts(); +export const mountRoot = ( + cssText?: string, + allowExternalCommunication = true, +) => { + if (allowExternalCommunication) { + loadFonts(); + } const mountedHost = document.querySelector(`[${ATTRIBUTE_NAME}]`); if (mountedHost) { diff --git a/packages/react-grab/src/utils/open-file.ts b/packages/react-grab/src/utils/open-file.ts index ad19996a8..6592dd314 100644 --- a/packages/react-grab/src/utils/open-file.ts +++ b/packages/react-grab/src/utils/open-file.ts @@ -26,6 +26,7 @@ export const openFile = async ( filePath: string, lineNumber: number | undefined, transformUrl?: (url: string, filePath: string, lineNumber?: number) => string, + allowExternalCommunication = true, ): Promise => { filePath = normalizeFileName(filePath); @@ -39,5 +40,18 @@ export const openFile = async ( const url = transformUrl ? transformUrl(rawUrl, filePath, lineNumber) : rawUrl; + if (!allowExternalCommunication) { + let targetUrl: URL; + try { + targetUrl = new URL(url, window.location.href); + } catch { + return; + } + const isHttpProtocol = + targetUrl.protocol === "http:" || targetUrl.protocol === "https:"; + if (isHttpProtocol && targetUrl.origin !== window.location.origin) { + return; + } + } window.open(url, "_blank", "noopener,noreferrer"); };