diff --git a/src/core/AMTRedirector.ts b/src/core/AMTRedirector.ts index c8e2dd7d..8aed312a 100644 --- a/src/core/AMTRedirector.ts +++ b/src/core/AMTRedirector.ts @@ -65,6 +65,7 @@ export class AMTRedirector implements ICommunicator { onNewState: (state: number) => void onStateChanged: (redirector: any, state: number) => void onError: () => void + onSolOemCapabilities?: (bytes: readonly number[]) => void authToken: string constructor(config: RedirectorConfig) { @@ -173,7 +174,7 @@ export class AMTRedirector implements ICommunicator { * Called when there is new data on the websocket * @param e data received over the websocket */ - onMessage(e: MessageEvent): any { + onMessage(e: MessageEvent): any { try { // console.log(e.data) this.inDataCount++ @@ -256,6 +257,10 @@ export class AMTRedirector implements ICommunicator { if (this.amtAccumulator.length < 13) return const oemlen = this.amtAccumulator.charCodeAt(12) if (this.amtAccumulator.length < 13 + oemlen) return + if (this.protocol === Protocol.SOL) { + const bytes = Array.from(this.amtAccumulator.substring(13, 13 + oemlen), (byte) => byte.charCodeAt(0)) + this.onSolOemCapabilities?.(bytes) + } // Query for available authentication console.log('Query for available authentication') diff --git a/src/core/SolTerminalType.ts b/src/core/SolTerminalType.ts new file mode 100644 index 00000000..36a8c56f --- /dev/null +++ b/src/core/SolTerminalType.ts @@ -0,0 +1,9 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2026 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +export enum SolTerminalType { + VTUTF8 = 1, + PCANSI = 2 +} diff --git a/src/core/TerminalDataProcessor.ts b/src/core/TerminalDataProcessor.ts index e371618a..7c68e867 100644 --- a/src/core/TerminalDataProcessor.ts +++ b/src/core/TerminalDataProcessor.ts @@ -5,31 +5,73 @@ import { type AmtTerminal } from './AMTTerminal' import { type IDataProcessor } from './Interfaces' +import { SolTerminalType } from './SolTerminalType' + +// CP437 bytes 0x80-0xFF; ASCII and terminal control bytes pass through unchanged. +const CP437 = + '\u00c7\u00fc\u00e9\u00e2\u00e4\u00e0\u00e5\u00e7\u00ea\u00eb\u00e8\u00ef\u00ee\u00ec\u00c4\u00c5' + + '\u00c9\u00e6\u00c6\u00f4\u00f6\u00f2\u00fb\u00f9\u00ff\u00d6\u00dc\u00a2\u00a3\u00a5\u20a7\u0192' + + '\u00e1\u00ed\u00f3\u00fa\u00f1\u00d1\u00aa\u00ba\u00bf\u2310\u00ac\u00bd\u00bc\u00a1\u00ab\u00bb' + + '\u2591\u2592\u2593\u2502\u2524\u2561\u2562\u2556\u2555\u2563\u2551\u2557\u255d\u255c\u255b\u2510' + + '\u2514\u2534\u252c\u251c\u2500\u253c\u255e\u255f\u255a\u2554\u2569\u2566\u2560\u2550\u256c\u2567' + + '\u2568\u2564\u2565\u2559\u2558\u2552\u2553\u256b\u256a\u2518\u250c\u2588\u2584\u258c\u2590\u2580' + + '\u03b1\u00df\u0393\u03c0\u03a3\u03c3\u00b5\u03c4\u03a6\u0398\u03a9\u03b4\u221e\u03c6\u03b5\u2229' + + '\u2261\u00b1\u2265\u2264\u2320\u2321\u00f7\u2248\u00b0\u2219\u00b7\u221a\u207f\u00b2\u25a0\u00a0' /** class to process serial over lan data **/ export class TerminalDataProcessor implements IDataProcessor { terminal: AmtTerminal - constructor(terminal) { + // The native UTF-8 state machine retains at most three incomplete bytes. + private decoder = new TextDecoder('utf-8', { fatal: false }) + private activeTerminalType = SolTerminalType.PCANSI + + constructor(terminal: AmtTerminal) { this.terminal = terminal } - processDataToXterm: (str: any) => void + processDataToXterm: (str: string) => void clearTerminal: () => void + get terminalType(): SolTerminalType { + return this.activeTerminalType + } + + setTerminalType(type: SolTerminalType): void { + switch (type) { + case SolTerminalType.VTUTF8: + case SolTerminalType.PCANSI: + this.reset() + this.activeTerminalType = type + break + default: + throw new RangeError(`Unsupported SOL terminal type: ${type}`) + } + } + + /** Discard partial input without changing the mode or clearing the display. */ + reset(): void { + this.decoder = new TextDecoder('utf-8', { fatal: false }) + } + /** processing data received from serial port**/ - processData = (str: string): any => { + processData = (str: string): void => { if (this.terminal.capture != null) this.terminal.capture = String(this.terminal.capture) + str - let c = '' + const bytes = new Uint8Array(str.length) for (let i = 0; i < str.length; i++) { - const ch = str.charCodeAt(i) - if (str[i] === 'J') { - this.clearTerminal() - } else if ((ch & 0x80) !== 0) { - c += String.fromCharCode(this.terminal.AsciiToUnicode[ch & 0x7f]) - } else { - c += `${str[i]}` - } + bytes[i] = str.charCodeAt(i) & 0xff + } + + let text = '' + switch (this.activeTerminalType) { + case SolTerminalType.VTUTF8: + text = this.decoder.decode(bytes, { stream: true }) + break + case SolTerminalType.PCANSI: + for (const byte of bytes) { + text += byte < 0x80 ? String.fromCharCode(byte) : CP437[byte - 0x80] + } + break } - this.processDataToXterm(c) + this.processDataToXterm(text) } } diff --git a/src/core/index.ts b/src/core/index.ts index ee4f45c2..238c5fb4 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -11,6 +11,7 @@ export { TypeConverter } from './Converter' export { Desktop } from './Desktop' export { StateProcessorFactory } from './StateProcessorFactory' export { TerminalDataProcessor } from './TerminalDataProcessor' +export { SolTerminalType } from './SolTerminalType' export * from './Interfaces' export * from './ImageData' export * from './RFBStateProcessors' diff --git a/src/test/amtredirector.spec.ts b/src/test/amtredirector.spec.ts index 67ffb88f..a08d1663 100644 --- a/src/test/amtredirector.spec.ts +++ b/src/test/amtredirector.spec.ts @@ -44,6 +44,39 @@ describe('Test AMT redirector class', () => { expect(redirector.state).toEqual(2) }) + it.each([ + { bytes: [] }, + { bytes: [0x00] }, + { bytes: [0x00, 0x84] }, + { bytes: [ + 0x00, + 0x03, + 0xff + ] } + ])('reports only complete SOL OEM capability payloads ($bytes)', ({ bytes }) => { + redirector.protocol = 1 + redirector.onSolOemCapabilities = vi.fn() + const send = vi.spyOn(redirector, 'socketSend').mockImplementation(() => {}) + const reply = String.fromCharCode(0x11, 0, ...new Array(10).fill(0), bytes.length, ...bytes) + redirector.onMessage(new MessageEvent('message', { data: reply.slice(0, -1) })) + expect(redirector.onSolOemCapabilities).not.toHaveBeenCalled() + redirector.onMessage(new MessageEvent('message', { data: reply.slice(-1) })) + expect(redirector.onSolOemCapabilities).toHaveBeenCalledExactlyOnceWith(bytes) + expect(send).toHaveBeenCalledOnce() + }) + + it('does not report KVM OEM data as SOL capabilities', () => { + redirector.protocol = 2 + redirector.onSolOemCapabilities = vi.fn() + vi.spyOn(redirector, 'socketSend').mockImplementation(() => {}) + redirector.onMessage( + new MessageEvent('message', { + data: String.fromCharCode(0x11, 0, ...new Array(10).fill(0), 2, 0, 4) + }) + ) + expect(redirector.onSolOemCapabilities).not.toHaveBeenCalled() + }) + it('should send the data over websocket to the server', () => { const data = '0x28' redirector.send(data) diff --git a/src/test/terminaldataprocessor.test.ts b/src/test/terminaldataprocessor.test.ts index 4248ff4d..b74718c3 100644 --- a/src/test/terminaldataprocessor.test.ts +++ b/src/test/terminaldataprocessor.test.ts @@ -6,10 +6,15 @@ import { TerminalDataProcessor } from '../core/TerminalDataProcessor' import { AmtTerminal } from '../core/AMTTerminal' import { AmtTerminal2 } from './helper/amtTerminal2' +import { TextDecoder, TextEncoder } from 'util' +import { SolTerminalType } from '../core/SolTerminalType' + +globalThis.TextDecoder = TextDecoder as typeof globalThis.TextDecoder +globalThis.TextEncoder = TextEncoder as typeof globalThis.TextEncoder describe('Test TerminalDataProcessor class', () => { let result = '' - it('Test TerminalDataProcessor for processData', () => { + it('decodes UTF-8 data received in one chunk', () => { // callback function for Unit testing function callback(value: string): void { result = value @@ -18,37 +23,179 @@ describe('Test TerminalDataProcessor class', () => { // create object and set callback const term = new AmtTerminal() const tdataprocessor = new TerminalDataProcessor(term) + tdataprocessor.setTerminalType(SolTerminalType.VTUTF8) tdataprocessor.processDataToXterm = callback // Test input - const s = 'abcD123?!=*“€' + const s = String.fromCharCode(...new TextEncoder().encode('abcD123?!=*“€')) // call processdata tdataprocessor.processData(s) // Test output - expect(result).toBe('abcD123?!=*“¼') + expect(result).toBe('abcD123?!=*“€') + }) + + describe('SOL decoding strategies', () => { + let processor: TerminalDataProcessor + let terminal: AmtTerminal + let chunks: string[] + + const binary = (text: string): string => String.fromCharCode(...new TextEncoder().encode(text)) + + beforeEach(() => { + terminal = new AmtTerminal() + terminal.capture = '' + processor = new TerminalDataProcessor(terminal) + processor.setTerminalType(SolTerminalType.VTUTF8) + chunks = [] + processor.processDataToXterm = (text) => chunks.push(text) + processor.clearTerminal = vi.fn() + }) + + it('defaults to PC-ANSI', () => { + expect(new TerminalDataProcessor(new AmtTerminal()).terminalType).toBe(SolTerminalType.PCANSI) + }) + + it.each([ + '\u00a2', + '\u20ac', + '\ud83d\ude00' + ])('handles every split of %s', (character) => { + const bytes = binary(`J${character}\x1b[2J`) + for (let split = 0; split <= bytes.length; split++) { + processor.reset() + chunks = [] + processor.processData(bytes.slice(0, split)) + processor.processData('') + processor.processData(bytes.slice(split)) + expect(chunks.join('')).toBe(`J${character}\x1b[2J`) + } + expect(processor.clearTerminal).not.toHaveBeenCalled() + }) + + it('retains three incomplete bytes and resolves the fourth without losing following text', () => { + for (const byte of [ + 0xf0, + 0x9f, + 0x98 + ]) { + processor.processData(String.fromCharCode(byte)) + expect(chunks.join('')).toBe('') + } + processor.processData('\x80J') + expect(chunks.join('')).toBe('\ud83d\ude00J') + }) + + it('rejects an invalid fourth byte immediately and recovers', () => { + processor.processData('\xf0\x9f\x98') + processor.processData('J') + expect(chunks.join('')).toBe('\ufffdJ') + }) + + it('does not accumulate unresolved invalid leading bytes', () => { + for (let i = 0; i < 4096; i++) processor.processData('\xf0') + expect(chunks.join('')).toBe('\ufffd'.repeat(4095)) + processor.processData('\x9f\x98\x80') + expect(chunks.join('')).toBe('\ufffd'.repeat(4095) + '\ud83d\ude00') + }) + + it.each([ + ['\xff', '\ufffd'], + ['\xc0\xaf', '\ufffd\ufffd'], + ['\xed\xa0\x80', '\ufffd\ufffd\ufffd'], + ['\xf4\x90\x80\x80', '\ufffd\ufffd\ufffd\ufffd'], + ['\xe2(A', '\ufffd(A'] + ])('replaces malformed UTF-8 %s and continues', (input, expected) => { + for (const byte of input) processor.processData(byte) + processor.processData('OK') + expect(chunks.join('')).toBe(expected + 'OK') + }) + + it('discards incomplete UTF-8 on reset without clearing capture or display', () => { + processor.processData('visible\xf0\x9f\x98') + processor.reset() + processor.processData('J') + expect(chunks.join('')).toBe('visibleJ') + expect(terminal.capture).toBe('visible\xf0\x9f\x98J') + expect(processor.terminalType).toBe(SolTerminalType.VTUTF8) + expect(processor.clearTerminal).not.toHaveBeenCalled() + }) + + it('maps all 128 CP437 high bytes independently of chunk boundaries', () => { + const expected = + 'ÇüéâäàåçêëèïîìÄÅ' + + 'ÉæÆôöòûùÿÖÜ¢£¥₧ƒ' + + 'áíóúñѪº¿⌐¬½¼¡«»' + + '░▒▓│┤╡╢╖╕╣║╗╝╜╛┐' + + '└┴┬├─┼╞╟╚╔╩╦╠═╬╧' + + '╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀' + + 'αßΓπΣσµτΦΘΩδ∞φε∩' + + '≡±≥≤⌠⌡÷≈°∙·√ⁿ²■\u00a0' + processor.setTerminalType(SolTerminalType.PCANSI) + for (let byte = 0x80; byte <= 0xff; byte++) processor.processData(String.fromCharCode(byte)) + expect(chunks.join('')).toBe(expected) + expect(terminal.capture).toBe(String.fromCharCode(...Array.from({ length: 128 }, (_, i) => i + 128))) + }) + + it('preserves every ASCII byte and ANSI attributes in PC-ANSI', () => { + processor.setTerminalType(SolTerminalType.PCANSI) + const ascii = String.fromCharCode(...Array.from({ length: 128 }, (_, i) => i)) + processor.processData(ascii + '\x1b[31mJ\x1b[0m\x1b[2J') + expect(chunks.join('')).toBe(ascii + '\x1b[31mJ\x1b[0m\x1b[2J') + }) + + it('switches modes without carrying partial UTF-8 state', () => { + processor.processData('\xe2\x82') + processor.setTerminalType(SolTerminalType.PCANSI) + processor.processData('\xda\xc4\xbf') + processor.setTerminalType(SolTerminalType.VTUTF8) + processor.processData(binary('\u20ac')) + expect(chunks.join('')).toBe('\u250c\u2500\u2510\u20ac') + expect(processor.clearTerminal).not.toHaveBeenCalled() + }) + + it('resets even when reselecting the current mode', () => { + processor.processData('\xe2\x82') + processor.setTerminalType(SolTerminalType.VTUTF8) + processor.processData('J') + expect(chunks.join('')).toBe('J') + }) + + it('rejects unsupported modes without mutating decoder state', () => { + processor.processData('\xe2\x82') + expect(() => { + // @ts-expect-error Exercise invalid values from untyped JavaScript consumers. + processor.setTerminalType(0) + }).toThrow(RangeError) + expect(processor.terminalType).toBe(SolTerminalType.VTUTF8) + processor.processData('\xac') + expect(chunks.join('')).toBe('\u20ac') + }) }) - it('Test TerminalDataProcessor for processData', () => { + it('decodes UTF-8 data split across chunks', () => { // callback function for Unit testing function callback(value: string): void { - result = value + result += value } // create object and set callback + result = '' const term = new AmtTerminal2(1) const tdataprocessor = new TerminalDataProcessor(term) + tdataprocessor.setTerminalType(SolTerminalType.VTUTF8) tdataprocessor.processDataToXterm = callback // Test input - const s = "123Z?“€'" + const encoded = new TextEncoder().encode("123Z?“€'") + const split = encoded.indexOf(0xe2) + 1 - // call processdata - tdataprocessor.processData(s) + tdataprocessor.processData(String.fromCharCode(...encoded.slice(0, split))) + tdataprocessor.processData(String.fromCharCode(...encoded.slice(split))) // Test output - expect(result).toBe("123Z?“¼'") - expect(term.capture).toBe("123Z?“€'") + expect(result).toBe("123Z?“€'") + expect(term.capture).toBe(String.fromCharCode(...encoded)) }) })