| // 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 { ToolFinder } from './tool_finder'; |
| |
| /** |
| * FuchsiaDevice represents the object returned from `ffx target list`. |
| */ |
| export class FuchsiaDevice { |
| public readonly nodeName: string; |
| // Maybe rcsState should be bool? |
| public rcsState: string; |
| public readonly serial: string; |
| public readonly targetType: string; |
| public readonly targetState: string; |
| public readonly addresses: Array<string>; |
| public isDefault: boolean = false; |
| |
| constructor(data: { [key: string]: any }) { |
| this.nodeName = data['nodename'] ?? '<not set>!'; |
| this.rcsState = data['rcs_state'] ?? '?'; |
| this.serial = data['serial'] ?? ''; |
| this.targetType = data['target_type'] ?? ''; |
| this.targetState = data['target_state'] ?? ''; |
| this.isDefault = data['is_default'] ?? ''; |
| this.addresses = new Array<string>(); |
| data['addresses']?.forEach((addr: string) => { |
| this.addresses.push(addr); |
| }); |
| } |
| |
| clone() { |
| // Do not use JSON.parse(JSON.stringify(obj)) to deepcopy, it creates problems like losing the |
| // ability to utilize methods. |
| return new FuchsiaDevice({ |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| 'nodename': this.nodeName, 'rcs_state': this.rcsState, 'serial': this.serial, |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| 'target_type': this.targetType, 'target_state': this.targetState, |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| 'is_default': this.isDefault, |
| }); |
| } |
| |
| disconnect() { this.rcsState = 'N'; } |
| public get connected() { return this.rcsState === 'Y'; } |
| } |
| |
| 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 deviceMap: { [key: string]: FuchsiaDevice }; |
| private ffxEvent: vscode.EventEmitter<FfxEventType>; |
| private defaultTargetEvent = new vscode.EventEmitter<FuchsiaDevice | null>(); |
| private lastDefaultDevice?: FuchsiaDevice; |
| // Have we ever used a default device for the IDE? If not and a candidate is available, set it as |
| // default automatically. |
| private hasEverSetDefaultDevice = false; |
| |
| onDidChangeConfiguration: vscode.Event<FfxEventType>; |
| onSetTarget: vscode.Event<FuchsiaDevice | null>; |
| toolFinder: ToolFinder | undefined; |
| |
| private readonly _onFfxInvocation = new vscode.EventEmitter<FfxInvocationEvent>(); |
| readonly onFfxInvocation = this._onFfxInvocation.event; |
| |
| constructor(cwd: string | undefined, ffxPath?: string, |
| toolFinder?: ToolFinder) { |
| this.spawnOptions = cwd ? { cwd: cwd } : {}; |
| this.pathInternal = ffxPath; |
| this.deviceMap = {}; |
| this.ffxEvent = new vscode.EventEmitter<FfxEventType>(); |
| this.onDidChangeConfiguration = this.ffxEvent.event; |
| this.onSetTarget = this.defaultTargetEvent.event; |
| this.toolFinder = toolFinder; |
| } |
| |
| /** |
| * Set the default fuchsia target for the IDE. Subscribers to 'onSetTarget' will get notified if |
| * the default target has been changed. |
| * |
| * This is not a setter for the targetDeviceProperty since it should only be called by this |
| * class, and not public. Typescript does not allow for different visibility for getters |
| * |
| * and setters for a property. |
| * @param target |
| */ |
| private setDefaultDeviceIDE(target?: FuchsiaDevice) { |
| if (target !== this.lastDefaultDevice) { |
| if (target) { |
| this.hasEverSetDefaultDevice = true; |
| // Make a deep copy of device so we can compare changes even on the same device. |
| this.lastDefaultDevice = target.clone(); |
| this.defaultTargetEvent.fire(target); |
| } else { |
| this.defaultTargetEvent.fire(null); |
| } |
| } |
| } |
| |
| /** |
| * Gets the target device information. |
| * @returns the target device used when running ffx commands. |
| * undefined if there is no default device configured. |
| */ |
| public get targetDevice(): FuchsiaDevice | null { |
| |
| for (let key in this.deviceMap) { |
| let device = this.deviceMap[key]; |
| if (device.isDefault) { |
| return device; |
| } |
| } |
| return null; |
| } |
| |
| /** |
| * 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.ffxEvent.fire(FfxEventType.ffxPathReset); |
| } else { |
| this.ffxEvent.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. |
| this.getTargetList().catch(err => { logger.warn(`Unable to refresh target list: ${err}`); }); |
| } |
| } |
| |
| /** |
| * Checks if ffx path has been set. |
| * @returns true if the ffx path has been set. |
| */ |
| public hasValidFfxPath() { |
| return this.pathInternal !== undefined; |
| } |
| |
| /** |
| * Get the path to the ffx tool. |
| * @returns the full path to ffx binary. |
| */ |
| public get path() { |
| return this.pathInternal; |
| } |
| |
| /** |
| * Start the emulator. |
| * @returns stdout of the command, or error contains the stderr. |
| */ |
| public startEmulator(): Promise<string> { |
| return this.runFfx(['emu', 'start'], defaultTimeoutMillis); |
| } |
| |
| /** |
| * Start the emulator. |
| * @returns stdout of the command, or error contains the stderr. |
| */ |
| public stopEmulator(): Promise<string> { |
| return this.runFfx(['emu', 'stop'], defaultTimeoutMillis); |
| } |
| |
| public listComponents(device?: FuchsiaDevice): Promise<Object> { |
| return this.runFfxJson(device?.nodeName, ['component', 'list']); |
| } |
| |
| /** |
| * Send simple command to target, for example `off`, `reboot`, etc. |
| * @param device to apply the command to, otherwise send to default. |
| * @param command to send to target |
| * @returns stdout of the command, or error containing the stderr. |
| */ |
| sendTargetCommand(device: FuchsiaDevice | undefined, ...command: string[]): Promise<string> { |
| let args: string[] = []; |
| if (device) { |
| args.push('--target', device.nodeName); |
| } |
| args.push('target', ...command); |
| |
| return this.runFfx(args, defaultTimeoutMillis); |
| } |
| |
| /** |
| * 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 poweroffTarget(device?: FuchsiaDevice): Promise<string> { |
| return this.sendTargetCommand(device, 'off'); |
| } |
| |
| /** |
| * Reboot target device. |
| * @param device the device to reboot,otherwise reboots the default device. |
| * |
| * @returns stdout of the command, or error contains the stderr. |
| */ |
| public rebootTarget(device?: FuchsiaDevice): Promise<string> { |
| return this.sendTargetCommand(device, 'reboot'); |
| } |
| |
| /** |
| * 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.sendTargetCommand(device, 'show'); |
| } |
| |
| /** |
| * Exports the device's snapshot to the current working directory. |
| * @param device |
| * |
| * @returns the path to the snapshot. |
| */ |
| public async exportSnapshotToCWD(device?: FuchsiaDevice): Promise<string> { |
| let cwd = this.spawnOptions.cwd ?? './'; |
| await this.sendTargetCommand(device, 'snapshot', '-d', cwd); |
| return cwd; |
| } |
| |
| /** |
| * Sets the specified device to be the default target device. This is only |
| * necessary if there are multiple devices. |
| * |
| * @param device the device to use, otherwise uses the default device. |
| * |
| * @returns stdout of the command, or error contains the stderr. |
| */ |
| public async defaultTarget(device: FuchsiaDevice): Promise<string> { |
| let args = []; |
| if (!device) { |
| return new Promise<string>((resolve, reject) => { |
| return reject(new Error('device name not known')); |
| }); |
| } |
| |
| args.push('target', 'default', 'set', device.nodeName); |
| |
| // Invoke ffx and await the completion. This is done so when |
| // the target list is refreshed, it will contain the expected |
| // default target. |
| const result = await this.runFfx(args, defaultTimeoutMillis); |
| |
| // Refresh the default target. |
| // 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. |
| this.getTargetList().catch(err => { |
| logger.info(`Unable to refresh target list: ${err}`); |
| }); |
| return result; |
| } |
| |
| /** |
| * Gets a dictionary of target device names to FuchsiaDevice objects. |
| * |
| * This also sets the flag indicating which device if set as the default, |
| * if any. |
| * |
| * @returns map of string to FuchsiaDevice where the key is the device.nodeName. |
| */ |
| public async getTargetList(): Promise<{ [key: string]: FuchsiaDevice }> { |
| |
| let args = ['--machine', 'json', 'target', 'list']; |
| let output: string; |
| |
| let defaultDeviceFFX: FuchsiaDevice | undefined = undefined; |
| // deviceCandidate help us set the default device if no default device has been set for the |
| // IDE. At the moment, this is chosen as the first device that we can connect to. |
| let deviceCandidate: FuchsiaDevice | undefined = undefined; |
| try { |
| const timeoutMillis: number | undefined = vscode.workspace.getConfiguration().get('fuchsia.connectionTimeout'); |
| output = await this.runFfx(args, timeoutMillis); |
| |
| let newDeviceMap: { [key: string]: FuchsiaDevice } = {}; |
| if (output && output.length > 0) { |
| for (let obj of JSON.parse(output)) { |
| let device = new FuchsiaDevice(obj); |
| newDeviceMap[device.nodeName] = device; |
| if (device.isDefault) { |
| defaultDeviceFFX = device; |
| } |
| // Find the first suitable default device candidate. |
| if (!deviceCandidate && device.connected) { |
| deviceCandidate = device; |
| } |
| } |
| } |
| this.deviceMap = newDeviceMap; |
| } catch (err) { |
| return new Promise<{ [key: string]: FuchsiaDevice }>((resolve, reject) => { |
| return reject(new Error(`unable to list devices: ${err}`)); |
| }); |
| } |
| |
| let defaultDevice = await this.findDefaultDeviceIDE(defaultDeviceFFX, deviceCandidate); |
| this.setDefaultDeviceIDE(defaultDevice); |
| logger.debug('Target list result:', 'ffx', this.deviceMap); |
| |
| return this.deviceMap; |
| } |
| |
| /** |
| * Find the IDE default device and if appropriate set the ffx default device. |
| * @param defaultDeviceFFX ffx default device. |
| * @param deviceCandidate cadidate to set as default or undefined. |
| * @returns IDE default device. |
| */ |
| private async findDefaultDeviceIDE(defaultDeviceFFX?: FuchsiaDevice, |
| deviceCandidate?: FuchsiaDevice): Promise<FuchsiaDevice | undefined> { |
| // first, check if we have a default device. |
| if (defaultDeviceFFX) { |
| return defaultDeviceFFX; |
| } |
| |
| // then if not, check if we're missing a last default device |
| // (in which case we want to try and use our new candidate, if appropriate |
| // & present) |
| if (!this.lastDefaultDevice) { |
| if (this.hasEverSetDefaultDevice || !deviceCandidate) { return undefined; } |
| await this.defaultTarget(deviceCandidate); // Make the default device change permanent. |
| return deviceCandidate; |
| } |
| |
| // if we do have a last default device, check if it exists (in which case, |
| // assume the user has meant to un-default it) |
| if (this.lastDefaultDevice.nodeName in this.deviceMap) { return undefined; } |
| |
| // otherwise, put it back in the device map but mark it as disconnected |
| // -- we assume it still physically exists, but ffx doesn't know about it |
| // any more... |
| const defaultName = this.lastDefaultDevice.nodeName; |
| let device = this.lastDefaultDevice.clone(); |
| device.disconnect(); |
| this.deviceMap[defaultName] = device; |
| return device; |
| } |
| |
| /** |
| * 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 |
| * @returns object with cmd: the path to ffx, and args, the array of arguments. |
| */ |
| private buildCommandLine(args: string[]): { cmd: string, args: string[] } | undefined { |
| if (this.path === undefined) { |
| return undefined; |
| } |
| return { cmd: this.path, args: ['--config', 'fuchsia.analytics.ffx_invoker=vscode-fuchsia', ...args] }; |
| } |
| |
| /** |
| * Runs ffx --target [device] --machine json with the given arguments. |
| * |
| * @param device the name of the target device to use on ffx log |
| * @param args additional arguments for the `ffx log` process. |
| * @param onData when data is received this command will be called |
| * @returns the ffx log process |
| */ |
| public runFfxJsonStreaming( |
| device: string | undefined, |
| args: string[], |
| onData: (data: Object) => void |
| ): FfxLog { |
| let ffxArgs = (device ? ['--target', device] : []); |
| ffxArgs.push('--machine', 'json'); |
| ffxArgs.push(...args); |
| return new JsonStreamProcess( |
| this.runFfxStreaming(ffxArgs), |
| onData, |
| (data) => { |
| logger.warn(`Error [${this.path} ${ffxArgs}]: ${data}`); |
| }); |
| } |
| |
| /** |
| * Spawns a new process running ffx with the given arguments. |
| * |
| * @param args command line arguments to ffx. |
| * @returns The ChildProcess is returned or undefined if there was a problem. |
| */ |
| public runFfxStreaming(args: string[]): ChildProcessWithoutNullStreams | undefined { |
| const fullargs = this.buildCommandLine(args); |
| 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; |
| } |
| |
| /** |
| * Runs ffx --target [device] --machine json with the given arguments. |
| * |
| * @param device the name of the target device to use on ffx log |
| * @param args additional arguments for the `ffx log` process. |
| * @returns the data returned by the command parsed as JSON |
| */ |
| public async runFfxJson( |
| device: string | undefined, |
| args: string[], |
| ): Promise<Object> { |
| let ffxArgs = (device ? ['--target', device] : []); |
| ffxArgs.push('--machine', 'json'); |
| ffxArgs.push(...args); |
| let data = await this.runFfx(ffxArgs); |
| return JSON.parse(data); |
| } |
| |
| /** |
| * 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. |
| * @returns stdout of the command, or Error containing stderr. |
| */ |
| runFfx(args: string[], timeoutMillis?: number): 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')); |
| } |
| |
| const cmd = this.runFfxStreaming(args); |
| 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}`)); |
| } |
| } |
| }); |
| }); |
| } |
| } |
| |
| export type FfxLog = JsonStreamProcess; |
| |
| /** |
| * A stream of JSON Data coming from a process. |
| */ |
| export class JsonStreamProcess { |
| private parser: JsonParser; |
| |
| constructor( |
| private process: ChildProcessWithoutNullStreams | undefined, |
| private onData: (data: Object) => void, |
| private onError: (data: string) => void, |
| ) { |
| this.parser = new JsonParser(); |
| this.process?.stdout.on('data', (chunk) => { |
| this.parser.write(chunk); |
| }); |
| this.process?.stderr.on('data', this.onError); |
| let self = this; |
| this.parser.onValue = function (value: Object) { |
| if (this.stack.length === 0) { |
| self.onData(value); |
| } |
| }; |
| } |
| |
| /** |
| * Kills the underlying process and all its children. |
| */ |
| public stop() { |
| const pid = this.process?.pid; |
| if (pid) { |
| try { |
| // This handles the in-tree use case where `ffx` launches a subprocess `fx ffx`. |
| process.kill(-pid); |
| } catch { |
| // If the process with the given `pid` didn't exist, this error will rise, which we can |
| // just swallow. |
| } |
| } |
| } |
| } |