diff --git a/package.nls.json b/package.nls.json index e19e9f450..44760d638 100644 --- a/package.nls.json +++ b/package.nls.json @@ -149,6 +149,9 @@ "node.killBehavior.description": "Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.", "browser.killBehavior.description": "Configures how browser processes are killed when stopping the session with `cleanUp: wholeBrowser`. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.", "node.label": "Node.js", + "reactNative.label": "React Native", + "reactNative.snippet.attach.label": "React Native: Attach", + "reactNative.snippet.attach.description": "Attach to a running React Native app", "node.launch.args.description": "Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.", "node.launch.autoAttachChildProcesses.description": "Attach debugger to new child processes automatically.", "node.launch.config.name": "Launch", diff --git a/src/adapter/threads.ts b/src/adapter/threads.ts index 2628291d0..d71c2b7f3 100644 --- a/src/adapter/threads.ts +++ b/src/adapter/threads.ts @@ -770,6 +770,13 @@ export class Thread implements IVariableStoreLocationProvider { // console.profile/console.endProfile. Otherwise, these just no-op. this._cdp.Profiler.enable({}); + // For React Native targets, enable the React Native application + // domain so the app knows a debugger is attached. This method is not part + // of the standard CDP protocol, so it's sent through the raw session. + if (this.launchConfig.type === DebugType.ReactNative) { + this._cdp.session.send('ReactNativeApplication.enable', {}); + } + this._ensureDebuggerEnabledAndRefreshDebuggerId(); if (this.launchConfig.noDebug) { diff --git a/src/build/generate-contributions.ts b/src/build/generate-contributions.ts index e27f9b5b9..fc3d455bd 100644 --- a/src/build/generate-contributions.ts +++ b/src/build/generate-contributions.ts @@ -43,6 +43,7 @@ import { INodeAttachConfiguration, INodeBaseConfiguration, INodeLaunchConfiguration, + IReactNativeAttachConfiguration, ITerminalLaunchConfiguration, KillBehavior, nodeAttachConfigDefaults, @@ -500,6 +501,41 @@ const nodeAttachConfig: IDebugger = { defaults: nodeAttachConfigDefaults, }; +// `websocketAddress` is resolved internally from the dev server target picker +// and must not be set by the user, so it's omitted from the React Native schema. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const { websocketAddress: _rnWebsocketAddress, ...reactNativeAttachAttributes } = + nodeAttachConfig.configurationAttributes; + +/** + * React Native attach configuration. Behaves like a Node attach, but the + * distinct `react-native` type drives React Native-specific behavior: the + * target is discovered from the dev server (via the target picker) and + * `ReactNativeApplication.enable` is sent on attach. Reuses the Node attach + * attributes, minus `websocketAddress`. + */ +const reactNativeAttachConfig: IDebugger = { + type: DebugType.ReactNative, + request: 'attach', + label: refString('reactNative.label'), + languages: commonLanguages, + configurationSnippets: [ + { + label: refString('reactNative.snippet.attach.label'), + description: refString('reactNative.snippet.attach.description'), + body: { + type: DebugType.ReactNative, + request: 'attach', + name: '${1:Attach to React Native}', + port: 8081, + skipFiles: [`${nodeInternalsToken}/**`], + }, + }, + ], + configurationAttributes: reactNativeAttachAttributes, + defaults: { ...nodeAttachConfigDefaults, type: DebugType.ReactNative }, +}; + /** * Node attach configuration. */ @@ -1162,6 +1198,7 @@ const editorBrowserAttachConfig: IDebugger = export const debuggers = [ nodeAttachConfig, + reactNativeAttachConfig, nodeLaunchConfig, nodeTerminalConfiguration, extensionHostConfig, diff --git a/src/common/contributionUtils.ts b/src/common/contributionUtils.ts index 9ca88124d..ad31013c4 100644 --- a/src/common/contributionUtils.ts +++ b/src/common/contributionUtils.ts @@ -80,6 +80,7 @@ export const enum DebugType { ExtensionHost = 'pwa-extensionHost', Terminal = 'node-terminal', Node = 'pwa-node', + ReactNative = 'react-native', Chrome = 'pwa-chrome', Edge = 'pwa-msedge', EditorBrowser = 'pwa-editor-browser', @@ -101,6 +102,7 @@ const debugTypes: { [K in DebugType]: null } = { [DebugType.ExtensionHost]: null, [DebugType.Terminal]: null, [DebugType.Node]: null, + [DebugType.ReactNative]: null, [DebugType.Chrome]: null, [DebugType.Edge]: null, [DebugType.EditorBrowser]: null, diff --git a/src/configuration.ts b/src/configuration.ts index f311d499d..5090406b1 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -576,6 +576,16 @@ export interface INodeAttachConfiguration extends INodeBaseConfiguration { attachExistingChildren: boolean; } +/** + * Configuration for a React Native attach. Structurally identical to a Node + * attach, but with a distinct `type` discriminant that drives React + * Native-specific behavior: the target is discovered from the dev server and + * `ReactNativeApplication.enable` is sent on attach. + */ +export type IReactNativeAttachConfiguration = + & Omit + & { type: DebugType.ReactNative }; + export interface IChromiumLaunchConfiguration extends IChromiumBaseConfiguration { request: 'launch'; @@ -787,6 +797,7 @@ export interface ITerminalDelegateConfiguration extends INodeBaseConfiguration { export type AnyNodeConfiguration = | INodeAttachConfiguration + | IReactNativeAttachConfiguration | INodeLaunchConfiguration | ITerminalLaunchConfiguration | IExtensionHostLaunchConfiguration @@ -817,6 +828,9 @@ export type ResolvingExtensionHostConfiguration = ResolvingConfiguration< IExtensionHostLaunchConfiguration >; export type ResolvingNodeAttachConfiguration = ResolvingConfiguration; +export type ResolvingReactNativeAttachConfiguration = ResolvingConfiguration< + IReactNativeAttachConfiguration +>; export type ResolvingNodeLaunchConfiguration = ResolvingConfiguration; export type ResolvingTerminalDelegateConfiguration = ResolvingConfiguration< ITerminalDelegateConfiguration @@ -829,6 +843,7 @@ export type ResolvingTerminalConfiguration = | ResolvingTerminalLaunchConfiguration; export type ResolvingNodeConfiguration = | ResolvingNodeAttachConfiguration + | ResolvingReactNativeAttachConfiguration | ResolvingNodeLaunchConfiguration; export type ResolvingChromeConfiguration = ResolvingConfiguration; export type ResolvingEdgeConfiguration = ResolvingConfiguration; @@ -839,6 +854,7 @@ export type AnyResolvingConfiguration = | ResolvingExtensionHostConfiguration | ResolvingChromeConfiguration | ResolvingNodeAttachConfiguration + | ResolvingReactNativeAttachConfiguration | ResolvingNodeLaunchConfiguration | ResolvingTerminalConfiguration | ResolvingEdgeConfiguration @@ -1136,6 +1152,7 @@ export function applyDefaults( const defaultBrowserLocation = location === 'remote' ? ('ui' as const) : ('workspace' as const); switch (config.type) { case DebugType.Node: + case DebugType.ReactNative: configWithDefaults = applyNodeDefaults(config); break; case DebugType.Edge: diff --git a/src/targets/node/nodeAttacher.ts b/src/targets/node/nodeAttacher.ts index a38640c9b..a9b75bd86 100644 --- a/src/targets/node/nodeAttacher.ts +++ b/src/targets/node/nodeAttacher.ts @@ -11,7 +11,11 @@ import { DebugType } from '../../common/contributionUtils'; import { ILogger, LogTag } from '../../common/logging'; import { delay } from '../../common/promiseUtil'; import { isLoopback } from '../../common/urlUtils'; -import { AnyLaunchConfiguration, INodeAttachConfiguration } from '../../configuration'; +import { + AnyLaunchConfiguration, + INodeAttachConfiguration, + IReactNativeAttachConfiguration, +} from '../../configuration'; import { retryGetNodeEndpoint } from '../browser/spawn/endpoints'; import { ISourcePathResolverFactory } from '../sourcePathResolverFactory'; import { IStopMetadata } from '../targets'; @@ -25,6 +29,13 @@ import { IProgram, StubProgram, WatchDogProgram } from './program'; import { IRestartPolicy, RestartPolicyFactory } from './restartPolicy'; import { WatchDog } from './watchdogSpawn'; +/** + * Configurations handled by the Node attacher. React Native attach is + * structurally identical to a Node attach and reuses this launcher; only the + * `type` discriminant differs. + */ +type NodeAttachConfiguration = INodeAttachConfiguration | IReactNativeAttachConfiguration; + /** * Attaches to ongoing Node processes. This works pretty similar to the * existing Node launcher, except with how we attach to the entry point: @@ -33,7 +44,7 @@ import { WatchDog } from './watchdogSpawn'; * child processes operate just like those we boot with the NodeLauncher. */ @injectable() -export class NodeAttacher extends NodeAttacherBase { +export class NodeAttacher extends NodeAttacherBase { private telemetry?: IProcessTelemetry; constructor( @@ -49,14 +60,17 @@ export class NodeAttacher extends NodeAttacherBase { /** * @inheritdoc */ - protected resolveParams(params: AnyLaunchConfiguration): INodeAttachConfiguration | undefined { - return params.type === DebugType.Node && params.request === 'attach' ? params : undefined; + protected resolveParams(params: AnyLaunchConfiguration): NodeAttachConfiguration | undefined { + return (params.type === DebugType.Node || params.type === DebugType.ReactNative) + && params.request === 'attach' + ? params + : undefined; } /** * @inheritdoc */ - protected async launchProgram(runData: IRunData): Promise { + protected async launchProgram(runData: IRunData): Promise { const doLaunch = async ( restartPolicy: IRestartPolicy, restarting?: IProgram, @@ -153,7 +167,7 @@ export class NodeAttacher extends NodeAttacherBase { */ protected override createLifecycle( cdp: Cdp.Api, - run: IRunData, + run: IRunData, target: Cdp.Target.TargetInfo, ) { if (target.openerId) { @@ -179,7 +193,7 @@ export class NodeAttacher extends NodeAttacherBase { protected async onFirstInitialize( cdp: Cdp.Api, - run: IRunData, + run: IRunData, parentInfo: Cdp.Target.TargetInfo, ) { // We use a lease file to indicate to the process that the debugger is @@ -222,7 +236,7 @@ export class NodeAttacher extends NodeAttacherBase { private async setEnvironmentVariables( cdp: Cdp.Api, - run: IRunData, + run: IRunData, leasePath: string, openerId: string, binary: NodeBinary, diff --git a/src/targets/sourcePathResolverFactory.ts b/src/targets/sourcePathResolverFactory.ts index 6f6f636e4..4495cb228 100644 --- a/src/targets/sourcePathResolverFactory.ts +++ b/src/targets/sourcePathResolverFactory.ts @@ -39,6 +39,7 @@ export class NodeOnlyPathResolverFactory implements ISourcePathResolverFactory { public create(c: AnyLaunchConfiguration, logger: ILogger) { if ( c.type === DebugType.Node + || c.type === DebugType.ReactNative || c.type === DebugType.Terminal || c.type === DebugType.ExtensionHost ) { @@ -68,6 +69,7 @@ export class SourcePathResolverFactory implements ISourcePathResolverFactory { public create(c: AnyLaunchConfiguration, logger: ILogger) { if ( c.type === DebugType.Node + || c.type === DebugType.ReactNative || c.type === DebugType.Terminal || c.type === DebugType.ExtensionHost ) { diff --git a/src/ui/configuration/index.ts b/src/ui/configuration/index.ts index 0564aff51..ad0f1c5cb 100644 --- a/src/ui/configuration/index.ts +++ b/src/ui/configuration/index.ts @@ -21,6 +21,7 @@ import { NodeInitialDebugConfigurationProvider, } from './nodeDebugConfigurationProvider'; import { NodeConfigurationResolver } from './nodeDebugConfigurationResolver'; +import { ReactNativeConfigurationResolver } from './reactNativeConfigurationResolver'; import { TerminalDebugConfigurationResolver } from './terminalDebugConfigurationResolver'; export const allConfigurationResolvers = [ @@ -29,6 +30,7 @@ export const allConfigurationResolvers = [ EditorBrowserDebugConfigurationResolver, ExtensionHostConfigurationResolver, NodeConfigurationResolver, + ReactNativeConfigurationResolver, TerminalDebugConfigurationResolver, ]; diff --git a/src/ui/configuration/reactNativeConfigurationResolver.ts b/src/ui/configuration/reactNativeConfigurationResolver.ts new file mode 100644 index 000000000..eb468fb17 --- /dev/null +++ b/src/ui/configuration/reactNativeConfigurationResolver.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { inject, injectable } from 'inversify'; +import * as vscode from 'vscode'; +import { CancellationToken } from 'vscode'; +import { IResourceProvider } from '../../adapter/resourceProvider'; +import { DebugType } from '../../common/contributionUtils'; +import { + applyNodeDefaults, + IReactNativeAttachConfiguration, + ResolvingConfiguration, +} from '../../configuration'; +import { ExtensionContext } from '../../ioc-extras'; +import { pickReactNativeProcess } from '../reactNativeProcessPicker'; +import { BaseConfigurationResolver } from './baseConfigurationResolver'; + +/** + * Configuration resolver for React Native attach. It reuses the Node + * attach machinery, but resolves the target by querying the dev server's + * `/json/list` endpoint and prompting the user to pick one. The chosen target's + * inspector WebSocket URL is written to `websocketAddress`, which the Node + * attacher connects to directly. + */ +@injectable() +export class ReactNativeConfigurationResolver + extends BaseConfigurationResolver +{ + constructor( + @inject(ExtensionContext) context: vscode.ExtensionContext, + @inject(IResourceProvider) private readonly resourceProvider: IResourceProvider, + ) { + super(context); + } + + /** + * @override + */ + protected async resolveDebugConfigurationAsync( + _folder: vscode.WorkspaceFolder | undefined, + config: ResolvingConfiguration, + cancellationToken?: CancellationToken, + ): Promise { + // React Native is attach-only: js-debug connects to an already-running app + // via the dev server, it never launches the app itself. (`request` is typed + // as `attach`, but the raw config from the user can be anything.) + const request: string = config.request; + if (request !== 'attach') { + throw new Error( + l10n.t('`{0}` configurations only support the `attach` request.', DebugType.ReactNative), + ); + } + + // The target's WebSocket URL is discovered from the dev server, so users + // must never provide it themselves. + if (config.websocketAddress) { + throw new Error( + l10n.t( + '`websocketAddress` is not supported for `{0}` configurations.', + DebugType.ReactNative, + ), + ); + } + + // The dev server port comes from the launch config; default to Metro's 8081. + const port = typeof config.port === 'number' ? config.port : 8081; + const websocketAddress = await pickReactNativeProcess( + this.resourceProvider, + port, + cancellationToken, + ); + if (!websocketAddress) { + return undefined; // cancelled + } + + config.websocketAddress = websocketAddress; + return applyNodeDefaults(config) as IReactNativeAttachConfiguration; + } + + /** + * @override + */ + protected getType() { + return DebugType.ReactNative as const; + } +} diff --git a/src/ui/reactNativeProcessPicker.ts b/src/ui/reactNativeProcessPicker.ts new file mode 100644 index 000000000..cbbfbe832 --- /dev/null +++ b/src/ui/reactNativeProcessPicker.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { CancellationToken } from 'vscode'; +import { IResourceProvider } from '../adapter/resourceProvider'; + +/** + * Shape of a single target returned from the React Native dev server's + * `/json/list` CDP endpoint. Only the fields we consume are typed; a real + * response contains more. + */ +export interface IReactNativeTarget { + title?: string; + description?: string; + appId?: string; + webSocketDebuggerUrl?: string; + deviceName?: string; +} + +interface ITargetQuickPickItem extends vscode.QuickPickItem { + target: IReactNativeTarget; +} + +/** + * Discovers debuggable React Native targets from the dev server (Metro) + * inspector proxy listening on the given port and prompts the user to pick + * one. A single dev server can expose multiple targets (e.g. several connected + * devices or apps), so the picker surfaces each target's name and description. + * + * Returns the chosen target's inspector WebSocket URL, or `undefined` if the + * user cancels. Throws if the dev server can't be reached or exposes no + * debuggable targets. + */ +export async function pickReactNativeProcess( + resourceProvider: IResourceProvider, + port: number, + cancellationToken?: CancellationToken, +): Promise { + const url = `http://localhost:${port}/json/list`; + + const response = await resourceProvider.fetchJson(url, cancellationToken); + if (!response.ok) { + throw new Error( + l10n.t( + 'Could not connect to the React Native dev server at {0}. Is Metro running? ({1})', + `localhost:${port}`, + response.error.message, + ), + ); + } + + const targets = response.body.filter(target => !!target.webSocketDebuggerUrl); + if (targets.length === 0) { + throw new Error( + l10n.t( + 'No React Native debug targets were found on the dev server at {0}.', + `localhost:${port}`, + ), + ); + } + + // With a single target there's no choice to make, so skip the picker. + if (targets.length === 1) { + return targets[0].webSocketDebuggerUrl; + } + + const items = targets.map(target => ({ + label: target.appId || target.title || l10n.t('React Native target'), + description: target.deviceName, + target, + })); + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: l10n.t('Pick the React Native target to attach to'), + }); + + return picked?.target.webSocketDebuggerUrl; +}