| // 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 readonly 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); |
| }); |
| } |
| } |
| |
| interface SpawnOptions { |
| cwd?: string |
| } |
| |
| export enum FfxEventType { |
| ffxPathSet, |
| ffxPathReset, |
| }; |
| |
| export interface FfxInvocationEvent { |
| args: string[] |
| } |
| |
| /** |
| * 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 lastDefaultTarget: FuchsiaDevice | null = null; |
| |
| 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. 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 setTargetDevice(target: FuchsiaDevice | null) { |
| if (this.lastDefaultTarget === null || target?.nodeName !== this.lastDefaultTarget.nodeName) { |
| this.lastDefaultTarget = target; |
| this.defaultTargetEvent.fire(target); |
| } |
| } |
| |
| /** |
| * 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; |
| } |
| |
| /** |
| * 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, 1 * 60 * 1000); |
| } |
| |
| /** |
| * 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, 1 * 60 * 1000); |
| |
| // 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; |
| |
| 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) { |
| this.setTargetDevice(device); |
| } |
| } |
| } |
| this.deviceMap = newDeviceMap; |
| } catch (err) { |
| this.deviceMap = {}; |
| return new Promise<{ [key: string]: FuchsiaDevice }>((resolve, reject) => { |
| return reject(new Error(`unable to list devices: ${err}`)); |
| }); |
| } |
| |
| let keys = Object.keys(this.deviceMap); |
| if (keys.length === 1) { |
| this.setTargetDevice(this.deviceMap[keys[0]]); |
| } |
| logger.debug('Target list result:', 'ffx', this.deviceMap); |
| |
| return this.deviceMap; |
| } |
| |
| /** |
| * 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 log |
| * |
| * @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 runLog( |
| device: string | undefined, |
| args: string[], |
| onData: (data: Object) => void |
| ): FfxLog { |
| let ffxArgs = (device ? ['--target', device] : []); |
| ffxArgs.push('--machine', 'json', 'log'); |
| 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 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. |
| } |
| } |
| } |
| } |