| // Copyright 2022 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as vscode from 'vscode'; |
| import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; |
| import * as logger from './logger'; |
| // @ts-ignore: no @types/jsonparse available. |
| import JsonParser from 'jsonparse'; |
| import { DataStreamProcess } from './process'; |
| |
| /** |
| * FuchsiaDevice represents the object returned from `ffx target list`. |
| * |
| * Instances of this object are immutable. |
| */ |
| export class FuchsiaDevice { |
| private readonly _data: { [key: string]: any }; |
| public readonly nodeName: string; |
| public readonly connected: boolean; |
| public readonly serial: string; |
| public readonly targetType: string; |
| public readonly targetState: string; |
| public readonly addresses: Array<string>; |
| public readonly isFfxDefault: boolean = false; |
| |
| constructor(data: { [key: string]: any }) { |
| this._data = JSON.parse(JSON.stringify(data)); |
| this.nodeName = data['nodename'] ?? '<not set>!'; |
| this.connected = data['rcs_state'] === 'Y'; |
| this.serial = data['serial'] ?? ''; |
| this.targetType = data['target_type'] ?? ''; |
| this.targetState = data['target_state'] ?? ''; |
| this.isFfxDefault = !!data['is_default']; |
| this.addresses = new Array<string>(); |
| data['addresses']?.forEach((addr: string) => { |
| this.addresses.push(addr); |
| }); |
| } |
| |
| public static fromNodename(nodename: string, connected: boolean = false): FuchsiaDevice { |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| return new FuchsiaDevice({nodename, 'rcs_state': connected ? 'Y' : 'N'}); |
| } |
| |
| disconnected(): FuchsiaDevice { |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| return new FuchsiaDevice({...this._data, 'rcs_state': 'N'}); |
| } |
| } |
| |
| interface SpawnOptions { |
| cwd?: string |
| } |
| |
| export enum FfxEventType { |
| ffxPathSet, |
| ffxPathReset, |
| }; |
| |
| export interface FfxInvocationEvent { |
| args: string[] |
| } |
| |
| // TODO: Should we make this value configuable? We already have the configuration key |
| // `fuchsia.connectionTimeout`, but that value is 1000ms, and it's not clear how to |
| // reconcile these different timeouts. |
| const defaultTimeoutMillis = 1 * 60 * 1000; |
| |
| /** |
| * Ffx encapsulates running the ffx command line to interact with |
| * devices running Fuchsia. |
| */ |
| export class Ffx { |
| private spawnOptions: SpawnOptions; |
| private pathInternal: string | undefined; |
| private ffxPathChangedEvent: vscode.EventEmitter<FfxEventType>; |
| private targetChangedEvent = new vscode.EventEmitter<FuchsiaDevice | null>(); |
| private defaultTarget: FuchsiaDevice | null = null; |
| |
| onDidChangeConfiguration: vscode.Event<FfxEventType>; |
| onSetTarget: vscode.Event<FuchsiaDevice | null>; |
| |
| private readonly _onFfxInvocation = new vscode.EventEmitter<FfxInvocationEvent>(); |
| readonly onFfxInvocation = this._onFfxInvocation.event; |
| |
| constructor( |
| cwd: string | undefined, |
| ffxPath?: string, |
| ) { |
| this.spawnOptions = cwd ? { cwd: cwd } : {}; |
| this.pathInternal = ffxPath; |
| this.ffxPathChangedEvent = new vscode.EventEmitter<FfxEventType>(); |
| this.onDidChangeConfiguration = this.ffxPathChangedEvent.event; |
| this.onSetTarget = this.targetChangedEvent.event; |
| } |
| |
| /** |
| * Sets/resets the ffx tool path. Subscribers to 'onDidChangeConfiguration' will get notified. |
| * @param ffxPath full path to the ffx binary. |
| */ |
| public set path(ffxPath: string | undefined) { |
| this.pathInternal = ffxPath; |
| if (ffxPath === undefined) { |
| this.ffxPathChangedEvent.fire(FfxEventType.ffxPathReset); |
| } else { |
| this.ffxPathChangedEvent.fire(FfxEventType.ffxPathSet); |
| // This causes the target list to be refreshed using the instance |
| // of ffx being set. Any errors are just logged since the developer |
| // is not really expecting any errors, and if this is a recurring error, |
| // it will be surfaced elsewhere. |
| // Increase timeout since this member is often set at extension |
| // initialization time, which may take longer to run ffx commands. This |
| // setter side-effect isn't blocking anyways, so there's no real harm in |
| // letting it run longer. |
| this.refreshTargets(5000).catch(err => { |
| logger.warn(`Unable to refresh target list: ${err}`); |
| }); |
| } |
| } |
| |
| /** |
| * Get the path to the ffx tool. |
| * @returns the full path to ffx binary. |
| */ |
| public get path() { |
| return this.pathInternal; |
| } |
| |
| /** |
| * Checks if ffx path has been set. |
| * @returns true if the ffx path has been set. |
| */ |
| public hasValidFfxPath() { |
| return this.pathInternal !== undefined; |
| } |
| |
| /** |
| * Set the default fuchsia target for the IDE. Subscribers to 'onSetTarget' |
| * will get notified if the default target has been changed. |
| * Note: This is different than and decoupled from the ffx default target. |
| * |
| * @param target |
| */ |
| public set targetDevice(newTarget: FuchsiaDevice | null) { |
| const oldTarget = this.defaultTarget; |
| this.defaultTarget = newTarget; |
| |
| if ( |
| newTarget?.nodeName === oldTarget?.nodeName && newTarget?.connected === oldTarget?.connected |
| ) { |
| return; |
| } |
| |
| if (newTarget) { |
| this.targetChangedEvent.fire(newTarget); |
| } else { |
| this.targetChangedEvent.fire(null); |
| } |
| } |
| |
| /** |
| * Gets the default target device information. |
| * Guaranteed to be non-null if any devices are connected; see |
| * Ffx#findDefaultTarget to see default targets are resolved. |
| * |
| * Note: This is different than and decoupled from the ffx default target. |
| * |
| * @returns the target device used when running ffx commands. |
| * undefined if there is no default device configured. |
| */ |
| public get targetDevice(): FuchsiaDevice | null { |
| return this.defaultTarget; |
| } |
| |
| /** |
| * Start the emulator and make it the default target. |
| * |
| * @param headless whether the emulator should be started headless. |
| * @param name the name of the emulator instance. |
| * @returns A promise to track the execution of the steps. |
| */ |
| public async startEmulator(headless: boolean, name: string = 'fuchsia-emulator'): Promise<string> { |
| const headlessArg = headless ? ['--headless'] : []; |
| const result = await this.runFfx(['emu', 'start', '--name', name, ...headlessArg], defaultTimeoutMillis, null); |
| const deviceMap = await this.refreshTargets(); |
| if (name in deviceMap) { |
| this.targetDevice = deviceMap[name]; |
| } else { |
| logger.error(`couldn't discover ${name} after emulator was started`, 'ffx emu'); |
| } |
| return result; |
| } |
| |
| /** |
| * Stop the emulator and unset from default targets. |
| * |
| * @param headless whether the emulator should be started headless. |
| * @param name the name of the emulator instance. |
| * @returns A promise to track the execution of the steps. |
| */ |
| public async stopEmulator(name: string = 'fuchsia-emulator'): Promise<string> { |
| try { |
| const result = await this.runFfx(['emu', 'stop', name], defaultTimeoutMillis, null); |
| |
| // Switch to a different default target. |
| if (this.defaultTarget?.nodeName === name) { |
| this.defaultTarget = null; |
| } |
| |
| return result; |
| } finally { |
| await this.refreshTargets(); |
| } |
| } |
| |
| public listComponents(device?: FuchsiaDevice): Promise<Object> { |
| return this.runFfxJson(['component', 'list'], null, device?.nodeName); |
| } |
| |
| public listProcesses(device?: FuchsiaDevice): Promise<Object> { |
| return this.runFfxJson(['process', 'tree'], null, device?.nodeName); |
| } |
| |
| /** |
| * Powers off the target device. |
| * |
| * @param device the device to turn off, otherwise turns off the default device. |
| * @returns stdout of the command, or error contains the stderr. |
| */ |
| public async poweroffTarget(device?: FuchsiaDevice): Promise<string> { |
| const result = await this.runFfx(['target', 'off'], defaultTimeoutMillis, device?.nodeName); |
| |
| // Switch to a different default target. |
| if ((device || this.defaultTarget)?.nodeName === this.defaultTarget?.nodeName) { |
| this.defaultTarget = null; |
| } |
| |
| await this.refreshTargets(); |
| return result; |
| } |
| |
| /** |
| * Reboot target device and wait for it to become reachable. |
| * |
| * @param device the device to reboot, otherwise reboots the default device. |
| * @param timeout the timeout in seconds to wait for the device to become available. |
| * @returns stdout of the command, or error contains the stderr. |
| */ |
| public async rebootTarget(device?: FuchsiaDevice, timeout?: number): Promise<string> { |
| const result = await this.runFfx(['target', 'reboot'], defaultTimeoutMillis, device?.nodeName); |
| await this.waitForTarget(device, timeout); |
| return result; |
| } |
| |
| /** |
| * Waits for a target to become reachable. |
| * |
| * @param device name of the device to wait for. |
| * @param timeout the timeout in seconds to wait for the device to become available. |
| */ |
| public async waitForTarget( |
| device?: FuchsiaDevice, |
| timeout: number = 300, |
| ): Promise<FuchsiaDevice> { |
| // TODO(http://fxbug.dev/406324580): Use `ffx target wait` once it's more reliable |
| // instead of repeatedly querying `ffx target list`. |
| const sleep = (millis: number) => new Promise(resolve => setTimeout(resolve, millis)); |
| |
| const timeLimit = Date.now() + timeout * 1000; |
| for (let curr = timeLimit; curr <= timeLimit; curr = Date.now()) { |
| const targets = await this.refreshTargets().catch((err): {[key: string]: FuchsiaDevice} => { |
| logger.warn('Failed to query list of targets', 'Ffx.waitForTarget', err); |
| return {}; |
| }); |
| const target = (device ?? this.targetDevice)?.nodeName; |
| if (target !== undefined && targets[target]?.connected) { |
| return targets[target]; |
| } |
| await sleep(100); |
| } |
| throw new Error( |
| `Target "${(device ?? this.targetDevice)?.nodeName}" ` + |
| `failed to come online within ${timeout} seconds.` |
| ); |
| } |
| |
| /** |
| * Shows the unstructured data containing the list of properties |
| * returned by the target device. |
| * |
| * @param device the device to use, otherwise uses the default device. |
| * @returns stdout of the command, or error contains the stderr. |
| */ |
| public showTarget(device?: FuchsiaDevice): Promise<string> { |
| return this.runFfx(['target', 'show'], defaultTimeoutMillis, device && device.nodeName); |
| } |
| |
| /** |
| * Exports the device's snapshot to the current working directory. |
| * |
| * @param device the device to use, otherwise uses the default device. |
| * @returns the path to the snapshot. |
| */ |
| public async exportSnapshotToCWD(device?: FuchsiaDevice): Promise<string> { |
| let cwd = this.spawnOptions.cwd ?? './'; |
| await this.runFfx(['target', 'snapshot', '-d', cwd], defaultTimeoutMillis, device && device.nodeName); |
| return cwd; |
| } |
| |
| /** |
| * Gets a dictionary of target device names to FuchsiaDevice objects. |
| * |
| * Has the side-effect of updating the current default target if it |
| * disconnects. |
| * |
| * @returns map of string to FuchsiaDevice where the key is the device.nodeName. |
| */ |
| public async refreshTargets(timeoutMillis?: number): Promise<{ [key: string]: FuchsiaDevice }> { |
| const deviceMap: { [key: string]: FuchsiaDevice } = {}; |
| |
| if (this.path) { |
| timeoutMillis = timeoutMillis ?? vscode.workspace.getConfiguration().get('fuchsia.connectionTimeout'); |
| const devices = await this.runFfxJson(['target', 'list'], timeoutMillis, null); |
| |
| for (const obj of devices) { |
| const device = new FuchsiaDevice(obj); |
| deviceMap[device.nodeName] = device; |
| } |
| logger.debug('Target list result:', 'ffx', deviceMap); |
| } |
| |
| this.targetDevice = this.findDefaultTarget(deviceMap); |
| return deviceMap; |
| } |
| |
| /** |
| * Determine the IDE default device and if one is available return the |
| * recommended IDE default device. |
| * |
| * The IDE default device is determined by: |
| * 1. Use the current IDE default device and refresh its connected state. |
| * 2. Use the default device reported by ffx. |
| * 3. Use the first connected device reported by ffx. |
| * 4. Use any device reported by ffx. |
| * |
| * May mutate `deviceMap` by inserting a disconnected device. |
| * |
| * @param deviceMap All of the devices discovered by ffx. |
| * @returns IDE default device. |
| */ |
| private findDefaultTarget(deviceMap: { [key: string]: FuchsiaDevice }): FuchsiaDevice | null { |
| const discoveredDevices = Object.values(deviceMap); |
| |
| // Determine the default target in the following precedence: |
| // 1. Preserve the last selected device if ffx is still tracking it. |
| const sameDefaultDevice = () => discoveredDevices.find( |
| device => device.nodeName === this.defaultTarget?.nodeName |
| ); |
| |
| // 2. Preserve the last selected device and mark as disconnected. |
| const lostDefaultDevice = () => { |
| if (this.defaultTarget) { |
| const disconnectedDefaultTarget = this.defaultTarget.disconnected(); |
| deviceMap[this.defaultTarget.nodeName] = disconnectedDefaultTarget; |
| return disconnectedDefaultTarget; |
| } |
| return null; |
| }; |
| |
| // 3. Use the ffx default device. |
| const ffxDefaultDevice = () => discoveredDevices.find( |
| device => device.isFfxDefault |
| ); |
| |
| // 4. Fallback to the first connected device. |
| const firstConnectedDevice = () => discoveredDevices.find( |
| device => device.connected |
| ); |
| |
| // 5. Fallback to a remaining disconnected device. |
| const anyDiscoveredDevice = () => discoveredDevices[0]; |
| |
| return sameDefaultDevice() |
| ?? lostDefaultDevice() |
| ?? ffxDefaultDevice() |
| ?? firstConnectedDevice() |
| ?? anyDiscoveredDevice() |
| ?? null; |
| } |
| |
| /** |
| * Builds the ffx commandline including the path to ffx and the appropriate |
| * configuration flags as needed. Please use this method if a command line |
| * for ffx is needed outside this class. This allows all ffx invocations from the extension |
| * to be consistently formed and correctly identified for analytics. |
| * |
| * The args array is unchanged. |
| * |
| * This can be executed with: |
| * |
| * const cmd = ffx.buildCommandLine(myargs); |
| * spawn(cmd.cmd, cmd.args); |
| * |
| * @param args: command line args to ffx |
| * @param device the name of the target device to use for ffx. |
| * Defaults to `Ffx.targetDevice?.nodeName` if undefined. |
| * Use null to omit the `--target` arg, otherwise will throw an exception if |
| * a device can't be found. |
| * @returns object with cmd: the path to ffx, and args, the array of arguments. |
| */ |
| private buildCommandLine( |
| args: string[], |
| device?: string | null |
| ): { cmd: string, args: string[] } | undefined { |
| if (!this.path) { |
| return; |
| } |
| |
| // Default `device` to the default device's nodeName, if available. |
| if (device === undefined) { |
| if (!this.targetDevice) { |
| throw new Error(`Unable to resolve the default target for ffx command: ${args}`); |
| } |
| device = this.targetDevice.nodeName; |
| } |
| |
| const targetArg = device ? ['--target', device] : []; |
| return { cmd: this.path, args: ['--config', 'fuchsia.analytics.ffx_invoker=vscode-fuchsia', ...targetArg, ...args] }; |
| } |
| |
| /** |
| * Spawns a new process running ffx with the given arguments. |
| * |
| * @param args command line arguments to ffx. |
| * @param device the name of the target device to use for ffx. |
| * Defaults to `Ffx.targetDevice?.nodeName` if undefined. |
| * Use null to omit the `--target` arg, otherwise will throw an exception if |
| * a device can't be found. |
| * @returns The ChildProcess is returned or undefined if there was a problem. |
| */ |
| public runFfxStreaming( |
| args: string[], |
| device?: string | null |
| ): ChildProcessWithoutNullStreams | undefined { |
| const fullargs = this.buildCommandLine(args, device); |
| if (!fullargs) { |
| return undefined; |
| } |
| |
| this._onFfxInvocation.fire({ args: fullargs.args }); |
| logger.debug(`Running: ${fullargs.cmd}`, 'ffx', fullargs); |
| const process = spawn(fullargs.cmd, fullargs.args, { detached: true, ...this.spawnOptions }); |
| return process; |
| } |
| |
| /** |
| * Spawns a new process running ffx with the given arguments. |
| * |
| * @param args command line arguments to ffx. |
| * @param device the name of the target device to use for ffx. |
| * Defaults to `Ffx.targetDevice?.nodeName` if undefined. |
| * Use null to omit the `--target` arg, otherwise will throw an exception if |
| * a device can't be found. |
| * @returns The ChildProcess is returned or undefined if there was a problem. |
| */ |
| public runFfxAsync( |
| args: string[], |
| onData: (data: Buffer) => void, |
| onError: (data: Buffer) => void, |
| device?: string | null, |
| ): DataStreamProcess | undefined { |
| const process = this.runFfxStreaming(args, device); |
| if (!process) { |
| return; |
| } |
| return new DataStreamProcess(process, onData, onError); |
| } |
| |
| /** |
| * Runs ffx --target [device] --machine json with the given arguments. |
| * |
| * @param args additional arguments for the `ffx log` process. |
| * @param timeoutMillis timeout in ms indicating max time to allow ffx to run. |
| * @param device the name of the target device to use for ffx. |
| * Defaults to `Ffx.targetDevice?.nodeName` if undefined. |
| * Use null to omit the `--target` arg, otherwise will throw an exception if |
| * a device can't be found. |
| * @returns the data returned by the command parsed as JSON |
| */ |
| async runFfxJson( |
| args: string[], |
| timeoutMillis?: number | null, |
| device?: string | null, |
| ): Promise<any> { |
| const stdout = await this.runFfx(['--machine', 'json', ...args], timeoutMillis, device); |
| return JSON.parse(stdout); |
| } |
| |
| /** |
| * Runs ffx returning the stdout of the command. There is an optional timeout |
| * in milliseconds to set the maximum running time of ffx. This is used to avoid |
| * waiting too long when there is a communication error. |
| * |
| * @param args command line arguments to ffx |
| * @param timeoutMillis timeout in ms indicating max time to allow ffx to run. |
| * @param device the name of the target device to use for ffx. |
| * Defaults to `Ffx.targetDevice?.nodeName` if undefined. |
| * Use null to omit the `--target` arg, otherwise will throw an exception if |
| * a device can't be found. |
| * @returns stdout of the command, or Error containing stderr. |
| */ |
| runFfx(args: string[], timeoutMillis?: number | null, device?: string | null): Promise<string> { |
| return new Promise<string>((resolve, reject) => { |
| if (!this.path) { |
| logger.info('The ffx path was not found'); |
| // Don't show a message to the user, let the caller do that. |
| return reject(new Error('ffx command not found')); |
| } |
| |
| let cmd: ChildProcessWithoutNullStreams | undefined; |
| try { |
| cmd = this.runFfxStreaming(args, device); |
| } catch (e) { |
| return reject(e); |
| } |
| let hasTimedOut = false; |
| let timeout: NodeJS.Timeout | undefined = undefined; |
| if (timeoutMillis) { |
| logger.debug(`Waiting ${timeoutMillis} for result.`); |
| timeout = setTimeout(() => { |
| hasTimedOut = true; |
| cmd?.kill(); |
| }, timeoutMillis); |
| } |
| |
| let output = ''; |
| cmd?.stdout.on('data', (data) => { output += data; }); |
| |
| let errorOutput = ''; |
| cmd?.stderr.on('data', (data) => { errorOutput += data; }); |
| |
| // Keep track of the error state so that the 'close' event handler below won't handle the |
| // error again. |
| let hasError = false; |
| cmd?.on('error', err => { |
| logger.warn(`exit: ${err}`, 'ffx'); |
| hasError = true; |
| return reject(err); |
| }); |
| |
| // Listen to 'close' instead of 'exit' event to correctly capture the output of ffx. |
| // The 'exit' event may be emitted when the stdio streams of the child process are still |
| // open, while the 'close' event is emitted after the stdio streams of a child process |
| // have been closed. |
| // See https://nodejs.org/api/child_process.html#class-childprocess for more details. |
| cmd?.on('close', (code, signal) => { |
| if (!hasError) { |
| if (hasTimedOut) { |
| logger.debug(`Timeout: args = ${args}`, 'ffx'); |
| return reject(new Error('ffx command timeout')); |
| } else if (timeout) { |
| clearTimeout(timeout); |
| } |
| logger.debug(`exit: ${code}: ${signal}`, 'ffx'); |
| if (code === 0) { |
| return resolve(output); |
| } else { |
| return reject( |
| new Error(`ffx returned with non-zero exit code ${code}: ${errorOutput}`)); |
| } |
| } |
| }); |
| }); |
| } |
| } |