From c31508f138046268b29f9fe6c20549c8739a759f Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Thu, 7 May 2026 09:21:21 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20phase=2010=20=E2=80=94=20reconnection,?= =?UTF-8?q?=20encoding=2010,=20disconnect=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/ericrfb-frontend/src/console.ts | 103 +++++++++++++++++++------ crates/ericrfb-frontend/src/login.ts | 3 + crates/ericrfb/src/codec/mod.rs | 1 + crates/ericrfb/src/codec/raw_tile.rs | 103 +++++++++++++++++++++++++ crates/ericrfb/src/session.rs | 13 +++- 5 files changed, 197 insertions(+), 26 deletions(-) create mode 100644 crates/ericrfb/src/codec/raw_tile.rs diff --git a/crates/ericrfb-frontend/src/console.ts b/crates/ericrfb-frontend/src/console.ts index edbdac7..1289710 100644 --- a/crates/ericrfb-frontend/src/console.ts +++ b/crates/ericrfb-frontend/src/console.ts @@ -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( ${boardName} + connecting...
@@ -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() } diff --git a/crates/ericrfb-frontend/src/login.ts b/crates/ericrfb-frontend/src/login.ts index 96b1d80..f1ac97b 100644 --- a/crates/ericrfb-frontend/src/login.ts +++ b/crates/ericrfb-frontend/src/login.ts @@ -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 diff --git a/crates/ericrfb/src/codec/mod.rs b/crates/ericrfb/src/codec/mod.rs index 18c0676..911794d 100644 --- a/crates/ericrfb/src/codec/mod.rs +++ b/crates/ericrfb/src/codec/mod.rs @@ -1,2 +1,3 @@ pub mod hextile; +pub mod raw_tile; pub mod tight; diff --git a/crates/ericrfb/src/codec/raw_tile.rs b/crates/ericrfb/src/codec/raw_tile.rs new file mode 100644 index 0000000..28c00b9 --- /dev/null +++ b/crates/ericrfb/src/codec/raw_tile.rs @@ -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)); + } +} diff --git a/crates/ericrfb/src/session.rs b/crates/ericrfb/src/session.rs index 2144408..355cd9c 100644 --- a/crates/ericrfb/src/session.rs +++ b/crates/ericrfb/src/session.rs @@ -1,7 +1,7 @@ use std::io::{BufReader, BufWriter}; use std::net::TcpStream; -use crate::codec::{hextile, tight}; +use crate::codec::{hextile, raw_tile, tight}; use crate::framebuffer::Framebuffer; use crate::handshake::{self, Config, ServerInit}; 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); } 5 => { - // Hextile hextile::decode_hextile( &mut self.reader, &mut self.framebuffer, @@ -222,6 +221,16 @@ impl ActiveSession { hdr.h, )?; } + 10 => { + raw_tile::decode_raw_tile( + &mut self.reader, + &mut self.framebuffer, + hdr.x, + hdr.y, + hdr.w, + hdr.h, + )?; + } other => { return Err(SessionError::UnsupportedEncoding(other)); }