Compare commits
2 commits
e50ee0a837
...
e3123252a4
Author | SHA1 | Date | |
---|---|---|---|
e3123252a4 | |||
608b7255c9 |
8 changed files with 75 additions and 255 deletions
|
@ -1,3 +1,7 @@
|
|||
# superqemu
|
||||
|
||||
A QEMU supervision library for Node.js
|
||||
A lean QEMU supervision library for Node.js.
|
||||
|
||||
# Usage
|
||||
|
||||
See the /examples directory in the repo for now. The library is pretty simple though (on purpose).
|
||||
|
|
12
RELEASE_NOTES.md
Normal file
12
RELEASE_NOTES.md
Normal file
|
@ -0,0 +1,12 @@
|
|||
# Superqemu Release Notes
|
||||
|
||||
|
||||
## `v0.2.4`
|
||||
|
||||
This release contans breaking changes:
|
||||
|
||||
- Superqemu no longer depends on nodejs-rfb, or provides its own VNC client support. Instead, it still sets up VNC in QEMU, and it provides the required information to connect, but allows you the control to connect to the VNC server QEMU has setup yourself.
|
||||
|
||||
## `v0.2.3`
|
||||
|
||||
- TCP support
|
|
@ -1,8 +1,12 @@
|
|||
// A simple example of how to use superqemu.
|
||||
// A simple/contrived? 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.
|
||||
//
|
||||
// Also note that while superqemu automatically sets up QEMU to use VNC,
|
||||
// it does not provide its own VNC client implementation.
|
||||
|
||||
import { QemuVM } from "../dist/index.js";
|
||||
import { QemuVM, VMState } from "../dist/index.js";
|
||||
|
||||
import pino from 'pino';
|
||||
|
||||
|
@ -18,6 +22,9 @@ let vm = new QemuVM(
|
|||
|
||||
vm.on('statechange', (newState) => {
|
||||
logger.info(`state changed to ${newState}`);
|
||||
if(newState == VMState.Started) {
|
||||
logger.info(vm.GetDisplayInfo(), `VM started: display info prepends this message`);
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
|
|
13
package.json
13
package.json
|
@ -1,13 +1,15 @@
|
|||
{
|
||||
"name": "@computernewb/superqemu",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"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"
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"RELEASE_NOTES.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "parcel build src/index.ts --target node --target types"
|
||||
|
@ -23,7 +25,6 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@computernewb/nodejs-rfb": "^0.3.0",
|
||||
"execa": "^8.0.1",
|
||||
"pino": "^9.3.1"
|
||||
},
|
||||
|
@ -35,5 +36,9 @@
|
|||
"pino-pretty": "^11.2.1",
|
||||
"typescript": ">=3.0.0"
|
||||
},
|
||||
"packageManager": "yarn@4.1.1"
|
||||
"packageManager": "yarn@4.4.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.computernewb.com/computernewb/superqemu.git"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,148 +0,0 @@
|
|||
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 vncConnectOpts: any;
|
||||
|
||||
constructor(vncConnectOpts: any) {
|
||||
super();
|
||||
|
||||
this.vncConnectOpts = vncConnectOpts;
|
||||
|
||||
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(this.vncConnectOpts);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
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
|
||||
};
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
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';
|
||||
|
@ -14,6 +13,7 @@ export enum VMState {
|
|||
Stopping
|
||||
}
|
||||
|
||||
/// VM definition.
|
||||
export type QemuVmDefinition = {
|
||||
id: string;
|
||||
command: string;
|
||||
|
@ -23,6 +23,17 @@ export type QemuVmDefinition = {
|
|||
vncPort: number | undefined;
|
||||
};
|
||||
|
||||
/// Display information.
|
||||
export interface QemuVMDisplayInfo {
|
||||
type: 'vnc-uds' | 'vnc-tcp';
|
||||
// 'vnc-uds'
|
||||
path?: string;
|
||||
|
||||
// 'vnc-tcp'
|
||||
host?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
/// Temporary path base (for UNIX sockets/etc.)
|
||||
const kVmTmpPathBase = `/tmp`;
|
||||
|
||||
|
@ -59,7 +70,7 @@ export class QemuVM extends EventEmitter {
|
|||
|
||||
private qemuProcess: ExecaChildProcess | null = null;
|
||||
|
||||
private display: QemuDisplay | null = null;
|
||||
private displayInfo: QemuVMDisplayInfo | null = null;
|
||||
private definition: QemuVmDefinition;
|
||||
private addedAdditionalArguments = false;
|
||||
|
||||
|
@ -85,27 +96,7 @@ export class QemuVM extends EventEmitter {
|
|||
|
||||
this.qmpInstance.on('connected', async () => {
|
||||
self.logger.info('QMP ready');
|
||||
|
||||
if (this.definition.forceTcp || process.platform === "win32") {
|
||||
this.display = new QemuDisplay({
|
||||
host: this.definition.vncHost || '127.0.0.1',
|
||||
port: this.definition.vncPort || 5900,
|
||||
path: null
|
||||
})
|
||||
} else {
|
||||
this.display = new QemuDisplay({
|
||||
path: 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();
|
||||
self.SetState(VMState.Started);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -121,16 +112,27 @@ export class QemuVM extends EventEmitter {
|
|||
cmd += ' -no-shutdown';
|
||||
if (this.definition.snapshot) cmd += ' -snapshot';
|
||||
cmd += ` -qmp stdio`;
|
||||
if (this.definition.forceTcp || process.platform === "win32") {
|
||||
if (this.definition.forceTcp || process.platform === 'win32') {
|
||||
let host = this.definition.vncHost || '127.0.0.1';
|
||||
let port = this.definition.vncPort || 5900;
|
||||
if (port < 5900) {
|
||||
throw new Error('VNC port must be greater than or equal to 5900');
|
||||
}
|
||||
cmd += ` -vnc ${host}:${port - 5900}`;
|
||||
this.displayInfo = {
|
||||
type: 'vnc-tcp',
|
||||
host: host,
|
||||
port: port
|
||||
};
|
||||
} else {
|
||||
cmd += ` -vnc unix:${this.GetVncPath()}`;
|
||||
this.displayInfo = {
|
||||
type: 'vnc-uds',
|
||||
path: this.GetVncPath()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
this.definition.command = cmd;
|
||||
this.addedAdditionalArguments = true;
|
||||
}
|
||||
|
@ -190,8 +192,13 @@ export class QemuVM extends EventEmitter {
|
|||
});
|
||||
}
|
||||
|
||||
GetDisplay() {
|
||||
return this.display!;
|
||||
// Gets the required information to connect to the VNC server
|
||||
// that Superqemu enables by adding arguments to the QEMU launch
|
||||
// command. Superqemu no longer directly has a VNC client.
|
||||
//
|
||||
// This is only null if the VM has never been started.
|
||||
GetDisplayInfo() {
|
||||
return this.displayInfo;
|
||||
}
|
||||
|
||||
GetState() {
|
||||
|
@ -213,6 +220,7 @@ export class QemuVM extends EventEmitter {
|
|||
this.emit('statechange', this.state);
|
||||
}
|
||||
|
||||
// No longer internal
|
||||
private GetVncPath() {
|
||||
return `${kVmTmpPathBase}/superqemu-${this.definition.id}-vnc`;
|
||||
}
|
||||
|
@ -243,16 +251,15 @@ export class QemuVM extends EventEmitter {
|
|||
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 (!this.definition.forceTcp || process.platform !== 'win32') {
|
||||
// Remove the VNC UDS socket.
|
||||
try {
|
||||
await unlink(this.GetVncPath());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (self.state != VMState.Stopping) {
|
||||
if (code == 0) {
|
||||
|
@ -287,14 +294,4 @@ export class QemuVM extends EventEmitter {
|
|||
self.qmpInstance.reset();
|
||||
self.qmpInstance.setWriter(writer);
|
||||
}
|
||||
|
||||
private async DisconnectDisplay() {
|
||||
try {
|
||||
this.display?.Disconnect();
|
||||
this.display = null;
|
||||
} catch (err) {
|
||||
// oh well lol
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,4 +1,2 @@
|
|||
export * from './QemuDisplay.js';
|
||||
export * from './QemuUtil.js';
|
||||
export * from './QemuVM.js';
|
||||
export * from './QmpClient.js';
|
||||
|
|
Loading…
Reference in a new issue