feat: phase 10 — reconnection, encoding 10, disconnect button
Frontend reconnection: - WebSocket auto-reconnects with exponential backoff (1s → 30s) - Re-authenticates with OmniView to get fresh APPLET_ID on reconnect - Credentials stored in sessionStorage for automatic re-login - Status bar shows connection state and reconnect countdown - Disconnect button returns to login screen Encoding 10 (Raw with tile interleave): - codec/raw_tile.rs: decodes encoding 10 per ByteColorRFBRenderer.for() - Flag byte bit 0 selects plain raw vs 16x16 tile-interleaved data - Deinterleave handles edge tiles smaller than 16x16 - Wired into session dispatch - 2 unit tests 39 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
TAG_BLIT, TAG_RESIZE,
|
||||
makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel,
|
||||
} from './protocol'
|
||||
import { showLogin } from './login'
|
||||
|
||||
export function startConsole(
|
||||
app: HTMLElement,
|
||||
@@ -15,6 +16,7 @@ export function startConsole(
|
||||
<span>${boardName}</span>
|
||||
<button id="btn-cad">Ctrl+Alt+Del</button>
|
||||
<button id="btn-fs">Fullscreen</button>
|
||||
<button id="btn-disconnect">Disconnect</button>
|
||||
<span class="status" id="status">connecting...</span>
|
||||
</div>
|
||||
<div class="console-wrap">
|
||||
@@ -26,29 +28,74 @@ export function startConsole(
|
||||
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()
|
||||
function setStatus(text: string, connected: boolean) {
|
||||
statusEl.textContent = text
|
||||
statusEl.classList.toggle('connected', connected)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
statusEl.textContent = 'disconnected'
|
||||
statusEl.classList.remove('connected')
|
||||
// WebSocket with reconnect
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: number | undefined
|
||||
let reconnectDelay = 1000
|
||||
|
||||
function connect() {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
setStatus('connecting...', false)
|
||||
|
||||
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${wsProto}//${location.host}/api/ws?applet_id=${encodeURIComponent(appletId)}&port=${port}`
|
||||
ws = new WebSocket(wsUrl)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus('connected', true)
|
||||
reconnectDelay = 1000
|
||||
canvas.focus()
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setStatus(`disconnected — reconnecting in ${reconnectDelay / 1000}s...`, false)
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus('connection error', false)
|
||||
}
|
||||
|
||||
ws.onmessage = handleMessage
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
statusEl.textContent = 'error'
|
||||
statusEl.classList.remove('connected')
|
||||
function scheduleReconnect() {
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
// Re-login to get a fresh APPLET_ID, then reconnect
|
||||
relogin()
|
||||
}, reconnectDelay)
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 30000)
|
||||
}
|
||||
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
async function relogin() {
|
||||
setStatus('re-authenticating...', false)
|
||||
try {
|
||||
const resp = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: sessionStorage.getItem('blekin_user') || 'administrator',
|
||||
password: sessionStorage.getItem('blekin_pass') || '',
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) throw new Error('re-auth failed')
|
||||
const data = await resp.json()
|
||||
appletId = data.applet_id
|
||||
port = data.port
|
||||
connect()
|
||||
} catch {
|
||||
setStatus(`re-auth failed — retry in ${reconnectDelay / 1000}s...`, false)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(ev: MessageEvent) {
|
||||
if (!(ev.data instanceof ArrayBuffer)) return
|
||||
const view = new DataView(ev.data)
|
||||
const tag = view.getUint8(0)
|
||||
@@ -80,7 +127,7 @@ export function startConsole(
|
||||
canvas.addEventListener('keydown', (e) => {
|
||||
e.preventDefault()
|
||||
const sc = codeToScancode(e.code)
|
||||
if (sc !== undefined && ws.readyState === WebSocket.OPEN) {
|
||||
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(makeKeyPress(sc))
|
||||
}
|
||||
})
|
||||
@@ -88,14 +135,14 @@ export function startConsole(
|
||||
canvas.addEventListener('keyup', (e) => {
|
||||
e.preventDefault()
|
||||
const sc = codeToScancode(e.code)
|
||||
if (sc !== undefined && ws.readyState === WebSocket.OPEN) {
|
||||
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(makeKeyRelease(sc))
|
||||
}
|
||||
})
|
||||
|
||||
// Mouse input
|
||||
function sendPointer(e: MouseEvent) {
|
||||
if (ws.readyState !== WebSocket.OPEN) return
|
||||
if (ws?.readyState !== WebSocket.OPEN) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const scaleX = canvas.width / rect.width
|
||||
const scaleY = canvas.height / rect.height
|
||||
@@ -131,22 +178,20 @@ export function startConsole(
|
||||
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
e.preventDefault()
|
||||
if (ws.readyState !== WebSocket.OPEN) return
|
||||
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) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(makeCtrlAltDel())
|
||||
}
|
||||
canvas.focus()
|
||||
@@ -160,4 +205,14 @@ export function startConsole(
|
||||
}
|
||||
canvas.focus()
|
||||
})
|
||||
|
||||
document.getElementById('btn-disconnect')!.addEventListener('click', () => {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
ws?.close()
|
||||
ws = null
|
||||
showLogin(app)
|
||||
})
|
||||
|
||||
// Start connection
|
||||
connect()
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ export function showLogin(app: HTMLElement) {
|
||||
}
|
||||
|
||||
const data: LoginResponse = await resp.json()
|
||||
// Store credentials for automatic reconnect
|
||||
sessionStorage.setItem('blekin_user', username)
|
||||
sessionStorage.setItem('blekin_pass', password)
|
||||
startConsole(app, data.applet_id, data.port, data.board_name)
|
||||
} catch (err) {
|
||||
errorDiv.textContent = (err as Error).message
|
||||
|
||||
Reference in New Issue
Block a user