Compare commits
41 Commits
c4e3df5a44
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
fe5e766dc6
|
|||
|
8c2ea95723
|
|||
|
a57247cb46
|
|||
|
ef48bd40cd
|
|||
|
d4bbe6450f
|
|||
|
f62084eac7
|
|||
|
edb6853e3a
|
|||
|
2e6f80f9ac
|
|||
|
7406b4ac02
|
|||
|
35db634317
|
|||
|
9bd215356b
|
|||
|
d503742542
|
|||
|
63aa9a400f
|
|||
|
ea18d97aa6
|
|||
|
dd029c7f93
|
|||
|
75a51def79
|
|||
|
4f7d69c75a
|
|||
|
acf99f849b
|
|||
|
e39555196d
|
|||
|
c31508f138
|
|||
|
865a08da17
|
|||
|
3ba05bcb05
|
|||
|
2539a1fd06
|
|||
|
ee4b0a2124
|
|||
|
8440d653b3
|
|||
|
022c38bdc2
|
|||
|
3b9ef6407c
|
|||
|
99e337d387
|
|||
|
5e5908804a
|
|||
|
075fef0ea9
|
|||
|
2700821559
|
|||
|
2627bab72a
|
|||
|
8692c0e46a
|
|||
|
3bd7ee8eac
|
|||
|
ab74f607e8
|
|||
|
c8f981f045
|
|||
|
21ed797302
|
|||
|
1164ffdd98
|
|||
|
e9823aff03
|
|||
|
1bd43fc1f9
|
|||
|
07db90094d
|
75
.gitea/workflows/publish.yml
Normal file
75
.gitea/workflows/publish.yml
Normal 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 )'
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,4 @@
|
||||
/target
|
||||
/dist
|
||||
/out
|
||||
crates/ericrfb-frontend/node_modules
|
||||
|
||||
1247
Cargo.lock
generated
1247
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,13 @@ members = ["crates/ericrfb", "crates/ericrfb-proxy"]
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = "0.7"
|
||||
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"
|
||||
|
||||
142
README.md
Normal file
142
README.md
Normal 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
|
||||
28
asset/nginx/blekin.kosherinata.internal.conf
Normal file
28
asset/nginx/blekin.kosherinata.internal.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
5
asset/sudoers.d/ui_gitea_ci
Normal file
5
asset/sudoers.d/ui_gitea_ci
Normal 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
|
||||
5
asset/sudoers.d/ws_gitea_ci
Normal file
5
asset/sudoers.d/ws_gitea_ci
Normal 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
|
||||
18
asset/systemd/blekin.service
Normal file
18
asset/systemd/blekin.service
Normal 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
|
||||
15
asset/systemd/step-kosherinata@.service
Normal file
15
asset/systemd/step-kosherinata@.service
Normal 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
7
config.toml.example
Normal 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
24
crates/ericrfb-frontend/.gitignore
vendored
Normal 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?
|
||||
12
crates/ericrfb-frontend/index.html
Normal file
12
crates/ericrfb-frontend/index.html
Normal 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
917
crates/ericrfb-frontend/package-lock.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
crates/ericrfb-frontend/package.json
Normal file
15
crates/ericrfb-frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
92
crates/ericrfb-frontend/src/hotkey.ts
Normal file
92
crates/ericrfb-frontend/src/hotkey.ts
Normal 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
|
||||
}
|
||||
61
crates/ericrfb-frontend/src/input.ts
Normal file
61
crates/ericrfb-frontend/src/input.ts
Normal 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]
|
||||
}
|
||||
63
crates/ericrfb-frontend/src/login.ts
Normal file
63
crates/ericrfb-frontend/src/login.ts
Normal 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'
|
||||
}
|
||||
})
|
||||
}
|
||||
29
crates/ericrfb-frontend/src/main.ts
Normal file
29
crates/ericrfb-frontend/src/main.ts
Normal 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)
|
||||
}
|
||||
370
crates/ericrfb-frontend/src/pages/console.ts
Normal file
370
crates/ericrfb-frontend/src/pages/console.ts
Normal 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 ▾</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()
|
||||
})
|
||||
}
|
||||
195
crates/ericrfb-frontend/src/pages/ports.ts
Normal file
195
crates/ericrfb-frontend/src/pages/ports.ts
Normal 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, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
}
|
||||
33
crates/ericrfb-frontend/src/protocol.ts
Normal file
33
crates/ericrfb-frontend/src/protocol.ts
Normal 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
|
||||
}
|
||||
60
crates/ericrfb-frontend/src/shell.ts
Normal file
60
crates/ericrfb-frontend/src/shell.ts
Normal 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')
|
||||
}
|
||||
326
crates/ericrfb-frontend/src/style.css
Normal file
326
crates/ericrfb-frontend/src/style.css
Normal 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; }
|
||||
23
crates/ericrfb-frontend/tsconfig.json
Normal file
23
crates/ericrfb-frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
16
crates/ericrfb-frontend/vite.config.ts
Normal file
16
crates/ericrfb-frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
@@ -5,8 +5,14 @@ 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
|
||||
|
||||
64
crates/ericrfb-proxy/src/config.rs
Normal file
64
crates/ericrfb-proxy/src/config.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
266
crates/ericrfb-proxy/src/kvm.rs
Normal file
266
crates/ericrfb-proxy/src/kvm.rs
Normal 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(">", ">")
|
||||
.replace("<", "<")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
}
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
119
crates/ericrfb-proxy/src/login.rs
Normal file
119
crates/ericrfb-proxy/src/login.rs
Normal 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() }),
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,58 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
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(())
|
||||
}
|
||||
|
||||
256
crates/ericrfb-proxy/src/ws.rs
Normal file
256
crates/ericrfb-proxy/src/ws.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
flate2.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
png = "0.17"
|
||||
|
||||
47
crates/ericrfb/examples/handshake.rs
Normal file
47
crates/ericrfb/examples/handshake.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
108
crates/ericrfb/examples/record.rs
Normal file
108
crates/ericrfb/examples/record.rs
Normal 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());
|
||||
}
|
||||
88
crates/ericrfb/examples/snapshot.rs
Normal file
88
crates/ericrfb/examples/snapshot.rs
Normal 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());
|
||||
}
|
||||
148
crates/ericrfb/src/codec/hextile.rs
Normal file
148
crates/ericrfb/src/codec/hextile.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
263
crates/ericrfb/src/codec/iip.rs
Normal file
263
crates/ericrfb/src/codec/iip.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4
crates/ericrfb/src/codec/mod.rs
Normal file
4
crates/ericrfb/src/codec/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod hextile;
|
||||
pub mod iip;
|
||||
pub mod raw_tile;
|
||||
pub mod tight;
|
||||
103
crates/ericrfb/src/codec/raw_tile.rs
Normal file
103
crates/ericrfb/src/codec/raw_tile.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use std::io::Read;
|
||||
|
||||
use crate::framebuffer::Framebuffer;
|
||||
use crate::proto::{self, read_exact, read_i8};
|
||||
|
||||
/// Decode encoding 10 (Raw with tile interleave).
|
||||
///
|
||||
/// Reads 1 flag byte. If bit 0 is clear, falls back to plain Raw.
|
||||
/// If bit 0 is set, reads w*h bytes of 16x16 tile-interleaved data
|
||||
/// and deinterleaves to row-major before blitting.
|
||||
///
|
||||
/// Reference: ByteColorRFBRenderer.for() line 109.
|
||||
pub fn decode_raw_tile(
|
||||
r: &mut impl Read,
|
||||
fb: &mut Framebuffer,
|
||||
rx: u16,
|
||||
ry: u16,
|
||||
rw: u16,
|
||||
rh: u16,
|
||||
) -> proto::Result<()> {
|
||||
let flag = read_i8(r)?;
|
||||
|
||||
let w = rw as usize;
|
||||
let h = rh as usize;
|
||||
let size = w * h;
|
||||
|
||||
if flag & 1 == 0 {
|
||||
// Plain raw — no interleave
|
||||
let data = read_exact(r, size)?;
|
||||
fb.apply_raw(rx, ry, rw, rh, &data);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Read tile-interleaved data
|
||||
let interleaved = read_exact(r, size)?;
|
||||
|
||||
// Clamp to framebuffer bounds
|
||||
let w = w.min(fb.width as usize - rx as usize);
|
||||
let h = h.min(fb.height as usize - ry as usize);
|
||||
|
||||
// Deinterleave 16x16 tiles to row-major.
|
||||
// Input is stored tile-by-tile: all pixels of tile (0,0), then tile (1,0), etc.
|
||||
// Each tile is row-major within itself.
|
||||
let tile = 16usize;
|
||||
let tiles_x = w.div_ceil(tile);
|
||||
let mut output = vec![0u8; w * h];
|
||||
|
||||
for row in 0..h {
|
||||
let tile_row = row / tile;
|
||||
let row_in_tile = row % tile;
|
||||
for col in 0..w {
|
||||
let tile_col = col / tile;
|
||||
let col_in_tile = col % tile;
|
||||
// Tile index in raster order
|
||||
let tile_idx = tile_row * tiles_x + tile_col;
|
||||
// Tile dimensions (edge tiles may be smaller)
|
||||
let tw = tile.min(w - tile_col * tile);
|
||||
// Offset within tile data: preceding full tiles + row offset + col offset
|
||||
let mut tile_data_start = 0usize;
|
||||
// Sum sizes of all preceding tiles
|
||||
for t in 0..tile_idx {
|
||||
let tr = t / tiles_x;
|
||||
let tc = t % tiles_x;
|
||||
let this_tw = tile.min(w - tc * tile);
|
||||
let this_th = tile.min(h - tr * tile);
|
||||
tile_data_start += this_tw * this_th;
|
||||
}
|
||||
let src = tile_data_start + row_in_tile * tw + col_in_tile;
|
||||
output[row * w + col] = interleaved[src];
|
||||
}
|
||||
}
|
||||
|
||||
fb.apply_raw(rx, ry, w as u16, h as u16, &output);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn test_raw_tile_plain_fallback() {
|
||||
let mut fb = Framebuffer::new(4, 4);
|
||||
// Flag byte with bit 0 clear → plain raw
|
||||
let mut data = vec![0x00i8 as u8]; // flag
|
||||
data.extend_from_slice(&[0x42; 16]); // 4x4 pixels
|
||||
let mut c = Cursor::new(data);
|
||||
decode_raw_tile(&mut c, &mut fb, 0, 0, 4, 4).unwrap();
|
||||
assert!(fb.pixels.iter().all(|&p| p == 0x42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_raw_tile_small_no_tile_boundary() {
|
||||
let mut fb = Framebuffer::new(4, 4);
|
||||
// Flag byte with bit 0 set, but 4x4 < 16x16 so no tile wrap occurs
|
||||
let mut data = vec![0x01u8]; // flag with interleave
|
||||
data.extend_from_slice(&[0xAA; 16]); // 4x4 pixels (all same, no deinterleave effect)
|
||||
let mut c = Cursor::new(data);
|
||||
decode_raw_tile(&mut c, &mut fb, 0, 0, 4, 4).unwrap();
|
||||
assert!(fb.pixels.iter().all(|&p| p == 0xAA));
|
||||
}
|
||||
}
|
||||
389
crates/ericrfb/src/codec/tight.rs
Normal file
389
crates/ericrfb/src/codec/tight.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
120
crates/ericrfb/src/framebuffer.rs
Normal file
120
crates/ericrfb/src/framebuffer.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
259
crates/ericrfb/src/handshake.rs
Normal file
259
crates/ericrfb/src/handshake.rs
Normal 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 1–11
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
234
crates/ericrfb/src/input.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1 +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
261
crates/ericrfb/src/msg.rs
Normal 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)
|
||||
}
|
||||
@@ -135,15 +135,11 @@ fn decode_modified_utf8(data: &[u8]) -> Result<String> {
|
||||
// Two-byte sequence: 110xxxxx 10xxxxxx
|
||||
12 | 13 => {
|
||||
if i + 1 >= data.len() {
|
||||
return Err(ProtoError::InvalidUtf8(
|
||||
"truncated 2-byte sequence".into(),
|
||||
));
|
||||
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(),
|
||||
));
|
||||
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}'));
|
||||
@@ -152,19 +148,14 @@ fn decode_modified_utf8(data: &[u8]) -> Result<String> {
|
||||
// Three-byte sequence: 1110xxxx 10xxxxxx 10xxxxxx
|
||||
14 => {
|
||||
if i + 2 >= data.len() {
|
||||
return Err(ProtoError::InvalidUtf8(
|
||||
"truncated 3-byte sequence".into(),
|
||||
));
|
||||
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(),
|
||||
));
|
||||
return Err(ProtoError::InvalidUtf8("invalid continuation byte".into()));
|
||||
}
|
||||
let cp =
|
||||
((b as u32 & 0x0F) << 12) | ((b2 as u32 & 0x3F) << 6) | (b3 as u32 & 0x3F);
|
||||
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;
|
||||
}
|
||||
@@ -340,7 +331,11 @@ mod tests {
|
||||
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}");
|
||||
assert_eq!(
|
||||
read_varint(&mut c).unwrap(),
|
||||
val,
|
||||
"roundtrip failed for {val}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +379,16 @@ mod tests {
|
||||
|
||||
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 });
|
||||
assert_eq!(
|
||||
hdr,
|
||||
RectHeader {
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: 640,
|
||||
h: 480,
|
||||
encoding: 7
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
259
crates/ericrfb/src/session.rs
Normal file
259
crates/ericrfb/src/session.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
121
doc/plan/implementation-feature-port-mapping.md
Normal file
121
doc/plan/implementation-feature-port-mapping.md
Normal 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
144
script/setup.sh
Executable 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
|
||||
Reference in New Issue
Block a user