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,
|
TAG_BLIT, TAG_RESIZE,
|
||||||
makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel,
|
makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel,
|
||||||
} from './protocol'
|
} from './protocol'
|
||||||
|
import { showLogin } from './login'
|
||||||
|
|
||||||
export function startConsole(
|
export function startConsole(
|
||||||
app: HTMLElement,
|
app: HTMLElement,
|
||||||
@@ -15,6 +16,7 @@ export function startConsole(
|
|||||||
<span>${boardName}</span>
|
<span>${boardName}</span>
|
||||||
<button id="btn-cad">Ctrl+Alt+Del</button>
|
<button id="btn-cad">Ctrl+Alt+Del</button>
|
||||||
<button id="btn-fs">Fullscreen</button>
|
<button id="btn-fs">Fullscreen</button>
|
||||||
|
<button id="btn-disconnect">Disconnect</button>
|
||||||
<span class="status" id="status">connecting...</span>
|
<span class="status" id="status">connecting...</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="console-wrap">
|
<div class="console-wrap">
|
||||||
@@ -26,29 +28,74 @@ export function startConsole(
|
|||||||
const ctx = canvas.getContext('2d')!
|
const ctx = canvas.getContext('2d')!
|
||||||
const statusEl = document.getElementById('status')!
|
const statusEl = document.getElementById('status')!
|
||||||
|
|
||||||
// WebSocket
|
function setStatus(text: string, connected: boolean) {
|
||||||
|
statusEl.textContent = text
|
||||||
|
statusEl.classList.toggle('connected', 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 wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const wsUrl = `${wsProto}//${location.host}/api/ws?applet_id=${encodeURIComponent(appletId)}&port=${port}`
|
const wsUrl = `${wsProto}//${location.host}/api/ws?applet_id=${encodeURIComponent(appletId)}&port=${port}`
|
||||||
const ws = new WebSocket(wsUrl)
|
ws = new WebSocket(wsUrl)
|
||||||
ws.binaryType = 'arraybuffer'
|
ws.binaryType = 'arraybuffer'
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
statusEl.textContent = 'connected'
|
setStatus('connected', true)
|
||||||
statusEl.classList.add('connected')
|
reconnectDelay = 1000
|
||||||
canvas.focus()
|
canvas.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
statusEl.textContent = 'disconnected'
|
setStatus(`disconnected — reconnecting in ${reconnectDelay / 1000}s...`, false)
|
||||||
statusEl.classList.remove('connected')
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
statusEl.textContent = 'error'
|
setStatus('connection error', false)
|
||||||
statusEl.classList.remove('connected')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (ev: MessageEvent) => {
|
ws.onmessage = handleMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
reconnectTimer = window.setTimeout(() => {
|
||||||
|
// Re-login to get a fresh APPLET_ID, then reconnect
|
||||||
|
relogin()
|
||||||
|
}, reconnectDelay)
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
if (!(ev.data instanceof ArrayBuffer)) return
|
||||||
const view = new DataView(ev.data)
|
const view = new DataView(ev.data)
|
||||||
const tag = view.getUint8(0)
|
const tag = view.getUint8(0)
|
||||||
@@ -80,7 +127,7 @@ export function startConsole(
|
|||||||
canvas.addEventListener('keydown', (e) => {
|
canvas.addEventListener('keydown', (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const sc = codeToScancode(e.code)
|
const sc = codeToScancode(e.code)
|
||||||
if (sc !== undefined && ws.readyState === WebSocket.OPEN) {
|
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) {
|
||||||
ws.send(makeKeyPress(sc))
|
ws.send(makeKeyPress(sc))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -88,14 +135,14 @@ export function startConsole(
|
|||||||
canvas.addEventListener('keyup', (e) => {
|
canvas.addEventListener('keyup', (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const sc = codeToScancode(e.code)
|
const sc = codeToScancode(e.code)
|
||||||
if (sc !== undefined && ws.readyState === WebSocket.OPEN) {
|
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) {
|
||||||
ws.send(makeKeyRelease(sc))
|
ws.send(makeKeyRelease(sc))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mouse input
|
// Mouse input
|
||||||
function sendPointer(e: MouseEvent) {
|
function sendPointer(e: MouseEvent) {
|
||||||
if (ws.readyState !== WebSocket.OPEN) return
|
if (ws?.readyState !== WebSocket.OPEN) return
|
||||||
const rect = canvas.getBoundingClientRect()
|
const rect = canvas.getBoundingClientRect()
|
||||||
const scaleX = canvas.width / rect.width
|
const scaleX = canvas.width / rect.width
|
||||||
const scaleY = canvas.height / rect.height
|
const scaleY = canvas.height / rect.height
|
||||||
@@ -131,22 +178,20 @@ export function startConsole(
|
|||||||
|
|
||||||
canvas.addEventListener('wheel', (e) => {
|
canvas.addEventListener('wheel', (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (ws.readyState !== WebSocket.OPEN) return
|
if (ws?.readyState !== WebSocket.OPEN) return
|
||||||
const rect = canvas.getBoundingClientRect()
|
const rect = canvas.getBoundingClientRect()
|
||||||
const scaleX = canvas.width / rect.width
|
const scaleX = canvas.width / rect.width
|
||||||
const scaleY = canvas.height / rect.height
|
const scaleY = canvas.height / rect.height
|
||||||
const x = Math.round((e.clientX - rect.left) * scaleX)
|
const x = Math.round((e.clientX - rect.left) * scaleX)
|
||||||
const y = Math.round((e.clientY - rect.top) * scaleY)
|
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
|
const scrollMask = e.deltaY < 0 ? 8 : 16
|
||||||
ws.send(makePointer(x, y, buttonMask | scrollMask))
|
ws.send(makePointer(x, y, buttonMask | scrollMask))
|
||||||
// Release scroll button immediately
|
|
||||||
ws.send(makePointer(x, y, buttonMask))
|
ws.send(makePointer(x, y, buttonMask))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Toolbar
|
// Toolbar
|
||||||
document.getElementById('btn-cad')!.addEventListener('click', () => {
|
document.getElementById('btn-cad')!.addEventListener('click', () => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
ws.send(makeCtrlAltDel())
|
ws.send(makeCtrlAltDel())
|
||||||
}
|
}
|
||||||
canvas.focus()
|
canvas.focus()
|
||||||
@@ -160,4 +205,14 @@ export function startConsole(
|
|||||||
}
|
}
|
||||||
canvas.focus()
|
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()
|
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)
|
startConsole(app, data.applet_id, data.port, data.board_name)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorDiv.textContent = (err as Error).message
|
errorDiv.textContent = (err as Error).message
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod hextile;
|
pub mod hextile;
|
||||||
|
pub mod raw_tile;
|
||||||
pub mod tight;
|
pub mod tight;
|
||||||
|
|||||||
103
crates/ericrfb/src/codec/raw_tile.rs
Normal file
103
crates/ericrfb/src/codec/raw_tile.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
use crate::framebuffer::Framebuffer;
|
||||||
|
use crate::proto::{self, read_exact, read_i8};
|
||||||
|
|
||||||
|
/// Decode encoding 10 (Raw with tile interleave).
|
||||||
|
///
|
||||||
|
/// Reads 1 flag byte. If bit 0 is clear, falls back to plain Raw.
|
||||||
|
/// If bit 0 is set, reads w*h bytes of 16x16 tile-interleaved data
|
||||||
|
/// and deinterleaves to row-major before blitting.
|
||||||
|
///
|
||||||
|
/// Reference: ByteColorRFBRenderer.for() line 109.
|
||||||
|
pub fn decode_raw_tile(
|
||||||
|
r: &mut impl Read,
|
||||||
|
fb: &mut Framebuffer,
|
||||||
|
rx: u16,
|
||||||
|
ry: u16,
|
||||||
|
rw: u16,
|
||||||
|
rh: u16,
|
||||||
|
) -> proto::Result<()> {
|
||||||
|
let flag = read_i8(r)?;
|
||||||
|
|
||||||
|
let w = rw as usize;
|
||||||
|
let h = rh as usize;
|
||||||
|
let size = w * h;
|
||||||
|
|
||||||
|
if flag & 1 == 0 {
|
||||||
|
// Plain raw — no interleave
|
||||||
|
let data = read_exact(r, size)?;
|
||||||
|
fb.apply_raw(rx, ry, rw, rh, &data);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read tile-interleaved data
|
||||||
|
let interleaved = read_exact(r, size)?;
|
||||||
|
|
||||||
|
// Clamp to framebuffer bounds
|
||||||
|
let w = w.min(fb.width as usize - rx as usize);
|
||||||
|
let h = h.min(fb.height as usize - ry as usize);
|
||||||
|
|
||||||
|
// Deinterleave 16x16 tiles to row-major.
|
||||||
|
// Input is stored tile-by-tile: all pixels of tile (0,0), then tile (1,0), etc.
|
||||||
|
// Each tile is row-major within itself.
|
||||||
|
let tile = 16usize;
|
||||||
|
let tiles_x = w.div_ceil(tile);
|
||||||
|
let mut output = vec![0u8; w * h];
|
||||||
|
|
||||||
|
for row in 0..h {
|
||||||
|
let tile_row = row / tile;
|
||||||
|
let row_in_tile = row % tile;
|
||||||
|
for col in 0..w {
|
||||||
|
let tile_col = col / tile;
|
||||||
|
let col_in_tile = col % tile;
|
||||||
|
// Tile index in raster order
|
||||||
|
let tile_idx = tile_row * tiles_x + tile_col;
|
||||||
|
// Tile dimensions (edge tiles may be smaller)
|
||||||
|
let tw = tile.min(w - tile_col * tile);
|
||||||
|
// Offset within tile data: preceding full tiles + row offset + col offset
|
||||||
|
let mut tile_data_start = 0usize;
|
||||||
|
// Sum sizes of all preceding tiles
|
||||||
|
for t in 0..tile_idx {
|
||||||
|
let tr = t / tiles_x;
|
||||||
|
let tc = t % tiles_x;
|
||||||
|
let this_tw = tile.min(w - tc * tile);
|
||||||
|
let this_th = tile.min(h - tr * tile);
|
||||||
|
tile_data_start += this_tw * this_th;
|
||||||
|
}
|
||||||
|
let src = tile_data_start + row_in_tile * tw + col_in_tile;
|
||||||
|
output[row * w + col] = interleaved[src];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fb.apply_raw(rx, ry, w as u16, h as u16, &output);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_raw_tile_plain_fallback() {
|
||||||
|
let mut fb = Framebuffer::new(4, 4);
|
||||||
|
// Flag byte with bit 0 clear → plain raw
|
||||||
|
let mut data = vec![0x00i8 as u8]; // flag
|
||||||
|
data.extend_from_slice(&[0x42; 16]); // 4x4 pixels
|
||||||
|
let mut c = Cursor::new(data);
|
||||||
|
decode_raw_tile(&mut c, &mut fb, 0, 0, 4, 4).unwrap();
|
||||||
|
assert!(fb.pixels.iter().all(|&p| p == 0x42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_raw_tile_small_no_tile_boundary() {
|
||||||
|
let mut fb = Framebuffer::new(4, 4);
|
||||||
|
// Flag byte with bit 0 set, but 4x4 < 16x16 so no tile wrap occurs
|
||||||
|
let mut data = vec![0x01u8]; // flag with interleave
|
||||||
|
data.extend_from_slice(&[0xAA; 16]); // 4x4 pixels (all same, no deinterleave effect)
|
||||||
|
let mut c = Cursor::new(data);
|
||||||
|
decode_raw_tile(&mut c, &mut fb, 0, 0, 4, 4).unwrap();
|
||||||
|
assert!(fb.pixels.iter().all(|&p| p == 0xAA));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::io::{BufReader, BufWriter};
|
use std::io::{BufReader, BufWriter};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
|
|
||||||
use crate::codec::{hextile, tight};
|
use crate::codec::{hextile, raw_tile, tight};
|
||||||
use crate::framebuffer::Framebuffer;
|
use crate::framebuffer::Framebuffer;
|
||||||
use crate::handshake::{self, Config, ServerInit};
|
use crate::handshake::{self, Config, ServerInit};
|
||||||
use crate::msg::{self, ServerMsg};
|
use crate::msg::{self, ServerMsg};
|
||||||
@@ -200,7 +200,6 @@ impl ActiveSession {
|
|||||||
.copy_rect(src_x, src_y, hdr.x, hdr.y, hdr.w, hdr.h);
|
.copy_rect(src_x, src_y, hdr.x, hdr.y, hdr.w, hdr.h);
|
||||||
}
|
}
|
||||||
5 => {
|
5 => {
|
||||||
// Hextile
|
|
||||||
hextile::decode_hextile(
|
hextile::decode_hextile(
|
||||||
&mut self.reader,
|
&mut self.reader,
|
||||||
&mut self.framebuffer,
|
&mut self.framebuffer,
|
||||||
@@ -222,6 +221,16 @@ impl ActiveSession {
|
|||||||
hdr.h,
|
hdr.h,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
10 => {
|
||||||
|
raw_tile::decode_raw_tile(
|
||||||
|
&mut self.reader,
|
||||||
|
&mut self.framebuffer,
|
||||||
|
hdr.x,
|
||||||
|
hdr.y,
|
||||||
|
hdr.w,
|
||||||
|
hdr.h,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
return Err(SessionError::UnsupportedEncoding(other));
|
return Err(SessionError::UnsupportedEncoding(other));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user