blob: 81e5b3d0f68ff6384606cf831c67b152cc95ea3b [file] [log] [blame]
// 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.
}
}
}
}