Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/core/AMTRedirector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Blob | ArrayBufferLike>): any {
onMessage(e: MessageEvent<Blob | ArrayBufferLike | string>): any {
try {
// console.log(e.data)
this.inDataCount++
Expand Down Expand Up @@ -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')
Expand Down
9 changes: 9 additions & 0 deletions src/core/SolTerminalType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*********************************************************************
* Copyright (c) Intel Corporation 2026
* SPDX-License-Identifier: Apache-2.0
**********************************************************************/

export enum SolTerminalType {
VTUTF8 = 1,
PCANSI = 2
}
68 changes: 55 additions & 13 deletions src/core/TerminalDataProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
33 changes: 33 additions & 0 deletions src/test/amtredirector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>(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<number>(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)
Expand Down
167 changes: 157 additions & 10 deletions src/test/terminaldataprocessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
})
})
Loading