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));
}