blob: 97599d177cfc6d302843da1035cad2466c488d37 [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';
// @ts-ignore: no @types/jsonparse available.
import * as JsonParser from 'jsonparse';
/**
* FuchsiaDevice represents the object returned from `ffx target list`.
*/
export class FuchsiaDevice {
private nodeNameData: string;
// Maybe rcsState should be bool?
private rcsStateData: string;
private serialNumberData: string;
private targetTypeData: string;
private targetStateData: string;
private addressesData: Array<string>;
private isDefaultData: boolean = false;
constructor(data: { [key: string]: any }) {
this.nodeNameData = data['nodename'] ?? '<not set>!';
this.rcsStateData = data['rcs_state'] ?? '?';
this.serialNumberData = data['serial'] ?? '';
this.targetTypeData = data['target_type'] ?? '';
this.targetStateData = data['target_state'] ?? '';
this.isDefaultData = data['is_default'] ?? '';
this.addressesData = new Array<string>();
data['addresses']?.forEach((addr: string) => {
this.addressesData.push(addr);
});
}
public get nodeName() {
return this.nodeNameData;
}
public get rcsState() {
return this.rcsStateData;
}
public get serial() {
return this.serialNumberData;
}
public get targetType() {
return this.targetTypeData;
}
public get targetState() {
return this.targetStateData;
}
public get addresses() {
return this.addressesData;
}
public isDefault() {
return this.isDefaultData;
}
public setDefault(flag: boolean) {
this.isDefaultData = flag;
}
}
interface SpawnOptions {
cwd?: string
}
export enum FfxEventType {
ffxPathSet,
ffxPathReset,
};
/**
* Ffx encapsulates running the ffx command line to interact with
* devices running Fuchsia.
*/
export class Ffx {
private log: vscode.OutputChannel;
private spawnOptions: SpawnOptions;
private ffxPath: string | undefined;
private deviceMap: { [key: string]: FuchsiaDevice };
private ffxEvent: vscode.EventEmitter<FfxEventType>;
private defaultTargetEvent = new vscode.EventEmitter<FuchsiaDevice>();
private lastDefaultTarget: FuchsiaDevice | null = null;
onDidChangeConfiguration: vscode.Event<FfxEventType>;
onSetTarget: vscode.Event<FuchsiaDevice>;
constructor(log: vscode.OutputChannel, cwd: string | undefined, ffxPath?: string) {
this.log = log;
this.spawnOptions = cwd ? { cwd: cwd } : {};
this.ffxPath = ffxPath;
this.deviceMap = {};
this.ffxEvent = new vscode.EventEmitter<FfxEventType>();
this.onDidChangeConfiguration = this.ffxEvent.event;
this.onSetTarget = this.defaultTargetEvent.event;
}
// Note: not static because it saves the state from previous tests & conflicts with running
// instance.
private setDefaultTarget(target: FuchsiaDevice) {
if (this.lastDefaultTarget === null || target.nodeName !== this.lastDefaultTarget.nodeName) {
this.lastDefaultTarget = target;
this.defaultTargetEvent.fire(target);
}
}
public setFfxPath(ffxPath: string | undefined) {
this.ffxPath = ffxPath;
if (ffxPath === undefined) {
this.ffxEvent.fire(FfxEventType.ffxPathReset);
} else {
this.ffxEvent.fire(FfxEventType.ffxPathSet);
this.getTargetList();
}
}
public hasValidFfxPath() {
return this.ffxPath !== undefined;
}
public getFfxPath() {
return this.ffxPath;
}
/**
* 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> {
let args = [];
if (device) {
args.push('--target', device.nodeName);
}
args.push('target', 'reboot');
return this.runFfx(args, 1 * 60 * 1000);
}
/**
* 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> {
let args = [];
if (device) {
args.push('--target', device.nodeName);
}
args.push('target', 'show');
return this.runFfx(args, 1 * 60 * 1000);
}
/**
* 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 defaultTarget(device: FuchsiaDevice): Promise<string> {
let args = [];
if (!device) {
return new Promise<string>((resolve, reject) => {
return reject('device name not known');
});
}
args.push('target', 'default', 'set', device.nodeName);
return this.runFfx(args, 1 * 60 * 1000).then((result): string => {
// indirectly refresh the default target.
this.getTargetList();
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 }> {
// TODO(fxbug.dev/94633): change back to --machine json target list.
let args = ['target', 'list', '--format', 'json'];
let output = await this.runFfx(args);
if (output && output.length > 0) {
for (let obj of JSON.parse(output)) {
let device = new FuchsiaDevice(obj);
this.deviceMap[device.nodeName] = device;
if (device.isDefault()) {
this.setDefaultTarget(device);
}
}
}
let keys = Object.keys(this.deviceMap);
if (keys.length === 1) {
this.setDefaultTarget(this.deviceMap[keys[0]]);
}
this.log.appendLine(JSON.stringify(this.deviceMap));
return this.deviceMap;
}
/**
* Runs ffx --target [device] --machine json log
*
* @param device the name of the target device to use on ffx log
* @param onData when data is received this command will be called
* @returns the ffx log process
*/
public runLog(device: string | undefined, onData: (data: Object) => void): FfxLog {
let args = (device)? ['--target', device] : [];
args = args.concat(['--machine', 'json', 'log']);
let cmd = `${this.ffxPath} ${args.join(' ')}`;
return new FfxJsonStream(
this.spawn(args),
onData,
(data) => {
this.log.appendLine(`Error [${cmd}]: ${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.
*/
spawn(args: string[]): ChildProcessWithoutNullStreams | undefined {
if (this.ffxPath === undefined) {
return undefined;
}
this.log.appendLine(`Running: ${this.ffxPath} ${args.join(' ')}`);
const process = spawn(this.ffxPath, args, 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.ffxPath) {
this.log.appendLine('The ffx path was not found');
return reject('ffx command not found');
}
this.log.append(`Runing: ${this.ffxPath} ${args}`);
const cmd = spawn(this.ffxPath, args, this.spawnOptions);
if (timeoutMillis) {
this.log.appendLine(` with timeout = ${timeoutMillis}ms`);
setTimeout(() => { cmd.kill(); }, timeoutMillis);
} else {
this.log.appendLine('');
}
let output = '';
cmd.stdout.on('data', (data) => { output += data; });
let errorOutput = '';
cmd.stderr.on('data', (data) => { errorOutput += data; });
cmd.on('error', err => {
this.log.appendLine(`exit: ${err}`);
return reject(`Error: ${err}`);
});
cmd.on('exit', (code, signal) => {
this.log.appendLine(`exit: ${code}: ${signal}`);
if (code === 0) {
return resolve(output);
} else {
return reject(`Error ${code}: ${errorOutput}`);
}
});
});
}
}
export type FfxLog = JsonStreamProcess;
/**
* A stream of JSON Data coming from a process.
*/
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.
*/
public stop() {
this.process?.kill();
}
}