feat: phase 8 — Vite/TS canvas-based KVM console frontend
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:
163
crates/ericrfb-frontend/src/console.ts
Normal file
163
crates/ericrfb-frontend/src/console.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
49
crates/ericrfb-frontend/src/input.ts
Normal file
49
crates/ericrfb-frontend/src/input.ts
Normal 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]
|
||||
}
|
||||
57
crates/ericrfb-frontend/src/login.ts
Normal file
57
crates/ericrfb-frontend/src/login.ts
Normal 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'
|
||||
}
|
||||
})
|
||||
}
|
||||
5
crates/ericrfb-frontend/src/main.ts
Normal file
5
crates/ericrfb-frontend/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import './style.css'
|
||||
import { showLogin } from './login'
|
||||
|
||||
const app = document.getElementById('app')!
|
||||
showLogin(app)
|
||||
33
crates/ericrfb-frontend/src/protocol.ts
Normal file
33
crates/ericrfb-frontend/src/protocol.ts
Normal 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
|
||||
}
|
||||
111
crates/ericrfb-frontend/src/style.css
Normal file
111
crates/ericrfb-frontend/src/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user