diff --git a/package.json b/package.json index 6f5fafaaa..6fa382fd9 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,12 @@ "category": "React Native", "enablement": "!config.security.workspace.trust.enabled || isWorkspaceTrusted" }, + { + "command": "reactNative.selectDevice", + "title": "%reactNative.command.selectDevice.title%", + "category": "React Native", + "enablement": "!config.security.workspace.trust.enabled || isWorkspaceTrusted" + }, { "command": "reactNative.launchExpoWeb", "title": "%reactNative.command.launchExpoWeb.title%", diff --git a/package.nls.json b/package.nls.json index 6b4043408..94fec838c 100644 --- a/package.nls.json +++ b/package.nls.json @@ -5,6 +5,7 @@ "reactNative.virtualWorkspaces.description": "Debugging is not possible in virtual workspaces.", "reactNative.command.launchAndroidSimulator.title": "Launch Android Emulator", "reactNative.command.launchIOSSimulator.title": "Launch iOS Simulator", + "reactNative.command.selectDevice.title": "Select Device", "reactNative.command.launchExpoWeb.title": "Expo - Launch ExpoWeb", "reactNative.command.selectAndInsertDebugConfiguration.title": "Select a React Native debug configuration", "reactNative.command.runAndroidSimulator.title": "Run Android on Emulator", diff --git a/src/common/error/errorStrings.ts b/src/common/error/errorStrings.ts index 9d443bec1..ec3136fb5 100644 --- a/src/common/error/errorStrings.ts +++ b/src/common/error/errorStrings.ts @@ -423,4 +423,5 @@ export const ERROR_STRINGS = { [InternalErrorCode.FailedToToggleNetworkView]: "Failed to config network view", [InternalErrorCode.FailedToRunEasBuild]: "Failed to run eas build", [InternalErrorCode.FailedToInstallPods]: "Failed to install pods", + [InternalErrorCode.FailedToSelectDevice]: "Failed to select device", }; diff --git a/src/common/error/internalErrorCode.ts b/src/common/error/internalErrorCode.ts index 61734b2ad..be707fa0d 100644 --- a/src/common/error/internalErrorCode.ts +++ b/src/common/error/internalErrorCode.ts @@ -40,6 +40,7 @@ export enum InternalErrorCode { FailedToToggleNetworkView = 136, FailedToRunEasBuild = 137, FailedToInstallPods = 138, + FailedToSelectDevice = 139, // Device Deployer errors IOSDeployNotFound = 201, diff --git a/src/debugger/debugSessionBase.ts b/src/debugger/debugSessionBase.ts index 6f2b6f18a..ee3937d49 100644 --- a/src/debugger/debugSessionBase.ts +++ b/src/debugger/debugSessionBase.ts @@ -17,7 +17,6 @@ import { ILaunchArgs, IRunOptions, PlatformType } from "../extension/launchArgs" import type { AppLauncher } from "../extension/appLauncher"; import { RNPackageVersions } from "../common/projectVersionHelper"; import { OutputChannelLogger } from "../extension/log/OutputChannelLogger"; -import { DeviceStatusIndicator } from "../extension/deviceStatusIndicator"; import { RNSession } from "./debugSessionWrapper"; nls.config({ @@ -234,8 +233,6 @@ export abstract class DebugSessionBase extends LoggingDebugSession { } } - DeviceStatusIndicator.hide(); - this.cancellationTokenSource.cancel(); this.cancellationTokenSource.dispose(); diff --git a/src/extension/android/androidTargetManager.ts b/src/extension/android/androidTargetManager.ts index 18dfb8f50..436bf13bc 100644 --- a/src/extension/android/androidTargetManager.ts +++ b/src/extension/android/androidTargetManager.ts @@ -140,12 +140,19 @@ export class AndroidTargetManager extends MobileTargetManager { return this.selectTarget(filter); } - protected async launchSimulator(emulatorTarget: IMobileTarget): Promise { + public async launchSimulator( + emulatorTarget: IMobileTarget, + coldBoot = false, + ): Promise { return new Promise((resolve, reject) => { let emulatorLaunchFailed = false; const emulatorProcess = this.childProcess.spawn( AndroidTargetManager.EMULATOR_COMMAND, - [AndroidTargetManager.EMULATOR_AVD_START_COMMAND, emulatorTarget.name as string], + [ + AndroidTargetManager.EMULATOR_AVD_START_COMMAND, + emulatorTarget.name as string, + ...(coldBoot ? ["-no-snapshot-load"] : []), + ], { detached: true, }, diff --git a/src/extension/appLauncher.ts b/src/extension/appLauncher.ts index e28f0c923..3beb498a1 100644 --- a/src/extension/appLauncher.ts +++ b/src/extension/appLauncher.ts @@ -38,7 +38,6 @@ import { LaunchScenariosManager } from "./launchScenariosManager"; import { createAdditionalWorkspaceFolder, onFolderAdded } from "./rn-extension"; import { RNProjectObserver } from "./rnProjectObserver"; import { GeneralMobilePlatform } from "./generalMobilePlatform"; -import { DeviceStatusIndicator } from "./deviceStatusIndicator"; nls.config({ messageFormat: nls.MessageFormat.bundle, @@ -205,7 +204,6 @@ export class AppLauncher { this.packager.getStatusIndicator().dispose(); void this.packager.stop(true); this.mobilePlatform.dispose(); - DeviceStatusIndicator.hide(); } public async openFileAtLocation(filename: string, lineNumber: number): Promise { @@ -341,14 +339,6 @@ export class AppLauncher { await this.mobilePlatform.runApp(); } - // Show device name in status bar after app is launched - if (this.mobilePlatform instanceof GeneralMobilePlatform) { - const target = this.mobilePlatform.getResolvedTarget(); - if (target?.name) { - DeviceStatusIndicator.show(target.name); - } - } - if (mobilePlatformOptions.isDirect) { if (launchArgs.useHermesEngine) { generator.step("mobilePlatform.enableHermesDebuggingMode"); diff --git a/src/extension/commands/index.ts b/src/extension/commands/index.ts index 407a37a1b..1e542ad35 100644 --- a/src/extension/commands/index.ts +++ b/src/extension/commands/index.ts @@ -16,6 +16,7 @@ export * from "./runIos"; export * from "./runMacOs"; export * from "./runWindows"; export * from "./selectAndInsertDebugConfiguration"; +export * from "./selectDevice"; export * from "./showDevMenu"; export * from "./startLogCatMonitor"; export * from "./startPackager"; diff --git a/src/extension/commands/selectDevice.ts b/src/extension/commands/selectDevice.ts new file mode 100644 index 000000000..ce3d298f0 --- /dev/null +++ b/src/extension/commands/selectDevice.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +import { ErrorHelper } from "../../common/error/errorHelper"; +import { InternalErrorCode } from "../../common/error/internalErrorCode"; +import { DeviceSelector } from "../deviceSelector"; +import { Command } from "./util/command"; + +export class SelectDevice extends Command { + codeName = "selectDevice"; + label = "Select Device"; + requiresProject = false; + error = ErrorHelper.getInternalError(InternalErrorCode.FailedToSelectDevice); + + async baseFn(): Promise { + await DeviceSelector.instance?.showPicker(); + } +} diff --git a/src/extension/deviceSelector.ts b/src/extension/deviceSelector.ts new file mode 100644 index 000000000..934d2c905 --- /dev/null +++ b/src/extension/deviceSelector.ts @@ -0,0 +1,573 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +import * as vscode from "vscode"; +import * as nls from "vscode-nls"; +import { ChildProcess } from "../common/node/childProcess"; +import { AdbHelper } from "./android/adb"; +import { AndroidTargetManager } from "./android/androidTargetManager"; +import { TargetType } from "./generalPlatform"; +import { IDebuggableIOSTarget, IOSTargetManager } from "./ios/iOSTargetManager"; +import { PlatformType } from "./launchArgs"; +import { OutputChannelLogger } from "./log/OutputChannelLogger"; +import { IDebuggableMobileTarget } from "./mobileTarget"; +import { ProjectsStorage } from "./projectsStorage"; + +nls.config({ + messageFormat: nls.MessageFormat.bundle, + bundleFormat: nls.BundleFormat.standalone, +})(); +const localize = nls.loadMessageBundle(); + +export interface Device { + platform: PlatformType.Android | PlatformType.iOS; + /** Stable identity, saved and passed as `target`: AVD name, Android serial or iOS UDID. */ + key: string; + /** Runtime id shown in the picker: serial (emulator-5554) or UDID. */ + id: string; + name: string; + isVirtual: boolean; + isOnline: boolean; + /** iOS only, e.g. "iOS 27.0". */ + system?: string; +} + +export interface DeviceItem extends vscode.QuickPickItem { + device?: Device; + coldBoot?: boolean; +} + +export function statusBarText(device: Device | undefined): string { + if (!device) { + return localize("NoDevice", "No Device"); + } + let kind = ""; + if (device.isVirtual) { + kind = device.platform === PlatformType.iOS ? " simulator" : " emulator"; + } + return `${device.name} (${device.platform}${kind})`; +} + +export function statusBarTooltip(devices: Device[]): string | undefined { + const count = devices.filter(device => device.isOnline).length; + if (count === 0) { + return undefined; + } + return count === 1 + ? localize("OneDeviceConnected", "1 Device Connected") + : localize("DevicesConnected", "{0} Devices Connected", count); +} + +export function buildPickerItems(devices: Device[], current: Device | undefined): DeviceItem[] { + const isCurrent = (device: Device) => device.key === current?.key; + const online = devices + .filter(device => device.isOnline) + .sort( + (a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || a.name.localeCompare(b.name), + ); + const offline = devices.filter(device => !device.isOnline && device.isVirtual); + const items: DeviceItem[] = []; + + if (online.length) { + items.push({ + kind: vscode.QuickPickItemKind.Separator, + label: localize("AvailableDevices", "Available Devices"), + }); + for (const device of online) { + items.push({ + label: `$(device-mobile) ${device.name}`, + description: `${device.id} - ${device.platform}`, + detail: isCurrent(device) ? localize("CurrentDevice", "Current Device") : undefined, + device, + }); + } + } + + if (offline.length) { + items.push({ + kind: vscode.QuickPickItemKind.Separator, + label: localize("OfflineEmulators", "Offline Emulators"), + }); + for (const device of offline) { + const label = `$(play) ${localize("StartDevice", "Start {0}", device.name)}`; + if (device.platform === PlatformType.Android) { + items.push({ label, description: "mobile emulator", device }); + items.push({ + label, + description: "mobile emulator (cold boot)", + device, + coldBoot: true, + }); + } else { + items.push({ + label, + description: `mobile simulator (${device.system ?? ""})`, + device, + }); + } + } + } + + return items; +} + +/** + * Picks the current device after a refresh. Pass `previous === undefined` on the first refresh. + * A newly connected device is selected unless the current one is the device the user picked + * (`saved`); the picked device is always re-selected when it reconnects. + */ +export function nextCurrent( + current: Device | undefined, + previous: Device[] | undefined, + devices: Device[], + saved: string | undefined, +): Device | undefined { + const online = devices.filter(device => device.isOnline); + const find = (key: string | undefined) => online.find(device => device.key === key); + const fallback = () => find(saved) ?? online[0]; + if (!previous) { + return fallback(); + } + + const wasOnline = new Set(previous.filter(device => device.isOnline).map(device => device.key)); + let next = find(current?.key); + for (const device of online) { + const isNew = !wasOnline.has(device.key); + if (isNew && (!next || device.key === saved || next.key !== saved)) { + next = device; + } + } + return next ?? fallback(); +} + +/** + * Fills `config.target` from the selected device when the launch config leaves the target open. + * Returns a note for the output channel when the selection could not be used. + */ +export function applySelectedDevice( + config: vscode.DebugConfiguration, + device: Device | undefined, +): string | undefined { + if ( + config.request !== "launch" || + (config.platform !== PlatformType.Android && config.platform !== PlatformType.iOS) + ) { + return undefined; + } + const target = typeof config.target === "string" ? config.target.toLowerCase() : ""; + if (target && target !== TargetType.Simulator && target !== TargetType.Device) { + return undefined; // an explicit target always wins + } + if (!device) { + return localize( + "NoDeviceSelected", + "No device selected; using the default target selection.", + ); + } + if (device.platform !== config.platform) { + return localize( + "SelectedDeviceMismatch", + "Selected device '{0}' ({1}) doesn't match '{2}' ({3}); using the default target selection.", + device.name, + device.platform, + config.name, + config.platform, + ); + } + const typeMatches = + !target || + (target === TargetType.Simulator && device.isVirtual) || + (target === TargetType.Device && !device.isVirtual); + if (typeMatches) { + config.target = device.key; + } + return undefined; +} + +interface RunningAndroidTarget { + id: string; + isVirtual: boolean; + name: string; +} + +/** Running emulators are keyed by AVD name (serials change between boots), phones by serial. */ +export function toAndroidDevices(running: RunningAndroidTarget[], avdNames: string[]): Device[] { + const devices = running.map( + (target): Device => ({ + platform: PlatformType.Android, + key: target.isVirtual ? target.name : target.id, + id: target.id, + name: target.name, + isVirtual: target.isVirtual, + isOnline: true, + }), + ); + const runningAvds = new Set(devices.filter(device => device.isVirtual).map(d => d.key)); + for (const avd of avdNames) { + if (!runningAvds.has(avd)) { + devices.push({ + platform: PlatformType.Android, + key: avd, + id: avd, + name: avd, + isVirtual: true, + isOnline: false, + }); + } + } + return devices; +} + +/** + * Flutter-style device selector: a status bar item showing the selected device, a picker to + * change it or start an offline emulator/simulator, and a debug config resolver that runs + * launches on the selected device. + */ +export class DeviceSelector implements vscode.DebugConfigurationProvider, vscode.Disposable { + public static instance: DeviceSelector | undefined; + + private static readonly SAVED_DEVICE_KEY = "reactNative.selectedDevice"; + private static readonly REFRESH_INTERVAL_MS = 5000; + // ponytail: `xcrun xctrace list devices` takes ~2.5 s, so physical iPhones refresh only every + // 6th tick (~30 s) and when the picker opens. Switch to `xcrun devicectl list devices` + // (0.14 s) once its JSON format settles (fields are deprecated in Xcode 27). + private static readonly IOS_DEVICES_EVERY_N_REFRESHES = 6; + + private readonly statusBarItem: vscode.StatusBarItem; + private readonly logger = OutputChannelLogger.getMainChannel(); + private readonly childProcess = new ChildProcess(); + private readonly simulatorManager = new IOSTargetManager(); + private readonly iosDeviceManager = new IOSTargetManager(); + private readonly onDidChangeDevices = new vscode.EventEmitter(); + private readonly androidNames = new Map(); + private readonly loggedErrors = new Set(); + private readonly disposables: vscode.Disposable[] = []; + private adbHelper?: AdbHelper; + private devices?: Device[]; + private iosDevices: Device[] = []; + private current?: Device; + private launchingText?: string; + private refreshing?: Promise; + private wantIosDevices = false; + private refreshCount = 0; + private timer?: NodeJS.Timeout; + + constructor(private readonly workspaceState: vscode.Memento) { + this.statusBarItem = vscode.window.createStatusBarItem( + "reactNative.device", + vscode.StatusBarAlignment.Right, + Number.MIN_SAFE_INTEGER, // rightmost: lower priority sits further right + ); + this.statusBarItem.name = "React Native Device"; + this.statusBarItem.command = "reactNative.selectDevice"; + DeviceSelector.instance = this; + } + + /** Shows the status bar item and starts refreshing. Safe to call more than once. */ + public start(): void { + if (this.timer) { + return; + } + this.timer = setInterval(() => { + if (vscode.window.state.focused) { + void this.refresh(); + } + }, DeviceSelector.REFRESH_INTERVAL_MS); + this.disposables.push( + vscode.window.onDidChangeWindowState(state => { + if (state.focused) { + void this.refresh(); + } + }), + ); + this.render(); + this.statusBarItem.show(); + void this.refresh(); + } + + public async showPicker(): Promise { + const quickPick = vscode.window.createQuickPick(); + quickPick.placeholder = localize("SelectDeviceToUse", "Select a device to use"); + quickPick.ignoreFocusOut = true; + const updateItems = () => { + quickPick.items = buildPickerItems(this.devices ?? [], this.current); + }; + const subscription = this.onDidChangeDevices.event(updateItems); + updateItems(); + quickPick.busy = true; + void this.refresh(true).finally(() => { + quickPick.busy = false; + }); + + const selection = await new Promise(resolve => { + quickPick.onDidAccept(() => resolve(quickPick.selectedItems[0])); + quickPick.onDidHide(() => resolve(undefined)); + quickPick.show(); + }); + subscription.dispose(); + quickPick.dispose(); + + if (selection?.device?.isOnline) { + this.select(selection.device); + } else if (selection?.device) { + await this.launch(selection.device, !!selection.coldBoot); + } + } + + public resolveDebugConfiguration( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): vscode.DebugConfiguration { + const note = applySelectedDevice(config, this.current); + if (note) { + this.logger.info(note); + } + return config; + } + + public dispose(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + this.disposables.forEach(disposable => disposable.dispose()); + this.onDidChangeDevices.dispose(); + this.statusBarItem.dispose(); + DeviceSelector.instance = undefined; + } + + private select(device: Device): void { + this.current = device; + void this.workspaceState.update(DeviceSelector.SAVED_DEVICE_KEY, device.key); + this.render(); + } + + private render(): void { + this.statusBarItem.text = this.launchingText ?? statusBarText(this.current); + this.statusBarItem.tooltip = statusBarTooltip(this.devices ?? []); + } + + /** Coalesces concurrent calls; a request for iOS devices during a run triggers one more run. */ + private refresh(withIosDevices = false): Promise { + this.wantIosDevices = this.wantIosDevices || withIosDevices; + if (!this.refreshing) { + this.refreshing = this.collectAll().finally(() => { + this.refreshing = undefined; + if (this.wantIosDevices) { + void this.refresh(); + } + }); + } + return this.refreshing; + } + + private async collectAll(): Promise { + const withIosDevices = + this.wantIosDevices || + this.refreshCount++ % DeviceSelector.IOS_DEVICES_EVERY_N_REFRESHES === 0; + this.wantIosDevices = false; + const [android, simulators, iosDevices] = await Promise.all([ + this.collectAndroid(), + this.collectIos(this.simulatorManager, TargetType.Simulator), + withIosDevices + ? this.collectIos(this.iosDeviceManager, TargetType.Device) + : Promise.resolve(this.iosDevices), + ]); + this.iosDevices = iosDevices; + this.update([...android, ...simulators, ...iosDevices]); + } + + private update(devices: Device[]): void { + const previous = this.devices; + if (previous && JSON.stringify(previous) === JSON.stringify(devices)) { + return; + } + this.devices = devices; + this.current = nextCurrent( + this.current, + previous, + devices, + this.workspaceState.get(DeviceSelector.SAVED_DEVICE_KEY), + ); + this.render(); + this.onDidChangeDevices.fire(); + } + + private async collectAndroid(): Promise { + const adbHelper = this.getAdbHelper(); + if (!adbHelper) { + return []; + } + const [avdNames, running] = await Promise.all([ + this.attempt("Android emulators", () => adbHelper.getAvdsNames(), [] as string[]), + this.attempt("Android devices", () => this.runningAndroidTargets(adbHelper), []), + ]); + return toAndroidDevices(running, avdNames); + } + + private async runningAndroidTargets(adbHelper: AdbHelper): Promise { + const online = await adbHelper.getOnlineTargets(); + for (const serial of this.androidNames.keys()) { + if (!online.some(target => target.id === serial)) { + this.androidNames.delete(serial); + } + } + return Promise.all( + online.map(async target => ({ + id: target.id, + isVirtual: target.isVirtualTarget, + name: await this.androidName(adbHelper, target), + })), + ); + } + + /** AVD name for emulators, model for phones. Lookups that fail are retried next refresh. */ + private async androidName( + adbHelper: AdbHelper, + target: IDebuggableMobileTarget, + ): Promise { + const cached = this.androidNames.get(target.id); + if (cached) { + return cached; + } + const name = target.isVirtualTarget + ? await adbHelper.getAvdNameById(target.id) + : ( + await adbHelper + .executeShellCommand(target.id, "getprop ro.product.model") + .catch(() => "") + ).trim(); + if (!name) { + return target.id; + } + this.androidNames.set(target.id, name); + return name; + } + + private getAdbHelper(): AdbHelper | undefined { + const project = Object.values(ProjectsStorage.projectsCache)[0]; + if (!this.adbHelper && project) { + try { + this.adbHelper = new AdbHelper( + project.getPackager().getProjectPath(), + project.getOrUpdateNodeModulesRoot(), + ); + } catch { + // node_modules is not resolvable yet; retried on the next refresh + } + } + return this.adbHelper; + } + + private async collectIos(manager: IOSTargetManager, targetType: TargetType): Promise { + if (process.platform !== "darwin") { + return []; + } + const source = targetType === TargetType.Simulator ? "iOS simulators" : "iOS devices"; + return this.attempt( + source, + async () => { + await manager.collectTargets(targetType); + const targets = (await manager.getTargetList()) as IDebuggableIOSTarget[]; + return targets.map( + (target): Device => ({ + platform: PlatformType.iOS, + key: target.id, + id: target.id, + name: target.name, + isVirtual: target.isVirtualTarget, + isOnline: target.isOnline, + system: target.system, + }), + ); + }, + [], + ); + } + + /** Runs `collect`. Its first failure per source is logged until it succeeds again. */ + private async attempt(source: string, collect: () => Promise, fallback: T): Promise { + try { + const result = await collect(); + this.loggedErrors.delete(source); + return result; + } catch (error) { + if (!this.loggedErrors.has(source)) { + this.loggedErrors.add(source); + this.logger.warning( + localize( + "CouldNotListDevices", + "Could not list {0}: {1}", + source, + error instanceof Error ? error.message : String(error), + ), + ); + } + return fallback; + } + } + + private async launch(device: Device, coldBoot: boolean): Promise { + const isIos = device.platform === PlatformType.iOS; + this.launchingText = isIos + ? localize("LaunchingSimulator", "Launching simulator...") + : localize("LaunchingEmulator", "Launching emulator..."); + this.render(); + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: localize("LaunchingDevice", "Launching {0}...", device.name), + }, + () => (isIos ? this.bootSimulator(device) : this.bootEmulator(device, coldBoot)), + ); + await this.refresh(); + this.select( + this.devices?.find(d => d.key === device.key && d.isOnline) ?? { + ...device, + isOnline: true, + }, + ); + } catch (error) { + void vscode.window.showErrorMessage( + localize( + "FailedToLaunchDevice", + "Failed to launch {0}: {1}", + device.name, + error instanceof Error ? error.message : String(error), + ), + ); + } finally { + this.launchingText = undefined; + this.render(); + } + } + + private async bootEmulator(device: Device, coldBoot: boolean): Promise { + const adbHelper = this.getAdbHelper(); + if (!adbHelper) { + throw new Error(localize("NoReactNativeProject", "No React Native project is open")); + } + await new AndroidTargetManager(adbHelper).launchSimulator( + { name: device.key, isOnline: false, isVirtualTarget: true }, + coldBoot, + ); + } + + private async bootSimulator(device: Device): Promise { + // A separate manager instance: launchSimulator re-collects targets while it waits, + // which would race with the refresh loop's simulatorManager. + await new IOSTargetManager().launchSimulator({ + id: device.id, + name: device.name, + system: device.system ?? "", + isVirtualTarget: true, + isOnline: false, + }); + // `simctl boot` starts the simulator without a window. Xcode 27 renamed Simulator.app to DeviceHub.app. + await this.childProcess + .execFileToString("open", ["-a", "DeviceHub"]) + .catch(() => this.childProcess.execFileToString("open", ["-a", "Simulator"])) + .catch(error => this.logger.warning(error)); + } +} diff --git a/src/extension/deviceStatusIndicator.ts b/src/extension/deviceStatusIndicator.ts deleted file mode 100644 index 31052b078..000000000 --- a/src/extension/deviceStatusIndicator.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for details. - -import { window, Disposable, StatusBarItem, StatusBarAlignment } from "vscode"; - -/** - * Shows the currently connected device/simulator name in the status bar during a debug session. - */ -export class DeviceStatusIndicator implements Disposable { - private static readonly DEVICE_ICON = "$(device-mobile)"; - private static instance: DeviceStatusIndicator | undefined; - - private statusBarItem: StatusBarItem; - - private constructor() { - this.statusBarItem = window.createStatusBarItem(StatusBarAlignment.Left, 9); - } - - private static getInstance(): DeviceStatusIndicator { - if (!DeviceStatusIndicator.instance) { - DeviceStatusIndicator.instance = new DeviceStatusIndicator(); - } - return DeviceStatusIndicator.instance; - } - - public static show(deviceName: string): void { - const instance = DeviceStatusIndicator.getInstance(); - instance.statusBarItem.text = `${DeviceStatusIndicator.DEVICE_ICON} ${deviceName}`; - instance.statusBarItem.tooltip = `Active debug target: ${deviceName}`; - instance.statusBarItem.show(); - } - - public static hide(): void { - DeviceStatusIndicator.instance?.statusBarItem.hide(); - } - - public dispose(): void { - this.statusBarItem.dispose(); - DeviceStatusIndicator.instance = undefined; - } -} diff --git a/src/extension/ios/iOSTargetManager.ts b/src/extension/ios/iOSTargetManager.ts index f047dd4da..36c98b99b 100644 --- a/src/extension/ios/iOSTargetManager.ts +++ b/src/extension/ios/iOSTargetManager.ts @@ -218,7 +218,7 @@ export class IOSTargetManager extends MobileTargetManager { return result?.toString(); } - protected async launchSimulator( + public async launchSimulator( virtualTarget: IDebuggableIOSTarget, ): Promise { return new Promise((resolve, reject) => { diff --git a/src/extension/rn-extension.ts b/src/extension/rn-extension.ts index 86593f068..a954c7644 100644 --- a/src/extension/rn-extension.ts +++ b/src/extension/rn-extension.ts @@ -41,6 +41,7 @@ import { } from "./debuggingConfiguration/launchJsonCompletionProvider"; import { ReactNativeSessionManager } from "./reactNativeSessionManager"; import { ProjectsStorage } from "./projectsStorage"; +import { DeviceSelector } from "./deviceSelector"; import { AppLauncher } from "./appLauncher"; import { LogCatMonitorManager } from "./android/logCatMonitorManager"; import { ExtensionConfigManager } from "./extensionConfigManager"; @@ -204,6 +205,19 @@ export async function activate(context: vscode.ExtensionContext): Promise ), ); + const deviceSelector = new DeviceSelector(EXTENSION_CONTEXT.workspaceState); + EXTENSION_CONTEXT.subscriptions.push( + deviceSelector, + vscode.debug.registerDebugConfigurationProvider( + DEBUG_TYPES.REACT_NATIVE, + deviceSelector, + ), + vscode.debug.registerDebugConfigurationProvider( + DEBUG_TYPES.REACT_NATIVE_DIRECT, + deviceSelector, + ), + ); + EXTENSION_CONTEXT.subscriptions.push( vscode.languages.registerCompletionItemProvider( { language: JsonLanguages.json }, @@ -402,6 +416,7 @@ export async function onFolderAdded(folder: vscode.WorkspaceFolder): Promise + item.kind === vscode.QuickPickItemKind.Separator + ? `-- ${item.label}` + : [item.label, item.description, item.detail, item.coldBoot && "cold"] + .filter(Boolean) + .join(" | "), + ); +} + +suite("DeviceSelector", function () { + suite("statusBarText", function () { + test("shows No Device when nothing is selected", function () { + assert.strictEqual(statusBarText(undefined), "No Device"); + }); + + test("labels emulators, simulators and physical devices", function () { + assert.strictEqual(statusBarText(pixel), "Pixel_7_API_34 (android emulator)"); + assert.strictEqual(statusBarText(phone), "Pixel 8 (android)"); + assert.strictEqual(statusBarText(iphone), "iPhone 17 (ios simulator)"); + }); + }); + + suite("statusBarTooltip", function () { + test("counts only online devices", function () { + assert.strictEqual(statusBarTooltip([]), undefined); + assert.strictEqual(statusBarTooltip([offlineAvd]), undefined); + assert.strictEqual(statusBarTooltip([pixel, offlineAvd]), "1 Device Connected"); + assert.strictEqual(statusBarTooltip([pixel, iphone]), "2 Devices Connected"); + }); + }); + + suite("buildPickerItems", function () { + test("lists the current device first, then offline emulators with cold boot rows", function () { + const items = buildPickerItems([iphone, pixel, offlineAvd, offlineSim], pixel); + assert.deepStrictEqual(describeItems(items), [ + "-- Available Devices", + "$(device-mobile) Pixel_7_API_34 | emulator-5554 - android | Current Device", + "$(device-mobile) iPhone 17 | UDID-1 - ios", + "-- Offline Emulators", + "$(play) Start Pixel_4_API_30 | mobile emulator", + "$(play) Start Pixel_4_API_30 | mobile emulator (cold boot) | cold", + "$(play) Start iPhone 17e | mobile simulator (iOS 27.0)", + ]); + assert.strictEqual(items[1].device, pixel); + assert.strictEqual(items[5].device, offlineAvd); + }); + + test("sorts available devices by name when none is current", function () { + const items = buildPickerItems([pixel, iphone], undefined); + assert.deepStrictEqual(describeItems(items), [ + "-- Available Devices", + "$(device-mobile) iPhone 17 | UDID-1 - ios", + "$(device-mobile) Pixel_7_API_34 | emulator-5554 - android", + ]); + }); + + test("omits empty sections", function () { + assert.deepStrictEqual(buildPickerItems([], undefined), []); + assert.deepStrictEqual(describeItems(buildPickerItems([offlineAvd], undefined)), [ + "-- Offline Emulators", + "$(play) Start Pixel_4_API_30 | mobile emulator", + "$(play) Start Pixel_4_API_30 | mobile emulator (cold boot) | cold", + ]); + }); + }); + + suite("nextCurrent", function () { + test("first refresh picks the saved device when it is online", function () { + assert.strictEqual( + nextCurrent(undefined, undefined, [pixel, iphone], "UDID-1"), + iphone, + ); + }); + + test("first refresh falls back to the first online device", function () { + assert.strictEqual( + nextCurrent(undefined, undefined, [offlineAvd, pixel, iphone], "gone"), + pixel, + ); + assert.strictEqual( + nextCurrent(undefined, undefined, [offlineAvd], undefined), + undefined, + ); + }); + + test("a newly connected device replaces a current device that was not picked", function () { + assert.strictEqual(nextCurrent(pixel, [pixel], [pixel, iphone], undefined), iphone); + }); + + test("a newly connected device does not replace the picked device", function () { + assert.strictEqual( + nextCurrent(pixel, [pixel], [pixel, iphone], "Pixel_7_API_34"), + pixel, + ); + }); + + test("the picked device is re-selected when it reconnects", function () { + assert.strictEqual( + nextCurrent(iphone, [iphone], [iphone, pixel], "Pixel_7_API_34"), + pixel, + ); + }); + + test("an offline AVD coming online counts as newly connected", function () { + const booted: Device = { ...offlineAvd, id: "emulator-5556", isOnline: true }; + assert.strictEqual( + nextCurrent(pixel, [pixel, offlineAvd], [pixel, booted], undefined), + booted, + ); + }); + + test("when the current device disconnects, prefers the saved device, then the first online", function () { + assert.strictEqual( + nextCurrent(iphone, [iphone, pixel, phone], [phone, pixel], "Pixel_7_API_34"), + pixel, + ); + assert.strictEqual(nextCurrent(iphone, [iphone, phone], [phone], undefined), phone); + assert.strictEqual(nextCurrent(iphone, [iphone], [], undefined), undefined); + }); + + test("returns the fresh object from the new device list", function () { + const stale: Device = { ...pixel, name: "old" }; + assert.strictEqual(nextCurrent(stale, [pixel], [pixel], undefined), pixel); + }); + }); + + suite("applySelectedDevice", function () { + const launch = (platform: string, target?: string): vscode.DebugConfiguration => ({ + name: platform === "ios" ? "Debug iOS" : "Debug Android", + type: "reactnative", + request: "launch", + platform, + ...(target === undefined ? {} : { target }), + }); + + test("fills an empty target with the device key", function () { + const config = launch("android"); + assert.strictEqual(applySelectedDevice(config, pixel), undefined); + assert.strictEqual(config.target, "Pixel_7_API_34"); + }); + + test("fills simulator/device targets only when the device type matches", function () { + const simulator = launch("android", "simulator"); + applySelectedDevice(simulator, pixel); + assert.strictEqual(simulator.target, "Pixel_7_API_34"); + + const device = launch("android", "device"); + applySelectedDevice(device, phone); + assert.strictEqual(device.target, "R5CT"); + + const wrongType = launch("android", "simulator"); + assert.strictEqual(applySelectedDevice(wrongType, phone), undefined); + assert.strictEqual(wrongType.target, "simulator"); + + const wrongType2 = launch("ios", "device"); + applySelectedDevice(wrongType2, iphone); + assert.strictEqual(wrongType2.target, "device"); + }); + + test("never overrides an explicit target", function () { + const config = launch("android", "emulator-5556"); + assert.strictEqual(applySelectedDevice(config, pixel), undefined); + assert.strictEqual(config.target, "emulator-5556"); + assert.strictEqual( + applySelectedDevice(launch("android", "emulator-5556"), undefined), + undefined, + ); + }); + + test("returns a note on platform mismatch and leaves the config alone", function () { + const config = launch("ios"); + assert.strictEqual( + applySelectedDevice(config, pixel), + "Selected device 'Pixel_7_API_34' (android) doesn't match 'Debug iOS' (ios); using the default target selection.", + ); + assert.strictEqual(config.target, undefined); + }); + + test("returns a note when no device is selected", function () { + assert.strictEqual( + applySelectedDevice(launch("android"), undefined), + "No device selected; using the default target selection.", + ); + }); + + test("ignores attach requests and other platforms", function () { + const attach: vscode.DebugConfiguration = { ...launch("android"), request: "attach" }; + assert.strictEqual(applySelectedDevice(attach, pixel), undefined); + assert.strictEqual(attach.target, undefined); + + const expo = launch("exponent"); + assert.strictEqual(applySelectedDevice(expo, pixel), undefined); + assert.strictEqual(expo.target, undefined); + }); + }); + + suite("toAndroidDevices", function () { + test("keys running emulators by AVD name, phones by serial, and lists AVDs that are not running", function () { + const devices = toAndroidDevices( + [ + { id: "emulator-5554", isVirtual: true, name: "Pixel_7_API_34" }, + { id: "R5CT", isVirtual: false, name: "Pixel 8" }, + ], + ["Pixel_4_API_30", "Pixel_7_API_34"], + ); + assert.deepStrictEqual(devices, [pixel, phone, offlineAvd]); + }); + }); +}); + +suite("AndroidTargetManager.launchSimulator", function () { + test("passes -no-snapshot-load for a cold boot and resolves with the emulator serial", async function () { + this.timeout(5000); + const spawnStub = sinon.stub(ChildProcess.prototype, "spawn").returns({ + spawnedProcess: { unref: () => undefined }, + outcome: new Promise(() => undefined), + } as any); + const adbHelper = { + getOnlineTargets: async () => [ + { id: "emulator-5554", isOnline: true, isVirtualTarget: true }, + ], + getAvdNameById: async () => "Pixel_4_API_30", + } as unknown as AdbHelper; + try { + const target = await new AndroidTargetManager(adbHelper).launchSimulator( + { name: "Pixel_4_API_30", isOnline: false, isVirtualTarget: true }, + true, + ); + assert.deepStrictEqual(spawnStub.firstCall.args.slice(0, 2), [ + "emulator", + ["-avd", "Pixel_4_API_30", "-no-snapshot-load"], + ]); + assert.strictEqual(target.id, "emulator-5554"); + } finally { + spawnStub.restore(); + } + }); +}); diff --git a/test/extension/deviceStatusIndicator.test.ts b/test/extension/deviceStatusIndicator.test.ts deleted file mode 100644 index 5947a45c4..000000000 --- a/test/extension/deviceStatusIndicator.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for details. - -import assert = require("assert"); -import * as vscode from "vscode"; -import * as sinon from "sinon"; -import { DeviceStatusIndicator } from "../../src/extension/deviceStatusIndicator"; - -function makeFakeStatusBarItem(): vscode.StatusBarItem { - return { - id: "", - name: "", - text: "", - tooltip: undefined, - color: undefined, - backgroundColor: undefined, - command: undefined, - accessibilityInformation: undefined, - alignment: vscode.StatusBarAlignment.Left, - priority: undefined, - show: sinon.stub(), - hide: sinon.stub(), - dispose: sinon.stub(), - } as unknown as vscode.StatusBarItem; -} - -suite("DeviceStatusIndicator", function () { - suite("extensionContext", function () { - let createStatusBarItemStub: Sinon.SinonStub; - let fakeItem: vscode.StatusBarItem; - setup(() => { - fakeItem = makeFakeStatusBarItem(); - createStatusBarItemStub = sinon.stub(vscode.window, "createStatusBarItem"); - createStatusBarItemStub.returns(fakeItem); - }); - teardown(() => { - const instance = (DeviceStatusIndicator as any).instance; - if (instance) { - instance.dispose(); - } - createStatusBarItemStub.restore(); - }); - test("show should set correct icon and device name in text", function () { - DeviceStatusIndicator.show("Test Device"); - assert.strictEqual(fakeItem.text, "$(device-mobile) Test Device"); - }); - test("show should set correct tooltip with device name", function () { - DeviceStatusIndicator.show("Test Device"); - assert.strictEqual(fakeItem.tooltip, "Active debug target: Test Device"); - }); - test("show should call show on the status bar item", function () { - DeviceStatusIndicator.show("Test Device"); - assert.ok((fakeItem.show as Sinon.SinonStub).calledOnce); - }); - test("hide should call hide on the status bar item after show", function () { - DeviceStatusIndicator.show("Test Device"); - DeviceStatusIndicator.hide(); - assert.ok((fakeItem.hide as Sinon.SinonStub).calledOnce); - }); - test("hide before show should not throw", function () { - assert.doesNotThrow(() => DeviceStatusIndicator.hide()); - }); - test("show should reuse singleton instance across multiple calls", function () { - DeviceStatusIndicator.show("Device A"); - DeviceStatusIndicator.show("Device B"); - assert.strictEqual(createStatusBarItemStub.callCount, 1); - assert.strictEqual(fakeItem.text, "$(device-mobile) Device B"); - }); - test("dispose should clear the singleton instance", function () { - DeviceStatusIndicator.show("Test Device"); - const instance = (DeviceStatusIndicator as any).instance as DeviceStatusIndicator; - instance.dispose(); - assert.strictEqual((DeviceStatusIndicator as any).instance, undefined); - }); - }); -}); diff --git a/test/extension/rn-extension.test.ts b/test/extension/rn-extension.test.ts index 18cfe909f..969f0dc55 100644 --- a/test/extension/rn-extension.test.ts +++ b/test/extension/rn-extension.test.ts @@ -203,6 +203,7 @@ suite("rn-extension", function () { "reactNative.runMacOS", "reactNative.runWindows", "reactNative.selectAndInsertDebugConfiguration", + "reactNative.selectDevice", "reactNative.showDevMenu", "reactNative.startLogCatMonitor", "reactNative.startPackager",