feat: phase 8 — Vite/TS canvas-based KVM console frontend
All checks were successful
CI / fmt (push) Successful in 28s
CI / check (push) Successful in 1m31s
CI / clippy (push) Successful in 1m29s

crates/ericrfb-frontend — vanilla TypeScript + Vite:

login.ts:
- Login form POSTs to /api/login, receives applet_id
- Error display, auto-transitions to console view on success

console.ts:
- Canvas-based renderer sized to framebuffer dimensions
- WebSocket binary protocol decoder: TAG_BLIT → putImageData,
  TAG_RESIZE → canvas resize
- Keyboard capture: keydown/keyup → JS code → e-RIC scancode → WS
- Mouse capture: move/click/wheel → scaled coords + button mask → WS
- Right-click and context menu suppressed for pass-through

input.ts:
- Full 104-key JS KeyboardEvent.code → scancode mapping table

protocol.ts:
- Binary message builders matching proxy WS protocol tags

Toolbar: Ctrl+Alt+Del button, Fullscreen toggle.
Dark theme, pixelated canvas rendering, cursor hidden over console.

Vite config proxies /api to localhost:3000 for dev mode.
Build outputs to ../../dist for proxy static serving.

Builds to 5.8KB JS + 1.4KB CSS gzipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 15:19:53 +03:00
parent 3bd7ee8eac
commit 8692c0e46a
13 changed files with 1428 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
import { codeToScancode } from './input'
import {
TAG_BLIT, TAG_RESIZE,
makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel,
} from './protocol'
export function startConsole(
app: HTMLElement,
appletId: string,
port: number,
boardName: string,
) {
app.innerHTML = `
<div class="toolbar">
<span>${boardName}</span>
<button id="btn-cad">Ctrl+Alt+Del</button>
<button id="btn-fs">Fullscreen</button>
<span class="status" id="status">connecting...</span>
</div>
<div class="console-wrap">
<canvas id="canvas" tabindex="0"></canvas>
</div>
`
const canvas = document.getElementById('canvas') as HTMLCanvasElement
const ctx = canvas.getContext('2d')!
const statusEl = document.getElementById('status')!
// WebSocket
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProto}//${location.host}/api/ws?applet_id=${encodeURIComponent(appletId)}&port=${port}`
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
statusEl.textContent = 'connected'
statusEl.classList.add('connected')
canvas.focus()
}
ws.onclose = () => {
statusEl.textContent = 'disconnected'
statusEl.classList.remove('connected')
}
ws.onerror = () => {
statusEl.textContent = 'error'
statusEl.classList.remove('connected')
}
ws.onmessage = (ev: MessageEvent) => {
if (!(ev.data instanceof ArrayBuffer)) return
const view = new DataView(ev.data)
const tag = view.getUint8(0)
switch (tag) {
case TAG_RESIZE: {
const w = view.getUint16(1)
const h = view.getUint16(3)
canvas.width = w
canvas.height = h
break
}
case TAG_BLIT: {
const x = view.getUint16(1)
const y = view.getUint16(3)
const w = view.getUint16(5)
const h = view.getUint16(7)
const rgba = new Uint8ClampedArray(ev.data, 9)
const img = new ImageData(rgba, w, h)
ctx.putImageData(img, x, y)
break
}
}
}
// Keyboard input
let buttonMask = 0
canvas.addEventListener('keydown', (e) => {
e.preventDefault()
const sc = codeToScancode(e.code)
if (sc !== undefined && ws.readyState === WebSocket.OPEN) {
ws.send(makeKeyPress(sc))
}
})
canvas.addEventListener('keyup', (e) => {
e.preventDefault()
const sc = codeToScancode(e.code)
if (sc !== undefined && ws.readyState === WebSocket.OPEN) {
ws.send(makeKeyRelease(sc))
}
})
// Mouse input
function sendPointer(e: MouseEvent) {
if (ws.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
const x = Math.round((e.clientX - rect.left) * scaleX)
const y = Math.round((e.clientY - rect.top) * scaleY)
ws.send(makePointer(
Math.max(0, Math.min(x, canvas.width - 1)),
Math.max(0, Math.min(y, canvas.height - 1)),
buttonMask,
))
}
canvas.addEventListener('mousemove', sendPointer)
canvas.addEventListener('mousedown', (e) => {
e.preventDefault()
canvas.focus()
if (e.button === 0) buttonMask |= 1
else if (e.button === 1) buttonMask |= 2
else if (e.button === 2) buttonMask |= 4
sendPointer(e)
})
canvas.addEventListener('mouseup', (e) => {
e.preventDefault()
if (e.button === 0) buttonMask &= ~1
else if (e.button === 1) buttonMask &= ~2
else if (e.button === 2) buttonMask &= ~4
sendPointer(e)
})
canvas.addEventListener('contextmenu', (e) => e.preventDefault())
canvas.addEventListener('wheel', (e) => {
e.preventDefault()
if (ws.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
const x = Math.round((e.clientX - rect.left) * scaleX)
const y = Math.round((e.clientY - rect.top) * scaleY)
// Scroll up = button 4 (bit 3), scroll down = button 5 (bit 4)
const scrollMask = e.deltaY < 0 ? 8 : 16
ws.send(makePointer(x, y, buttonMask | scrollMask))
// Release scroll button immediately
ws.send(makePointer(x, y, buttonMask))
})
// Toolbar
document.getElementById('btn-cad')!.addEventListener('click', () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(makeCtrlAltDel())
}
canvas.focus()
})
document.getElementById('btn-fs')!.addEventListener('click', () => {
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
app.requestFullscreen()
}
canvas.focus()
})
}

View File

@@ -0,0 +1,49 @@
// JavaScript KeyboardEvent.code → e-RIC scancode (KbdLayout_104pc)
// Must match crates/ericrfb/src/input.rs js_code_to_scancode()
const KEY_MAP: Record<string, number> = {
Escape: 0,
F1: 59, F2: 60, F3: 61, F4: 62, F5: 63, F6: 64,
F7: 65, F8: 66, F9: 67, F10: 68, F11: 69, F12: 70,
Backquote: 1,
Digit1: 2, Digit2: 3, Digit3: 4, Digit4: 5, Digit5: 6,
Digit6: 7, Digit7: 8, Digit8: 9, Digit9: 10, Digit0: 11,
Minus: 12, Equal: 13, Backspace: 14,
Tab: 15,
KeyQ: 16, KeyW: 17, KeyE: 18, KeyR: 19, KeyT: 20,
KeyY: 21, KeyU: 22, KeyI: 23, KeyO: 24, KeyP: 25,
BracketLeft: 26, BracketRight: 27,
CapsLock: 28,
KeyA: 29, KeyS: 30, KeyD: 31, KeyF: 32, KeyG: 33,
KeyH: 34, KeyJ: 35, KeyK: 36, KeyL: 37,
Semicolon: 38, Quote: 39, Enter: 40,
ShiftLeft: 41, Backslash: 42,
KeyZ: 43, KeyX: 44, KeyC: 45, KeyV: 46, KeyB: 47,
KeyN: 48, KeyM: 49, Comma: 50, Period: 51, Slash: 52,
ShiftRight: 53,
ControlLeft: 54, MetaLeft: 105, AltLeft: 55,
Space: 56,
AltRight: 57, MetaRight: 106, ControlRight: 58,
PrintScreen: 71, ScrollLock: 72, Pause: 73,
Insert: 75, Home: 76, PageUp: 77,
Delete: 78, End: 79, PageDown: 80,
ArrowUp: 81, ArrowLeft: 82, ArrowDown: 83, ArrowRight: 84,
NumLock: 85, NumpadDivide: 86, NumpadMultiply: 87, NumpadSubtract: 88,
NumpadAdd: 89, NumpadEnter: 98,
Numpad7: 90, Numpad8: 94, Numpad9: 99,
Numpad4: 91, Numpad5: 92, Numpad6: 93,
Numpad1: 95, Numpad2: 96, Numpad3: 97,
Numpad0: 100, NumpadDecimal: 101,
}
export function codeToScancode(code: string): number | undefined {
return KEY_MAP[code]
}

View File

@@ -0,0 +1,57 @@
import { startConsole } from './console'
interface LoginResponse {
applet_id: string
port: number
protocol_version: string
board_name: string
}
export function showLogin(app: HTMLElement) {
app.innerHTML = `
<div class="login">
<form class="login-form">
<h1>blekin KVM Console</h1>
<input type="text" name="username" placeholder="Username" value="administrator" autocomplete="username" />
<input type="password" name="password" placeholder="Password" autocomplete="current-password" />
<button type="submit">Connect</button>
<div class="login-error" hidden></div>
</form>
</div>
`
const form = app.querySelector('form')!
const errorDiv = app.querySelector('.login-error')! as HTMLElement
const button = app.querySelector('button')!
form.addEventListener('submit', async (e) => {
e.preventDefault()
errorDiv.hidden = true
button.disabled = true
button.textContent = 'Connecting...'
const username = (form.querySelector('[name=username]') as HTMLInputElement).value
const password = (form.querySelector('[name=password]') as HTMLInputElement).value
try {
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!resp.ok) {
const err = await resp.json()
throw new Error(err.error || 'Login failed')
}
const data: LoginResponse = await resp.json()
startConsole(app, data.applet_id, data.port, data.board_name)
} catch (err) {
errorDiv.textContent = (err as Error).message
errorDiv.hidden = false
button.disabled = false
button.textContent = 'Connect'
}
})
}

View File

@@ -0,0 +1,5 @@
import './style.css'
import { showLogin } from './login'
const app = document.getElementById('app')!
showLogin(app)

View File

@@ -0,0 +1,33 @@
// Binary WS protocol tags — must match crates/ericrfb-proxy/src/ws.rs
// Server → Client
export const TAG_BLIT = 0x01
export const TAG_RESIZE = 0x03
// Client → Server
export const TAG_KEY_PRESS = 0x10
export const TAG_KEY_RELEASE = 0x11
export const TAG_POINTER = 0x12
export const TAG_CTRL_ALT_DEL = 0x13
export function makeKeyPress(scancode: number): ArrayBuffer {
return new Uint8Array([TAG_KEY_PRESS, scancode]).buffer
}
export function makeKeyRelease(scancode: number): ArrayBuffer {
return new Uint8Array([TAG_KEY_RELEASE, scancode]).buffer
}
export function makePointer(x: number, y: number, mask: number): ArrayBuffer {
const buf = new ArrayBuffer(6)
const view = new DataView(buf)
view.setUint8(0, TAG_POINTER)
view.setUint16(1, x)
view.setUint16(3, y)
view.setUint8(5, mask)
return buf
}
export function makeCtrlAltDel(): ArrayBuffer {
return new Uint8Array([TAG_CTRL_ALT_DEL]).buffer
}

View File

@@ -0,0 +1,111 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
height: 100vh;
overflow: hidden;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Login */
.login {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.login-form {
background: #2a2a2a;
padding: 2rem;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 320px;
}
.login-form h1 {
font-size: 1.25rem;
text-align: center;
}
.login-form input {
padding: 0.5rem;
border: 1px solid #444;
border-radius: 4px;
background: #333;
color: #e0e0e0;
font-size: 0.9rem;
}
.login-form button {
padding: 0.5rem;
border: none;
border-radius: 4px;
background: #4a7c59;
color: white;
font-size: 0.9rem;
cursor: pointer;
}
.login-form button:hover { background: #5a9c69; }
.login-form button:disabled { opacity: 0.5; cursor: not-allowed; }
.login-error {
color: #e74c3c;
font-size: 0.85rem;
text-align: center;
}
/* Toolbar */
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: #222;
border-bottom: 1px solid #333;
font-size: 0.8rem;
}
.toolbar button {
padding: 0.25rem 0.75rem;
border: 1px solid #444;
border-radius: 3px;
background: #333;
color: #ccc;
cursor: pointer;
font-size: 0.8rem;
}
.toolbar button:hover { background: #444; }
.toolbar .status {
margin-left: auto;
color: #888;
}
.toolbar .status.connected { color: #4a7c59; }
/* Console */
.console-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
overflow: hidden;
}
.console-wrap canvas {
image-rendering: pixelated;
cursor: none;
}