| // 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(); |
| } |
| } |