init
This commit is contained in:
commit
7ec901885d
13 changed files with 769 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
.parcel-cache
|
||||
.yarn/
|
||||
dist/
|
||||
node_modules
|
||||
yarn.lock
|
20
.prettierrc.json
Normal file
20
.prettierrc.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 200,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "consistent",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"singleAttributePerLine": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "none",
|
||||
"useTabs": true,
|
||||
"vueIndentScriptAndStyle": false
|
||||
}
|
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
|
@ -0,0 +1 @@
|
|||
nodeLinker: node-modules
|
19
LICENSE
Normal file
19
LICENSE
Normal file
|
@ -0,0 +1,19 @@
|
|||
Copyright 2023-2024 Lily Tsuru/modeco80 <lily.modeco80@protonmail.ch>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# superqemu
|
||||
|
||||
A QEMU supervision library for Node.js
|
25
examples/simple.js
Normal file
25
examples/simple.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
// A simple example of how to use superqemu.
|
||||
// Note that this example requires a valid desktop environment to function
|
||||
// due to `-display gtk`, but you can remove it and run it headless.
|
||||
|
||||
import { QemuVM } from "../dist/index.js";
|
||||
|
||||
import pino from 'pino';
|
||||
|
||||
let logger = pino();
|
||||
|
||||
let vm = new QemuVM(
|
||||
{
|
||||
id: "testvm",
|
||||
command: "qemu-system-x86_64 -M pc,hpet=off,accel=kvm -cpu host -m 512 -display gtk",
|
||||
snapshot: true
|
||||
}
|
||||
);
|
||||
|
||||
vm.on('statechange', (newState) => {
|
||||
logger.info(`state changed to ${newState}`);
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await vm.Start();
|
||||
})();
|
39
package.json
Normal file
39
package.json
Normal file
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "@computenewb/superqemu",
|
||||
"version": "1.0.0",
|
||||
"description": "A simple and easy to use QEMU supervision runtime for Node.js",
|
||||
"exports": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./dist",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "parcel build src/index.ts --target node --target types"
|
||||
},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"targets": {
|
||||
"types": {},
|
||||
"node": {
|
||||
"context": "node",
|
||||
"isLibrary": true,
|
||||
"outputFormat": "esmodule"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@computernewb/nodejs-rfb": "^0.3.0",
|
||||
"execa": "^8.0.1",
|
||||
"pino": "^9.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@parcel/packager-ts": "2.12.0",
|
||||
"@parcel/transformer-typescript-types": "2.12.0",
|
||||
"@types/node": "^20.14.10",
|
||||
"parcel": "^2.12.0",
|
||||
"pino-pretty": "^11.2.1",
|
||||
"typescript": ">=3.0.0"
|
||||
},
|
||||
"packageManager": "yarn@4.1.1"
|
||||
}
|
150
src/QemuDisplay.ts
Normal file
150
src/QemuDisplay.ts
Normal file
|
@ -0,0 +1,150 @@
|
|||
import { VncClient } from '@computernewb/nodejs-rfb';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Size, Rect, Clamp, BatchRects } from './QemuUtil.js';
|
||||
|
||||
const kQemuFps = 60;
|
||||
|
||||
export type VncRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// events:
|
||||
//
|
||||
// 'resize' -> (w, h) -> done when resize occurs
|
||||
// 'rect' -> (x, y, ImageData) -> framebuffer
|
||||
// 'frame' -> () -> done at end of frame
|
||||
|
||||
export class QemuDisplay extends EventEmitter {
|
||||
private displayVnc = new VncClient({
|
||||
debug: false,
|
||||
fps: kQemuFps,
|
||||
|
||||
encodings: [
|
||||
VncClient.consts.encodings.raw,
|
||||
|
||||
//VncClient.consts.encodings.pseudoQemuAudio,
|
||||
VncClient.consts.encodings.pseudoDesktopSize
|
||||
// For now?
|
||||
//VncClient.consts.encodings.pseudoCursor
|
||||
]
|
||||
});
|
||||
|
||||
private vncShouldReconnect: boolean = false;
|
||||
private vncSocketPath: string;
|
||||
|
||||
constructor(socketPath: string) {
|
||||
super();
|
||||
|
||||
this.vncSocketPath = socketPath;
|
||||
|
||||
this.displayVnc.on('connectTimeout', () => {
|
||||
this.Reconnect();
|
||||
});
|
||||
|
||||
this.displayVnc.on('authError', () => {
|
||||
this.Reconnect();
|
||||
});
|
||||
|
||||
this.displayVnc.on('disconnect', () => {
|
||||
this.Reconnect();
|
||||
});
|
||||
|
||||
this.displayVnc.on('closed', () => {
|
||||
this.Reconnect();
|
||||
});
|
||||
|
||||
this.displayVnc.on('firstFrameUpdate', () => {
|
||||
// apparently this library is this good.
|
||||
// at least it's better than the two others which exist.
|
||||
this.displayVnc.changeFps(kQemuFps);
|
||||
this.emit('connected');
|
||||
|
||||
this.emit('resize', { width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight });
|
||||
//this.emit('rect', { x: 0, y: 0, width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight });
|
||||
this.emit('frame');
|
||||
});
|
||||
|
||||
this.displayVnc.on('desktopSizeChanged', (size: Size) => {
|
||||
this.emit('resize', size);
|
||||
});
|
||||
|
||||
let rects: Rect[] = [];
|
||||
|
||||
this.displayVnc.on('rectUpdateProcessed', (rect: Rect) => {
|
||||
rects.push(rect);
|
||||
});
|
||||
|
||||
this.displayVnc.on('frameUpdated', (fb: Buffer) => {
|
||||
// use the cvmts batcher
|
||||
let batched = BatchRects(this.Size(), rects);
|
||||
this.emit('rect', batched);
|
||||
|
||||
// unbatched (watch the performace go now)
|
||||
//for(let rect of rects)
|
||||
// this.emit('rect', rect);
|
||||
|
||||
rects = [];
|
||||
|
||||
this.emit('frame');
|
||||
});
|
||||
}
|
||||
|
||||
private Reconnect() {
|
||||
if (this.displayVnc.connected) return;
|
||||
|
||||
if (!this.vncShouldReconnect) return;
|
||||
|
||||
// TODO: this should also give up after a max tries count
|
||||
// if we fail after max tries, emit a event
|
||||
|
||||
this.displayVnc.connect({
|
||||
path: this.vncSocketPath
|
||||
});
|
||||
}
|
||||
|
||||
Connect() {
|
||||
this.vncShouldReconnect = true;
|
||||
this.Reconnect();
|
||||
}
|
||||
|
||||
Disconnect() {
|
||||
this.vncShouldReconnect = false;
|
||||
this.displayVnc.disconnect();
|
||||
|
||||
// bye bye!
|
||||
this.displayVnc.removeAllListeners();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
Connected() {
|
||||
return this.displayVnc.connected;
|
||||
}
|
||||
|
||||
Buffer(): Buffer {
|
||||
return this.displayVnc.fb;
|
||||
}
|
||||
|
||||
Size(): Size {
|
||||
if (!this.displayVnc.connected)
|
||||
return {
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
|
||||
return {
|
||||
width: this.displayVnc.clientWidth,
|
||||
height: this.displayVnc.clientHeight
|
||||
};
|
||||
}
|
||||
|
||||
MouseEvent(x: number, y: number, buttons: number) {
|
||||
if (this.displayVnc.connected) this.displayVnc.sendPointerEvent(Clamp(x, 0, this.displayVnc.clientWidth), Clamp(y, 0, this.displayVnc.clientHeight), buttons);
|
||||
}
|
||||
|
||||
KeyboardEvent(keysym: number, pressed: boolean) {
|
||||
if (this.displayVnc.connected) this.displayVnc.sendKeyEvent(keysym, pressed);
|
||||
}
|
||||
}
|
55
src/QemuUtil.ts
Normal file
55
src/QemuUtil.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
export type Size = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type Rect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function Clamp(input: number, min: number, max: number) {
|
||||
return Math.min(Math.max(input, min), max);
|
||||
}
|
||||
|
||||
export function BatchRects(size: Size, rects: Array<Rect>): Rect {
|
||||
var mergedX = size.width;
|
||||
var mergedY = size.height;
|
||||
var mergedHeight = 0;
|
||||
var mergedWidth = 0;
|
||||
|
||||
// can't batch these
|
||||
if (rects.length == 0) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: size.width,
|
||||
height: size.height
|
||||
};
|
||||
}
|
||||
|
||||
if (rects.length == 1) {
|
||||
if (rects[0].width == size.width && rects[0].height == size.height) {
|
||||
return rects[0];
|
||||
}
|
||||
}
|
||||
|
||||
rects.forEach((r) => {
|
||||
if (r.x < mergedX) mergedX = r.x;
|
||||
if (r.y < mergedY) mergedY = r.y;
|
||||
});
|
||||
|
||||
rects.forEach((r) => {
|
||||
if (r.height + r.y - mergedY > mergedHeight) mergedHeight = r.height + r.y - mergedY;
|
||||
if (r.width + r.x - mergedX > mergedWidth) mergedWidth = r.width + r.x - mergedX;
|
||||
});
|
||||
|
||||
return {
|
||||
x: mergedX,
|
||||
y: mergedY,
|
||||
width: mergedWidth,
|
||||
height: mergedHeight
|
||||
};
|
||||
}
|
277
src/QemuVM.ts
Normal file
277
src/QemuVM.ts
Normal file
|
@ -0,0 +1,277 @@
|
|||
import { execaCommand, ExecaChildProcess } from 'execa';
|
||||
import { EventEmitter } from 'events';
|
||||
import { QmpClient, IQmpClientWriter, QmpEvent } from './QmpClient.js';
|
||||
import { QemuDisplay } from './QemuDisplay.js';
|
||||
import { unlink } from 'node:fs/promises';
|
||||
|
||||
import pino from 'pino';
|
||||
import { Readable, Writable } from 'stream';
|
||||
|
||||
export enum VMState {
|
||||
Stopped,
|
||||
Starting,
|
||||
Started,
|
||||
Stopping
|
||||
}
|
||||
|
||||
export type QemuVmDefinition = {
|
||||
id: string;
|
||||
command: string;
|
||||
snapshot: boolean;
|
||||
};
|
||||
|
||||
/// Temporary path base (for UNIX sockets/etc.)
|
||||
const kVmTmpPathBase = `/tmp`;
|
||||
|
||||
// writer implementation for process standard I/O
|
||||
class StdioWriter implements IQmpClientWriter {
|
||||
stdout;
|
||||
stdin;
|
||||
client;
|
||||
|
||||
constructor(stdout: Readable, stdin: Writable, client: QmpClient) {
|
||||
this.stdout = stdout;
|
||||
this.stdin = stdin;
|
||||
this.client = client;
|
||||
|
||||
this.stdout.on('data', (data) => {
|
||||
this.client.feed(data);
|
||||
});
|
||||
}
|
||||
|
||||
writeSome(buffer: Buffer) {
|
||||
if (!this.stdin.closed) this.stdin.write(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
export declare interface QemuVM {
|
||||
on(event: 'statechange', listener: (newState: VMState) => void): this;
|
||||
}
|
||||
|
||||
export class QemuVM extends EventEmitter {
|
||||
private state = VMState.Stopped;
|
||||
|
||||
// QMP stuff.
|
||||
private qmpInstance: QmpClient = new QmpClient();
|
||||
|
||||
private qemuProcess: ExecaChildProcess | null = null;
|
||||
|
||||
private display: QemuDisplay | null = null;
|
||||
private definition: QemuVmDefinition;
|
||||
private addedAdditionalArguments = false;
|
||||
|
||||
private logger: pino.Logger;
|
||||
|
||||
constructor(def: QemuVmDefinition) {
|
||||
super();
|
||||
this.definition = def;
|
||||
this.logger = pino({
|
||||
name: `SuperQEMU.QemuVM/${this.definition.id}`
|
||||
});
|
||||
|
||||
let self = this;
|
||||
|
||||
// Handle the STOP event sent when using -no-shutdown
|
||||
this.qmpInstance.on(QmpEvent.Stop, async () => {
|
||||
await self.qmpInstance.execute('system_reset');
|
||||
});
|
||||
|
||||
this.qmpInstance.on(QmpEvent.Reset, async () => {
|
||||
await self.qmpInstance.execute('cont');
|
||||
});
|
||||
|
||||
this.qmpInstance.on('connected', async () => {
|
||||
self.logger.info('QMP ready');
|
||||
|
||||
this.display = new QemuDisplay(this.GetVncPath());
|
||||
|
||||
self.display?.on('connected', () => {
|
||||
// The VM can now be considered started
|
||||
self.logger.info('Display connected');
|
||||
self.SetState(VMState.Started);
|
||||
});
|
||||
|
||||
// now that QMP has connected, connect to the display
|
||||
self.display?.Connect();
|
||||
});
|
||||
}
|
||||
|
||||
async Start() {
|
||||
// Don't start while either trying to start or starting.
|
||||
//if (this.state == VMState.Started || this.state == VMState.Starting) return;
|
||||
if (this.qemuProcess) return;
|
||||
|
||||
let cmd = this.definition.command;
|
||||
|
||||
// Build additional command line statements to enable qmp/vnc over unix sockets
|
||||
if (!this.addedAdditionalArguments) {
|
||||
cmd += ' -no-shutdown';
|
||||
if (this.definition.snapshot) cmd += ' -snapshot';
|
||||
cmd += ` -qmp stdio -vnc unix:${this.GetVncPath()}`;
|
||||
this.definition.command = cmd;
|
||||
this.addedAdditionalArguments = true;
|
||||
}
|
||||
|
||||
await this.StartQemu(cmd);
|
||||
}
|
||||
|
||||
SnapshotsSupported(): boolean {
|
||||
return this.definition.snapshot;
|
||||
}
|
||||
|
||||
async Reboot(): Promise<void> {
|
||||
await this.MonitorCommand('system_reset');
|
||||
}
|
||||
|
||||
async Stop() {
|
||||
this.AssertState(VMState.Started, 'cannot use QemuVM#Stop on a non-started VM');
|
||||
|
||||
// Indicate we're stopping, so we don't erroneously start trying to restart everything we're going to tear down.
|
||||
this.SetState(VMState.Stopping);
|
||||
|
||||
// Stop the QEMU process, which will bring down everything else.
|
||||
await this.StopQemu();
|
||||
}
|
||||
|
||||
async Reset() {
|
||||
this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM');
|
||||
await this.StopQemu();
|
||||
}
|
||||
|
||||
async QmpCommand(command: string, args: any | null): Promise<any> {
|
||||
return await this.qmpInstance?.execute(command, args);
|
||||
}
|
||||
|
||||
async MonitorCommand(command: string) {
|
||||
this.AssertState(VMState.Started, 'cannot use QemuVM#MonitorCommand on a non-started VM');
|
||||
let result = await this.QmpCommand('human-monitor-command', {
|
||||
'command-line': command
|
||||
});
|
||||
if (result == null) result = '';
|
||||
return result;
|
||||
}
|
||||
|
||||
async ChangeRemovableMedia(deviceName: string, imagePath: string): Promise<void> {
|
||||
this.AssertState(VMState.Started, 'cannot use QemuVM#ChangeRemovableMedia on a non-started VM');
|
||||
// N.B: if this throws, the code which called this should handle the error accordingly
|
||||
await this.QmpCommand('blockdev-change-medium', {
|
||||
device: deviceName, // techinically deprecated, but I don't feel like figuring out QOM path just for a simple function
|
||||
filename: imagePath
|
||||
});
|
||||
}
|
||||
|
||||
async EjectRemovableMedia(deviceName: string) {
|
||||
this.AssertState(VMState.Started, 'cannot use QemuVM#EjectRemovableMedia on a non-started VM');
|
||||
await this.QmpCommand('eject', {
|
||||
device: deviceName
|
||||
});
|
||||
}
|
||||
|
||||
GetDisplay() {
|
||||
return this.display!;
|
||||
}
|
||||
|
||||
GetState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/// Private fun bits :)
|
||||
|
||||
private VMLog() {
|
||||
return this.logger;
|
||||
}
|
||||
|
||||
private AssertState(stateShouldBe: VMState, message: string) {
|
||||
if (this.state !== stateShouldBe) throw new Error(message);
|
||||
}
|
||||
|
||||
private SetState(state: VMState) {
|
||||
this.state = state;
|
||||
this.emit('statechange', this.state);
|
||||
}
|
||||
|
||||
private GetVncPath() {
|
||||
return `${kVmTmpPathBase}/superqemu-${this.definition.id}-vnc`;
|
||||
}
|
||||
|
||||
private async StartQemu(split: string) {
|
||||
let self = this;
|
||||
|
||||
this.SetState(VMState.Starting);
|
||||
|
||||
this.logger.info(`Starting QEMU with command \"${split}\"`);
|
||||
|
||||
// Start QEMU
|
||||
this.qemuProcess = execaCommand(split, {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
});
|
||||
|
||||
this.qemuProcess.stderr?.on('data', (data) => {
|
||||
self.logger.error(`QEMU stderr: ${data.toString('utf8')}`);
|
||||
});
|
||||
|
||||
this.qemuProcess.on('spawn', async () => {
|
||||
self.logger.info('QEMU started');
|
||||
await self.QmpStdioInit();
|
||||
});
|
||||
|
||||
this.qemuProcess.on('exit', async (code) => {
|
||||
self.logger.info('QEMU process exited');
|
||||
|
||||
// Disconnect from the display and QMP connections.
|
||||
await self.DisconnectDisplay();
|
||||
|
||||
self.qmpInstance.reset();
|
||||
self.qmpInstance.setWriter(null);
|
||||
|
||||
// Remove the VNC UDS socket.
|
||||
try {
|
||||
await unlink(this.GetVncPath());
|
||||
} catch (_) {}
|
||||
|
||||
if (self.state != VMState.Stopping) {
|
||||
if (code == 0) {
|
||||
await self.StartQemu(split);
|
||||
} else {
|
||||
self.logger.error('QEMU exited with a non-zero exit code. This usually means an error in the command line. Stopping VM.');
|
||||
// Note that we've already tore down everything upon entry to this event handler; therefore
|
||||
// we can simply set the state and move on.
|
||||
this.SetState(VMState.Stopped);
|
||||
}
|
||||
} else {
|
||||
// Indicate we have stopped.
|
||||
this.SetState(VMState.Stopped);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async StopQemu() {
|
||||
if (this.qemuProcess) {
|
||||
this.qemuProcess?.kill('SIGTERM');
|
||||
this.qemuProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async QmpStdioInit() {
|
||||
let self = this;
|
||||
|
||||
self.logger.info('Initializing QMP over stdio');
|
||||
|
||||
// Setup the QMP client.
|
||||
let writer = new StdioWriter(this.qemuProcess?.stdout!, this.qemuProcess?.stdin!, self.qmpInstance);
|
||||
self.qmpInstance.reset();
|
||||
self.qmpInstance.setWriter(writer);
|
||||
}
|
||||
|
||||
private async DisconnectDisplay() {
|
||||
try {
|
||||
this.display?.Disconnect();
|
||||
this.display = null;
|
||||
} catch (err) {
|
||||
// oh well lol
|
||||
}
|
||||
}
|
||||
|
||||
}
|
170
src/QmpClient.ts
Normal file
170
src/QmpClient.ts
Normal file
|
@ -0,0 +1,170 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
|
||||
enum QmpClientState {
|
||||
Handshaking,
|
||||
Connected
|
||||
}
|
||||
|
||||
function qmpStringify(obj: any) {
|
||||
return JSON.stringify(obj) + '\r\n';
|
||||
}
|
||||
|
||||
// this writer interface is used to poll back to a higher level
|
||||
// I/O layer that we want to write some data.
|
||||
export interface IQmpClientWriter {
|
||||
writeSome(data: Buffer): void;
|
||||
}
|
||||
|
||||
export type QmpClientCallback = (err: Error | null, res: any | null) => void;
|
||||
|
||||
type QmpClientCallbackEntry = {
|
||||
id: number;
|
||||
callback: QmpClientCallback | null;
|
||||
};
|
||||
|
||||
export enum QmpEvent {
|
||||
BlockIOError = 'BLOCK_IO_ERROR',
|
||||
Reset = 'RESET',
|
||||
Resume = 'RESUME',
|
||||
RtcChange = 'RTC_CHANGE',
|
||||
Shutdown = 'SHUTDOWN',
|
||||
Stop = 'STOP',
|
||||
VncConnected = 'VNC_CONNECTED',
|
||||
VncDisconnected = 'VNC_DISCONNECTED',
|
||||
VncInitialized = 'VNC_INITIALIZED',
|
||||
Watchdog = 'WATCHDOG'
|
||||
}
|
||||
|
||||
class LineStream extends EventEmitter {
|
||||
// The given line seperator for the stream
|
||||
lineSeperator = '\r\n';
|
||||
buffer = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
push(data: Buffer) {
|
||||
this.buffer += data.toString('utf-8');
|
||||
|
||||
let lines = this.buffer.split(this.lineSeperator);
|
||||
if (lines.length > 1) {
|
||||
this.buffer = lines.pop()!;
|
||||
lines = lines.filter((l) => !!l);
|
||||
lines.forEach((l) => this.emit('line', l));
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.buffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
// A QMP client
|
||||
export class QmpClient extends EventEmitter {
|
||||
private state = QmpClientState.Handshaking;
|
||||
private writer: IQmpClientWriter | null = null;
|
||||
|
||||
private lastID = 0;
|
||||
private callbacks = new Array<QmpClientCallbackEntry>();
|
||||
|
||||
private lineStream = new LineStream();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
let self = this;
|
||||
this.lineStream.on('line', (line: string) => {
|
||||
self.handleQmpLine(line);
|
||||
});
|
||||
}
|
||||
|
||||
setWriter(writer: IQmpClientWriter | null) {
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
feed(data: Buffer): void {
|
||||
// Forward to the line stream. It will generate 'line' events
|
||||
// as it is able to split out lines automatically.
|
||||
this.lineStream.push(data);
|
||||
}
|
||||
|
||||
private handleQmpLine(line: string) {
|
||||
let obj = JSON.parse(line);
|
||||
|
||||
switch (this.state) {
|
||||
case QmpClientState.Handshaking:
|
||||
if (obj['return'] != undefined) {
|
||||
// Once we get a return from our handshake execution,
|
||||
// we have exited handshake state.
|
||||
this.state = QmpClientState.Connected;
|
||||
this.emit('connected');
|
||||
return;
|
||||
} else if (obj['QMP'] != undefined) {
|
||||
// Send a `qmp_capabilities` command, to exit handshake state.
|
||||
// We do not support any of the supported extended QMP capabilities currently,
|
||||
// and probably never will (due to their relative uselessness.)
|
||||
let capabilities = qmpStringify({
|
||||
execute: 'qmp_capabilities'
|
||||
});
|
||||
|
||||
this.writer?.writeSome(Buffer.from(capabilities, 'utf8'));
|
||||
}
|
||||
break;
|
||||
|
||||
case QmpClientState.Connected:
|
||||
if (obj['return'] != undefined || obj['error'] != undefined) {
|
||||
if (obj['id'] == null) return;
|
||||
|
||||
let cb = this.callbacks.find((v) => v.id == obj['id']);
|
||||
if (cb == undefined) return;
|
||||
|
||||
let error: Error | null = obj.error ? new Error(obj.error.desc) : null;
|
||||
|
||||
if (cb.callback) cb.callback(error, obj.return || null);
|
||||
|
||||
this.callbacks.slice(this.callbacks.indexOf(cb));
|
||||
} else if (obj['event']) {
|
||||
this.emit(obj.event, {
|
||||
timestamp: obj.timestamp,
|
||||
data: obj.data
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Executes a QMP command, using a user-provided callback for completion notification
|
||||
executeCallback(command: string, args: any | undefined, callback: QmpClientCallback | null) {
|
||||
let entry = {
|
||||
callback: callback,
|
||||
id: ++this.lastID
|
||||
};
|
||||
|
||||
let qmpOut: any = {
|
||||
execute: command,
|
||||
id: entry.id
|
||||
};
|
||||
|
||||
if (args !== undefined) qmpOut['arguments'] = args;
|
||||
|
||||
this.callbacks.push(entry);
|
||||
this.writer?.writeSome(Buffer.from(qmpStringify(qmpOut), 'utf8'));
|
||||
}
|
||||
|
||||
// Executes a QMP command asynchronously.
|
||||
async execute(command: string, args: any | undefined = undefined): Promise<any> {
|
||||
return new Promise((res, rej) => {
|
||||
this.executeCallback(command, args, (err, result) => {
|
||||
if (err) rej(err);
|
||||
res(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
// Reset the line stream so it doesn't go awry
|
||||
this.lineStream.reset();
|
||||
this.state = QmpClientState.Handshaking;
|
||||
}
|
||||
}
|
4
src/index.ts
Normal file
4
src/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from './QemuDisplay.js';
|
||||
export * from './QemuUtil.js';
|
||||
export * from './QemuVM.js';
|
||||
export * from './QmpClient.js';
|
1
tsconfig.json
Symbolic link
1
tsconfig.json
Symbolic link
|
@ -0,0 +1 @@
|
|||
../tsconfig.json
|
Loading…
Reference in a new issue