Compare commits

..

43 Commits

Author SHA1 Message Date
fe5e766dc6 doc: add README with build, config, deployment, and OSCAR hotkey guide
All checks were successful
Publish / frontend (push) Has been skipped
Publish / backend (push) Has been skipped
CI / fmt (push) Successful in 30s
CI / clippy (push) Successful in 1m32s
CI / check (push) Successful in 1m35s
Covers architecture, building, configuration, deployment, port
switching with Avocent OSCAR (tested hotkey syntax with recommended
pause settings), full hotkey syntax reference, and protocol heritage
naming the Peppercon e-RIC/LARA lineage.

Completes success criterion 5: source on git.lair.cafe with a README
that names the Peppercon e-RIC heritage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:51:37 +03:00
8c2ea95723 fix: account for proxy pipeline latency in switch overlay duration
All checks were successful
Publish / backend (push) Has been skipped
CI / fmt (push) Successful in 31s
Publish / frontend (push) Successful in 45s
CI / check (push) Successful in 1m29s
CI / clippy (push) Successful in 1m27s
Add 4s for the WS-to-Belkin-to-Avocent pipeline delay before the key
sequence starts executing, plus 1s settle time after for the video
stream to stabilize. Total overlay = 4s + (pauses * pause_duration) + 1s.

[deploy: frontend]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:39:40 +03:00
a57247cb46 fix: switch overlay dismiss — use display:none and named callback
All checks were successful
Publish / backend (push) Has been skipped
CI / fmt (push) Successful in 35s
Publish / frontend (push) Successful in 55s
CI / clippy (push) Successful in 1m23s
CI / check (push) Successful in 1m27s
[deploy: frontend]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:33:35 +03:00
ef48bd40cd ci: verify publish gating (no deploy directive)
All checks were successful
CI / fmt (push) Successful in 29s
CI / check (push) Successful in 1m19s
Publish / frontend (push) Has been skipped
Publish / backend (push) Has been skipped
CI / clippy (push) Successful in 1m17s
2026-05-07 13:28:32 +03:00
d4bbe6450f ci: use bracketed deploy directives to avoid false matches
Some checks are pending
CI / fmt (push) Waiting to run
CI / clippy (push) Waiting to run
Publish / frontend (push) Waiting to run
Publish / backend (push) Waiting to run
CI / check (push) Successful in 1m14s
Changed from "deploy: frontend" to "[deploy: frontend]" to prevent
substring matches against prose in the commit message body.

Directives: [deploy: frontend], [deploy: backend],
[deploy: frontend, backend]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:26:43 +03:00
f62084eac7 ci: gate publish jobs on commit message deploy directives
Some checks are pending
Publish / backend (push) Waiting to run
CI / fmt (push) Successful in 33s
Publish / frontend (push) Successful in 45s
CI / clippy (push) Successful in 1m43s
CI / check (push) Successful in 1m44s
Publish jobs now only run when the commit message contains:
- "deploy: frontend" — deploys frontend only
- "deploy: backend" — deploys backend only
- "deploy: backend, frontend" or "deploy: frontend, backend" — both

Case-insensitive matching. Commits without a deploy directive
will only run CI (check/fmt/clippy), not publish.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:24:44 +03:00
edb6853e3a fix: switch overlay dismissal and port dropdown selection persistence
All checks were successful
CI / fmt (push) Successful in 34s
CI / check (push) Successful in 1m14s
CI / clippy (push) Successful in 1m19s
Publish / frontend (push) Successful in 52s
Publish / backend (push) Successful in 2m36s
- Overlay now reliably dismissed: use window.setTimeout and inline
  overlay hide instead of separate function that could lose DOM ref
- Minimum 1s duration to prevent flash for short hotkey sequences
- Port dropdown preserves user's selection across page refreshes
  by tracking selectedPort locally instead of always using device's
  active_port
- Prevent duplicate change listener on port select across remounts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:20:30 +03:00
2e6f80f9ac feat: suspend input during port switch with overlay and countdown
All checks were successful
CI / fmt (push) Successful in 34s
Publish / frontend (push) Successful in 55s
CI / clippy (push) Successful in 1m21s
CI / check (push) Successful in 1m37s
Publish / backend (push) Successful in 2m51s
When switching ports via the console dropdown:
- Input (keyboard, mouse, wheel) is suspended immediately to prevent
  stray events from interfering with the OSCAR hotkey sequence
- A semi-transparent overlay with spinner and countdown timer appears
  over the console canvas
- Duration is calculated from the actual key_pause_duration setting
  multiplied by the number of * pause tokens in the port's hotkey,
  plus a 500ms buffer
- Input resumes and overlay disappears when the timer expires

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:11:47 +03:00
7406b4ac02 feat: auto-resume session on page refresh
All checks were successful
CI / fmt (push) Successful in 35s
Publish / frontend (push) Successful in 48s
CI / check (push) Successful in 1m28s
CI / clippy (push) Successful in 1m25s
Publish / backend (push) Successful in 2m42s
On load, check sessionStorage for stored credentials (saved during
login). If present, auto-authenticate and skip the login form. Falls
back to showing login if the stored credentials fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 12:19:03 +03:00
35db634317 fix: hotkey-based port switching and HTML entity unescaping
All checks were successful
CI / fmt (push) Successful in 36s
Publish / frontend (push) Successful in 44s
CI / check (push) Successful in 1m23s
CI / clippy (push) Successful in 1m36s
Publish / backend (push) Successful in 2m45s
Three fixes for KVM port management:

1. HTML unescape scraped values — the > character in hotkey strings
   (e.g., PrintScreen>0>1) gets entity-encoded to &gt; in the device's
   HTML. Added html_unescape() to the scraper so hotkeys round-trip
   correctly.

2. Send hotkeys over WebSocket — port switching via the Belkin web form
   only changes the active port number, it doesn't send the hotkey
   sequence to the downstream KVM. Now when switching ports from the
   console dropdown, blekin parses the Belkin hotkey syntax and sends
   the key press/release sequence over the existing WebSocket connection.

3. New hotkey.ts parser — converts Belkin hotkey syntax to scancode
   sequences:
   - > and -> = sequential (tap each key)
   - + = simultaneous (hold all, release in reverse)
   - Supports all key names: PrintScreen, Ctrl, Alt, Shift, F1-F12,
     letters, digits, navigation keys, numpad

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 12:13:57 +03:00
9bd215356b fix: add __templates__ and image button coords to KVM form submissions
All checks were successful
CI / fmt (push) Successful in 34s
Publish / frontend (push) Successful in 46s
CI / clippy (push) Successful in 1m21s
CI / check (push) Successful in 1m32s
Publish / backend (push) Successful in 2m44s
The Belkin firmware requires:
1. A hidden __templates__ field in all form POSTs
2. Image button actions submitted as name.x=0&name.y=0 (not name=value)
3. ECG_kvm_powerport_cnt hidden field

Without these, the device returns "Permission denied" on port config
saves. Also fixed the switch endpoint with the same pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 11:50:38 +03:00
d503742542 fix: port count change immediately updates table rows
Some checks failed
CI / fmt (push) Failing after 39s
Publish / frontend (push) Successful in 47s
CI / clippy (push) Successful in 1m44s
CI / check (push) Successful in 1m47s
Publish / backend (push) Successful in 2m50s
When the port count dropdown changes, the table now re-renders with
the new number of rows, preserving existing values for ports that
were already visible. This allows configuring ports > 16 after
switching to a higher port count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 11:38:52 +03:00
63aa9a400f feat: Send Key dropdown for browser-intercepted keys
Some checks failed
CI / fmt (push) Failing after 40s
Publish / frontend (push) Successful in 44s
CI / check (push) Successful in 1m21s
CI / clippy (push) Successful in 1m45s
Publish / backend (push) Successful in 2m51s
Adds a "Send Key" dropdown menu to the console toolbar for keys that
browsers intercept before JavaScript can capture them (Print Screen,
Scroll Lock, Pause/Break). Also includes Escape, Tab, Caps Lock,
Num Lock, and Ctrl+Alt+Del.

Each menu item sends a press+release scancode pair directly over the
WebSocket, bypassing browser key capture entirely. This enables
activating downstream KVM switch menus (e.g., Avocent OSCAR via
Print Screen) from the blekin interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 11:32:10 +03:00
ea18d97aa6 feat: KVM port management — configuration, switching, and navigation shell
Some checks failed
CI / fmt (push) Failing after 43s
Publish / frontend (push) Successful in 44s
CI / check (push) Successful in 1m20s
CI / clippy (push) Successful in 1m41s
Publish / backend (push) Successful in 2m49s
Backend (crates/ericrfb-proxy):
- Session cookie now persisted in AppState for device API calls
- New kvm.rs with three REST endpoints:
  GET /api/kvm/ports — scrapes kvm.asp, returns port config as JSON
  PUT /api/kvm/ports — saves port names, hotkeys, visibility, count
  POST /api/kvm/switch — switches active KVM port via home2.asp
- HTML scraping extracts form values from predictable firmware HTML

Frontend (crates/ericrfb-frontend):
- New shell.ts: sidebar navigation with page routing pattern
  (Console, Ports — extensible for Virtual Media, Users, etc.)
- Console refactored into pages/console.ts with mount/unmount lifecycle
  - Port switcher dropdown in toolbar (fetches port list, switches on change)
  - WebSocket auto-reconnects after port switch
- New pages/ports.ts: editable port configuration table
  - Port count, key pause duration, per-port name/hotkey/show-in-console
  - Save, reload, and per-port switch buttons
  - Active port highlighted
- Dark theme sidebar with active state indicators

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 11:18:36 +03:00
dd029c7f93 doc: port mapping implementation planning 2026-05-07 11:13:36 +03:00
75a51def79 fix: correct keyboard scancode mapping from KeyTranslator.java
All checks were successful
CI / fmt (push) Successful in 39s
Publish / frontend (push) Successful in 45s
CI / check (push) Successful in 1m22s
CI / clippy (push) Successful in 1m38s
Publish / backend (push) Successful in 2m48s
The mapping was built assuming keynr followed physical keyboard order
with Escape=0. In reality, KeyTranslator.java maps Java VK_* codes to
keynr values with a different layout:

- keynr 0 = Backquote (not Escape)
- keynr 59 = Escape
- keynr 27 = Enter (not 40)
- keynr 40 = Backslash (not 42)

This caused the number row and QWERTY row to be off by 1 (Escape
was inserted at position 0, pushing everything). The home row
(CapsLock=28, A=29...L=37) happened to align by coincidence, which
is why 'g'(33) worked but 'r'(should be 18, was 19=T) didn't.

Also fixes: F1-F12 off by 1, PrintScreen/ScrollLock/Pause off by 1,
numpad operator keys swapped with numpad 7/8/9.

Corrected both Rust (input.rs) and TypeScript (input.ts) mappings
to match the authoritative KeyTranslator.java VK→keynr table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 10:42:02 +03:00
4f7d69c75a fix: Tight palette selector 0 reads two separate color bytes
All checks were successful
CI / fmt (push) Successful in 46s
Publish / frontend (push) Successful in 51s
CI / check (push) Successful in 1m28s
CI / clippy (push) Successful in 1m35s
Publish / backend (push) Successful in 2m45s
When pal_selector=0 (no predefined palette), the Java code reads two
separate full bytes for the 2-color palette (line 421-422 of
ByteColorRFBRenderer.java). We were reading one packed byte and
splitting into nibbles, consuming 1 byte too few and misaligning all
subsequent reads including the zlib compressed data.

This caused "deflate decompression error" on stream 1 because the
zlib header (78 da) was offset by 1 byte.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 09:55:21 +03:00
acf99f849b feat: phase 9 — encoding 9 (IIP) with tile-versioned delta cache
All checks were successful
CI / check (push) Successful in 1m22s
Publish / frontend (push) Successful in 43s
CI / fmt (push) Successful in 54s
CI / clippy (push) Successful in 1m40s
Publish / backend (push) Successful in 2m27s
codec/iip.rs:
- TileCache: (fb_width/16 × fb_height/16) tiles, 8 versions × 256 bytes
  each, matching t.java's (8, 16*16) allocation
- TileEntry: versioned read/write at byte offsets within tile data
- decode_iip() handles all 4 modes from the control byte:
  - Mode 0/12 (cache-read): tile control bytes select which cached
    version of each 16x16 tile to display, no new pixel data on wire
  - Mode 4 (write-only): Tight-decoded pixel data written to cache
  - Mode 8 (update+read): conditionally writes new data to cache
    (bit 7 of control byte = 0 means update), then reads from cache
- Tile control bytes compressed via zlib (varint length) when >= 12
- Sub-types 1-4/8 map to bit-depths 1/2/4/4/8 bpp
- Cache resized on framebuffer resize (ModeChange msg 128)

Wired into session dispatch for encoding 9. Not advertised in default
encoding list — only active if explicitly requested.

codec/tight.rs: made get_or_init() pub for IIP's zlib access.

39 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 09:39:49 +03:00
e39555196d chore: deployment todos 2026-05-07 09:23:39 +03:00
c31508f138 feat: phase 10 — reconnection, encoding 10, disconnect button
All checks were successful
Publish / frontend (push) Successful in 42s
CI / fmt (push) Successful in 44s
CI / check (push) Successful in 1m43s
CI / clippy (push) Successful in 1m43s
Publish / backend (push) Successful in 2m47s
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>
2026-05-07 09:21:21 +03:00
865a08da17 chore: setup backend environment 2026-05-07 09:08:28 +03:00
3ba05bcb05 chore: setup hosting environment 2026-05-07 08:41:16 +03:00
2539a1fd06 ci: ssh resolved
All checks were successful
CI / fmt (push) Successful in 43s
Publish / frontend (push) Successful in 58s
CI / clippy (push) Successful in 1m31s
CI / check (push) Successful in 1m34s
Publish / backend (push) Successful in 2m36s
2026-05-06 18:50:33 +03:00
ee4b0a2124 ci: debug ssh
All checks were successful
CI / fmt (push) Successful in 38s
CI / check (push) Successful in 1m52s
CI / clippy (push) Successful in 1m51s
Publish / frontend (push) Successful in 37s
Publish / backend (push) Successful in 2m27s
2026-05-06 18:38:50 +03:00
8440d653b3 ci: debug ssh
Some checks are pending
Publish / backend (push) Waiting to run
CI / fmt (push) Successful in 37s
Publish / frontend (push) Successful in 46s
CI / check (push) Successful in 1m46s
CI / clippy (push) Successful in 1m51s
2026-05-06 18:31:01 +03:00
022c38bdc2 fix(ci): frontend SSH init should test UI_HOST not WS_HOST
Some checks failed
CI / fmt (push) Successful in 44s
CI / clippy (push) Successful in 1m19s
CI / check (push) Successful in 1m34s
Publish / backend (push) Waiting to run
Publish / frontend (push) Failing after 51s
Also bump ConnectTimeout to 5s for the frontend SSH init.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 18:08:36 +03:00
3b9ef6407c ci: debug ssh
Some checks are pending
CI / fmt (push) Waiting to run
Publish / frontend (push) Waiting to run
Publish / backend (push) Waiting to run
CI / check (push) Successful in 1m34s
CI / clippy (push) Successful in 1m26s
2026-05-06 18:01:34 +03:00
99e337d387 ci: debug ssh
Some checks failed
Publish / backend (push) Waiting to run
CI / fmt (push) Successful in 36s
Publish / frontend (push) Failing after 39s
CI / check (push) Successful in 1m31s
CI / clippy (push) Successful in 1m36s
2026-05-06 17:35:30 +03:00
5e5908804a fix(ci): use --rsync-path 'sudo rsync' for privileged deploys
Some checks failed
CI / fmt (push) Successful in 43s
Publish / frontend (push) Failing after 44s
CI / clippy (push) Successful in 1m20s
CI / check (push) Successful in 1m31s
Publish / backend (push) Failing after 22m42s
Simplified deploy steps — rsync directly to final paths using
sudo rsync on the remote instead of temp-file staging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 16:27:49 +03:00
075fef0ea9 fix(ci): drop Node.js install step — already on runner
Some checks failed
CI / check (push) Waiting to run
CI / fmt (push) Successful in 49s
CI / clippy (push) Successful in 1m37s
Publish / backend (push) Waiting to run
Publish / frontend (push) Failing after 28s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 16:16:07 +03:00
2700821559 fix(ci): use Node.js tarball instead of fnm (runner lacks unzip)
Some checks failed
CI / check (push) Waiting to run
CI / fmt (push) Waiting to run
CI / clippy (push) Waiting to run
Publish / frontend (push) Waiting to run
Publish / backend (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 16:15:43 +03:00
2627bab72a ci: add publish workflow with frontend and backend deploy
Some checks failed
Publish / backend (push) Waiting to run
Publish / frontend (push) Failing after 36s
CI / fmt (push) Successful in 53s
CI / clippy (push) Successful in 1m20s
CI / check (push) Successful in 1m28s
publish.yml — triggered on push to main, two parallel jobs:

frontend:
- Builds Vite frontend (fnm + npm ci + npm run build)
- Rsyncs dist/ to gitea_ci@UI_HOST:UI_PATH/
- Rsyncs nginx config to UI_HOST, creates sites-enabled symlink,
  runs nginx -t && systemctl reload nginx

backend:
- Builds release binary (cargo build --release -p ericrfb-proxy)
- Stops blekin.service on WS_HOST
- Rsyncs binary to WS_HOST:/usr/local/bin/ericrfb-proxy via sudo rsync
- Rsyncs systemd unit to WS_HOST:/etc/systemd/system/blekin.service
- Enables and starts the service

asset/nginx/blekin.kosherinata.internal.conf:
- Serves static frontend from UI_PATH
- Reverse proxies /api/ to frootmig:3000 with WebSocket upgrade
- 24h read/send timeouts for long-lived KVM sessions

asset/systemd/blekin.service:
- Runs ericrfb-proxy with BLEKIN_HOST=10.3.0.130
- Restart on failure with 5s backoff

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 16:12:46 +03:00
8692c0e46a feat: phase 8 — Vite/TS canvas-based KVM console frontend
All checks were successful
CI / fmt (push) Successful in 28s
CI / check (push) Successful in 1m31s
CI / clippy (push) Successful in 1m29s
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>
2026-05-06 15:19:53 +03:00
3bd7ee8eac feat: phase 7 — proxy daemon with HTTP login and WebSocket bridge
All checks were successful
CI / fmt (push) Successful in 38s
CI / check (push) Successful in 1m20s
CI / clippy (push) Successful in 1m19s
ericrfb-proxy axum server with three endpoints:

POST /api/login:
- Proxies credentials to OmniView auth.asp
- Extracts session cookie, fetches title_app.asp
- Returns JSON with applet_id, port, protocol_version, board_name

GET /api/ws?applet_id=...&port=...:
- WebSocket upgrade, connects to OmniView via e-RIC RFB
- Bidirectional pump: OmniView frames → RGBA blits over WS,
  browser input events → key/mouse/hotkey to OmniView
- Binary protocol: TAG_BLIT(0x01), TAG_RESIZE(0x03) server→client;
  TAG_KEY_PRESS(0x10), TAG_KEY_RELEASE(0x11), TAG_POINTER(0x12),
  TAG_CTRL_ALT_DEL(0x13) client→server

Static file fallback via tower-http ServeDir.

Config via config.toml or BLEKIN_HOST env var.

Tested against real OmniView:
- Login endpoint returns valid APPLET_ID
- WebSocket upgrade succeeds (HTTP 101)
- Session connects and pumps frames

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 15:11:50 +03:00
ab74f607e8 feat: phase 6 — keyboard/mouse input + fix Tight zlib init
All checks were successful
CI / fmt (push) Successful in 30s
CI / check (push) Successful in 1m9s
CI / clippy (push) Successful in 1m9s
input.rs:
- write_key_press/release/tap: scancode | 0x80 = press, bare = release
- JavaScript KeyboardEvent.code → e-RIC scancode mapping (104pc layout)
- Hotkey sequence sender (raw hex byte strings from applet params)
- write_ctrl_alt_del() using HOTKEYCODE_0 "36 f0 37 f0 4e"
- PointerEvent writer already in msg.rs (8 bytes, absolute mode)
- 5 unit tests

Tight zlib fix:
- Changed Decompress::new(true) for zlib-wrapped format (matching
  Java's Inflater() which expects zlib header 78 9C)
- Added output length verification after decompression
- Tested: Tight-encoded frames now decode correctly from real device

37 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 14:51:50 +03:00
c8f981f045 feat: phase 5 — Tight decoder with zlib streams and sub-palettes
All checks were successful
CI / fmt (push) Successful in 36s
CI / check (push) Successful in 1m1s
CI / clippy (push) Successful in 1m4s
codec/tight.rs:
- Full Tight (encoding 7) decoder per ByteColorRFBRenderer.a() line 324
- Control byte: bottom 4 bits = zlib stream reset flags,
  top 4 bits = subencoding (0-15)
- Subencoding 8: solid fill (1-byte color)
- Subencoding 15: palette-indexed fill (selector + index)
- Subencodings 4-7: filtered data with optional 2-color palette
- Subencodings 10-13: reduced bit-depth packed (1/2/4 bpp)
- Subencodings 0-3: raw 8bpp data
- Data >= 12 bytes uses zlib compression with varint length
- 4 persistent zlib streams with reset-on-flag logic
- All 4 hardcoded sub-palettes ported as RGB332 indices:
  PALETTE_BW (2), PALETTE_GRAY4 (4), PALETTE_GRAY16 (16),
  PALETTE_COLOR16 (16 EGA-like colors)
- Bit-depth unpackers: 1bpp, 2bpp, 4bpp (MSB-first)
- 5 unit tests

Updated examples to request [7, 5, 1, 0, -250] (Tight preferred).
Tested against real OmniView: correct rendering with Tight encoding.
32 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 14:44:06 +03:00
21ed797302 feat: phase 4 — Hextile decoder and recording example
All checks were successful
CI / fmt (push) Successful in 28s
CI / check (push) Successful in 1m12s
CI / clippy (push) Successful in 1m12s
codec/hextile.rs:
- Full Hextile (encoding 5) decoder per ByteColorRFBRenderer.int()
- Handles: Raw tiles, BackgroundSpecified, ForegroundSpecified,
  AnySubrects, SubrectsColoured flags
- Background/foreground colors persist across tiles
- 4 unit tests covering all subencoding paths

framebuffer.rs:
- Added fill_rect() for Hextile background/subrect fills

session.rs:
- Wired Hextile encoding 5 into the rect dispatch

examples/record.rs:
- 30-second (configurable) recording session
- Saves 1 PNG per second to out/ directory
- Requests encodings [5, 1, 0] (Hextile, CopyRect, Raw)
- Tested against real OmniView: 10 frames in 10s, no errors

27 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 14:35:20 +03:00
1164ffdd98 fix: send SetPixelFormat to request 8bpp RGB332
All checks were successful
CI / fmt (push) Successful in 35s
CI / clippy (push) Successful in 57s
CI / check (push) Successful in 1m0s
The OmniView defaults to 16bpp when no SetPixelFormat is sent.
The Java applet sends SetPixelFormat (msg type 0) at
ByteColorRFBRenderer.new() line 76 to request 8bpp RGB332
(bpp=8, depth=8, true_color, red_max=7, green_max=7, blue_max=3,
shifts 0/3/6). Without this, pixel data arrives as 16bpp pairs
that produce green stripe artifacts when interpreted as 8bpp.

Verified: snapshot now matches the reference screenshot from
http://10.3.0.130/screenshot.jpg (dark screen with "Free" label).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 14:31:37 +03:00
e9823aff03 feat: phase 3 — framebuffer, raw decoder, session pump, snapshot
All checks were successful
CI / fmt (push) Successful in 37s
CI / check (push) Successful in 1m0s
CI / clippy (push) Successful in 1m4s
framebuffer.rs:
- Framebuffer struct (8bpp RGB332, row-major)
- apply_raw() blit, copy_rect() with overlap-safe logic
- to_rgba() via compile-time RGB332 LUT

session.rs:
- ActiveSession: connect + SetEncodings + initial FBUpdateRequest
- Full server message dispatch loop (all 15 message types)
- Raw (encoding 0) and CopyRect (encoding 1) decoders
- Ping response, bandwidth probe bookends, mode change resize

examples/snapshot.rs:
- Connects, waits for first FramebufferUpdate, saves PNG
- Tested against real OmniView at 10.3.0.130:443
- Successfully captured 640x480 frame

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 14:23:43 +03:00
1bd43fc1f9 feat: phase 2 — handshake, message writers, and server message dispatch
All checks were successful
CI / fmt (push) Successful in 30s
CI / check (push) Successful in 1m1s
CI / clippy (push) Successful in 1m4s
handshake.rs:
- Config, PixelFormat, ServerInit, Session types
- connect() walks all 11 handshake steps per aw.g() line 226
- Auth error mapping from aw.a(int) line 350 (7 error codes)

msg.rs — client-to-server writers:
- SetEncodings (type 2), FramebufferUpdateRequest (type 3)
- KeyEvent (type 4), PointerEvent (type 5, 8 bytes)
- PingResponse (type 149), BandwidthMarker (type 151)

msg.rs — server message dispatch:
- ServerMsg enum covering all 15 message types
- Readers for ping, bandwidth probe, ack, debug string,
  RFB command, server cut text, server name update,
  layout/locale, RDP event, FB update header

examples/handshake.rs: connects and prints session info.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 14:11:31 +03:00
07db90094d style: apply rustfmt to proto.rs
All checks were successful
CI / fmt (push) Successful in 32s
CI / check (push) Successful in 55s
CI / clippy (push) Successful in 59s
Fixes CI fmt check failure — rustfmt wants multi-line assert_eq! for
long struct literals and the varint roundtrip assertion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 13:59:54 +03:00
c4e3df5a44 ci: add Gitea Actions workflow for check, fmt, clippy
Some checks failed
CI / fmt (push) Failing after 40s
CI / check (push) Successful in 1m2s
CI / clippy (push) Successful in 1m10s
Three parallel jobs on the rust runner:
- cargo check --workspace
- rustfmt --check on all .rs files
- cargo clippy --workspace -- -D warnings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 13:47:58 +03:00
3db2927add feat: phase 0+1 — workspace scaffold and protocol primitives
Phase 0: Cargo workspace with ericrfb (lib) and ericrfb-proxy (bin)
crates, .envrc for RUST_LOG, workspace dependency pins.

Phase 1: ericrfb/src/proto.rs implements all wire primitives:
- read/write helpers matching h.java (u8, i8, u16-BE, i16-BE, i32-BE)
- varint reader/writer matching aw.int() (1-3 bytes, Tight lengths)
- modified-UTF-8 string reader matching h.byte()
- RectHeader { x: u16, y: u16, w: u16, h: u16, encoding: i32 }
- RGB332 → RGBA compile-time LUT (256 entries)

20 tests including proptest varint roundtrip over [0, 2^22).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 13:44:24 +03:00
52 changed files with 8794 additions and 0 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
export RUST_LOG=ericrfb=debug,ericrfb_proxy=debug

25
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,25 @@
name: CI
on:
push:
branches: ["*"]
pull_request:
jobs:
check:
runs-on: rust
steps:
- uses: actions/checkout@v4
- run: cargo check --workspace
fmt:
runs-on: rust
steps:
- uses: actions/checkout@v4
- run: rustfmt --check --edition 2024 $(find crates -name '*.rs')
clippy:
runs-on: rust
steps:
- uses: actions/checkout@v4
- run: cargo clippy --workspace -- -D warnings

View File

@@ -0,0 +1,75 @@
name: Publish
on:
push:
branches: [main]
env:
PUBLISH_KEY: |
${{ secrets.PUBLISH_KEY }}
jobs:
frontend:
runs-on: rust
if: >-
contains(github.event.head_commit.message, '[deploy: frontend]')
|| contains(github.event.head_commit.message, '[deploy: backend, frontend]')
|| contains(github.event.head_commit.message, '[deploy: frontend, backend]')
steps:
- uses: actions/checkout@v4
- name: Build frontend
run: |
cd crates/ericrfb-frontend
npm ci
npm run build
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${PUBLISH_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new gitea_ci@${{ vars.UI_HOST }} 'echo $(hostname -f) connection succeeded'
- name: Deploy static files to UI host
run: |
rsync --archive --compress --verbose --delete dist/ gitea_ci@${{ vars.UI_HOST }}:${{ vars.UI_PATH }}/
- name: Deploy nginx config and reload
run: |
rsync --archive --compress --verbose --rsync-path 'sudo rsync' asset/nginx/blekin.kosherinata.internal.conf gitea_ci@${{ vars.UI_HOST }}:/etc/nginx/sites-available/blekin.kosherinata.internal.conf
ssh gitea_ci@${{ vars.UI_HOST }} 'sudo /usr/bin/ln -sf /etc/nginx/sites-available/blekin.kosherinata.internal.conf /etc/nginx/sites-enabled/blekin.kosherinata.internal.conf && sudo /usr/bin/nginx -t && sudo /usr/bin/systemctl reload nginx.service'
backend:
runs-on: rust
if: >-
contains(github.event.head_commit.message, '[deploy: backend]')
|| contains(github.event.head_commit.message, '[deploy: backend, frontend]')
|| contains(github.event.head_commit.message, '[deploy: frontend, backend]')
steps:
- uses: actions/checkout@v4
- name: Build release binary
run: cargo build --release -p ericrfb-proxy
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${PUBLISH_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new gitea_ci@${{ vars.WS_HOST }} 'echo $(hostname -f) connection succeeded'
- name: Stop service
run: |
ssh gitea_ci@${{ vars.WS_HOST }} 'if systemctl is-active --quiet blekin.service; then sudo /usr/bin/systemctl stop blekin.service; fi'
- name: Deploy binary
run: |
rsync --archive --compress --verbose --rsync-path 'sudo rsync' target/release/ericrfb-proxy gitea_ci@${{ vars.WS_HOST }}:/usr/local/bin/ericrfb-proxy
- name: Deploy systemd unit
run: |
rsync --archive --compress --verbose --rsync-path 'sudo rsync' asset/systemd/blekin.service gitea_ci@${{ vars.WS_HOST }}:/etc/systemd/system/blekin.service
- name: Start and enable service
run: |
ssh gitea_ci@${{ vars.WS_HOST }} 'sudo /usr/bin/systemctl start blekin.service && ( systemctl is-enabled --quiet blekin.service || sudo /usr/bin/systemctl enable blekin.service )'

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
/dist
/out
crates/ericrfb-frontend/node_modules

2414
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[workspace]
resolver = "2"
members = ["crates/ericrfb", "crates/ericrfb-proxy"]
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["ws"] }
bytes = "1"
flate2 = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
tower-http = { version = "0.5", features = ["fs", "cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "2"

142
README.md Normal file
View File

@@ -0,0 +1,142 @@
# blekin
A Rust proxy that translates the Belkin OmniView Remote IP Manager's proprietary
e-RIC RFB protocol (Peppercon LARA, originally served via a Java applet) into a
modern HTML5 KVM console. No Java anywhere in the stack.
## What it does
```
Browser (Vite + TypeScript)
│ WebSocket (binary, RGBA blits + input events)
blekin proxy (Rust, tokio + axum)
│ HTTP session (cookie + APPLET_ID extraction)
│ TCP to OmniView:443 (e-RIC RFB)
Belkin OmniView Remote IP Manager → downstream KVM switch → servers
```
The proxy authenticates with the OmniView's web interface, establishes an e-RIC
RFB session over TCP, decodes the proprietary 8bpp protocol to RGBA, and bridges
video frames and input events to a browser-based console over WebSocket.
## Features
- Full KVM console: keyboard, mouse, scroll wheel
- Encodings: Raw, CopyRect, Hextile, Tight (with zlib), IIP tile cache, Raw-tile
- Send Key menu for browser-intercepted keys (Print Screen, Scroll Lock, etc.)
- Port switching with downstream KVM support (Avocent OSCAR tested)
- Port configuration: naming, hotkeys, visibility
- Auto-reconnect with exponential backoff
- Session persistence across page refreshes
- Dark theme, fullscreen mode
## Building
```sh
# Backend
cargo build --release -p ericrfb-proxy
# Frontend
cd crates/ericrfb-frontend
npm ci
npm run build # outputs to ../../dist/
```
## Configuration
The proxy reads `config.toml` or falls back to the `BLEKIN_HOST` environment variable:
```toml
bind = "0.0.0.0:3000"
static_dir = "dist"
[omniview]
host = "10.3.0.130"
http_port = 80
rfb_port = 443
```
## Deployment
The systemd unit runs the proxy as a service. Nginx serves the static frontend
and reverse-proxies `/api/` to the proxy:
```sh
# Backend
sudo cp target/release/ericrfb-proxy /usr/local/bin/
sudo cp asset/systemd/blekin.service /etc/systemd/system/
sudo systemctl enable --now blekin.service
# Frontend
sudo cp -r dist/* /var/www/blekin.example.com/
sudo cp asset/nginx/blekin.example.conf /etc/nginx/sites-available/
sudo ln -sf /etc/nginx/sites-available/blekin.example.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
## Port switching with downstream KVM
The Belkin OmniView can control a downstream KVM switch (e.g., Avocent AutoView)
by sending configurable hotkey sequences when switching ports. Configure these
in the Ports page of the blekin interface.
### Avocent OSCAR setup
The Avocent OSCAR menu is invoked with Print Screen, then expects the port's EID
(Electronic ID) typed as digits, followed by Enter. Each phase needs a pause to
let the OSCAR process the input.
**Recommended settings:**
- **Key pause:** `1000` ms (in the Ports page global settings)
- **Hotkey format:** `PRINTSCREEN-***<EID digits>-***ENTER`
The `*` character inserts a pause of the configured duration. Three `***` = 3
seconds, which gives the OSCAR reliable processing time.
**Examples** (with key pause = 1000ms):
| Server | Avocent EID | Hotkey |
|--------|-------------|--------|
| server-a | 01 | `PRINTSCREEN-***1-***ENTER` |
| server-b | 05 | `PRINTSCREEN-***5-***ENTER` |
| server-c | 17 | `PRINTSCREEN-***1-7-***ENTER` |
| server-d | 22 | `PRINTSCREEN-***2-2-***ENTER` |
| server-e | 31 | `PRINTSCREEN-***3-1-***ENTER` |
Note: don't type the leading zero in EIDs (per Avocent OSCAR convention).
### Hotkey syntax reference
The Belkin hotkey syntax uses key names connected by operators:
| Operator | Meaning |
|----------|---------|
| `+` | Press additionally (hold previous, press next) |
| `-` | Release all pressed keys |
| `>` | Release most recently pressed key only |
| `*` | Pause for the configured key pause duration |
Key names: `A`-`Z`, `0`-`9`, `F1`-`F12`, `PRINTSCREEN`, `ENTER`, `ESCAPE`,
`LCTRL`/`CTRL`, `LALT`/`ALT`, `LSHIFT`/`SHIFT`, `SPACE`, `TAB`, `DELETE`,
`INSERT`, `HOME`, `END`, `PAGE_UP`, `PAGE_DOWN`, `UP`, `DOWN`, `LEFT`, `RIGHT`,
`BACK_SPACE`, `NUM_LOCK`, `NUMPAD0`-`NUMPAD9`, `NUMPADPLUS`, `NUMPADMINUS`,
`NUMPADMUL`, `NUMPAD/`, `NUMPADENTER`, `SCROLL_LOCK`, `BREAK`, `CAPS_LOCK`.
## Protocol heritage
The e-RIC RFB protocol is a proprietary variant of VNC/RFB developed by
Peppercon (later acquired by Raritan) under the name "LARA" (LAN Attached
Remote Access). It was used in several KVM-over-IP products including the Belkin
OmniView Remote IP Manager (F1DE101H). The protocol shares structural
similarities with standard RFB but uses its own handshake, pixel format
negotiation, and encoding extensions.
This implementation was reverse-engineered from the CFR-decompiled `rc.jar` Java
applet that originally provided the browser-based console.
## License
MIT

View File

@@ -0,0 +1,28 @@
server {
server_name blekin.kosherinata.internal;
listen 443 ssl;
http2 on;
ssl_certificate /etc/nginx/tls/cert/blekin.kosherinata.internal.pem;
ssl_certificate_key /etc/nginx/tls/key/blekin.kosherinata.internal.pem;
#ssl_trusted_certificate /etc/pki/ca-trust/source/anchors/root-internal.pem;
ssl_protocols TLSv1.3;
root /var/www/blekin.kosherinata.internal;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://frootmig.kosherinata.internal:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}

View File

@@ -0,0 +1,5 @@
gitea_ci ALL=(root) NOPASSWD: /usr/bin/ln -sf /etc/nginx/sites-available/blekin.kosherinata.internal.conf /etc/nginx/sites-enabled/blekin.kosherinata.internal.conf
gitea_ci ALL=(root) NOPASSWD: /usr/bin/nginx -t
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/blekin.kosherinata.internal/
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/nginx/sites-available/blekin.kosherinata.internal.conf

View File

@@ -0,0 +1,5 @@
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/ericrfb-proxy
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/blekin.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl start blekin.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl stop blekin.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable blekin.service

View File

@@ -0,0 +1,18 @@
[Unit]
Description=blekin e-RIC RFB to HTML5 KVM proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=blekin
Group=blekin
ExecStart=/usr/local/bin/ericrfb-proxy
WorkingDirectory=/var/lib/blekin
Environment=RUST_LOG=ericrfb_proxy=info
Environment=BLEKIN_HOST=10.3.0.130
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,15 @@
[Unit]
Description=step cert renew for %i.kosherinata.internal
Documentation=https://smallstep.com/docs/step-ca/renewal
[Service]
Type=oneshot
ExecCondition=/usr/bin/step certificate needs-renewal \
/etc/nginx/tls/cert/%i.kosherinata.internal.pem
ExecStart=/usr/bin/step ca renew \
--force \
--ca-url https://ca.internal \
--root /etc/pki/ca-trust/source/anchors/root-internal.pem \
/etc/nginx/tls/cert/%i.kosherinata.internal.pem \
/etc/nginx/tls/key/%i.kosherinata.internal.pem
ExecStartPost=/usr/bin/systemctl reload nginx.service

7
config.toml.example Normal file
View File

@@ -0,0 +1,7 @@
bind = "0.0.0.0:3000"
static_dir = "dist"
[omniview]
host = "10.3.0.130"
http_port = 80
rfb_port = 443

24
crates/ericrfb-frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>blekin — KVM Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

917
crates/ericrfb-frontend/package-lock.json generated Normal file
View File

@@ -0,0 +1,917 @@
{
"name": "ericrfb-frontend",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ericrfb-frontend",
"version": "0.0.0",
"devDependencies": {
"typescript": "~6.0.2",
"vite": "^8.0.10"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.17"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.17",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "ericrfb-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~6.0.2",
"vite": "^8.0.10"
}
}

View File

@@ -0,0 +1,92 @@
// Parse Belkin hotkey syntax and convert to scancode sequences.
//
// Syntax: [confirm] <keyname>((+|->|>)<keyname>)*
// + = press simultaneously (hold first, press second)
// > = press sequentially (release first, then press second)
// -> = same as >
//
// Examples:
// PrintScreen>0>1 → tap PrintScreen, tap 0, tap 1
// Ctrl+Alt+Del → hold Ctrl+Alt, tap Del, release all
// PrintScreen>1>7 → tap PrintScreen, tap 1, tap 7
import { makeKeyPress, makeKeyRelease } from './protocol'
// Belkin key names → e-RIC scancodes (from KeyTranslator.java)
const KEY_NAMES: Record<string, number> = {
// Letters
A: 29, B: 47, C: 45, D: 31, E: 17, F: 32, G: 33, H: 34, I: 22,
J: 35, K: 36, L: 37, M: 49, N: 48, O: 23, P: 24, Q: 15, R: 18,
S: 30, T: 19, U: 21, V: 46, W: 16, X: 44, Y: 20, Z: 43,
// Digits
'0': 10, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
// Modifiers
Ctrl: 54, Alt: 55, Shift: 41, Win: 105,
CtrlLeft: 54, CtrlRight: 58, AltLeft: 55, AltRight: 57,
ShiftLeft: 41, ShiftRight: 53,
// Special keys
Esc: 59, Escape: 59, Tab: 14, CapsLock: 28, Space: 56,
Enter: 27, Return: 27, Backspace: 13, Del: 78, Delete: 78,
Insert: 75, Home: 76, End: 79, PageUp: 77, PageDown: 80,
PrintScreen: 72, Print: 72, SysRq: 72,
ScrollLock: 73, Pause: 74, Break: 74,
NumLock: 85,
// Arrow keys
Up: 81, Down: 83, Left: 82, Right: 84,
// F-keys
F1: 60, F2: 61, F3: 62, F4: 63, F5: 64, F6: 65,
F7: 66, F8: 67, F9: 68, F10: 69, F11: 70, F12: 71,
// Numpad
NumPlus: 89, 'Num+': 89, NumMinus: 99, 'Num-': 99,
NumMultiply: 94, 'Num*': 94, NumDivide: 90, 'Num/': 90,
NumEnter: 98,
}
/**
* Parse a Belkin hotkey string into a sequence of WS messages to send.
* Returns an array of ArrayBuffer messages (press/release pairs).
*/
export function parseHotkey(hotkey: string): ArrayBuffer[] {
if (!hotkey.trim()) return []
const messages: ArrayBuffer[] = []
// Split on > (sequential) first, preserving + groups
const groups = hotkey.split(/->|>/).map(g => g.trim()).filter(Boolean)
for (const group of groups) {
// Each group may contain + (simultaneous keys)
const keys = group.split('+').map(k => k.trim()).filter(Boolean)
const scancodes: number[] = []
for (const key of keys) {
const sc = KEY_NAMES[key]
if (sc !== undefined) {
scancodes.push(sc)
}
}
if (scancodes.length === 0) continue
if (scancodes.length === 1) {
// Single key: press then release
messages.push(makeKeyPress(scancodes[0]))
messages.push(makeKeyRelease(scancodes[0]))
} else {
// Combo: press all in order, release in reverse
for (const sc of scancodes) {
messages.push(makeKeyPress(sc))
}
for (const sc of scancodes.reverse()) {
messages.push(makeKeyRelease(sc))
}
}
}
return messages
}

View File

@@ -0,0 +1,61 @@
// JavaScript KeyboardEvent.code → e-RIC scancode (KbdLayout_104pc)
// Derived from KeyTranslator.java line 14 (Java VK_* → keynr table).
// Must match crates/ericrfb/src/input.rs js_code_to_scancode()
const KEY_MAP: Record<string, number> = {
// Number row (keynr 0-13)
Backquote: 0,
Digit1: 1, Digit2: 2, Digit3: 3, Digit4: 4, Digit5: 5,
Digit6: 6, Digit7: 7, Digit8: 8, Digit9: 9, Digit0: 10,
Minus: 11, Equal: 12, Backspace: 13,
// QWERTY row (keynr 14-27)
Tab: 14,
KeyQ: 15, KeyW: 16, KeyE: 17, KeyR: 18, KeyT: 19,
KeyY: 20, KeyU: 21, KeyI: 22, KeyO: 23, KeyP: 24,
BracketLeft: 25, BracketRight: 26, Enter: 27,
// Home row (keynr 28-40)
CapsLock: 28,
KeyA: 29, KeyS: 30, KeyD: 31, KeyF: 32, KeyG: 33,
KeyH: 34, KeyJ: 35, KeyK: 36, KeyL: 37,
Semicolon: 38, Quote: 39, Backslash: 40,
// Bottom row (keynr 41-53)
ShiftLeft: 41,
KeyZ: 43, KeyX: 44, KeyC: 45, KeyV: 46, KeyB: 47,
KeyN: 48, KeyM: 49, Comma: 50, Period: 51, Slash: 52,
ShiftRight: 53,
// Modifiers (keynr 54-58)
ControlLeft: 54, AltLeft: 55, Space: 56,
AltRight: 57, ControlRight: 58,
// Escape + Function keys (keynr 59-71)
Escape: 59,
F1: 60, F2: 61, F3: 62, F4: 63, F5: 64, F6: 65,
F7: 66, F8: 67, F9: 68, F10: 69, F11: 70, F12: 71,
// Navigation cluster (keynr 72-84)
PrintScreen: 72, ScrollLock: 73, Pause: 74,
Insert: 75, Home: 76, PageUp: 77,
Delete: 78, End: 79, PageDown: 80,
ArrowUp: 81, ArrowLeft: 82, ArrowDown: 83, ArrowRight: 84,
// Numpad (keynr 85-101)
NumLock: 85,
Numpad7: 86, Numpad8: 87, Numpad9: 88,
NumpadAdd: 89, NumpadDivide: 90,
Numpad4: 91, Numpad5: 92, Numpad6: 93,
NumpadMultiply: 94,
Numpad1: 95, Numpad2: 96, Numpad3: 97,
NumpadEnter: 98, NumpadSubtract: 99,
Numpad0: 100, NumpadDecimal: 101,
// Windows/Meta keys (keynr 105-106)
MetaLeft: 105, MetaRight: 106,
}
export function codeToScancode(code: string): number | undefined {
return KEY_MAP[code]
}

View File

@@ -0,0 +1,63 @@
import { mountShell } from './shell'
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()
sessionStorage.setItem('blekin_user', username)
sessionStorage.setItem('blekin_pass', password)
mountShell(app, {
appletId: data.applet_id,
port: data.port,
boardName: data.board_name,
})
} catch (err) {
errorDiv.textContent = (err as Error).message
errorDiv.hidden = false
button.disabled = false
button.textContent = 'Connect'
}
})
}

View File

@@ -0,0 +1,29 @@
import './style.css'
import { showLogin } from './login'
import { mountShell } from './shell'
const app = document.getElementById('app')!
// Try to resume a session from stored credentials
const user = sessionStorage.getItem('blekin_user')
const pass = sessionStorage.getItem('blekin_pass')
if (user && pass) {
app.innerHTML = '<div class="login"><div class="login-form"><h1>Reconnecting...</h1></div></div>'
fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass }),
})
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
mountShell(app, { appletId: data.applet_id, port: data.port, boardName: data.board_name })
})
.catch(() => {
sessionStorage.removeItem('blekin_user')
sessionStorage.removeItem('blekin_pass')
showLogin(app)
})
} else {
showLogin(app)
}

View File

@@ -0,0 +1,370 @@
import { codeToScancode } from '../input'
import { parseHotkey } from '../hotkey'
import {
TAG_BLIT, TAG_RESIZE,
makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel,
} from '../protocol'
import type { SessionInfo } from '../shell'
let ws: WebSocket | null = null
let reconnectTimer: number | undefined
let reconnectDelay = 1000
let containerEl: HTMLElement | null = null
let session: SessionInfo
let buttonMask = 0
export function mountConsole(el: HTMLElement, s: SessionInfo) {
containerEl = el
session = s
el.innerHTML = `
<div class="toolbar">
<span>${s.boardName}</span>
<select id="port-select" title="Switch KVM port"></select>
<div class="sendkey-wrap">
<button id="btn-sendkey">Send Key &#x25BE;</button>
<div class="sendkey-menu" id="sendkey-menu" hidden>
<button data-sc="72">Print Screen</button>
<button data-sc="73">Scroll Lock</button>
<button data-sc="74">Pause / Break</button>
<hr />
<button data-sc="59">Escape</button>
<button data-sc="14">Tab</button>
<button data-sc="28">Caps Lock</button>
<button data-sc="85">Num Lock</button>
<hr />
<button data-cad="1">Ctrl + Alt + Del</button>
</div>
</div>
<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>
`
loadPortList()
connect()
wireInputHandlers()
wireToolbar()
}
export function unmountConsole() {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = undefined
ws?.close()
ws = null
buttonMask = 0
inputSuspended = false
portSelectWired = false
if (containerEl) containerEl.innerHTML = ''
containerEl = null
}
// ---------------------------------------------------------------------------
// WebSocket
// ---------------------------------------------------------------------------
function setStatus(text: string, connected: boolean) {
const el = document.getElementById('status')
if (el) {
el.textContent = text
el.classList.toggle('connected', connected)
}
}
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(session.appletId)}&port=${session.port}`
ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
setStatus('connected', true)
reconnectDelay = 1000
document.getElementById('canvas')?.focus()
}
ws.onclose = () => {
if (!containerEl) return // unmounted
setStatus(`disconnected — reconnecting in ${reconnectDelay / 1000}s...`, false)
scheduleReconnect()
}
ws.onerror = () => setStatus('connection error', false)
ws.onmessage = handleMessage
}
function scheduleReconnect() {
reconnectTimer = window.setTimeout(relogin, reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, 30000)
}
async function relogin() {
if (!containerEl) return
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()
session = { ...session, 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)
const canvas = document.getElementById('canvas') as HTMLCanvasElement | null
if (!canvas) return
const ctx = canvas.getContext('2d')!
switch (tag) {
case TAG_RESIZE: {
canvas.width = view.getUint16(1)
canvas.height = view.getUint16(3)
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)
ctx.putImageData(new ImageData(rgba, w, h), x, y)
break
}
}
}
// ---------------------------------------------------------------------------
// Port switcher
// ---------------------------------------------------------------------------
let portHotkeys: Record<number, string> = {}
let keyPauseDuration = 2000
let inputSuspended = false
let selectedPort = -1
let portSelectWired = false
async function loadPortList() {
try {
const resp = await fetch('/api/kvm/ports')
if (!resp.ok) return
const data = await resp.json()
keyPauseDuration = data.key_pause_duration || 2000
const select = document.getElementById('port-select') as HTMLSelectElement
if (!select) return
select.innerHTML = ''
portHotkeys = {}
// Use locally tracked selection if we've switched, otherwise use device's active port
const activePort = selectedPort >= 0 ? selectedPort : data.active_port
for (const p of data.ports) {
const opt = document.createElement('option')
opt.value = String(p.index)
opt.textContent = p.name || `Port ${p.index + 1}`
opt.selected = p.index === activePort
select.appendChild(opt)
if (p.hotkey) portHotkeys[p.index] = p.hotkey
}
if (!portSelectWired) {
select.addEventListener('change', () => {
const port = parseInt(select.value)
switchToPort(port)
})
portSelectWired = true
}
} catch { /* port list optional */ }
}
let switchTimerId = 0
function switchToPort(port: number) {
selectedPort = port
const hotkey = portHotkeys[port]
if (!hotkey || ws?.readyState !== WebSocket.OPEN) return
// Total duration: proxy round-trip + key sequence execution + settle time
const pauseCount = (hotkey.match(/\*/g) || []).length
const proxyLatency = 4000 // WS → proxy → Belkin → Avocent pipeline delay
const settleTime = 1000 // wait for video stream to stabilize after switch
const duration = proxyLatency + (pauseCount * keyPauseDuration) + settleTime
// Suspend input and show overlay
inputSuspended = true
if (switchTimerId) window.clearTimeout(switchTimerId)
showSwitchOverlay(duration)
// Send the hotkey sequence
const messages = parseHotkey(hotkey)
for (const msg of messages) {
ws.send(msg)
}
// Update Belkin's active port tracking
fetch('/api/kvm/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port }),
}).catch(() => {})
// Resume input after the switch completes
switchTimerId = window.setTimeout(dismissOverlay, duration)
}
function dismissOverlay() {
switchTimerId = 0
inputSuspended = false
const overlay = document.getElementById('switch-overlay')
if (overlay) overlay.style.display = 'none'
document.getElementById('canvas')?.focus()
}
function showSwitchOverlay(duration: number) {
let overlay = document.getElementById('switch-overlay')
if (!overlay) {
overlay = document.createElement('div')
overlay.id = 'switch-overlay'
overlay.className = 'switch-overlay'
document.querySelector('.console-wrap')?.appendChild(overlay)
}
overlay.style.display = ''
overlay.innerHTML = `
<div class="switch-overlay-content">
<div class="switch-spinner"></div>
<div>Switching port...</div>
<div class="switch-timer" id="switch-timer"></div>
</div>
`
// Countdown timer
const endTime = Date.now() + duration
const tick = () => {
const timerEl = document.getElementById('switch-timer')
if (!timerEl) return
const remaining = Math.max(0, endTime - Date.now())
timerEl.textContent = `${(remaining / 1000).toFixed(1)}s`
if (remaining > 0) requestAnimationFrame(tick)
}
tick()
}
// ---------------------------------------------------------------------------
// Input handlers
// ---------------------------------------------------------------------------
function wireInputHandlers() {
const canvas = document.getElementById('canvas')! as HTMLCanvasElement
canvas.addEventListener('keydown', (e) => {
e.preventDefault()
if (inputSuspended) return
const sc = codeToScancode(e.code)
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) ws.send(makeKeyPress(sc))
})
canvas.addEventListener('keyup', (e) => {
e.preventDefault()
if (inputSuspended) return
const sc = codeToScancode(e.code)
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) ws.send(makeKeyRelease(sc))
})
function sendPointer(e: MouseEvent) {
if (inputSuspended || ws?.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) * canvas.width / rect.width)
const y = Math.round((e.clientY - rect.top) * canvas.height / rect.height)
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 (inputSuspended || ws?.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) * canvas.width / rect.width)
const y = Math.round((e.clientY - rect.top) * canvas.height / rect.height)
const scrollMask = e.deltaY < 0 ? 8 : 16
ws.send(makePointer(x, y, buttonMask | scrollMask))
ws.send(makePointer(x, y, buttonMask))
})
}
function wireToolbar() {
// Send Key dropdown
const sendKeyBtn = document.getElementById('btn-sendkey')!
const sendKeyMenu = document.getElementById('sendkey-menu')!
sendKeyBtn.addEventListener('click', (e) => {
e.stopPropagation()
sendKeyMenu.hidden = !sendKeyMenu.hidden
})
// Close menu on outside click
document.addEventListener('click', () => { sendKeyMenu.hidden = true })
sendKeyMenu.addEventListener('click', (e) => e.stopPropagation())
sendKeyMenu.querySelectorAll('button[data-sc]').forEach(btn => {
btn.addEventListener('click', () => {
const sc = parseInt((btn as HTMLElement).dataset.sc!)
if (ws?.readyState === WebSocket.OPEN) {
ws.send(makeKeyPress(sc))
ws.send(makeKeyRelease(sc))
}
sendKeyMenu.hidden = true
document.getElementById('canvas')?.focus()
})
})
sendKeyMenu.querySelectorAll('button[data-cad]').forEach(btn => {
btn.addEventListener('click', () => {
if (ws?.readyState === WebSocket.OPEN) ws.send(makeCtrlAltDel())
sendKeyMenu.hidden = true
document.getElementById('canvas')?.focus()
})
})
// Fullscreen
document.getElementById('btn-fs')?.addEventListener('click', () => {
const shell = document.querySelector('.shell')
if (document.fullscreenElement) document.exitFullscreen()
else shell?.requestFullscreen()
document.getElementById('canvas')?.focus()
})
}

View File

@@ -0,0 +1,195 @@
import type { SessionInfo } from '../shell'
let containerEl: HTMLElement | null = null
interface PortInfo {
index: number
name: string
hotkey: string
show_in_rc: boolean
}
interface PortsData {
port_count: number
key_pause_duration: number
active_port: number
ports: PortInfo[]
}
export function mountPorts(el: HTMLElement, _session: SessionInfo) {
containerEl = el
el.innerHTML = `
<div class="ports-page">
<div class="ports-header">
<h2>KVM Port Configuration</h2>
<div class="ports-actions">
<button id="btn-reload" class="btn">Reload</button>
<button id="btn-save" class="btn btn-primary">Save</button>
</div>
</div>
<div class="ports-toast" id="toast" hidden></div>
<div class="ports-global">
<label>
Port count
<select id="port-count">
${[1, 2, 4, 8, 12, 16, 24, 32, 48, 64].map(n => `<option value="${n}">${n}</option>`).join('')}
</select>
</label>
<label>
Key pause
<input type="number" id="key-pause" min="0" max="9999" value="100" />
<span>ms</span>
</label>
</div>
<table class="ports-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Hotkey</th>
<th>Show</th>
<th></th>
</tr>
</thead>
<tbody id="ports-body"></tbody>
</table>
</div>
`
document.getElementById('btn-reload')?.addEventListener('click', loadPorts)
document.getElementById('btn-save')?.addEventListener('click', savePorts)
document.getElementById('port-count')?.addEventListener('change', onPortCountChange)
loadPorts()
}
export function unmountPorts() {
if (containerEl) containerEl.innerHTML = ''
containerEl = null
}
function showToast(msg: string, success: boolean) {
const el = document.getElementById('toast')
if (!el) return
el.textContent = msg
el.className = `ports-toast ${success ? 'toast-ok' : 'toast-err'}`
el.hidden = false
setTimeout(() => { el.hidden = true }, 3000)
}
async function loadPorts() {
try {
const resp = await fetch('/api/kvm/ports')
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data: PortsData = await resp.json()
renderPorts(data)
} catch (e) {
showToast(`Failed to load: ${e}`, false)
}
}
let currentActivePort = 0
function renderPorts(data: PortsData) {
const countEl = document.getElementById('port-count') as HTMLSelectElement
const pauseEl = document.getElementById('key-pause') as HTMLInputElement
if (countEl) countEl.value = String(data.port_count)
if (pauseEl) pauseEl.value = String(data.key_pause_duration)
currentActivePort = data.active_port
renderPortRows(data.ports, data.active_port)
}
function renderPortRows(ports: PortInfo[], activePort: number) {
const tbody = document.getElementById('ports-body')!
tbody.innerHTML = ''
for (const p of ports) {
const tr = document.createElement('tr')
tr.className = p.index === activePort ? 'active-port' : ''
tr.innerHTML = `
<td class="port-num">${p.index + 1}</td>
<td><input type="text" class="port-name" data-idx="${p.index}" value="${esc(p.name)}" maxlength="20" /></td>
<td><input type="text" class="port-hotkey" data-idx="${p.index}" value="${esc(p.hotkey)}" maxlength="64" /></td>
<td><input type="checkbox" class="port-show" data-idx="${p.index}" ${p.show_in_rc ? 'checked' : ''} /></td>
<td><button class="btn btn-sm btn-switch" data-idx="${p.index}">Switch</button></td>
`
tbody.appendChild(tr)
}
// Wire switch buttons
tbody.querySelectorAll('.btn-switch').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt((btn as HTMLElement).dataset.idx!)
try {
await fetch('/api/kvm/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port: idx }),
})
showToast(`Switched to port ${idx + 1}`, true)
loadPorts()
} catch (e) {
showToast(`Switch failed: ${e}`, false)
}
})
})
}
function onPortCountChange() {
const countEl = document.getElementById('port-count') as HTMLSelectElement
const newCount = parseInt(countEl?.value || '16')
// Read current values from the existing rows
const ports: PortInfo[] = []
for (let i = 0; i < newCount; i++) {
const nameEl = document.querySelector(`.port-name[data-idx="${i}"]`) as HTMLInputElement | null
const hotkeyEl = document.querySelector(`.port-hotkey[data-idx="${i}"]`) as HTMLInputElement | null
const showEl = document.querySelector(`.port-show[data-idx="${i}"]`) as HTMLInputElement | null
ports.push({
index: i,
name: nameEl?.value || '',
hotkey: hotkeyEl?.value || '',
show_in_rc: showEl?.checked || false,
})
}
renderPortRows(ports, currentActivePort)
}
async function savePorts() {
const countEl = document.getElementById('port-count') as HTMLSelectElement
const pauseEl = document.getElementById('key-pause') as HTMLInputElement
const portCount = parseInt(countEl?.value || '16')
const keyPause = parseInt(pauseEl?.value || '100')
const ports: PortInfo[] = []
for (let i = 0; i < portCount; i++) {
const nameEl = document.querySelector(`.port-name[data-idx="${i}"]`) as HTMLInputElement
const hotkeyEl = document.querySelector(`.port-hotkey[data-idx="${i}"]`) as HTMLInputElement
const showEl = document.querySelector(`.port-show[data-idx="${i}"]`) as HTMLInputElement
ports.push({
index: i,
name: nameEl?.value || '',
hotkey: hotkeyEl?.value || '',
show_in_rc: showEl?.checked || false,
})
}
try {
const resp = await fetch('/api/kvm/ports', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port_count: portCount, key_pause_duration: keyPause, ports }),
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
showToast('Configuration saved', true)
loadPorts()
} catch (e) {
showToast(`Save failed: ${e}`, false)
}
}
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}

View 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
}

View File

@@ -0,0 +1,60 @@
import { mountConsole, unmountConsole } from './pages/console'
import { mountPorts, unmountPorts } from './pages/ports'
export interface SessionInfo {
appletId: string
port: number
boardName: string
}
type PageId = 'console' | 'ports'
let currentPage: PageId | null = null
let contentEl: HTMLElement
let session: SessionInfo
const pages: Record<PageId, { mount: (el: HTMLElement, s: SessionInfo) => void; unmount: () => void }> = {
console: { mount: mountConsole, unmount: unmountConsole },
ports: { mount: mountPorts, unmount: unmountPorts },
}
function navigate(page: PageId) {
if (currentPage === page) return
if (currentPage) pages[currentPage].unmount()
// Update nav links
document.querySelectorAll('.nav-link').forEach(el => {
el.classList.toggle('active', el.getAttribute('data-page') === page)
})
currentPage = page
pages[page].mount(contentEl, session)
}
export function mountShell(app: HTMLElement, s: SessionInfo) {
session = s
app.innerHTML = `
<div class="shell">
<nav class="sidebar">
<div class="sidebar-brand">blekin</div>
<a href="#" data-page="console" class="nav-link active">Console</a>
<a href="#" data-page="ports" class="nav-link">Ports</a>
</nav>
<main class="content" id="page-content"></main>
</div>
`
contentEl = document.getElementById('page-content')!
// Wire up navigation
document.querySelectorAll('.nav-link').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault()
const page = el.getAttribute('data-page') as PageId
if (page) navigate(page)
})
})
// Default to console
navigate('console')
}

View File

@@ -0,0 +1,326 @@
* { 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; }
/* Shell layout */
.shell {
display: flex;
height: 100vh;
}
.sidebar {
width: 160px;
min-width: 160px;
background: #222;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
padding: 0.5rem 0;
}
.sidebar-brand {
padding: 0.75rem 1rem;
font-size: 1.1rem;
font-weight: 600;
color: #4a7c59;
border-bottom: 1px solid #333;
margin-bottom: 0.25rem;
}
.nav-link {
display: block;
padding: 0.5rem 1rem;
color: #999;
text-decoration: none;
font-size: 0.85rem;
border-left: 3px solid transparent;
}
.nav-link:hover { color: #ccc; background: #2a2a2a; }
.nav-link.active { color: #e0e0e0; border-left-color: #4a7c59; background: #2a2a2a; }
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Console toolbar */
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: #1e1e1e;
border-bottom: 1px solid #333;
font-size: 0.8rem;
}
.toolbar button, .toolbar select {
padding: 0.25rem 0.5rem;
border: 1px solid #444;
border-radius: 3px;
background: #333;
color: #ccc;
cursor: pointer;
font-size: 0.8rem;
}
.toolbar button:hover, .toolbar select:hover { background: #444; }
.toolbar .status { margin-left: auto; color: #888; }
.toolbar .status.connected { color: #4a7c59; }
/* Send Key dropdown */
.sendkey-wrap {
position: relative;
}
.sendkey-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 100;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
padding: 0.25rem 0;
min-width: 160px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.sendkey-menu button {
display: block;
width: 100%;
padding: 0.35rem 0.75rem;
border: none;
background: none;
color: #ccc;
text-align: left;
cursor: pointer;
font-size: 0.8rem;
}
.sendkey-menu button:hover { background: #383838; }
.sendkey-menu hr {
border: none;
border-top: 1px solid #3a3a3a;
margin: 0.2rem 0;
}
/* Console canvas */
.console-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
overflow: hidden;
}
.console-wrap canvas {
image-rendering: pixelated;
cursor: none;
}
/* Port switch overlay */
.switch-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.switch-overlay-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
color: #ccc;
font-size: 0.9rem;
}
.switch-spinner {
width: 32px;
height: 32px;
border: 3px solid #444;
border-top-color: #4a7c59;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.switch-timer {
font-variant-numeric: tabular-nums;
color: #888;
font-size: 0.8rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Console wrap needs position for overlay */
.console-wrap {
position: relative;
}
/* Ports config page */
.ports-page {
padding: 1.5rem;
overflow-y: auto;
max-height: 100vh;
}
.ports-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.ports-header h2 { font-size: 1.1rem; font-weight: 500; }
.ports-actions { display: flex; gap: 0.5rem; }
.ports-toast {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.85rem;
margin-bottom: 1rem;
}
.toast-ok { background: #1a3a1a; color: #6ece6e; }
.toast-err { background: #3a1a1a; color: #e74c3c; }
.ports-global {
display: flex;
gap: 1.5rem;
align-items: center;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.ports-global label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ports-global input, .ports-global select {
padding: 0.3rem 0.5rem;
border: 1px solid #444;
border-radius: 3px;
background: #2a2a2a;
color: #e0e0e0;
font-size: 0.85rem;
width: 80px;
}
.ports-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.ports-table th {
text-align: left;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #444;
color: #999;
font-weight: 500;
}
.ports-table td {
padding: 0.3rem 0.5rem;
border-bottom: 1px solid #2a2a2a;
}
.ports-table .port-num { color: #666; width: 2rem; text-align: center; }
.ports-table input[type="text"] {
padding: 0.25rem 0.4rem;
border: 1px solid #444;
border-radius: 3px;
background: #2a2a2a;
color: #e0e0e0;
font-size: 0.85rem;
width: 100%;
}
.ports-table input[type="checkbox"] { cursor: pointer; }
.active-port { background: #1a2a1a; }
.active-port .port-num { color: #4a7c59; font-weight: 600; }
/* Buttons */
.btn {
padding: 0.35rem 0.75rem;
border: 1px solid #444;
border-radius: 4px;
background: #333;
color: #ccc;
cursor: pointer;
font-size: 0.8rem;
}
.btn:hover { background: #444; }
.btn-primary { background: #4a7c59; border-color: #4a7c59; color: white; }
.btn-primary:hover { background: #5a9c69; }
.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.75rem; }

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "es2023",
"module": "esnext",
"lib": ["ES2023", "DOM"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
ws: true,
},
},
},
build: {
outDir: '../../dist',
emptyOutDir: true,
},
})

View File

@@ -0,0 +1,18 @@
[package]
name = "ericrfb-proxy"
version = "0.1.0"
edition = "2024"
[dependencies]
ericrfb = { path = "../ericrfb" }
futures-util = "0.3"
tokio.workspace = true
axum.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json = "1"
toml.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true

View File

@@ -0,0 +1,64 @@
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct ProxyConfig {
/// Address to bind the proxy HTTP server to.
#[serde(default = "default_bind")]
pub bind: String,
/// Directory to serve static frontend files from.
#[serde(default = "default_static_dir")]
pub static_dir: String,
pub omniview: OmniviewConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OmniviewConfig {
pub host: String,
/// HTTP port for login/applet page (usually 80).
#[serde(default = "default_http_port")]
pub http_port: u16,
/// TCP port for e-RIC RFB protocol (usually 443).
#[serde(default = "default_rfb_port")]
pub rfb_port: u16,
}
fn default_bind() -> String {
"0.0.0.0:3000".into()
}
fn default_static_dir() -> String {
"dist".into()
}
fn default_http_port() -> u16 {
80
}
fn default_rfb_port() -> u16 {
443
}
pub fn load() -> anyhow::Result<ProxyConfig> {
let path = std::env::var("BLEKIN_CONFIG").unwrap_or_else(|_| "config.toml".into());
match std::fs::read_to_string(&path) {
Ok(contents) => Ok(toml::from_str(&contents)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!("no config file at {path}, using defaults + BLEKIN_HOST env");
let host = std::env::var("BLEKIN_HOST").unwrap_or_else(|_| "10.3.0.130".into());
Ok(ProxyConfig {
bind: default_bind(),
static_dir: default_static_dir(),
omniview: OmniviewConfig {
host,
http_port: default_http_port(),
rfb_port: default_rfb_port(),
},
})
}
Err(e) => Err(e.into()),
}
}

View File

@@ -0,0 +1,266 @@
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use crate::AppState;
use crate::login::ErrorResponse;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
#[derive(Serialize)]
pub struct PortsResponse {
pub port_count: u16,
pub key_pause_duration: u16,
pub active_port: u16,
pub ports: Vec<PortInfo>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct PortInfo {
pub index: u16,
pub name: String,
pub hotkey: String,
pub show_in_rc: bool,
}
#[derive(Deserialize)]
pub struct SavePortsRequest {
pub port_count: u16,
pub key_pause_duration: u16,
pub ports: Vec<PortInfo>,
}
#[derive(Deserialize)]
pub struct SwitchRequest {
pub port: u16,
}
#[derive(Serialize)]
pub struct SwitchResponse {
pub active_port: u16,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async fn get_cookie(state: &AppState) -> Result<String, (StatusCode, Json<ErrorResponse>)> {
state.session_cookie.read().await.clone().ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "not logged in".into(),
}),
)
})
}
fn device_url(state: &AppState, path: &str) -> String {
format!(
"http://{}:{}{path}",
state.config.omniview.host, state.config.omniview.http_port
)
}
fn api_err(msg: impl Into<String>) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse { error: msg.into() }),
)
}
// ---------------------------------------------------------------------------
// HTML scraping helpers
// ---------------------------------------------------------------------------
fn extract_input_value(html: &str, name: &str) -> Option<String> {
let needle = format!("name=\"{name}\"");
let pos = html.find(&needle)?;
let after = &html[pos..];
let val_needle = "value=\"";
let val_pos = after.find(val_needle)? + val_needle.len();
let end = after[val_pos..].find('"')? + val_pos;
Some(html_unescape(&after[val_pos..end]))
}
fn html_unescape(s: &str) -> String {
s.replace("&gt;", ">")
.replace("&lt;", "<")
.replace("&amp;", "&")
.replace("&quot;", "\"")
}
fn extract_selected_option(html: &str, name: &str) -> Option<String> {
let needle = format!("name=\"{name}\"");
let pos = html.find(&needle)?;
let after = &html[pos..];
// Find "selected>" then the text until newline or '<'
let sel_pos = after.find("selected>")?;
let text_start = sel_pos + "selected>".len();
let text_end = after[text_start..].find(['<', '\n']).unwrap_or(0) + text_start;
Some(after[text_start..text_end].trim().to_string())
}
fn has_checked(html: &str, name: &str) -> bool {
let needle = format!("name=\"{name}\"");
if let Some(pos) = html.find(&needle) {
// Check if "checked" appears nearby before the next input/tag
let region = &html[pos.saturating_sub(80)..html.len().min(pos + 120)];
region.contains("checked")
} else {
false
}
}
// ---------------------------------------------------------------------------
// GET /api/kvm/ports
// ---------------------------------------------------------------------------
pub async fn get_ports(
State(state): State<AppState>,
) -> Result<Json<PortsResponse>, (StatusCode, Json<ErrorResponse>)> {
let cookie = get_cookie(&state).await?;
let html = state
.http_client
.get(device_url(&state, "/kvm.asp"))
.header("cookie", &cookie)
.send()
.await
.map_err(|e| api_err(format!("fetch kvm.asp: {e}")))?
.text()
.await
.map_err(|e| api_err(format!("read kvm.asp: {e}")))?;
let port_count: u16 = extract_selected_option(&html, "ECG_kvm_nr_ports")
.and_then(|s| s.parse().ok())
.unwrap_or(16);
let key_pause_duration: u16 = extract_input_value(&html, "ECG_key_pause_duration")
.and_then(|s| s.parse().ok())
.unwrap_or(100);
let active_port: u16 = extract_selected_option(&html, "kvm_active_port_0")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let mut ports = Vec::with_capacity(port_count as usize);
for i in 0..port_count {
let name = extract_input_value(&html, &format!("ECG_kvm_portname_{i}")).unwrap_or_default();
let hotkey = extract_input_value(&html, &format!("ECG_kvm_hotkey_{i}")).unwrap_or_default();
let show_in_rc = has_checked(&html, &format!("ECG_kvm_show_in_rc_{i}"));
ports.push(PortInfo {
index: i,
name,
hotkey,
show_in_rc,
});
}
Ok(Json(PortsResponse {
port_count,
key_pause_duration,
active_port,
ports,
}))
}
// ---------------------------------------------------------------------------
// PUT /api/kvm/ports
// ---------------------------------------------------------------------------
pub async fn save_ports(
State(state): State<AppState>,
Json(req): Json<SavePortsRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let cookie = get_cookie(&state).await?;
let mut form: Vec<(String, String)> = vec![
// Hidden template field required by the firmware
("__templates__".into(), " kvm_port_list kvm".into()),
("ECG_kvm_nr_ports".into(), req.port_count.to_string()),
(
"ECG_key_pause_duration".into(),
req.key_pause_duration.to_string(),
),
("ECG_kvm_portname_cnt".into(), req.port_count.to_string()),
("ECG_kvm_hotkey_cnt".into(), req.port_count.to_string()),
("ECG_kvm_powerport_cnt".into(), req.port_count.to_string()),
("ECG_kvm_show_in_rc_cnt".into(), req.port_count.to_string()),
];
for port in &req.ports {
form.push((
format!("ECG_kvm_portname_{}", port.index),
port.name.clone(),
));
form.push((
format!("ECG_kvm_hotkey_{}", port.index),
port.hotkey.clone(),
));
if port.show_in_rc {
form.push((format!("ECG_kvm_show_in_rc_{}", port.index), "yes".into()));
}
}
// Image button submits with .x/.y coordinates
form.push(("action_apply.x".into(), "0".into()));
form.push(("action_apply.y".into(), "0".into()));
let resp = state
.http_client
.post(device_url(&state, "/kvm.asp"))
.header("cookie", &cookie)
.form(&form)
.send()
.await
.map_err(|e| api_err(format!("post kvm.asp: {e}")))?;
let body = resp
.text()
.await
.map_err(|e| api_err(format!("read response: {e}")))?;
if body.contains("ERIC_RESPONSE_OK") {
Ok(Json(serde_json::json!({"ok": true})))
} else {
Err(api_err("device rejected configuration update"))
}
}
// ---------------------------------------------------------------------------
// POST /api/kvm/switch
// ---------------------------------------------------------------------------
pub async fn switch_port(
State(state): State<AppState>,
Json(req): Json<SwitchRequest>,
) -> Result<Json<SwitchResponse>, (StatusCode, Json<ErrorResponse>)> {
let cookie = get_cookie(&state).await?;
let form = [
(
"__templates__",
" kvm_port_list rc_preview power_control_ipmi power_control_intern power_control_direct".to_string(),
),
("kvm_active_port_0", req.port.to_string()),
("action_switch_0.x", "0".into()),
("action_switch_0.y", "0".into()),
];
state
.http_client
.post(device_url(&state, "/home2.asp"))
.header("cookie", &cookie)
.form(&form)
.send()
.await
.map_err(|e| api_err(format!("post home2.asp: {e}")))?;
Ok(Json(SwitchResponse {
active_port: req.port,
}))
}

View File

@@ -0,0 +1,119 @@
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use crate::AppState;
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub applet_id: String,
pub port: u16,
pub protocol_version: String,
pub board_name: String,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
pub async fn handle_login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
let cfg = &state.config.omniview;
let base = format!("http://{}:{}", cfg.host, cfg.http_port);
// POST credentials to auth.asp
let auth_resp = state
.http_client
.post(format!("{base}/auth.asp"))
.form(&[
("login", req.username.as_str()),
("password", req.password.as_str()),
("action_login.x", "0"),
("action_login.y", "0"),
])
.send()
.await
.map_err(|e| auth_err(format!("connect failed: {e}")))?;
// Extract session cookie
let cookie = auth_resp
.headers()
.get("set-cookie")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(';').next())
.ok_or_else(|| auth_err("no session cookie in response"))?
.to_string();
// Check redirect — should go to home.asp on success
let location = auth_resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !location.contains("home.asp") {
return Err(auth_err("authentication failed"));
}
// Fetch the applet page to extract params
let applet_resp = state
.http_client
.get(format!("{base}/title_app.asp"))
.header("cookie", &cookie)
.send()
.await
.map_err(|e| auth_err(format!("failed to fetch applet page: {e}")))?;
let html = applet_resp
.text()
.await
.map_err(|e| auth_err(format!("failed to read applet page: {e}")))?;
let applet_id = extract_param(&html, "APPLET_ID")
.ok_or_else(|| auth_err("APPLET_ID not found in applet page"))?;
let port = extract_param(&html, "PORT")
.and_then(|s| s.parse().ok())
.unwrap_or(cfg.rfb_port);
let protocol_version =
extract_param(&html, "PROTOCOL_VERSION").unwrap_or_else(|| "01.11".into());
let board_name =
extract_param(&html, "BOARD_NAME").unwrap_or_else(|| "Remote IP Manager".into());
// Persist session cookie for KVM API calls
*state.session_cookie.write().await = Some(cookie);
tracing::info!(
"login successful: board={board_name}, applet_id={}...",
&applet_id[..applet_id.len().min(16)]
);
Ok(Json(LoginResponse {
applet_id,
port,
protocol_version,
board_name,
}))
}
fn extract_param(html: &str, name: &str) -> Option<String> {
let needle = format!("{name}\" value=\"");
let start = html.find(&needle)? + needle.len();
let end = html[start..].find('"')? + start;
Some(html[start..end].to_string())
}
fn auth_err(msg: impl Into<String>) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::UNAUTHORIZED,
Json(ErrorResponse { error: msg.into() }),
)
}

View File

@@ -0,0 +1,58 @@
mod config;
mod kvm;
mod login;
mod ws;
use std::sync::Arc;
use axum::Router;
use axum::routing::{get, post};
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use tower_http::services::ServeDir;
use tracing_subscriber::EnvFilter;
#[derive(Clone)]
pub struct AppState {
pub config: Arc<config::ProxyConfig>,
pub http_client: reqwest::Client,
pub session_cookie: Arc<RwLock<Option<String>>>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let cfg = config::load()?;
tracing::info!(
"blekin proxy starting — OmniView at {}:{}, binding to {}",
cfg.omniview.host,
cfg.omniview.http_port,
cfg.bind
);
let state = AppState {
config: Arc::new(cfg.clone()),
http_client: reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.redirect(reqwest::redirect::Policy::none())
.build()?,
session_cookie: Arc::new(RwLock::new(None)),
};
let app = Router::new()
.route("/api/login", post(login::handle_login))
.route("/api/ws", get(ws::handle_ws))
.route("/api/kvm/ports", get(kvm::get_ports).put(kvm::save_ports))
.route("/api/kvm/switch", post(kvm::switch_port))
.fallback_service(ServeDir::new(&cfg.static_dir))
.with_state(state);
let listener = TcpListener::bind(&cfg.bind).await?;
tracing::info!("listening on {}", cfg.bind);
axum::serve(listener, app).await?;
Ok(())
}

View File

@@ -0,0 +1,256 @@
use axum::extract::ws::{Message, WebSocket};
use axum::extract::{Query, State, WebSocketUpgrade};
use axum::response::IntoResponse;
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use tokio::sync::mpsc;
use ericrfb::framebuffer::Framebuffer;
use ericrfb::handshake::Config;
use ericrfb::input;
use ericrfb::msg;
use ericrfb::proto::RGB332_LUT;
use ericrfb::session::{ActiveSession, Event};
use crate::AppState;
// ---------------------------------------------------------------------------
// WS binary protocol tags
// ---------------------------------------------------------------------------
// Proxy → Browser
const TAG_BLIT: u8 = 0x01;
const TAG_RESIZE: u8 = 0x03;
// Browser → Proxy
const TAG_KEY_PRESS: u8 = 0x10;
const TAG_KEY_RELEASE: u8 = 0x11;
const TAG_POINTER: u8 = 0x12;
const TAG_CTRL_ALT_DEL: u8 = 0x13;
#[derive(Deserialize)]
pub struct WsQuery {
pub applet_id: String,
#[serde(default = "default_port")]
pub port: u16,
}
fn default_port() -> u16 {
443
}
pub async fn handle_ws(
ws: WebSocketUpgrade,
State(state): State<AppState>,
Query(query): Query<WsQuery>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| run_session(socket, state, query))
}
async fn run_session(socket: WebSocket, state: AppState, query: WsQuery) {
let cfg = Config::new(&state.config.omniview.host, query.port, &query.applet_id);
tracing::info!(
"WS session starting: {}:{}",
state.config.omniview.host,
query.port
);
// Connect to OmniView in a blocking task (handshake is sync IO)
let session = match tokio::task::spawn_blocking(move || {
ActiveSession::connect(&cfg, &[7, 5, 1, 0, -250])
})
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => {
tracing::error!("OmniView connect failed: {e}");
return;
}
Err(e) => {
tracing::error!("spawn_blocking panicked: {e}");
return;
}
};
tracing::info!(
"Connected to OmniView: {}x{}",
session.framebuffer.width,
session.framebuffer.height
);
let (ws_tx, ws_rx) = socket.split();
let (blit_tx, blit_rx) = mpsc::channel::<Message>(64);
// Channel for input events from browser → OmniView writer
let (input_tx, input_rx) = mpsc::channel::<InputEvent>(64);
// Task: forward blit messages to WebSocket
let ws_send_task = tokio::spawn(forward_ws_send(ws_tx, blit_rx));
// Task: receive input from WebSocket
let ws_recv_task = tokio::spawn(forward_ws_recv(ws_rx, input_tx));
// Task: OmniView session pump (blocking)
let pump_task = tokio::task::spawn_blocking(move || run_pump(session, blit_tx, input_rx));
// Wait for any task to finish (on error or disconnect)
tokio::select! {
r = ws_send_task => { tracing::debug!("ws_send finished: {r:?}"); }
r = ws_recv_task => { tracing::debug!("ws_recv finished: {r:?}"); }
r = pump_task => { tracing::debug!("pump finished: {r:?}"); }
}
tracing::info!("WS session ended");
}
// ---------------------------------------------------------------------------
// OmniView pump (runs on blocking thread)
// ---------------------------------------------------------------------------
enum InputEvent {
KeyPress(u8),
KeyRelease(u8),
Pointer { x: u16, y: u16, mask: u8 },
CtrlAltDel,
}
fn run_pump(
mut session: ActiveSession,
blit_tx: mpsc::Sender<Message>,
mut input_rx: mpsc::Receiver<InputEvent>,
) {
// Send initial resize message
let w = session.framebuffer.width;
let h = session.framebuffer.height;
let _ = blit_tx.blocking_send(make_resize_msg(w, h));
loop {
// Drain any pending input events
while let Ok(evt) = input_rx.try_recv() {
if let Err(e) = handle_input(&mut session, evt) {
tracing::error!("input error: {e}");
return;
}
}
// Process one server message
match session.process_one() {
Ok(Some(Event::FramebufferDirty)) => {
// Send full framebuffer as RGBA blit
let msg = make_full_blit(&session.framebuffer);
if blit_tx.blocking_send(msg).is_err() {
return; // WS closed
}
// Request next update
if let Err(e) = session.request_update() {
tracing::error!("request_update error: {e}");
return;
}
}
Ok(Some(Event::Resize { width, height })) => {
let _ = blit_tx.blocking_send(make_resize_msg(width, height));
}
Ok(_) => {}
Err(e) => {
tracing::error!("session error: {e}");
return;
}
}
}
}
fn handle_input(session: &mut ActiveSession, evt: InputEvent) -> Result<(), String> {
match evt {
InputEvent::KeyPress(sc) => {
input::write_key_press(&mut session.writer, sc).map_err(|e| e.to_string())
}
InputEvent::KeyRelease(sc) => {
input::write_key_release(&mut session.writer, sc).map_err(|e| e.to_string())
}
InputEvent::Pointer { x, y, mask } => {
msg::write_pointer_event(&mut session.writer, x, y, mask).map_err(|e| e.to_string())
}
InputEvent::CtrlAltDel => {
input::write_ctrl_alt_del(&mut session.writer).map_err(|e| e.to_string())
}
}
}
// ---------------------------------------------------------------------------
// Binary message builders
// ---------------------------------------------------------------------------
fn make_full_blit(fb: &Framebuffer) -> Message {
let w = fb.width;
let h = fb.height;
// Header: tag(1) + x(2) + y(2) + w(2) + h(2) = 9 bytes
let mut buf = Vec::with_capacity(9 + (w as usize * h as usize * 4));
buf.push(TAG_BLIT);
buf.extend_from_slice(&0u16.to_be_bytes()); // x
buf.extend_from_slice(&0u16.to_be_bytes()); // y
buf.extend_from_slice(&w.to_be_bytes());
buf.extend_from_slice(&h.to_be_bytes());
// RGBA pixels
for &px in &fb.pixels {
buf.extend_from_slice(&RGB332_LUT[px as usize]);
}
Message::Binary(buf)
}
fn make_resize_msg(w: u16, h: u16) -> Message {
let mut buf = Vec::with_capacity(5);
buf.push(TAG_RESIZE);
buf.extend_from_slice(&w.to_be_bytes());
buf.extend_from_slice(&h.to_be_bytes());
Message::Binary(buf)
}
// ---------------------------------------------------------------------------
// WebSocket forwarding tasks
// ---------------------------------------------------------------------------
async fn forward_ws_send(
mut tx: futures_util::stream::SplitSink<WebSocket, Message>,
mut rx: mpsc::Receiver<Message>,
) {
while let Some(msg) = rx.recv().await {
if tx.send(msg).await.is_err() {
break;
}
}
}
async fn forward_ws_recv(
mut rx: futures_util::stream::SplitStream<WebSocket>,
tx: mpsc::Sender<InputEvent>,
) {
while let Some(Ok(msg)) = rx.next().await {
match msg {
Message::Binary(data) if !data.is_empty() => {
if let Some(evt) = parse_input(&data)
&& tx.send(evt).await.is_err()
{
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
}
fn parse_input(data: &[u8]) -> Option<InputEvent> {
match data[0] {
TAG_KEY_PRESS if data.len() >= 2 => Some(InputEvent::KeyPress(data[1])),
TAG_KEY_RELEASE if data.len() >= 2 => Some(InputEvent::KeyRelease(data[1])),
TAG_POINTER if data.len() >= 6 => {
let x = u16::from_be_bytes([data[1], data[2]]);
let y = u16::from_be_bytes([data[3], data[4]]);
let mask = data[5];
Some(InputEvent::Pointer { x, y, mask })
}
TAG_CTRL_ALT_DEL => Some(InputEvent::CtrlAltDel),
_ => None,
}
}

12
crates/ericrfb/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "ericrfb"
version = "0.1.0"
edition = "2024"
[dependencies]
flate2.workspace = true
thiserror.workspace = true
[dev-dependencies]
proptest = "1"
png = "0.17"

View File

@@ -0,0 +1,47 @@
use std::env;
use ericrfb::handshake::{Config, connect};
fn main() {
let args: Vec<String> = env::args().collect();
let host = args
.iter()
.position(|a| a == "--host")
.and_then(|i| args.get(i + 1))
.expect("usage: --host <ip> --applet-id <token> [--port <port>]");
let applet_id = args
.iter()
.position(|a| a == "--applet-id")
.and_then(|i| args.get(i + 1))
.expect("usage: --host <ip> --applet-id <token> [--port <port>]");
let port: u16 = args
.iter()
.position(|a| a == "--port")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(443);
let cfg = Config::new(host, port, applet_id);
println!("Connecting to {}:{}...", cfg.host, cfg.port);
match connect(&cfg) {
Ok(session) => {
println!(
"Connected: name={:?}, {}x{}, version={}.{}, format={}",
session.server_name,
session.width(),
session.height(),
session.server_version.0,
session.server_version.1,
session.pixel_format.label,
);
}
Err(e) => {
eprintln!("Handshake failed: {e}");
std::process::exit(1);
}
}
}

View File

@@ -0,0 +1,108 @@
use std::env;
use std::fs::{self, File};
use std::io::BufWriter;
use std::path::Path;
use std::time::{Duration, Instant};
use ericrfb::handshake::Config;
use ericrfb::session::{ActiveSession, Event};
fn save_png(fb: &ericrfb::framebuffer::Framebuffer, path: &Path) {
let rgba = fb.to_rgba();
let file = File::create(path).expect("cannot create PNG file");
let bw = BufWriter::new(file);
let mut encoder = png::Encoder::new(bw, fb.width as u32, fb.height as u32);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().expect("png header failed");
writer.write_image_data(&rgba).expect("png write failed");
}
fn main() {
let args: Vec<String> = env::args().collect();
let host = args
.iter()
.position(|a| a == "--host")
.and_then(|i| args.get(i + 1))
.expect("usage: --host <ip> --applet-id <token> [--port <port>] [--duration <secs>]");
let applet_id = args
.iter()
.position(|a| a == "--applet-id")
.and_then(|i| args.get(i + 1))
.expect("usage: --host <ip> --applet-id <token> [--port <port>] [--duration <secs>]");
let port: u16 = args
.iter()
.position(|a| a == "--port")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(443);
let duration_secs: u64 = args
.iter()
.position(|a| a == "--duration")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(30);
let out_dir = Path::new("out");
fs::create_dir_all(out_dir).expect("cannot create out/");
let cfg = Config::new(host, port, applet_id);
println!("Connecting to {}:{}...", cfg.host, cfg.port);
// Request Tight (7), Hextile (5), CopyRect (1), Raw (0), cursor pseudo (-250)
let mut session = ActiveSession::connect(&cfg, &[7, 5, 1, 0, -250]).expect("connect failed");
println!(
"Connected: {}x{}, recording for {duration_secs}s...",
session.framebuffer.width, session.framebuffer.height
);
let start = Instant::now();
let duration = Duration::from_secs(duration_secs);
let mut last_save = Instant::now() - Duration::from_secs(2); // force first save
let mut frame_count = 0u32;
loop {
if start.elapsed() >= duration {
break;
}
match session.process_one() {
Ok(Some(Event::FramebufferDirty)) => {
// Save at most 1 PNG per second
if last_save.elapsed() >= Duration::from_secs(1) {
let path = out_dir.join(format!("frame_{frame_count:04}.png"));
save_png(&session.framebuffer, &path);
println!(
"[{:.1}s] saved {}",
start.elapsed().as_secs_f64(),
path.display()
);
frame_count += 1;
last_save = Instant::now();
}
// Request next update
if let Err(e) = session.request_update() {
eprintln!("Error requesting update: {e}");
break;
}
}
Ok(Some(Event::Resize { width, height })) => {
println!("Resized to {width}x{height}");
}
Ok(Some(Event::Debug(s))) => {
eprintln!("[debug] {s}");
}
Ok(Some(_)) | Ok(None) => {}
Err(e) => {
eprintln!("Error: {e}");
break;
}
}
}
println!("Done. Saved {frame_count} frames to {}/", out_dir.display());
}

View File

@@ -0,0 +1,88 @@
use std::env;
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;
use ericrfb::handshake::Config;
use ericrfb::session::{ActiveSession, Event};
fn main() {
let args: Vec<String> = env::args().collect();
let host = args
.iter()
.position(|a| a == "--host")
.and_then(|i| args.get(i + 1))
.expect("usage: --host <ip> --applet-id <token> [--port <port>] [--output <file.png>]");
let applet_id = args
.iter()
.position(|a| a == "--applet-id")
.and_then(|i| args.get(i + 1))
.expect("usage: --host <ip> --applet-id <token> [--port <port>] [--output <file.png>]");
let port: u16 = args
.iter()
.position(|a| a == "--port")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(443);
let output = args
.iter()
.position(|a| a == "--output")
.and_then(|i| args.get(i + 1).map(|s| s.as_str()))
.unwrap_or("frame.png");
let cfg = Config::new(host, port, applet_id);
println!("Connecting to {}:{}...", cfg.host, cfg.port);
// Request Tight (7), Hextile (5), CopyRect (1), Raw (0), cursor pseudo (-250)
let mut session = ActiveSession::connect(&cfg, &[7, 5, 1, 0, -250]).expect("connect failed");
println!(
"Connected: {}x{}, waiting for first frame...",
session.framebuffer.width, session.framebuffer.height
);
// Process messages until we get a FramebufferDirty event
loop {
match session.process_one() {
Ok(Some(Event::FramebufferDirty)) => {
println!("Got framebuffer update, saving to {output}");
break;
}
Ok(Some(Event::Resize { width, height })) => {
println!("Resized to {width}x{height}");
}
Ok(Some(Event::Debug(s))) => {
eprintln!("[debug] {s}");
}
Ok(Some(Event::RfbCommand(k, v))) => {
eprintln!("[rfb-cmd] {k}={v}");
}
Ok(Some(_)) => {}
Ok(None) => {}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
// Write PNG
let rgba = session.framebuffer.to_rgba();
let w = session.framebuffer.width as u32;
let h = session.framebuffer.height as u32;
let path = Path::new(output);
let file = File::create(path).expect("cannot create output file");
let bw = BufWriter::new(file);
let mut encoder = png::Encoder::new(bw, w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().expect("png header failed");
writer.write_image_data(&rgba).expect("png write failed");
println!("Saved {w}x{h} frame to {}", path.display());
}

View File

@@ -0,0 +1,148 @@
use std::io::Read;
use crate::framebuffer::Framebuffer;
use crate::proto::{self, read_exact, read_u8};
// Subencoding flag bits — ByteColorRFBRenderer.int(), line 192
const RAW: u8 = 1;
const BACKGROUND_SPECIFIED: u8 = 2;
const FOREGROUND_SPECIFIED: u8 = 4;
const ANY_SUBRECTS: u8 = 8;
const SUBRECTS_COLOURED: u8 = 16;
/// Decode a Hextile-encoded rectangle into the framebuffer.
///
/// The rectangle is divided into 16x16 tiles (edge tiles may be smaller).
/// Background and foreground colors persist across tiles within one call.
///
/// Reference: ByteColorRFBRenderer.int() line 169.
pub fn decode_hextile(
r: &mut impl Read,
fb: &mut Framebuffer,
rx: u16,
ry: u16,
rw: u16,
rh: u16,
) -> proto::Result<()> {
let mut bg: u8 = 0;
let mut fg: u8 = 0;
let mut ty = ry;
while ty < ry + rh {
let tile_h = (ry + rh - ty).min(16);
let mut tx = rx;
while tx < rx + rw {
let tile_w = (rx + rw - tx).min(16);
let flags = read_u8(r)?;
if flags & RAW != 0 {
// Raw tile: read tile_w * tile_h bytes
let size = tile_w as usize * tile_h as usize;
let data = read_exact(r, size)?;
fb.apply_raw(tx, ty, tile_w, tile_h, &data);
tx += 16;
continue;
}
if flags & BACKGROUND_SPECIFIED != 0 {
bg = read_u8(r)?;
}
// Fill tile with background
fb.fill_rect(tx, ty, tile_w, tile_h, bg);
if flags & FOREGROUND_SPECIFIED != 0 {
fg = read_u8(r)?;
}
if flags & ANY_SUBRECTS != 0 {
let num_subrects = read_u8(r)?;
let coloured = flags & SUBRECTS_COLOURED != 0;
for _ in 0..num_subrects {
let color = if coloured { read_u8(r)? } else { fg };
let xy = read_u8(r)?;
let wh = read_u8(r)?;
let sx = (xy >> 4) as u16;
let sy = (xy & 0x0F) as u16;
let sw = ((wh >> 4) + 1) as u16;
let sh = ((wh & 0x0F) + 1) as u16;
fb.fill_rect(tx + sx, ty + sy, sw, sh, color);
}
}
tx += 16;
}
ty += 16;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn test_hextile_raw_tile() {
let mut fb = Framebuffer::new(16, 16);
// One 16x16 tile, Raw subencoding
let mut data = vec![RAW]; // flags
data.extend_from_slice(&[0x42u8; 256]); // 16*16 raw pixels
let mut c = Cursor::new(data);
decode_hextile(&mut c, &mut fb, 0, 0, 16, 16).unwrap();
assert_eq!(fb.pixels[0], 0x42);
assert_eq!(fb.pixels[255], 0x42);
}
#[test]
fn test_hextile_bg_fill() {
let mut fb = Framebuffer::new(16, 16);
// One tile: background=0x09, no subrects
let data = vec![BACKGROUND_SPECIFIED, 0x09];
let mut c = Cursor::new(data);
decode_hextile(&mut c, &mut fb, 0, 0, 16, 16).unwrap();
assert!(fb.pixels.iter().all(|&p| p == 0x09));
}
#[test]
fn test_hextile_subrects_coloured() {
let mut fb = Framebuffer::new(16, 16);
// Background=0x00, 1 coloured subrect at (2,3) size 4x5 color 0xFF
let data = vec![
BACKGROUND_SPECIFIED | ANY_SUBRECTS | SUBRECTS_COLOURED,
0x00, // bg
1, // num_subrects
0xFF, // subrect color
0x23, // xy: x=2, y=3
0x34, // wh: w=3+1=4, h=4+1=5
];
let mut c = Cursor::new(data);
decode_hextile(&mut c, &mut fb, 0, 0, 16, 16).unwrap();
assert_eq!(fb.pixels[0], 0x00); // background
assert_eq!(fb.pixels[3 * 16 + 2], 0xFF); // subrect at (2,3)
assert_eq!(fb.pixels[7 * 16 + 5], 0xFF); // subrect at (5,7)
assert_eq!(fb.pixels[8 * 16 + 2], 0x00); // below subrect
}
#[test]
fn test_hextile_fg_subrects() {
let mut fb = Framebuffer::new(16, 16);
// Background=0x00, foreground=0xAA, 1 subrect at (0,0) size 2x2
let data = vec![
BACKGROUND_SPECIFIED | FOREGROUND_SPECIFIED | ANY_SUBRECTS,
0x00, // bg
0xAA, // fg
1, // num_subrects
0x00, // xy: x=0, y=0
0x11, // wh: w=1+1=2, h=1+1=2
];
let mut c = Cursor::new(data);
decode_hextile(&mut c, &mut fb, 0, 0, 16, 16).unwrap();
assert_eq!(fb.pixels[0], 0xAA);
assert_eq!(fb.pixels[1], 0xAA);
assert_eq!(fb.pixels[16], 0xAA);
assert_eq!(fb.pixels[17], 0xAA);
assert_eq!(fb.pixels[2], 0x00);
}
}

View File

@@ -0,0 +1,263 @@
use std::io::Read;
use crate::codec::tight;
use crate::framebuffer::Framebuffer;
use crate::proto::{self, read_exact, read_u8, read_varint};
const TILE: usize = 16;
const VERSIONS: usize = 8;
const TILE_BYTES: usize = TILE * TILE; // 256 bytes at 8bpp
/// Per-tile versioned pixel cache — stores 8bpp data.
/// Reference: t.java — 8 versions × 256 bytes per tile.
struct TileEntry {
data: [[u8; TILE_BYTES]; VERSIONS],
}
#[allow(dead_code)]
impl TileEntry {
fn new() -> Self {
Self {
data: [[0u8; TILE_BYTES]; VERSIONS],
}
}
/// Write `len` bytes from `src[src_off..]` into version at `offset`.
fn write(&mut self, version: usize, offset: usize, src: &[u8], src_off: usize, len: usize) {
let v = version % VERSIONS;
self.data[v][offset..offset + len].copy_from_slice(&src[src_off..src_off + len]);
}
/// Read `len` bytes from version at `offset` into `dst[dst_off..]`.
fn read(&self, version: usize, offset: usize, dst: &mut [u8], dst_off: usize, len: usize) {
let v = version % VERSIONS;
dst[dst_off..dst_off + len].copy_from_slice(&self.data[v][offset..offset + len]);
}
}
/// Tile cache for encoding 9 (IIP). One entry per 16×16 tile on screen.
pub struct TileCache {
tiles: Vec<TileEntry>,
tiles_x: usize,
tiles_y: usize,
}
impl TileCache {
pub fn new(fb_width: u16, fb_height: u16) -> Self {
let tx = fb_width as usize / TILE;
let ty = fb_height as usize / TILE;
let count = tx * ty;
let mut tiles = Vec::with_capacity(count);
for _ in 0..count {
tiles.push(TileEntry::new());
}
Self {
tiles,
tiles_x: tx,
tiles_y: ty,
}
}
pub fn resize(&mut self, fb_width: u16, fb_height: u16) {
*self = Self::new(fb_width, fb_height);
}
fn get(&self, tile_x: usize, tile_y: usize) -> &TileEntry {
&self.tiles[tile_y * self.tiles_x + tile_x]
}
fn get_mut(&mut self, tile_x: usize, tile_y: usize) -> &mut TileEntry {
&mut self.tiles[tile_y * self.tiles_x + tile_x]
}
}
/// Decode encoding 9 (IIP) — tile-cached delta compression.
///
/// Control byte layout:
/// - bits 0-3: sub-type (1=1bpp, 2=2bpp, 3=4bpp_gray, 4=4bpp_color, 8=8bpp)
/// - bits 4-5: zlib stream index (0-3)
/// - bits 6-7: mode (0=cache-read, 4=write-only, 8=update+read, 12=read-only)
///
/// Reference: ByteColorRFBRenderer.do() line 248.
#[allow(clippy::too_many_arguments)]
pub fn decode_iip(
r: &mut impl Read,
fb: &mut Framebuffer,
cache: &mut TileCache,
zlib: &mut tight::ZlibStreams,
rx: u16,
ry: u16,
rw: u16,
rh: u16,
) -> proto::Result<()> {
let control = read_u8(r)?;
let stream_idx = ((control >> 4) & 3) as usize;
let mode = (control >> 4) & 0x0C; // 0, 4, 8, or 12
let sub_type = control & 0x0F;
// Determine palette from sub-type (same as tight sub-palettes)
let _palette_id = match sub_type {
1 => 10, // 1bpp BW
2 => 11, // 2bpp gray4
3 => 12, // 4bpp gray16
4 => 13, // 4bpp color16
8 => 0, // 8bpp direct
_ => return Ok(()), // unknown sub-type, skip
};
// Calculate aligned tile region
let y_start = ry as usize;
let y_end = ry as usize + rh as usize;
let w = rw as usize;
let tile_y_end = if !y_end.is_multiple_of(TILE) {
y_end / TILE * TILE
} else {
y_end
};
let aligned_h = tile_y_end - y_start;
let num_tile_ctrl = (w / TILE) * (aligned_h / TILE);
// Read tile control bytes (compressed or raw)
let tile_ctrl = if num_tile_ctrl < 12 {
read_exact(r, num_tile_ctrl)?
} else {
let comp_len = read_varint(r)? as usize;
let compressed = read_exact(r, comp_len)?;
let decompressor = zlib.get_or_init(stream_idx);
let mut output = vec![0u8; num_tile_ctrl];
let before_out = decompressor.total_out();
decompressor
.decompress(&compressed, &mut output, flate2::FlushDecompress::None)
.map_err(|e| {
proto::ProtoError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
})?;
let produced = (decompressor.total_out() - before_out) as usize;
if produced != num_tile_ctrl {
return Err(proto::ProtoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("iip zlib: produced {produced}, expected {num_tile_ctrl}"),
)));
}
output
};
if mode == 0 || mode == 12 {
// Cache-read only: no new pixel data on the wire.
// Read tile versions from cache directly to framebuffer.
read_cache_to_fb(fb, cache, &tile_ctrl, rx as usize, y_start, w, aligned_h);
} else {
// Mode 4 or 8: Tight-encoded pixel data follows.
// Decode Tight data into framebuffer, then update tile cache from framebuffer.
tight::decode_tight(r, fb, zlib, rx, ry, rw, rh)?;
// Update tile cache from the framebuffer pixels we just wrote
update_cache_from_fb(
fb,
cache,
&tile_ctrl,
rx as usize,
y_start,
w,
aligned_h,
mode,
);
// For mode 8, re-read from cache (may differ if some tiles weren't updated)
if mode == 8 {
read_cache_to_fb(fb, cache, &tile_ctrl, rx as usize, y_start, w, aligned_h);
}
}
Ok(())
}
/// Read tile versions from cache into the framebuffer.
fn read_cache_to_fb(
fb: &mut Framebuffer,
cache: &TileCache,
ctrl: &[u8],
rx: usize,
ry: usize,
rw: usize,
aligned_h: usize,
) {
let stride = fb.width as usize;
let mut ci = 0;
for ty in (0..aligned_h).step_by(TILE) {
for tx in (0..rw).step_by(TILE) {
let version = (ctrl[ci] & 0x7F) as usize;
ci += 1;
let tile_x = (rx + tx) / TILE;
let tile_y = (ry + ty) / TILE;
if tile_x >= cache.tiles_x || tile_y >= cache.tiles_y {
continue;
}
let entry = cache.get(tile_x, tile_y);
let tw = TILE.min(rw - tx);
let th = TILE.min(aligned_h - ty);
for row in 0..th {
let fb_off = (ry + ty + row) * stride + rx + tx;
let cache_off = row * TILE;
fb.pixels[fb_off..fb_off + tw]
.copy_from_slice(&entry.data[version % VERSIONS][cache_off..cache_off + tw]);
}
}
}
}
/// Update tile cache from framebuffer pixels.
#[allow(clippy::too_many_arguments)]
fn update_cache_from_fb(
fb: &Framebuffer,
cache: &mut TileCache,
ctrl: &[u8],
rx: usize,
ry: usize,
rw: usize,
aligned_h: usize,
mode: u8,
) {
let stride = fb.width as usize;
let mut ci = 0;
for ty in (0..aligned_h).step_by(TILE) {
for _tx_idx in 0..(rw / TILE) {
let tx = _tx_idx * TILE;
let byte = ctrl[ci];
ci += 1;
let version = (byte & 0x7F) as usize;
let should_update = if mode == 8 {
byte & 0x80 == 0 // bit 7 clear = update
} else {
true // mode 4: always update
};
if !should_update {
continue;
}
let tile_x = (rx + tx) / TILE;
let tile_y = (ry + ty) / TILE;
if tile_x >= cache.tiles_x || tile_y >= cache.tiles_y {
continue;
}
let entry = cache.get_mut(tile_x, tile_y);
let tw = TILE.min(rw - tx);
let th = TILE.min(aligned_h - ty);
for row in 0..th {
let fb_off = (ry + ty + row) * stride + rx + tx;
let cache_off = row * TILE;
entry.data[version % VERSIONS][cache_off..cache_off + tw]
.copy_from_slice(&fb.pixels[fb_off..fb_off + tw]);
}
}
}
}

View File

@@ -0,0 +1,4 @@
pub mod hextile;
pub mod iip;
pub mod raw_tile;
pub mod tight;

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

View File

@@ -0,0 +1,389 @@
use std::io::Read;
use flate2::Decompress;
use crate::framebuffer::Framebuffer;
use crate::proto::{self, read_exact, read_u8, read_varint};
// ---------------------------------------------------------------------------
// Palettes — ByteColorRFBRenderer.case(), line 691
//
// The Java applet stores these as 24-bit RGB values. We store the nearest
// RGB332 index for each entry so we can write directly to our 8bpp framebuffer.
// ---------------------------------------------------------------------------
/// Nearest RGB332 index for a given 24-bit color.
const fn closest_rgb332(r: u8, g: u8, b: u8) -> u8 {
let ri = ((r as u16 * 7 + 127) / 255) as u8;
let gi = ((g as u16 * 7 + 127) / 255) as u8;
let bi = ((b as u16 * 3 + 127) / 255) as u8;
ri | (gi << 3) | (bi << 6)
}
/// Palette C: 1bpp B/W (subencoding 10, palette selector 1).
const PALETTE_BW: [u8; 2] = [closest_rgb332(0, 0, 0), closest_rgb332(255, 255, 255)];
/// Palette x: 2bpp 4-gray (subencoding 11, palette selector 2).
const PALETTE_GRAY4: [u8; 4] = [
closest_rgb332(0, 0, 0),
closest_rgb332(128, 128, 128),
closest_rgb332(192, 192, 192),
closest_rgb332(255, 255, 255),
];
/// Palette L: 4bpp 16-gray (subencoding 12, palette selector 3).
#[rustfmt::skip]
const PALETTE_GRAY16: [u8; 16] = [
closest_rgb332(0, 0, 0), closest_rgb332(33, 33, 33),
closest_rgb332(50, 50, 50), closest_rgb332(67, 67, 67),
closest_rgb332(92, 92, 92), closest_rgb332(105, 105, 105),
closest_rgb332(117, 117, 117), closest_rgb332(134, 134, 134),
closest_rgb332(151, 151, 151), closest_rgb332(163, 163, 163),
closest_rgb332(178, 178, 178), closest_rgb332(193, 193, 193),
closest_rgb332(209, 209, 209), closest_rgb332(226, 226, 226),
closest_rgb332(79, 79, 79), closest_rgb332(255, 255, 255),
];
/// Palette I: 4bpp 16-color EGA-like (subencoding 13, palette selector 4).
#[rustfmt::skip]
const PALETTE_COLOR16: [u8; 16] = [
closest_rgb332(0, 0, 0), closest_rgb332(128, 0, 0),
closest_rgb332(255, 0, 0), closest_rgb332(0, 128, 0),
closest_rgb332(128, 128, 0), closest_rgb332(255, 255, 0),
closest_rgb332(0, 255, 0), closest_rgb332(0, 0, 128),
closest_rgb332(128, 0, 128), closest_rgb332(0, 128, 128),
closest_rgb332(128, 128, 128), closest_rgb332(192, 192, 192),
closest_rgb332(255, 0, 255), closest_rgb332(0, 255, 255),
closest_rgb332(255, 255, 255), closest_rgb332(0, 0, 255),
];
fn palette_for_selector(selector: u8) -> Option<&'static [u8]> {
match selector {
1 => Some(&PALETTE_BW),
2 => Some(&PALETTE_GRAY4),
3 => Some(&PALETTE_GRAY16),
4 => Some(&PALETTE_COLOR16),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Zlib stream state — persists across rectangles
// ---------------------------------------------------------------------------
pub struct ZlibStreams {
streams: [Option<Decompress>; 4],
}
impl ZlibStreams {
pub fn new() -> Self {
Self {
streams: [None, None, None, None],
}
}
pub fn get_or_init(&mut self, idx: usize) -> &mut Decompress {
self.streams[idx].get_or_insert_with(|| Decompress::new(true))
}
fn reset(&mut self, idx: usize) {
self.streams[idx] = None;
}
}
impl Default for ZlibStreams {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tight decoder — ByteColorRFBRenderer.a() line 324
//
// Control byte: bottom 4 bits = stream reset flags, top 4 bits = subencoding.
// Subencoding 8: solid fill (1 byte color).
// Subencoding 15: palette-indexed fill (selector + index).
// Subencodings 4-7: filtered data (optional palette filter).
// Subencodings 10-13: reduced bit-depth packed.
// Subencodings 0-3, 9, 14: raw 8bpp data.
// Data >= 12 bytes is zlib-compressed (varint length prefix).
// ---------------------------------------------------------------------------
pub fn decode_tight(
r: &mut impl Read,
fb: &mut Framebuffer,
zlib: &mut ZlibStreams,
rx: u16,
ry: u16,
rw: u16,
rh: u16,
) -> proto::Result<()> {
let control = read_u8(r)?;
// Bottom 4 bits: stream reset flags
for i in 0..4 {
if (control >> i) & 1 != 0 {
zlib.reset(i);
}
}
// Top 4 bits: subencoding
let subenc = control >> 4;
if subenc == 8 {
// Solid fill: 1 byte color
let color = read_u8(r)?;
fb.fill_rect(rx, ry, rw, rh, color);
return Ok(());
}
if subenc == 15 {
// Palette-indexed fill: 1 byte palette selector, 1 byte index
let selector = read_u8(r)?;
let index = read_u8(r)?;
let color = if let Some(pal) = palette_for_selector(selector) {
pal[index as usize % pal.len()]
} else {
// Selector 0 or unknown: use as direct RGB332 index
index
};
fb.fill_rect(rx, ry, rw, rh, color);
return Ok(());
}
// Determine row width and palette for decompressed data
let mut row_bytes = rw as usize;
let mut palette_2: Option<[u8; 2]> = None;
if (subenc | 3) == 7 {
// Subencodings 4-7: read filter byte
let filter = read_u8(r)?;
let filter_id = filter & 0x0F;
let pal_selector = (filter >> 4) & 0x0F;
if filter_id == 1 {
// Palette filter: 2-color sub-palette, 1 bit per pixel
let num_colors = read_u8(r)? + 1;
if num_colors != 2 {
return Err(proto::ProtoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("tight palette size {num_colors}, expected 2"),
)));
}
palette_2 = Some(if let Some(pal) = palette_for_selector(pal_selector) {
let packed_colors = read_u8(r)?;
match pal_selector {
1 => [
pal[(packed_colors >> 1) as usize],
pal[(packed_colors & 1) as usize],
],
2 => [
pal[(packed_colors >> 2) as usize],
pal[(packed_colors & 3) as usize],
],
3 | 4 => [
pal[(packed_colors >> 4) as usize],
pal[(packed_colors & 0xF) as usize],
],
_ => [packed_colors >> 4, packed_colors & 0x0F],
}
} else {
// Selector 0: two separate RGB332 color bytes (line 421-422)
let c0 = read_u8(r)?;
let c1 = read_u8(r)?;
[c0, c1]
});
row_bytes = (rw as usize).div_ceil(8);
}
// filter_id 0 = copy (no transform), row_bytes stays as rw
} else {
// Subencodings 0-3, 9-14: fixed bit-depth from subencoding value
match subenc {
10 => row_bytes = (rw as usize).div_ceil(8),
11 => row_bytes = (rw as usize).div_ceil(4),
12 | 13 => row_bytes = (rw as usize).div_ceil(2),
_ => {} // 0-3, 9, 14: raw 8bpp, row_bytes = rw
}
}
// Read (and decompress if needed) the pixel data
let total_bytes = rh as usize * row_bytes;
let decompressed = if total_bytes < 12 {
read_exact(r, total_bytes)?
} else {
let comp_len = read_varint(r)? as usize;
let compressed = read_exact(r, comp_len)?;
// Select zlib stream
let stream_idx = if subenc & 8 != 0 {
0
} else {
(subenc & 3) as usize
};
let decompressor = zlib.get_or_init(stream_idx);
let mut output = vec![0u8; total_bytes];
let before_out = decompressor.total_out();
decompressor
.decompress(&compressed, &mut output, flate2::FlushDecompress::None)
.map_err(|e| {
proto::ProtoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"zlib: {e} (stream {stream_idx}, in={comp_len}, expected_out={total_bytes})"
),
))
})?;
let produced = (decompressor.total_out() - before_out) as usize;
if produced != total_bytes {
return Err(proto::ProtoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("zlib: produced {produced} bytes, expected {total_bytes}"),
)));
}
output
};
// Apply decompressed data to framebuffer
if let Some(pal) = palette_2 {
// 2-color palette: 1 bit per pixel, MSB first
unpack_1bpp(fb, rx, ry, rw, rh, &decompressed, &pal);
} else {
match subenc {
10 => unpack_1bpp(fb, rx, ry, rw, rh, &decompressed, &PALETTE_BW),
11 => unpack_2bpp(fb, rx, ry, rw, rh, &decompressed, &PALETTE_GRAY4),
12 => unpack_4bpp(fb, rx, ry, rw, rh, &decompressed, &PALETTE_GRAY16),
13 => unpack_4bpp(fb, rx, ry, rw, rh, &decompressed, &PALETTE_COLOR16),
_ => {
// Raw 8bpp — each byte is an RGB332 index
fb.apply_raw(rx, ry, rw, rh, &decompressed);
}
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Bit-depth unpacking
// ---------------------------------------------------------------------------
/// 1bpp: each byte packs 8 pixels MSB-first. Row-padded to byte boundary.
fn unpack_1bpp(fb: &mut Framebuffer, x: u16, y: u16, w: u16, h: u16, data: &[u8], pal: &[u8; 2]) {
let stride = fb.width as usize;
let row_bytes = (w as usize).div_ceil(8);
for row in 0..h as usize {
let row_data = &data[row * row_bytes..];
let fb_offset = (y as usize + row) * stride + x as usize;
for col in 0..w as usize {
let byte_idx = col / 8;
let bit_idx = 7 - (col % 8);
let bit = (row_data[byte_idx] >> bit_idx) & 1;
fb.pixels[fb_offset + col] = pal[bit as usize];
}
}
}
/// 2bpp: each byte packs 4 pixels, 2 bits each, MSB-first.
fn unpack_2bpp(fb: &mut Framebuffer, x: u16, y: u16, w: u16, h: u16, data: &[u8], pal: &[u8; 4]) {
let stride = fb.width as usize;
let row_bytes = (w as usize).div_ceil(4);
for row in 0..h as usize {
let row_data = &data[row * row_bytes..];
let fb_offset = (y as usize + row) * stride + x as usize;
for col in 0..w as usize {
let byte_idx = col / 4;
let shift = 6 - (col % 4) * 2;
let idx = (row_data[byte_idx] >> shift) & 3;
fb.pixels[fb_offset + col] = pal[idx as usize];
}
}
}
/// 4bpp: each byte packs 2 pixels, high nibble first.
fn unpack_4bpp(fb: &mut Framebuffer, x: u16, y: u16, w: u16, h: u16, data: &[u8], pal: &[u8; 16]) {
let stride = fb.width as usize;
let row_bytes = (w as usize).div_ceil(2);
for row in 0..h as usize {
let row_data = &data[row * row_bytes..];
let fb_offset = (y as usize + row) * stride + x as usize;
for col in 0..w as usize {
let byte_idx = col / 2;
let idx = if col % 2 == 0 {
(row_data[byte_idx] >> 4) & 0x0F
} else {
row_data[byte_idx] & 0x0F
};
fb.pixels[fb_offset + col] = pal[idx as usize];
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn test_solid_fill() {
let mut fb = Framebuffer::new(8, 8);
let mut zlib = ZlibStreams::new();
// Control: no resets, subencoding 8 (solid fill)
let data = vec![0x80, 0x42]; // control=0x80 (subenc 8), color=0x42
let mut c = Cursor::new(data);
decode_tight(&mut c, &mut fb, &mut zlib, 0, 0, 8, 8).unwrap();
assert!(fb.pixels.iter().all(|&p| p == 0x42));
}
#[test]
fn test_palette_fill() {
let mut fb = Framebuffer::new(8, 8);
let mut zlib = ZlibStreams::new();
// Control: subencoding 15, selector 1 (BW palette), index 1 (white)
let data = vec![0xF0, 1, 1]; // subenc 15, selector=1, index=1
let mut c = Cursor::new(data);
decode_tight(&mut c, &mut fb, &mut zlib, 0, 0, 8, 8).unwrap();
assert!(fb.pixels.iter().all(|&p| p == 0xFF)); // white in RGB332
}
#[test]
fn test_raw_small_uncompressed() {
let mut fb = Framebuffer::new(4, 2);
let mut zlib = ZlibStreams::new();
// Control: subencoding 0 (raw), no filter, 4*2=8 < 12 so uncompressed
let mut data = vec![0x00]; // control: subenc 0
data.extend_from_slice(&[0x09; 8]); // 4x2 raw pixels
let mut c = Cursor::new(data);
decode_tight(&mut c, &mut fb, &mut zlib, 0, 0, 4, 2).unwrap();
assert!(fb.pixels.iter().all(|&p| p == 0x09));
}
#[test]
fn test_stream_reset() {
let mut zlib = ZlibStreams::new();
// Init stream 1
zlib.get_or_init(1);
assert!(zlib.streams[1].is_some());
// Control byte with bit 1 set (reset stream 1), subenc 8 (fill)
let mut fb = Framebuffer::new(1, 1);
let data = vec![0x82, 0x00]; // bits: 0b10 resets stream 1, subenc 8
let mut c = Cursor::new(data);
decode_tight(&mut c, &mut fb, &mut zlib, 0, 0, 1, 1).unwrap();
assert!(zlib.streams[1].is_none());
}
#[test]
fn test_closest_rgb332() {
assert_eq!(closest_rgb332(0, 0, 0), 0x00);
assert_eq!(closest_rgb332(255, 255, 255), 0xFF);
// Pure red: r=7 → bits 0-2
assert_eq!(closest_rgb332(255, 0, 0), 0x07);
// Pure green: g=7 → bits 3-5
assert_eq!(closest_rgb332(0, 255, 0), 0x38);
// Pure blue: b=3 → bits 6-7
assert_eq!(closest_rgb332(0, 0, 255), 0xC0);
}
}

View File

@@ -0,0 +1,120 @@
use crate::proto::RGB332_LUT;
/// 8bpp framebuffer storing raw RGB332 pixels. Converts to RGBA on demand.
#[derive(Debug, Clone)]
pub struct Framebuffer {
pub width: u16,
pub height: u16,
/// Raw 8bpp pixel data, row-major, `width * height` bytes.
pub pixels: Vec<u8>,
}
impl Framebuffer {
pub fn new(width: u16, height: u16) -> Self {
let size = width as usize * height as usize;
Self {
width,
height,
pixels: vec![0; size],
}
}
/// Resize the framebuffer, discarding old contents.
pub fn resize(&mut self, width: u16, height: u16) {
self.width = width;
self.height = height;
let size = width as usize * height as usize;
self.pixels.resize(size, 0);
}
/// Blit raw 8bpp data into the framebuffer at (x, y) with dimensions (w, h).
/// `data` must contain exactly `w * h` bytes.
pub fn apply_raw(&mut self, x: u16, y: u16, w: u16, h: u16, data: &[u8]) {
debug_assert_eq!(data.len(), w as usize * h as usize);
let stride = self.width as usize;
for row in 0..h as usize {
let dst_offset = (y as usize + row) * stride + x as usize;
let src_offset = row * w as usize;
let dst = &mut self.pixels[dst_offset..dst_offset + w as usize];
dst.copy_from_slice(&data[src_offset..src_offset + w as usize]);
}
}
/// Fill a rectangle with a single 8bpp color value.
pub fn fill_rect(&mut self, x: u16, y: u16, w: u16, h: u16, color: u8) {
let stride = self.width as usize;
for row in 0..h as usize {
let offset = (y as usize + row) * stride + x as usize;
self.pixels[offset..offset + w as usize].fill(color);
}
}
/// Copy a rectangle within the framebuffer (CopyRect encoding).
/// Handles overlapping regions correctly.
pub fn copy_rect(&mut self, src_x: u16, src_y: u16, dst_x: u16, dst_y: u16, w: u16, h: u16) {
let stride = self.width as usize;
let w = w as usize;
if src_y <= dst_y {
// Copy bottom-to-top to handle downward overlap
for row in (0..h as usize).rev() {
let src_off = (src_y as usize + row) * stride + src_x as usize;
let dst_off = (dst_y as usize + row) * stride + dst_x as usize;
self.pixels.copy_within(src_off..src_off + w, dst_off);
}
} else {
// Copy top-to-bottom to handle upward overlap
for row in 0..h as usize {
let src_off = (src_y as usize + row) * stride + src_x as usize;
let dst_off = (dst_y as usize + row) * stride + dst_x as usize;
self.pixels.copy_within(src_off..src_off + w, dst_off);
}
}
}
/// Convert the entire framebuffer to RGBA (4 bytes per pixel).
pub fn to_rgba(&self) -> Vec<u8> {
let mut rgba = Vec::with_capacity(self.pixels.len() * 4);
for &px in &self.pixels {
rgba.extend_from_slice(&RGB332_LUT[px as usize]);
}
rgba
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_raw() {
let mut fb = Framebuffer::new(4, 4);
// Fill a 2x2 region at (1,1) with value 0xFF
fb.apply_raw(1, 1, 2, 2, &[0xFF; 4]);
assert_eq!(fb.pixels[0], 0); // (0,0)
assert_eq!(fb.pixels[5], 0xFF); // (1,1)
assert_eq!(fb.pixels[6], 0xFF); // (2,1)
assert_eq!(fb.pixels[9], 0xFF); // (1,2)
assert_eq!(fb.pixels[10], 0xFF); // (2,2)
assert_eq!(fb.pixels[15], 0); // (3,3)
}
#[test]
fn test_copy_rect_no_overlap() {
let mut fb = Framebuffer::new(4, 4);
fb.apply_raw(0, 0, 2, 2, &[1, 2, 3, 4]);
fb.copy_rect(0, 0, 2, 2, 2, 2);
// Check destination
assert_eq!(fb.pixels[10], 1); // (2,2)
assert_eq!(fb.pixels[11], 2); // (3,2)
assert_eq!(fb.pixels[14], 3); // (2,3)
assert_eq!(fb.pixels[15], 4); // (3,3)
}
#[test]
fn test_to_rgba_size() {
let fb = Framebuffer::new(2, 2);
let rgba = fb.to_rgba();
assert_eq!(rgba.len(), 2 * 2 * 4);
}
}

View File

@@ -0,0 +1,259 @@
use std::io::{BufReader, BufWriter, Read, Write};
use std::net::TcpStream;
use thiserror::Error;
use crate::proto::{self, read_exact, read_i32_be, read_modified_utf8, read_u8, read_u16_be};
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
#[derive(Debug, Error)]
pub enum HandshakeError {
#[error("protocol error: {0}")]
Protocol(#[from] proto::ProtoError),
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
#[error("auth rejected: {0}")]
AuthRejected(String),
#[error("invalid server banner")]
InvalidBanner,
}
/// Map error status codes from aw.a(int), line 350.
fn auth_error_message(code: i32) -> &'static str {
match code {
1 => "no permission",
2 => "exclusive access active",
3 => "manually rejected",
4 => "server password disabled",
5 => "loopback connection is senseless",
6 => "authentication failed",
7 => "access to this kvm port denied",
_ => "unknown error",
}
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct Config {
pub host: String,
pub port: u16,
pub applet_id: String,
pub protocol_version: String,
pub port_id: u8,
pub shared: bool,
}
impl Config {
pub fn new(host: impl Into<String>, port: u16, applet_id: impl Into<String>) -> Self {
Self {
host: host.into(),
port,
applet_id: applet_id.into(),
protocol_version: "01.11".into(),
port_id: 0,
shared: true,
}
}
}
// ---------------------------------------------------------------------------
// Pixel format (from aw.i(), line 519)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct PixelFormat {
pub exclusive: bool,
pub color_depth: u16,
pub label: String,
}
fn read_pixel_format(r: &mut impl Read) -> Result<PixelFormat, HandshakeError> {
let flag = read_u8(r)?;
let color_depth = read_u16_be(r)?;
let label_len = read_u16_be(r)? as usize;
let label_bytes = read_exact(r, label_len)?;
let label = String::from_utf8_lossy(&label_bytes).into_owned();
Ok(PixelFormat {
exclusive: flag == 1,
color_depth,
label,
})
}
// ---------------------------------------------------------------------------
// ServerInit (from aw.k(), line 435)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct ServerInit {
pub supports_resize: bool,
pub width: u16,
pub height: u16,
pub bits_per_pixel: u8,
pub depth: u8,
pub big_endian: bool,
pub true_color: bool,
pub red_max: u16,
pub green_max: u16,
pub blue_max: u16,
pub red_shift: u8,
pub green_shift: u8,
pub blue_shift: u8,
}
/// Read a ServerInit struct from a stream. Public for reuse in ModeChange (msg 128).
pub fn read_server_init_from(r: &mut impl Read) -> Result<ServerInit, HandshakeError> {
read_server_init(r)
}
fn read_server_init(r: &mut impl Read) -> Result<ServerInit, HandshakeError> {
let supports_resize = read_u8(r)? != 0;
let width = read_u16_be(r)?;
let height = read_u16_be(r)?;
let bits_per_pixel = read_u8(r)?;
let depth = read_u8(r)?;
let big_endian = read_u8(r)? != 0;
let true_color = read_u8(r)? != 0;
let red_max = read_u16_be(r)?;
let green_max = read_u16_be(r)?;
let blue_max = read_u16_be(r)?;
let red_shift = read_u8(r)?;
let green_shift = read_u8(r)?;
let blue_shift = read_u8(r)?;
let _pad = read_exact(r, 3)?;
Ok(ServerInit {
supports_resize,
width,
height,
bits_per_pixel,
depth,
big_endian,
true_color,
red_max,
green_max,
blue_max,
red_shift,
green_shift,
blue_shift,
})
}
// ---------------------------------------------------------------------------
// Session — returned after successful handshake
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub struct Session {
pub server_version: (u8, u8),
pub server_name: String,
pub pixel_format: PixelFormat,
pub server_init: ServerInit,
pub reader: BufReader<TcpStream>,
pub writer: BufWriter<TcpStream>,
}
impl Session {
pub fn width(&self) -> u16 {
self.server_init.width
}
pub fn height(&self) -> u16 {
self.server_init.height
}
}
// ---------------------------------------------------------------------------
// Handshake — aw.g(), line 226, steps 111
// ---------------------------------------------------------------------------
pub fn connect(cfg: &Config) -> Result<Session, HandshakeError> {
let addr = format!("{}:{}", cfg.host, cfg.port);
let stream = TcpStream::connect(&addr)?;
let read_stream = stream.try_clone()?;
let mut r = BufReader::with_capacity(32768, read_stream);
let mut w = BufWriter::new(stream);
// Step 1: C→S 75 bytes auth string, zero-padded, ISO-8859-1
let auth_str = format!("e-RIC AUTH={}", cfg.applet_id);
let auth_bytes = auth_str.as_bytes();
let mut auth_buf = [0u8; 75];
let copy_len = auth_bytes.len().min(75);
auth_buf[..copy_len].copy_from_slice(&auth_bytes[..copy_len]);
w.write_all(&auth_buf)?;
w.flush()?;
// Step 2: S→C 1 byte status
let status = read_u8(&mut r)?;
if status == 3 {
let error_code = read_i32_be(&mut r)?;
return Err(HandshakeError::AuthRejected(
auth_error_message(error_code).into(),
));
}
// Step 3: S→C 15 bytes banner "-RIC RFB MM.NN\n"
// The status byte (101 = 'e') is the first byte of "e-RIC RFB MM.NN\n".
let banner = read_exact(&mut r, 15)?;
if banner[0] != b'-'
|| banner[1] != b'R'
|| banner[2] != b'I'
|| banner[3] != b'C'
|| banner[4] != b' '
|| banner[5] != b'R'
|| banner[6] != b'F'
|| banner[7] != b'B'
|| banner[8] != b' '
|| banner[14] != b'\n'
{
return Err(HandshakeError::InvalidBanner);
}
let major = (banner[9] - b'0') * 10 + (banner[10] - b'0');
let minor = (banner[12] - b'0') * 10 + (banner[13] - b'0');
// Step 4: S→C 1 byte sync
let _sync1 = read_u8(&mut r)?;
// Step 5: S→C server name (1 pad + modified-UTF-8 string)
let _pad = read_u8(&mut r)?;
let server_name = read_modified_utf8(&mut r)?;
// Step 6: S→C 1 byte sync
let _sync2 = read_u8(&mut r)?;
// Step 7: S→C pixel format struct (variable length)
let pixel_format = read_pixel_format(&mut r)?;
// Step 8: C→S 16 bytes client version "e-RIC RFB 01.11\n"
let version_str = format!("e-RIC RFB {}\n", cfg.protocol_version);
let version_bytes = version_str.as_bytes();
let mut version_buf = [0u8; 16];
let copy_len = version_bytes.len().min(16);
version_buf[..copy_len].copy_from_slice(&version_bytes[..copy_len]);
w.write_all(&version_buf)?;
w.flush()?;
// Step 9: C→S 2 bytes [shared_flag, port_id]
w.write_all(&[if cfg.shared { 1 } else { 0 }, cfg.port_id])?;
w.flush()?;
// Step 10: S→C 1 byte sync
let _sync3 = read_u8(&mut r)?;
// Step 11: S→C 19 bytes ServerInit
let server_init = read_server_init(&mut r)?;
Ok(Session {
server_version: (major, minor),
server_name,
pixel_format,
server_init,
reader: r,
writer: w,
})
}

234
crates/ericrfb/src/input.rs Normal file
View File

@@ -0,0 +1,234 @@
use std::io::Write;
use crate::proto;
/// e-RIC key scancode from KbdLayout_104pc.java.
/// Press = scancode | 0x80, release = scancode.
/// Sent via msg type 4: `[4, code]`.
pub fn write_key_press(w: &mut impl Write, scancode: u8) -> proto::Result<()> {
w.write_all(&[4, scancode | 0x80])?;
w.flush()?;
Ok(())
}
pub fn write_key_release(w: &mut impl Write, scancode: u8) -> proto::Result<()> {
w.write_all(&[4, scancode])?;
w.flush()?;
Ok(())
}
/// Send a complete key tap (press + release).
pub fn write_key_tap(w: &mut impl Write, scancode: u8) -> proto::Result<()> {
write_key_press(w, scancode)?;
write_key_release(w, scancode)?;
Ok(())
}
/// Send a hotkey sequence from the applet's HOTKEYCODE params.
/// Bytes are space-separated hex values, sent raw via msg type 4.
pub fn write_hotkey_sequence(w: &mut impl Write, hex_str: &str) -> proto::Result<()> {
for token in hex_str.split_whitespace() {
if let Ok(byte) = u8::from_str_radix(token, 16) {
w.write_all(&[4, byte])?;
}
}
w.flush()?;
Ok(())
}
/// Ctrl+Alt+Delete hotkey sequence from the OmniView's HOTKEYCODE_0 param.
pub const HOTKEY_CTRL_ALT_DEL: &str = "36 f0 37 f0 4e";
// ---------------------------------------------------------------------------
// JavaScript KeyboardEvent.code → e-RIC scancode mapping
//
// Maps browser key codes to KbdLayout_104pc keycodes.
// keynr == keycode for almost all keys in this layout.
// ---------------------------------------------------------------------------
/// Map a JavaScript `KeyboardEvent.code` string to an e-RIC scancode.
/// Returns `None` for unmapped keys.
pub fn js_code_to_scancode(code: &str) -> Option<u8> {
// Mapping derived from KeyTranslator.java line 14 (Java VK_* → keynr table).
Some(match code {
// Number row (keynr 0-13)
"Backquote" => 0,
"Digit1" => 1,
"Digit2" => 2,
"Digit3" => 3,
"Digit4" => 4,
"Digit5" => 5,
"Digit6" => 6,
"Digit7" => 7,
"Digit8" => 8,
"Digit9" => 9,
"Digit0" => 10,
"Minus" => 11,
"Equal" => 12,
"Backspace" => 13,
// QWERTY row (keynr 14-27)
"Tab" => 14,
"KeyQ" => 15,
"KeyW" => 16,
"KeyE" => 17,
"KeyR" => 18,
"KeyT" => 19,
"KeyY" => 20,
"KeyU" => 21,
"KeyI" => 22,
"KeyO" => 23,
"KeyP" => 24,
"BracketLeft" => 25,
"BracketRight" => 26,
"Enter" => 27,
// Home row (keynr 28-40)
"CapsLock" => 28,
"KeyA" => 29,
"KeyS" => 30,
"KeyD" => 31,
"KeyF" => 32,
"KeyG" => 33,
"KeyH" => 34,
"KeyJ" => 35,
"KeyK" => 36,
"KeyL" => 37,
"Semicolon" => 38,
"Quote" => 39,
"Backslash" => 40,
// Bottom row (keynr 41-53)
"ShiftLeft" => 41,
"KeyZ" => 43,
"KeyX" => 44,
"KeyC" => 45,
"KeyV" => 46,
"KeyB" => 47,
"KeyN" => 48,
"KeyM" => 49,
"Comma" => 50,
"Period" => 51,
"Slash" => 52,
"ShiftRight" => 53,
// Modifiers (keynr 54-58)
"ControlLeft" => 54,
"AltLeft" => 55,
"Space" => 56,
"AltRight" => 57,
"ControlRight" => 58,
// Escape + Function keys (keynr 59-71)
"Escape" => 59,
"F1" => 60,
"F2" => 61,
"F3" => 62,
"F4" => 63,
"F5" => 64,
"F6" => 65,
"F7" => 66,
"F8" => 67,
"F9" => 68,
"F10" => 69,
"F11" => 70,
"F12" => 71,
// Navigation cluster (keynr 72-84)
"PrintScreen" => 72,
"ScrollLock" => 73,
"Pause" => 74,
"Insert" => 75,
"Home" => 76,
"PageUp" => 77,
"Delete" => 78,
"End" => 79,
"PageDown" => 80,
"ArrowUp" => 81,
"ArrowLeft" => 82,
"ArrowDown" => 83,
"ArrowRight" => 84,
// Numpad (keynr 85-101)
"NumLock" => 85,
"Numpad7" => 86,
"Numpad8" => 87,
"Numpad9" => 88,
"NumpadAdd" => 89,
"NumpadDivide" => 90,
"Numpad4" => 91,
"Numpad5" => 92,
"Numpad6" => 93,
"NumpadMultiply" => 94,
"Numpad1" => 95,
"Numpad2" => 96,
"Numpad3" => 97,
"NumpadEnter" => 98,
"NumpadSubtract" => 99,
"Numpad0" => 100,
"NumpadDecimal" => 101,
// Windows/Meta keys (keynr 105-106)
"MetaLeft" => 105,
"MetaRight" => 106,
_ => return None,
})
}
/// Send Ctrl+Alt+Delete via the applet's hotkey sequence.
pub fn write_ctrl_alt_del(w: &mut impl Write) -> proto::Result<()> {
write_hotkey_sequence(w, HOTKEY_CTRL_ALT_DEL)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_key_press_sets_bit7() {
let mut buf = Vec::new();
write_key_press(&mut buf, 29).unwrap(); // 'A' scancode
assert_eq!(buf, [4, 29 | 0x80]);
}
#[test]
fn test_key_release_no_bit7() {
let mut buf = Vec::new();
write_key_release(&mut buf, 29).unwrap();
assert_eq!(buf, [4, 29]);
}
#[test]
fn test_key_tap() {
let mut buf = Vec::new();
write_key_tap(&mut buf, 29).unwrap();
assert_eq!(buf, [4, 29 | 0x80, 4, 29]);
}
#[test]
fn test_hotkey_sequence() {
let mut buf = Vec::new();
write_hotkey_sequence(&mut buf, "36 f0 37").unwrap();
assert_eq!(buf, [4, 0x36, 4, 0xF0, 4, 0x37]);
}
#[test]
fn test_js_code_mapping() {
// From KeyTranslator.java: VK_A(65)→29, VK_ESCAPE(27)→59, etc.
assert_eq!(js_code_to_scancode("KeyA"), Some(29));
assert_eq!(js_code_to_scancode("KeyR"), Some(18));
assert_eq!(js_code_to_scancode("KeyG"), Some(33));
assert_eq!(js_code_to_scancode("Escape"), Some(59));
assert_eq!(js_code_to_scancode("Backspace"), Some(13));
assert_eq!(js_code_to_scancode("Enter"), Some(27));
assert_eq!(js_code_to_scancode("Backslash"), Some(40));
assert_eq!(js_code_to_scancode("Backquote"), Some(0));
assert_eq!(js_code_to_scancode("F1"), Some(60));
assert_eq!(js_code_to_scancode("ControlLeft"), Some(54));
assert_eq!(js_code_to_scancode("Delete"), Some(78));
assert_eq!(js_code_to_scancode("Numpad7"), Some(86));
assert_eq!(js_code_to_scancode("NumpadMultiply"), Some(94));
assert_eq!(js_code_to_scancode("Unknown"), None);
}
}

View File

@@ -0,0 +1,7 @@
pub mod codec;
pub mod framebuffer;
pub mod handshake;
pub mod input;
pub mod msg;
pub mod proto;
pub mod session;

261
crates/ericrfb/src/msg.rs Normal file
View File

@@ -0,0 +1,261 @@
use std::io::Write;
use crate::proto::{self, read_exact, read_i32_be, read_modified_utf8, read_u8, read_u16_be};
// ---------------------------------------------------------------------------
// Client-to-server message writers
// ---------------------------------------------------------------------------
/// Msg type 0: SetPixelFormat — aw.a(...), line 572.
/// Sets the server to send pixels in 8bpp RGB332 format.
/// Matches ByteColorRFBRenderer.new() line 76:
/// a(D.o, 8, false, true, 7, 7, 3, 0, 3, 6)
pub fn write_set_pixel_format_rgb332(w: &mut impl Write) -> proto::Result<()> {
#[rustfmt::skip]
let buf: [u8; 20] = [
0, // msg type 0 = SetPixelFormat
0, 0, 0, // padding
8, // bits-per-pixel
8, // depth
0, // big-endian = false
1, // true-colour = true
0, 7, // red-max = 7 (u16-BE)
0, 7, // green-max = 7 (u16-BE)
0, 3, // blue-max = 3 (u16-BE)
0, // red-shift = 0
3, // green-shift = 3
6, // blue-shift = 6
0, 0, 0, // padding
];
w.write_all(&buf)?;
w.flush()?;
Ok(())
}
/// Msg type 2: SetEncodings — aw.a(int[], int), line 597.
/// Encoding IDs are i32 (negative values are pseudo-encodings).
pub fn write_set_encodings(w: &mut impl Write, encodings: &[i32]) -> proto::Result<()> {
let count = encodings.len() as u16;
let mut buf = vec![0u8; 4 + 4 * encodings.len()];
buf[0] = 2; // msg type
// buf[1] = 0 (pad)
buf[2] = (count >> 8) as u8;
buf[3] = count as u8;
for (i, &enc) in encodings.iter().enumerate() {
let bytes = enc.to_be_bytes();
buf[4 + i * 4..4 + i * 4 + 4].copy_from_slice(&bytes);
}
w.write_all(&buf)?;
w.flush()?;
Ok(())
}
/// Msg type 3: FramebufferUpdateRequest — aw.a(...), line 562.
pub fn write_fb_update_request(
w: &mut impl Write,
x: u16,
y: u16,
width: u16,
height: u16,
incremental: bool,
) -> proto::Result<()> {
let buf: [u8; 10] = [
3, // msg type
if incremental { 1 } else { 0 },
(x >> 8) as u8,
x as u8,
(y >> 8) as u8,
y as u8,
(width >> 8) as u8,
width as u8,
(height >> 8) as u8,
height as u8,
];
w.write_all(&buf)?;
w.flush()?;
Ok(())
}
/// Msg type 4: KeyEvent — aw.a(byte), line 655.
/// Single scancode byte.
pub fn write_key_event(w: &mut impl Write, scancode: u8) -> proto::Result<()> {
w.write_all(&[4, scancode])?;
w.flush()?;
Ok(())
}
/// Msg type 5: PointerEvent — aw.a(boolean, int, int, int, int), line 612.
/// 8 bytes: [5, mask, x_u16, y_u16, extra_u16].
pub fn write_pointer_event(
w: &mut impl Write,
x: u16,
y: u16,
button_mask: u8,
) -> proto::Result<()> {
let buf: [u8; 8] = [
5, // msg type (absolute mode)
button_mask,
(x >> 8) as u8,
x as u8,
(y >> 8) as u8,
y as u8,
0,
0, // extra_u16 = 0 in absolute mode
];
w.write_all(&buf)?;
w.flush()?;
Ok(())
}
/// Msg type 149: PingResponse — aw.if(int), line 636.
/// 8 bytes: [149(-107 signed), 0, 0, 0, n_i32].
pub fn write_ping_response(w: &mut impl Write, payload: i32) -> proto::Result<()> {
let mut buf = [0u8; 8];
buf[0] = 149u8; // -107 as u8
// buf[1..4] = 0 (pad)
buf[4..8].copy_from_slice(&payload.to_be_bytes());
w.write_all(&buf)?;
w.flush()?;
Ok(())
}
/// Msg type 151: bandwidth measurement bookend — aw.for(byte), line 649.
/// 2 bytes: [151, phase]. Phase 1 = start, 2 = done.
pub fn write_bandwidth_marker(w: &mut impl Write, phase: u8) -> proto::Result<()> {
w.write_all(&[151u8, phase])?;
w.flush()?;
Ok(())
}
// ---------------------------------------------------------------------------
// Server-to-client message types
// ---------------------------------------------------------------------------
/// Server message type tag, read as the first byte of each server message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServerMsg {
FramebufferUpdate, // 0
SetColourMapEntries, // 1
Bell, // 2
ServerCutText, // 3
ServerNameUpdate, // 7
PixelFormatChange, // 8
LayoutLocale, // 9
DesktopResize, // 16
Ack, // 17
ModeChange, // 128
DebugString, // 131
RfbCommand, // 132
Ping, // 148
BandwidthProbe, // 150
RdpEvent, // 161
Unknown(u8),
}
impl From<u8> for ServerMsg {
fn from(b: u8) -> Self {
match b {
0 => Self::FramebufferUpdate,
1 => Self::SetColourMapEntries,
2 => Self::Bell,
3 => Self::ServerCutText,
7 => Self::ServerNameUpdate,
8 => Self::PixelFormatChange,
9 => Self::LayoutLocale,
16 => Self::DesktopResize,
17 => Self::Ack,
128 => Self::ModeChange,
131 => Self::DebugString,
132 => Self::RfbCommand,
148 => Self::Ping,
150 => Self::BandwidthProbe,
161 => Self::RdpEvent,
other => Self::Unknown(other),
}
}
}
// ---------------------------------------------------------------------------
// Server message readers (for dispatch loop)
// ---------------------------------------------------------------------------
/// Read ping payload: 3 pad bytes + i32 — aw.b(), line 629.
pub fn read_ping(r: &mut impl std::io::Read) -> proto::Result<i32> {
let _pad = read_exact(r, 3)?;
read_i32_be(r)
}
/// Read and discard bandwidth probe: 1 pad + u16 len + data — aw.do(), line 642.
pub fn read_bandwidth_probe(r: &mut impl std::io::Read) -> proto::Result<()> {
let _pad = read_u8(r)?;
let len = read_u16_be(r)? as usize;
let _data = read_exact(r, len)?;
Ok(())
}
/// Read 2-byte ack (no-op) — aw.for(), line 553.
pub fn read_ack(r: &mut impl std::io::Read) -> proto::Result<()> {
let _b1 = read_u8(r)?;
let _b2 = read_u8(r)?;
Ok(())
}
/// Read debug string — aw.d(), line 498.
/// 3 pad bytes + i32 length + string bytes.
pub fn read_debug_string(r: &mut impl std::io::Read) -> proto::Result<String> {
let _pad = read_exact(r, 3)?;
let len = read_i32_be(r)? as usize;
let data = read_exact(r, len)?;
Ok(String::from_utf8_lossy(&data).into_owned())
}
/// Read RFB command — aw.long(), line 507.
/// 1 pad + u16 key_len + u16 val_len + key bytes + val bytes.
pub fn read_rfb_command(r: &mut impl std::io::Read) -> proto::Result<(String, String)> {
let _pad = read_u8(r)?;
let key_len = read_u16_be(r)? as usize;
let val_len = read_u16_be(r)? as usize;
let key_bytes = read_exact(r, key_len)?;
let val_bytes = read_exact(r, val_len)?;
Ok((
String::from_utf8_lossy(&key_bytes).into_owned(),
String::from_utf8_lossy(&val_bytes).into_owned(),
))
}
/// Read server cut text — aw.goto(), line 464 (reads i32 error code, reused
/// for ServerCutText which reads the text via standard RFB: 3 pad + u32 len + text).
pub fn read_server_cut_text(r: &mut impl std::io::Read) -> proto::Result<String> {
let _pad = read_exact(r, 3)?;
let len = read_i32_be(r)? as usize;
let data = read_exact(r, len)?;
Ok(String::from_utf8_lossy(&data).into_owned())
}
/// Read server name update — aw.l(), line 413.
/// 1 pad + modified-UTF-8 string.
pub fn read_server_name_update(r: &mut impl std::io::Read) -> proto::Result<String> {
let _pad = read_u8(r)?;
read_modified_utf8(r)
}
/// Read layout/locale string — aw.else(), line 529.
/// 1 pad + u16 len + string bytes.
pub fn read_layout_locale(r: &mut impl std::io::Read) -> proto::Result<String> {
let _pad = read_u8(r)?;
let len = read_u16_be(r)? as usize;
let data = read_exact(r, len)?;
Ok(String::from_utf8_lossy(&data).into_owned())
}
/// Read RDP event type byte — aw.case(), line 558.
pub fn read_rdp_event(r: &mut impl std::io::Read) -> proto::Result<i8> {
proto::read_i8(r)
}
/// Read framebuffer update header — aw.null(), line 459.
/// 1 pad byte + u16 num_rects.
pub fn read_fb_update_header(r: &mut impl std::io::Read) -> proto::Result<u16> {
let _pad = read_u8(r)?;
read_u16_be(r)
}

454
crates/ericrfb/src/proto.rs Normal file
View File

@@ -0,0 +1,454 @@
use std::io::{self, Read, Write};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ProtoError {
#[error("i/o error: {0}")]
Io(#[from] io::Error),
#[error("unexpected end of stream")]
UnexpectedEof,
#[error("invalid modified-UTF-8: {0}")]
InvalidUtf8(String),
}
pub type Result<T> = std::result::Result<T, ProtoError>;
// ---------------------------------------------------------------------------
// Read primitives — mirrors h.java
// ---------------------------------------------------------------------------
/// Read 1 byte as unsigned (h.new, line 106).
pub fn read_u8(r: &mut impl Read) -> Result<u8> {
let mut buf = [0u8; 1];
r.read_exact(&mut buf)?;
Ok(buf[0])
}
/// Read 1 byte as signed (h.try, line 91).
pub fn read_i8(r: &mut impl Read) -> Result<i8> {
Ok(read_u8(r)? as i8)
}
/// Read 2 bytes big-endian as unsigned (h.int, line 138).
pub fn read_u16_be(r: &mut impl Read) -> Result<u16> {
let mut buf = [0u8; 2];
r.read_exact(&mut buf)?;
Ok(u16::from_be_bytes(buf))
}
/// Read 2 bytes big-endian as signed (h.char, line 121).
pub fn read_i16_be(r: &mut impl Read) -> Result<i16> {
let mut buf = [0u8; 2];
r.read_exact(&mut buf)?;
Ok(i16::from_be_bytes(buf))
}
/// Read 4 bytes big-endian as signed (h.do, line 172).
pub fn read_i32_be(r: &mut impl Read) -> Result<i32> {
let mut buf = [0u8; 4];
r.read_exact(&mut buf)?;
Ok(i32::from_be_bytes(buf))
}
/// Read exactly `n` bytes into a new vec.
pub fn read_exact(r: &mut impl Read, n: usize) -> Result<Vec<u8>> {
let mut buf = vec![0u8; n];
r.read_exact(&mut buf)?;
Ok(buf)
}
// ---------------------------------------------------------------------------
// Varint — aw.int(), line 484
//
// 13 byte variable-length integer, top bit = continuation.
// Byte 0: value bits [6:0]
// Byte 1 (if bit 7 of byte 0 set): value bits [13:7]
// Byte 2 (if bit 7 of byte 1 set): value bits [21:14] (all 8 bits used)
//
// Maximum representable value: (0x7F) | (0x7F << 7) | (0xFF << 14)
// = 127 + 16256 + 4177920 = 4194303 = 0x3FFFFF
// ---------------------------------------------------------------------------
/// Read a 13 byte varint (aw.int, line 484). Used for Tight compressed-stream
/// lengths, NOT for rectangle header coords.
pub fn read_varint(r: &mut impl Read) -> Result<u32> {
let b0 = read_u8(r)? as u32;
let mut val = b0 & 0x7F;
if b0 & 0x80 != 0 {
let b1 = read_u8(r)? as u32;
val |= (b1 & 0x7F) << 7;
if b1 & 0x80 != 0 {
let b2 = read_u8(r)? as u32;
val |= (b2 & 0xFF) << 14;
}
}
Ok(val)
}
/// Write a value as a 13 byte varint. Panics if `val > 0x3FFFFF`.
pub fn write_varint(w: &mut impl Write, val: u32) -> Result<()> {
assert!(val <= 0x3F_FFFF, "varint overflow: {val}");
if val < 0x80 {
w.write_all(&[val as u8])?;
} else if val < 0x4000 {
w.write_all(&[(val & 0x7F) as u8 | 0x80, ((val >> 7) & 0x7F) as u8])?;
} else {
w.write_all(&[
(val & 0x7F) as u8 | 0x80,
((val >> 7) & 0x7F) as u8 | 0x80,
((val >> 14) & 0xFF) as u8,
])?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Modified-UTF-8 string — h.byte(), line 188
//
// Java's modified-UTF-8: u16 byte-length prefix, then encoded bytes.
// Encoding matches standard UTF-8 for BMP codepoints except:
// - U+0000 is encoded as [0xC0, 0x80] (2 bytes, not 1)
// - Supplementary characters use surrogate pairs
// We accept standard UTF-8 as well for robustness.
// ---------------------------------------------------------------------------
/// Read a modified-UTF-8 string (h.byte, line 188).
/// Format: u16-BE length prefix + `length` bytes of Java modified-UTF-8.
pub fn read_modified_utf8(r: &mut impl Read) -> Result<String> {
let len = read_u16_be(r)? as usize;
let data = read_exact(r, len)?;
decode_modified_utf8(&data)
}
fn decode_modified_utf8(data: &[u8]) -> Result<String> {
let mut out = String::with_capacity(data.len());
let mut i = 0;
while i < data.len() {
let b = data[i];
match b >> 4 {
// Single byte: 0x01..=0x7F (standard ASCII, but NOT 0x00)
0..=7 => {
out.push(b as char);
i += 1;
}
// Two-byte sequence: 110xxxxx 10xxxxxx
12 | 13 => {
if i + 1 >= data.len() {
return Err(ProtoError::InvalidUtf8("truncated 2-byte sequence".into()));
}
let b2 = data[i + 1];
if b2 & 0xC0 != 0x80 {
return Err(ProtoError::InvalidUtf8("invalid continuation byte".into()));
}
let cp = ((b as u32 & 0x1F) << 6) | (b2 as u32 & 0x3F);
out.push(char::from_u32(cp).unwrap_or('\u{FFFD}'));
i += 2;
}
// Three-byte sequence: 1110xxxx 10xxxxxx 10xxxxxx
14 => {
if i + 2 >= data.len() {
return Err(ProtoError::InvalidUtf8("truncated 3-byte sequence".into()));
}
let b2 = data[i + 1];
let b3 = data[i + 2];
if (b2 & 0xC0 != 0x80) || (b3 & 0xC0 != 0x80) {
return Err(ProtoError::InvalidUtf8("invalid continuation byte".into()));
}
let cp = ((b as u32 & 0x0F) << 12) | ((b2 as u32 & 0x3F) << 6) | (b3 as u32 & 0x3F);
out.push(char::from_u32(cp).unwrap_or('\u{FFFD}'));
i += 3;
}
_ => {
return Err(ProtoError::InvalidUtf8(format!(
"invalid leading byte: 0x{b:02X}"
)));
}
}
}
Ok(out)
}
// ---------------------------------------------------------------------------
// Rectangle header — aw.f(), line 468
//
// 4 × u16-BE coords + 1 × i32-BE encoding = 12 bytes fixed.
// Encoding is i32 because standard RFB uses negative pseudo-encoding IDs
// (e.g. -250).
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RectHeader {
pub x: u16,
pub y: u16,
pub w: u16,
pub h: u16,
pub encoding: i32,
}
impl RectHeader {
pub fn read_from(r: &mut impl Read) -> Result<Self> {
Ok(Self {
x: read_u16_be(r)?,
y: read_u16_be(r)?,
w: read_u16_be(r)?,
h: read_u16_be(r)?,
encoding: read_i32_be(r)?,
})
}
}
// ---------------------------------------------------------------------------
// Write helpers — for client-to-server messages
// ---------------------------------------------------------------------------
pub fn write_u8(w: &mut impl Write, v: u8) -> Result<()> {
w.write_all(&[v])?;
Ok(())
}
pub fn write_u16_be(w: &mut impl Write, v: u16) -> Result<()> {
w.write_all(&v.to_be_bytes())?;
Ok(())
}
pub fn write_i32_be(w: &mut impl Write, v: i32) -> Result<()> {
w.write_all(&v.to_be_bytes())?;
Ok(())
}
// ---------------------------------------------------------------------------
// RGB332 lookup table — ByteColorRFBRenderer constructor, lines 57-66
//
// DirectColorModel(8, 7, 56, 192):
// red mask = 0b_0000_0111 (bits 0-2), shift to 8-bit: v * 255 / 7
// green mask = 0b_0011_1000 (bits 3-5), shift to 8-bit: v * 255 / 7
// blue mask = 0b_1100_0000 (bits 6-7), shift to 8-bit: v * 255 / 3
// ---------------------------------------------------------------------------
/// Expand an 8bpp RGB332 index to an RGBA pixel (alpha = 0xFF).
pub const fn rgb332_to_rgba(idx: u8) -> [u8; 4] {
let r3 = idx & 0x07;
let g3 = (idx >> 3) & 0x07;
let b2 = (idx >> 6) & 0x03;
[
(r3 as u16 * 255 / 7) as u8,
(g3 as u16 * 255 / 7) as u8,
(b2 as u16 * 255 / 3) as u8,
0xFF,
]
}
/// Precomputed RGB332 → RGBA lookup table (256 entries, 4 bytes each).
pub const RGB332_LUT: [[u8; 4]; 256] = {
let mut lut = [[0u8; 4]; 256];
let mut i = 0u16;
while i < 256 {
lut[i as usize] = rgb332_to_rgba(i as u8);
i += 1;
}
lut
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
// -- read primitives --
#[test]
fn test_read_u8() {
let mut c = Cursor::new([0x42u8]);
assert_eq!(read_u8(&mut c).unwrap(), 0x42);
}
#[test]
fn test_read_i8() {
let mut c = Cursor::new([0xFEu8]); // -2 as i8
assert_eq!(read_i8(&mut c).unwrap(), -2);
}
#[test]
fn test_read_u16_be() {
let mut c = Cursor::new([0x01u8, 0x00]);
assert_eq!(read_u16_be(&mut c).unwrap(), 256);
}
#[test]
fn test_read_i16_be() {
let mut c = Cursor::new([0xFF, 0xFE]); // -2 as i16
assert_eq!(read_i16_be(&mut c).unwrap(), -2);
}
#[test]
fn test_read_i32_be() {
let mut c = Cursor::new([0xFF, 0xFF, 0xFF, 0x06]); // -250 as i32
assert_eq!(read_i32_be(&mut c).unwrap(), -250);
}
// -- varint --
#[test]
fn test_varint_single_byte() {
// Values 0..=127 are encoded as a single byte
let mut c = Cursor::new([0x00]);
assert_eq!(read_varint(&mut c).unwrap(), 0);
let mut c = Cursor::new([0x7F]);
assert_eq!(read_varint(&mut c).unwrap(), 127);
}
#[test]
fn test_varint_two_bytes() {
// 128 = 0x80: byte0 = (128 & 0x7F) | 0x80 = 0x80, byte1 = 128 >> 7 = 1
let mut c = Cursor::new([0x80, 0x01]);
assert_eq!(read_varint(&mut c).unwrap(), 128);
// 16383 = 0x3FFF: byte0 = 0xFF, byte1 = 0x7F
let mut c = Cursor::new([0xFF, 0x7F]);
assert_eq!(read_varint(&mut c).unwrap(), 16383);
}
#[test]
fn test_varint_three_bytes() {
// 16384 = 0x4000: byte0 = 0x80, byte1 = 0x80, byte2 = 0x01
let mut c = Cursor::new([0x80, 0x80, 0x01]);
assert_eq!(read_varint(&mut c).unwrap(), 16384);
// max: 0x3FFFFF = 4194303
let mut c = Cursor::new([0xFF, 0xFF, 0xFF]);
assert_eq!(read_varint(&mut c).unwrap(), 0x3F_FFFF);
}
#[test]
fn test_varint_roundtrip() {
for val in [0, 1, 127, 128, 255, 16383, 16384, 100_000, 0x3F_FFFF] {
let mut buf = Vec::new();
write_varint(&mut buf, val).unwrap();
let mut c = Cursor::new(&buf);
assert_eq!(
read_varint(&mut c).unwrap(),
val,
"roundtrip failed for {val}"
);
}
}
// -- modified UTF-8 --
#[test]
fn test_read_modified_utf8_ascii() {
// u16 length = 5, then "hello"
let data = [0x00, 0x05, b'h', b'e', b'l', b'l', b'o'];
let mut c = Cursor::new(&data[..]);
assert_eq!(read_modified_utf8(&mut c).unwrap(), "hello");
}
#[test]
fn test_read_modified_utf8_null() {
// Java modified-UTF-8 encodes U+0000 as [0xC0, 0x80]
let data = [0x00, 0x02, 0xC0, 0x80];
let mut c = Cursor::new(&data[..]);
assert_eq!(read_modified_utf8(&mut c).unwrap(), "\0");
}
#[test]
fn test_read_modified_utf8_multibyte() {
// U+00E9 (é) = [0xC3, 0xA9] in UTF-8
let data = [0x00, 0x02, 0xC3, 0xA9];
let mut c = Cursor::new(&data[..]);
assert_eq!(read_modified_utf8(&mut c).unwrap(), "é");
}
// -- rect header --
#[test]
fn test_rect_header() {
// x=10, y=20, w=640, h=480, encoding=7 (Tight)
let mut data = Vec::new();
data.extend_from_slice(&10u16.to_be_bytes());
data.extend_from_slice(&20u16.to_be_bytes());
data.extend_from_slice(&640u16.to_be_bytes());
data.extend_from_slice(&480u16.to_be_bytes());
data.extend_from_slice(&7i32.to_be_bytes());
let mut c = Cursor::new(&data[..]);
let hdr = RectHeader::read_from(&mut c).unwrap();
assert_eq!(
hdr,
RectHeader {
x: 10,
y: 20,
w: 640,
h: 480,
encoding: 7
}
);
}
#[test]
fn test_rect_header_negative_encoding() {
// encoding = -250 (pseudo-encoding)
let mut data = Vec::new();
data.extend_from_slice(&0u16.to_be_bytes());
data.extend_from_slice(&0u16.to_be_bytes());
data.extend_from_slice(&0u16.to_be_bytes());
data.extend_from_slice(&0u16.to_be_bytes());
data.extend_from_slice(&(-250i32).to_be_bytes());
let mut c = Cursor::new(&data[..]);
let hdr = RectHeader::read_from(&mut c).unwrap();
assert_eq!(hdr.encoding, -250);
}
// -- proptest --
use proptest::prelude::*;
proptest! {
#[test]
fn prop_varint_roundtrip(val in 0u32..=0x3F_FFFF) {
let mut buf = Vec::new();
write_varint(&mut buf, val).unwrap();
let mut c = Cursor::new(&buf);
let decoded = read_varint(&mut c).unwrap();
prop_assert_eq!(decoded, val);
}
}
// -- RGB332 LUT --
#[test]
fn test_rgb332_black() {
assert_eq!(RGB332_LUT[0x00], [0, 0, 0, 255]);
}
#[test]
fn test_rgb332_white() {
// 0xFF = r=7, g=7, b=3 → [255, 255, 255, 255]
assert_eq!(RGB332_LUT[0xFF], [255, 255, 255, 255]);
}
#[test]
fn test_rgb332_pure_red() {
// pure red = r=7, g=0, b=0 → 0x07
assert_eq!(RGB332_LUT[0x07], [255, 0, 0, 255]);
}
#[test]
fn test_rgb332_pure_green() {
// pure green = r=0, g=7, b=0 → 0x38
assert_eq!(RGB332_LUT[0x38], [0, 255, 0, 255]);
}
#[test]
fn test_rgb332_pure_blue() {
// pure blue = r=0, g=0, b=3 → 0xC0
assert_eq!(RGB332_LUT[0xC0], [0, 0, 255, 255]);
}
}

View File

@@ -0,0 +1,259 @@
use std::io::{BufReader, BufWriter};
use std::net::TcpStream;
use crate::codec::{hextile, iip, raw_tile, tight};
use crate::framebuffer::Framebuffer;
use crate::handshake::{self, Config, ServerInit};
use crate::msg::{self, ServerMsg};
use crate::proto::{self, RectHeader, read_exact, read_u8};
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error("handshake: {0}")]
Handshake(#[from] handshake::HandshakeError),
#[error("protocol: {0}")]
Proto(#[from] proto::ProtoError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("unsupported encoding: {0}")]
UnsupportedEncoding(i32),
#[error("unsupported message type: {0}")]
UnsupportedMessage(u8),
}
/// Events emitted by the session pump to consumers.
#[derive(Debug)]
pub enum Event {
/// A region of the framebuffer was updated.
FramebufferDirty,
/// The framebuffer was resized.
Resize { width: u16, height: u16 },
/// Bell from server.
Bell,
/// Server sent a debug string.
Debug(String),
/// Server sent an RFB command (key, value).
RfbCommand(String, String),
/// Server name updated.
NameUpdate(String),
}
/// Active protocol session with framebuffer.
pub struct ActiveSession {
pub framebuffer: Framebuffer,
pub server_name: String,
pub reader: BufReader<TcpStream>,
pub writer: BufWriter<TcpStream>,
pub server_init: ServerInit,
zlib: tight::ZlibStreams,
tile_cache: iip::TileCache,
}
impl ActiveSession {
/// Connect, handshake, send SetEncodings + initial FBUpdateRequest.
pub fn connect(cfg: &Config, encodings: &[i32]) -> Result<Self, SessionError> {
let raw = handshake::connect(cfg)?;
let w = raw.server_init.width;
let h = raw.server_init.height;
let mut session = Self {
framebuffer: Framebuffer::new(w, h),
server_name: raw.server_name,
reader: raw.reader,
writer: raw.writer,
server_init: raw.server_init,
zlib: tight::ZlibStreams::new(),
tile_cache: iip::TileCache::new(w, h),
};
// Tell server to send 8bpp RGB332 pixels
msg::write_set_pixel_format_rgb332(&mut session.writer)?;
// Send SetEncodings
msg::write_set_encodings(&mut session.writer, encodings)?;
// Request full non-incremental framebuffer update
msg::write_fb_update_request(
&mut session.writer,
0,
0,
session.framebuffer.width,
session.framebuffer.height,
false,
)?;
Ok(session)
}
/// Request an incremental framebuffer update.
pub fn request_update(&mut self) -> Result<(), SessionError> {
msg::write_fb_update_request(
&mut self.writer,
0,
0,
self.framebuffer.width,
self.framebuffer.height,
true,
)?;
Ok(())
}
/// Process one server message. Returns an event if meaningful to the consumer.
pub fn process_one(&mut self) -> Result<Option<Event>, SessionError> {
let msg_type = read_u8(&mut self.reader)?;
let msg = ServerMsg::from(msg_type);
match msg {
ServerMsg::FramebufferUpdate => {
self.handle_fb_update()?;
Ok(Some(Event::FramebufferDirty))
}
ServerMsg::Bell => Ok(Some(Event::Bell)),
ServerMsg::Ping => {
let payload = msg::read_ping(&mut self.reader)?;
msg::write_ping_response(&mut self.writer, payload)?;
Ok(None)
}
ServerMsg::BandwidthProbe => {
msg::write_bandwidth_marker(&mut self.writer, 1)?;
msg::read_bandwidth_probe(&mut self.reader)?;
msg::write_bandwidth_marker(&mut self.writer, 2)?;
Ok(None)
}
ServerMsg::Ack => {
msg::read_ack(&mut self.reader)?;
Ok(None)
}
ServerMsg::DebugString => {
let s = msg::read_debug_string(&mut self.reader)?;
Ok(Some(Event::Debug(s)))
}
ServerMsg::RfbCommand => {
let (k, v) = msg::read_rfb_command(&mut self.reader)?;
Ok(Some(Event::RfbCommand(k, v)))
}
ServerMsg::ServerCutText => {
let _text = msg::read_server_cut_text(&mut self.reader)?;
Ok(None)
}
ServerMsg::ServerNameUpdate => {
let name = msg::read_server_name_update(&mut self.reader)?;
self.server_name = name.clone();
Ok(Some(Event::NameUpdate(name)))
}
ServerMsg::LayoutLocale => {
let _locale = msg::read_layout_locale(&mut self.reader)?;
Ok(None)
}
ServerMsg::DesktopResize => {
// Reads same struct as handshake pixel-format (aw.i, line 519)
let _flag = read_u8(&mut self.reader)?;
let _depth = proto::read_u16_be(&mut self.reader)?;
let label_len = proto::read_u16_be(&mut self.reader)? as usize;
let _label = read_exact(&mut self.reader, label_len)?;
Ok(None)
}
ServerMsg::ModeChange => {
// Re-read ServerInit (aw.k, line 435) — framebuffer dimensions may change
let si = handshake::read_server_init_from(&mut self.reader)?;
let old_w = self.framebuffer.width;
let old_h = self.framebuffer.height;
self.server_init = si;
if self.server_init.width != old_w || self.server_init.height != old_h {
self.framebuffer
.resize(self.server_init.width, self.server_init.height);
self.tile_cache
.resize(self.server_init.width, self.server_init.height);
return Ok(Some(Event::Resize {
width: self.server_init.width,
height: self.server_init.height,
}));
}
Ok(None)
}
ServerMsg::RdpEvent => {
let _event_type = msg::read_rdp_event(&mut self.reader)?;
Ok(None)
}
ServerMsg::PixelFormatChange => {
// aw.e(), line 537: 1 pad + 4×u8 + 8×u16 = 21 bytes
let _data = read_exact(&mut self.reader, 21)?;
Ok(None)
}
ServerMsg::SetColourMapEntries => Err(SessionError::UnsupportedMessage(msg_type)),
ServerMsg::Unknown(t) => Err(SessionError::UnsupportedMessage(t)),
}
}
fn handle_fb_update(&mut self) -> Result<(), SessionError> {
let num_rects = msg::read_fb_update_header(&mut self.reader)?;
for _ in 0..num_rects {
let hdr = RectHeader::read_from(&mut self.reader)?;
match hdr.encoding {
0 => {
// Raw: read w*h bytes
let size = hdr.w as usize * hdr.h as usize;
let data = read_exact(&mut self.reader, size)?;
self.framebuffer
.apply_raw(hdr.x, hdr.y, hdr.w, hdr.h, &data);
}
1 => {
// CopyRect: read src_x, src_y (u16 each)
let src_x = proto::read_u16_be(&mut self.reader)?;
let src_y = proto::read_u16_be(&mut self.reader)?;
self.framebuffer
.copy_rect(src_x, src_y, hdr.x, hdr.y, hdr.w, hdr.h);
}
5 => {
hextile::decode_hextile(
&mut self.reader,
&mut self.framebuffer,
hdr.x,
hdr.y,
hdr.w,
hdr.h,
)?;
}
7 => {
// Tight
tight::decode_tight(
&mut self.reader,
&mut self.framebuffer,
&mut self.zlib,
hdr.x,
hdr.y,
hdr.w,
hdr.h,
)?;
}
9 => {
iip::decode_iip(
&mut self.reader,
&mut self.framebuffer,
&mut self.tile_cache,
&mut self.zlib,
hdr.x,
hdr.y,
hdr.w,
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));
}
}
}
Ok(())
}
}

View File

@@ -0,0 +1,121 @@
# KVM port configuration and switching
## Context
The Belkin OmniView fronts an Avocent KVM switch with up to 16 ports. The legacy web interface at `http://10.3.0.130` allows port naming, hotkey assignment, visibility toggling, and active port switching. Users currently need to visit that interface separately. We want blekin to fully replace it — starting with port management, with a navigation structure that supports adding more settings pages later.
## Belkin device API (from HTML form inspection)
**GET /kvm.asp** — returns HTML form with current port config:
- `ECG_kvm_nr_ports`: port count (1,2,4,8,12,16,24,32,48,64)
- `ECG_key_pause_duration`: ms (default 100)
- Per port N (0-indexed): `ECG_kvm_portname_N`, `ECG_kvm_hotkey_N`, `ECG_kvm_show_in_rc_N`
- `kvm_active_port_0`: currently selected port
**POST /kvm.asp** — save port config (same fields + `action_apply=Apply`)
**POST /home2.asp** — switch active port: `kvm_active_port_0=<0-15>&action_switch_0=Switch`
All requests require `pp_session_id` cookie.
## Changes
### Backend: persist session cookie
Modify `AppState` in `main.rs` to hold the device session cookie:
```rust
pub session_cookie: Arc<tokio::sync::RwLock<Option<String>>>,
```
Update `login.rs` to store the cookie into shared state after successful auth.
### Backend: new REST endpoints (`crates/ericrfb-proxy/src/kvm.rs`)
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/kvm/ports` | GET | Fetch port config (scrape kvm.asp, return JSON) |
| `/api/kvm/ports` | PUT | Save port config (build form, POST to kvm.asp) |
| `/api/kvm/switch` | POST | Switch active port (POST to home2.asp) |
**GET /api/kvm/ports response:**
```json
{
"port_count": 16,
"key_pause_duration": 100,
"active_port": 0,
"ports": [
{ "index": 0, "name": "oolon", "hotkey": "", "show_in_rc": true },
...
]
}
```
HTML scraping: simple string/regex matching on the predictable firmware HTML (extract `value="..."` from named inputs, `selected` from dropdowns, `checked` from checkboxes).
### Frontend: app shell with navigation (`crates/ericrfb-frontend/src/shell.ts`)
After login, render a shell layout:
```
+----------+----------------------------------+
| sidebar | content area |
| | |
| Console | (active page rendered here) |
| Ports | |
| | |
+----------+----------------------------------+
```
Each page module exports `mount(container)` and `unmount()`. The shell swaps them on nav clicks. This pattern supports adding Virtual Media, Users, Device Settings etc. later without restructuring.
### Frontend: refactor console into page module
Move `console.ts` logic into a `pages/console.ts` with mount/unmount pattern:
- `mount(el, session)`: creates toolbar + canvas, connects WebSocket
- `unmount()`: tears down WebSocket, removes event listeners
Add port switcher `<select>` to the console toolbar (fetches port list from `/api/kvm/ports`, calls `/api/kvm/switch` on change, triggers reconnect).
### Frontend: port config page (`crates/ericrfb-frontend/src/pages/ports.ts`)
Editable table of ports with name, hotkey, show-in-console fields. Port count dropdown and key-pause input at the top. Save button PUTs to `/api/kvm/ports`. Each row has a Switch button that POSTs to `/api/kvm/switch`. Active port highlighted.
## Files
### New
| File | Purpose |
|------|---------|
| `crates/ericrfb-proxy/src/kvm.rs` | GET/PUT ports, POST switch, HTML scraping |
| `crates/ericrfb-frontend/src/shell.ts` | App shell, sidebar nav, page routing |
| `crates/ericrfb-frontend/src/pages/console.ts` | Console page (refactored from console.ts) |
| `crates/ericrfb-frontend/src/pages/ports.ts` | Port configuration page |
### Modified
| File | Changes |
|------|---------|
| `crates/ericrfb-proxy/src/main.rs` | session_cookie in AppState, new routes, `mod kvm` |
| `crates/ericrfb-proxy/src/login.rs` | Store cookie in shared state after auth |
| `crates/ericrfb-proxy/Cargo.toml` | Add `regex = "1"` |
| `crates/ericrfb-frontend/src/main.ts` | After login → mountShell() instead of startConsole() |
| `crates/ericrfb-frontend/src/login.ts` | Success callback → shell mount |
| `crates/ericrfb-frontend/src/style.css` | Shell layout, sidebar, ports page styles |
| `crates/ericrfb-frontend/src/console.ts` | Delete or re-export from pages/console.ts |
## Implementation order
1. Backend: session cookie persistence in AppState + login.rs
2. Backend: kvm.rs with three endpoints, test with curl
3. Frontend: shell.ts with sidebar navigation
4. Frontend: refactor console.ts → pages/console.ts with mount/unmount
5. Frontend: pages/ports.ts wired to API
6. Frontend: port switcher dropdown in console toolbar
## Verification
- `cargo test && cargo clippy` pass
- `npx tsc --noEmit` passes
- `curl /api/kvm/ports` returns correct JSON matching device state
- `curl -X POST /api/kvm/switch -d '{"port":0}'` switches the active port
- `curl -X PUT /api/kvm/ports` with config JSON updates port names on device
- Frontend: navigate between Console and Ports pages without breaking WebSocket
- Frontend: switch port from console toolbar, console reconnects to new port
- Frontend: edit port names in Ports page, save, verify on device

144
script/setup.sh Executable file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env bash
ui_host=oolon.kosherinata.internal
ws_host=frootmig.kosherinata.internal
app_fqdn=blekin.kosherinata.internal
repo_path=~/git/grenade/blekin
fedora_trusted_root_path=/etc/pki/ca-trust/source/anchors/root-internal.pem
fedora_intermediate_path=/etc/pki/ca-trust/source/anchors/intermediate-internal.pem
if ssh ${ws_host} 'id blekin 2> /dev/null || sudo useradd --system --create-home --home-dir /var/lib/blekin --user-group blekin'; then
echo "blekin system user created or observed on ${ws_host}"
else
echo "failed to create blekin system user on ${ws_host}"
exit 1
fi
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
--chown root:root \
${repo_path}/asset/systemd/blekin.service \
${ws_host}:/etc/systemd/system/blekin.service \
&& ssh ${ws_host} sudo systemctl daemon-reload; then
echo "blekin.service synced to ${ws_host}"
else
echo "failed to sync blekin.service to ${ws_host}"
exit 1
fi
if ssh ${ws_host} systemctl is-active --quiet blekin.service; then
if ssh ${ws_host} sudo systemctl restart blekin.service; then
echo "blekin.service restarted on ${ws_host}"
else
echo "failed to restart blekin.service on ${ws_host}"
exit 1
fi
else
if ssh ${ws_host} sudo systemctl start blekin.service; then
echo "blekin.service started on ${ws_host}"
else
echo "failed to start blekin.service on ${ws_host}"
exit 1
fi
fi
app_cert_is_valid=false
app_cert_remote_path=/etc/nginx/tls/cert/${app_fqdn}.pem
app_key_remote_path=/etc/nginx/tls/key/${app_fqdn}.pem
app_cert_local_path=/tmp/${app_fqdn}.pem
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
${ui_host}:${app_cert_remote_path} \
${app_cert_local_path} 2> /dev/null; then
if openssl verify \
-trusted ${fedora_trusted_root_path} \
-untrusted ${fedora_intermediate_path} \
${app_cert_local_path}; then
echo "verified ${app_fqdn} cert from ${ui_host}"
app_cert_is_valid=true
else
echo "failed to verify ${app_fqdn} cert from ${ui_host}"
exit 1
fi
else
echo "observed missing ${app_fqdn} cert on ${ui_host}"
fi
if [ "${app_cert_is_valid}" = "true" ]; then
echo "observed valid cert for ${app_fqdn} on ${ui_host}"
else
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
--chmod 600 \
--chown root:root \
~/.step/secrets/provisioner \
${ui_host}:/tmp/provisioner; then
echo "provisioner secret synced to ${ui_host}"
else
echo "failed to sync provisioner secret to ${ui_host}"
exit 1
fi
if ssh ${ui_host} sudo step ca certificate \
--force \
--provisioner lair \
--provisioner-password-file /tmp/provisioner \
--ca-url https://ca.internal \
--root /etc/pki/ca-trust/source/anchors/root-internal.pem \
--san ${app_fqdn} \
${app_fqdn} \
${app_cert_remote_path} \
${app_key_remote_path}; then
echo "acquired ${app_fqdn} cert on ${ui_host}"
else
echo "failed to acquire ${app_fqdn} cert on ${ui_host}"
fi
ssh ${ui_host} sudo rm -f /tmp/provisioner
fi
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
--chown root:root \
${repo_path}/asset/nginx/${app_fqdn}.conf \
${ui_host}:/etc/nginx/sites-available/${app_fqdn}.conf; then
echo "${app_fqdn}.conf synced to ${ui_host}"
else
echo "failed to sync ${app_fqdn}.conf to ${ui_host}"
fi
if ssh ${ui_host} sudo ln -sf /etc/nginx/sites-available/${app_fqdn}.conf /etc/nginx/sites-enabled/${app_fqdn}.conf; then
echo "${app_fqdn} enabled on ${ui_host}"
else
echo "failed to enable ${app_fqdn} on ${ui_host}"
fi
if ssh ${ui_host} 'sudo nginx -t && sudo systemctl reload nginx.service'; then
echo "nginx reloaded on ${ui_host}"
else
echo "failed to reload nginx on ${ui_host}"
fi
# todo:
# frootmig:
# sudo useradd --system --create-home --home-dir /var/lib/blekin --user-group blekin
# sync asset/sudoers.d/ws_gitea_ci to /etc/sudoers.d/gitea_ci
# oolon:
# ssh ${ui_host} sudo mkdir -p /etc/nginx/tls/${app_fqdn}
# sudo semanage fcontext -a -t httpd_sys_content_t "/var/www/blekin.kosherinata.internal(/.*)?"
# sudo restorecon -Rv /var/www/blekin.kosherinata.internal/
# sync asset/sudoers.d/ui_gitea_ci to /etc/sudoers.d/gitea_ci
# # Create the service definition
#sudo firewall-cmd --permanent --new-service=blekin
#sudo firewall-cmd --permanent --service=blekin --set-description="blekin e-RIC RFB proxy"
#sudo firewall-cmd --permanent --service=blekin --add-port=3000/tcp
# Enable it in the active zone
#sudo firewall-cmd --permanent --add-service=blekin
#sudo firewall-cmd --reload