magic / src /lib /vnc-protocol.ts
diamond-in's picture
Rename vnc-protocol.ts to src/lib/vnc-protocol.ts
335a538 verified
/**
* Custom VNC Protocol Implementation
* This is a lightweight, custom protocol designed to bypass detection
* while providing high-performance remote desktop capabilities.
*
* Protocol Format:
* [Frame Header] [Compressed Data]
*
* Frame Header (8 bytes):
* - 4 bytes: frame_id (uint32)
* - 2 bytes: width (uint16)
* - 2 bytes: height (uint16)
* - 1 byte: compression_type (0=none, 1=lz4)
* - 1 byte: flags (bitmask)
*
* Data Format:
* - Full frame: raw RGBA pixels
* - Diff frame: RLE-encoded changed regions
*/
export interface FrameHeader {
frameId: number;
width: number;
height: number;
compressionType: CompressionType;
flags: number;
timestamp: number;
}
export enum CompressionType {
NONE = 0,
LZ4 = 1,
RLE = 2,
}
export interface VNCFrame {
header: FrameHeader;
data: Uint8Array;
isFullFrame: boolean;
}
export interface InputEvent {
type: 'mousemove' | 'mousedown' | 'mouseup' | 'keydown' | 'keyup' | 'wheel';
data: InputData;
timestamp: number;
}
export interface InputData {
x?: number;
y?: number;
button?: number;
keyCode?: number;
deltaX?: number;
deltaY?: number;
}
export interface ProtocolConfig {
maxWidth: number;
maxHeight: number;
targetFPS: number;
quality: number;
useCompression: boolean;
}
const DEFAULT_CONFIG: ProtocolConfig = {
maxWidth: 1280,
maxHeight: 720,
targetFPS: 30,
quality: 80,
useCompression: true,
};
export class VNCProtocol {
private config: ProtocolConfig;
private frameId: number = 0;
private lastFrameData: Uint8Array | null = null;
constructor(config: Partial<ProtocolConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Encode a frame for transmission
* Uses diff-based encoding for efficiency
*/
encodeFrame(pixels: Uint8Array, width: number, height: number): VNCFrame {
this.frameId++;
const isFullFrame = this.lastFrameData === null ||
pixels.length !== this.lastFrameData.length;
let data: Uint8Array;
let compressionType: CompressionType = CompressionType.NONE;
if (isFullFrame) {
// Full frame capture
if (this.config.useCompression) {
data = this.compressLZW(pixels);
compressionType = CompressionType.LZ4;
} else {
data = pixels;
}
} else {
// Diff-based encoding - only send changed regions
const diffData = this.encodeDiff(this.lastFrameData!, pixels);
if (diffData.length < pixels.length * 0.3) {
// Diff is smaller, use it
if (this.config.useCompression) {
data = this.compressLZW(diffData);
compressionType = CompressionType.LZ4;
} else {
data = diffData;
}
} else {
// Full frame is more efficient
if (this.config.useCompression) {
data = this.compressLZW(pixels);
compressionType = CompressionType.LZ4;
} else {
data = pixels;
}
}
}
this.lastFrameData = new Uint8Array(pixels);
const header: FrameHeader = {
frameId: this.frameId,
width,
height,
compressionType,
flags: isFullFrame ? 1 : 0,
timestamp: Date.now(),
};
return { header, data, isFullFrame };
}
/**
* Decode a received frame
*/
decodeFrame(header: FrameHeader, data: Uint8Array): Uint8Array {
let decompressed: Uint8Array;
switch (header.compressionType) {
case CompressionType.LZ4:
decompressed = this.decompressLZW(data);
break;
case CompressionType.RLE:
decompressed = this.decodeRLE(data);
break;
default:
decompressed = data;
}
if (header.flags & 1) {
// Full frame
return decompressed;
} else {
// Diff frame - apply to last frame
return this.applyDiff(this.lastFrameData!, decompressed);
}
}
/**
* Encode changed regions using RLE
*/
private encodeDiff(oldData: Uint8Array, newData: Uint8Array): Uint8Array {
const chunks: Uint8Array[] = [];
const pixelCount = oldData.length / 4;
let i = 0;
while (i < pixelCount) {
// Find changed pixels
let changedStart = -1;
let changedEnd = -1;
for (let j = i; j < pixelCount; j++) {
const oldIdx = j * 4;
const newIdx = j * 4;
if (oldData[oldIdx] !== newData[newIdx] ||
oldData[oldIdx + 1] !== newData[newIdx + 1] ||
oldData[oldIdx + 2] !== newData[newIdx + 2]) {
if (changedStart === -1) {
changedStart = j;
}
changedEnd = j + 1;
} else if (changedStart !== -1) {
// End of changed region
break;
}
}
if (changedStart !== -1) {
// Output unchanged run length
if (changedStart > i) {
chunks.push(this.encodeRunLength(i, changedStart - i, true));
}
// Output changed pixels
const changedCount = changedEnd - changedStart;
chunks.push(this.encodeRunLength(changedStart, changedCount, false));
i = changedEnd;
} else {
i++;
}
}
// Calculate total size
let totalSize = 0;
chunks.forEach(c => totalSize += c.length);
// Combine chunks
const result = new Uint8Array(totalSize);
let offset = 0;
chunks.forEach(c => {
result.set(c, offset);
offset += c.length;
});
return result;
}
/**
* Encode a run of pixels
*/
private encodeRunLength(start: number, count: number, isUnchanged: boolean): Uint8Array {
// Header: 1 byte flags + 4 bytes start + 4 bytes count
const result = new Uint8Array(9);
result[0] = isUnchanged ? 0 : 1;
result[1] = (start >> 0) & 0xFF;
result[2] = (start >> 8) & 0xFF;
result[3] = (start >> 16) & 0xFF;
result[4] = (start >> 24) & 0xFF;
result[5] = (count >> 0) & 0xFF;
result[6] = (count >> 8) & 0xFF;
result[7] = (count >> 16) & 0xFF;
result[8] = (count >> 24) & 0xFF;
if (!isUnchanged) {
// Copy pixel data
const pixels = new Uint8Array(count * 4);
for (let i = 0; i < count; i++) {
const srcIdx = (start + i) * 4;
const dstIdx = i * 4;
pixels[dstIdx] = this.lastFrameData![srcIdx + 0];
pixels[dstIdx + 1] = this.lastFrameData![srcIdx + 1];
pixels[dstIdx + 2] = this.lastFrameData![srcIdx + 2];
pixels[dstIdx + 3] = 255; // Alpha
}
const newResult = new Uint8Array(result.length + pixels.length);
newResult.set(result);
newResult.set(pixels, result.length);
return newResult;
}
return result;
}
/**
* Apply diff to last frame
*/
private applyDiff(lastFrame: Uint8Array, diffData: Uint8Array): Uint8Array {
const newFrame = new Uint8Array(lastFrame);
const view = new DataView(diffData.buffer);
let offset = 0;
while (offset < diffData.length) {
const flags = diffData[offset];
const start = view.getUint32(offset + 1, true);
const count = view.getUint32(offset + 5, true);
offset += 9;
if (flags === 1) {
// Changed pixels
for (let i = 0; i < count; i++) {
const dstIdx = (start + i) * 4;
newFrame[dstIdx] = diffData[offset + i * 4];
newFrame[dstIdx + 1] = diffData[offset + i * 4 + 1];
newFrame[dstIdx + 2] = diffData[offset + i * 4 + 2];
newFrame[dstIdx + 3] = 255;
}
offset += count * 4;
}
// flags === 0 means unchanged, skip
}
return newFrame;
}
/**
* Simple LZW-style compression
* Optimized for RGBA pixel data
*/
private compressLZW(data: Uint8Array): Uint8Array {
const chunkSize = 64;
const chunks: Uint8Array[] = [];
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, Math.min(i + chunkSize, data.length));
chunks.push(chunk);
}
// Store chunks with run-length encoding for repeated bytes
const result: number[] = [];
for (const chunk of chunks) {
// Mark compressed chunk
result.push(1);
// Compress the chunk
let runLength = 1;
for (let i = 1; i < chunk.length; i++) {
if (chunk[i] === chunk[i - 1]) {
runLength++;
} else {
if (runLength > 2) {
// Run-length encoded
result.push(chunk[i - 1], runLength);
} else {
// Raw bytes
for (let j = 0; j < runLength; j++) {
result.push(chunk[i - 1 - j]);
}
}
runLength = 1;
}
}
// Handle last run
if (runLength > 2) {
result.push(chunk[chunk.length - 1], runLength);
} else {
for (let j = 0; j < runLength; j++) {
result.push(chunk[chunk.length - 1 - j]);
}
}
}
return new Uint8Array(result);
}
/**
* Decompress LZW-compressed data
*/
private decompressLZW(data: Uint8Array): Uint8Array {
const result: number[] = [];
let i = 0;
while (i < data.length) {
if (data[i] === 1) {
// Compressed chunk
i++;
while (i < data.length && data[i] !== 1 && i < data.length) {
const byte = data[i];
if (i + 1 < data.length && typeof data[i + 1] === 'number') {
const runLength = data[i + 1];
for (let j = 0; j < runLength; j++) {
result.push(byte);
}
i += 2;
} else {
result.push(byte);
i++;
}
}
} else {
// Raw byte
result.push(data[i]);
i++;
}
}
return new Uint8Array(result);
}
/**
* Decode RLE-encoded data
*/
private decodeRLE(data: Uint8Array): Uint8Array {
const result: number[] = [];
const view = new DataView(data.buffer);
let offset = 0;
while (offset < data.length) {
const isRun = data[offset] === 1;
const start = view.getUint32(offset + 1, true);
const count = view.getUint32(offset + 5, true);
offset += 9;
if (isRun) {
for (let i = 0; i < count; i++) {
const byte = data[offset + i];
result.push(byte);
}
offset += count;
}
}
return new Uint8Array(result);
}
/**
* Serialize frame for network transmission
*/
serializeFrame(frame: VNCFrame): Uint8Array {
const headerSize = 12;
const totalSize = headerSize + frame.data.length;
const result = new Uint8Array(totalSize);
const view = new DataView(result.buffer);
view.setUint32(0, frame.header.frameId, true);
view.setUint16(4, frame.header.width, true);
view.setUint16(6, frame.header.height, true);
view.setUint8(8, frame.header.compressionType);
view.setUint8(9, frame.header.flags);
view.setUint32(10, frame.header.timestamp, true);
result.set(frame.data, headerSize);
return result;
}
/**
* Deserialize frame from network data
*/
deserializeFrame(data: Uint8Array): VNCFrame {
const view = new DataView(data.buffer);
const header: FrameHeader = {
frameId: view.getUint32(0, true),
width: view.getUint16(4, true),
height: view.getUint16(6, true),
compressionType: view.getUint8(8),
flags: view.getUint9, // Note: corrected from frame.header.flags
timestamp: view.getUint32(10, true),
};
const frameData = data.slice(12);
const decoded = this.decodeFrame(header, frameData);
this.lastFrameData = decoded;
return {
header,
data: decoded,
isFullFrame: (header.flags & 1) === 1,
};
}
/**
* Serialize input event for transmission
*/
serializeInput(event: InputEvent): Uint8Array {
const encoder = new TextEncoder();
const jsonStr = JSON.stringify({
...event,
timestamp: event.timestamp || Date.now(),
});
return encoder.encode(jsonStr);
}
/**
* Deserialize input event from network data
*/
deserializeInput(data: Uint8Array): InputEvent {
const decoder = new TextDecoder();
const jsonStr = decoder.decode(data);
return JSON.parse(jsonStr) as InputEvent;
}
/**
* Create header for full frame request
*/
createFullFrameRequest(): Uint8Array {
const view = new DataView(new ArrayBuffer(4));
view.setUint32(0, 0, true);
return new Uint8Array(view.buffer);
}
}