Add authenticated WebSocket serial terminal - dirty commit with front-

and backend issues
This commit is contained in:
2026-08-24 19:45:36 +02:00
parent 8b5417881c
commit 5f7ea5b79d
22 changed files with 8845 additions and 45 deletions
+43 -7
View File
@@ -12,7 +12,7 @@ Universal wireless serial adaptor firmware for the ESP32-S3.
- 8 MB octal PSRAM - 8 MB octal PSRAM
- Adafruit MAX3243 full-pinout RS-232 breakout, product 5988 - Adafruit MAX3243 full-pinout RS-232 breakout, product 5988
The firmware has completed **Phase 0 hardware characterization**, the **Phase 1 serial-core foundation**, the **Phase 2 transport-neutral session broker**, native USB CDC-ACM, and the **Phase 4 Wi-Fi foundation**. The current Phase 5A foundation adds authenticated HTTPS with a persistent device-specific identity and physical-console recovery. WebSocket/xterm.js serial transport is deliberately deferred to the next web phase. The MAX3243 diagnostics and recovery consoles remain available. No electrical test starts automatically; UART1 starts when requested explicitly or when a host opens native USB CDC. The firmware has completed **Phase 0 hardware characterization**, the **Phase 1 serial-core foundation**, the **Phase 2 transport-neutral session broker**, native USB CDC-ACM, the **Phase 4 Wi-Fi foundation**, and the **Phase 5 authenticated HTTPS web terminal**. Phase 5B adds an offline xterm.js interface and bounded WebSocket transport to the persistent HTTPS identity and recovery foundation from Phase 5A. The MAX3243 diagnostics and recovery consoles remain available. No electrical test starts automatically; UART1 starts when requested explicitly or when a native USB or authenticated web-terminal session opens.
## Hardware wiring ## Hardware wiring
@@ -273,16 +273,22 @@ Wi-Fi credentials currently reside as plaintext in the application-owned `wifi_a
8. Test `wifi stop`, `wifi start`, and `wifi reconnect` while confirming UART0 and native USB serial operation remain unaffected. 8. Test `wifi stop`, `wifi start`, and `wifi reconnect` while confirming UART0 and native USB serial operation remain unaffected.
9. If available, test a WPA3-only profile and a wrong password, then inspect the disconnect reason and counters. 9. If available, test a WPA3-only profile and a wrong password, then inspect the disconnect reason and counters.
### Phase 5A authenticated HTTPS foundation ### Phase 5 authenticated HTTPS web terminal
One ESP-IDF HTTPS server listens on TCP port 443 across whichever AP and station interfaces are active. There is no plaintext port 80 listener. This slice intentionally exposes only: One ESP-IDF HTTPS server listens on TCP port 443 across whichever AP and station interfaces are active. There is no plaintext port 80 listener. Phase 5A established persistent authentication, certificate management, and recovery; Phase 5B adds these local-only browser resources and transport endpoints:
```text ```text
GET / GET /
GET /api/status GET /api/status
POST /api/ws-ticket
WSS /ws/serial?ticket=<one-time-ticket>
GET /assets/xterm.css
GET /assets/xterm.js
GET /assets/addon-fit.js
GET /assets/app.js
``` ```
Both endpoints require HTTP Basic authentication over TLS. `/` is a small self-contained status landing page; `/api/status` returns JSON containing uptime plus non-secret Wi-Fi, serial-service, broker, native-USB, and HTTPS state/counters. No WebSocket, xterm.js terminal, or web broker client exists in Phase 5A. The page, status API, assets, and ticket endpoint require HTTP Basic authentication over TLS. `/` is now a responsive xterm.js serial workspace; `/api/status` returns JSON containing uptime plus non-secret Wi-Fi, serial-service, broker, native-USB, HTTPS, and WebSocket state/counters. xterm.js and FitAddon are pinned, vendored, compressed, and served by the ESP32 itself, so the terminal works while connected only to the fallback AP and never depends on a CDN.
On first boot, the device generates and persists: On first boot, the device generates and persists:
@@ -332,7 +338,37 @@ openssl s_client -connect 192.168.4.1:443 -servername esp32-sak-device.local </d
Replace the example SNI name with the DNS SAN printed by `web certificate info`. SNI is not required for this single-certificate server, but supplying the device name makes the test representative of future hostname use. Repeat the fingerprint check after reboot to confirm persistence, then optionally test each explicit rotation command and verify that only the requested material changes. Replace the example SNI name with the DNS SAN printed by `web certificate info`. SNI is not required for this single-certificate server, but supplying the device name makes the test representative of future hostname use. Repeat the fingerprint check after reboot to confirm persistence, then optionally test each explicit rotation command and verify that only the requested material changes.
HTTPS is memory-bounded to two simultaneous client sockets; ESP-IDF documents approximately 40 KiB per TLS socket. Basic authentication is acceptable here only because plaintext HTTP is disabled. It is an initial administration mechanism, not the final authorization design. #### WebSocket authentication and broker behavior
Browser JavaScript cannot reliably attach a Basic `Authorization` header to a WebSocket constructor. The authenticated page therefore obtains a 192-bit random, RAM-only ticket with `POST /api/ws-ticket`, then presents that ticket once in the WSS URL. The server stores only its SHA-256 digest, accepts it once within 30 seconds, binds it to the current credential generation, and creates no broker client until validation succeeds. Credential rotation invalidates outstanding tickets and closes active web-terminal sessions.
ESP-IDF 5.5 sends the RFC 6455 `101 Switching Protocols` response before invoking the application WebSocket handler. Consequently, an invalid ticket receives the protocol upgrade and is then closed immediately rather than receiving an HTTP `401`; it never gains a broker session, serial output, or writer access. Strict rejection before `101` would require a framework-level pre-handshake authorization hook that ESP-IDF 5.5 does not provide.
Each accepted browser becomes a normal `SESSION_BROKER_CLIENT_WEB`. Opening the first terminal starts UART1 if necessary and automatically requests the writer lease. A competing web or USB client remains a read-only observer when another client owns the lease. The page clearly reports its role and provides **Request control**, **Release control**, and **Reconnect** actions. Broker ownership remains authoritative even if a browser is stale or malicious.
Serial traffic uses binary WebSocket frames. Browser input is UTF-8 encoded and split into at most 1024-byte frames; output is drained in at most 512-byte frames. Each web session permits only one queued/in-flight TLS frame. If a browser stops reading, its own 4096-byte broker observer queue eventually drops data without blocking UART reception, USB, or another broker observer. ESP-IDF performs TLS sends on one shared HTTP task, so a slow TLS peer can delay other HTTPS work for at most the configured one-second socket timeout; this is bounded rather than absolute per-socket isolation.
A direct ticket diagnostic is available without exposing the ticket in firmware logs:
```sh
curl -k -u 'admin:YOUR_24_CHARACTER_PASSWORD' -X POST https://192.168.4.1/api/ws-ticket
```
For end-to-end validation:
1. Open `https://192.168.4.1/`, accept the device certificate warning, and authenticate as `admin`.
2. Confirm xterm.js loads without Internet access and the page reaches **Connected / Writer** when no other writer exists.
3. Send text, terminal escape sequences, UTF-8, and pasted input through an RS-232 loopback or peer; verify exact traffic through `web counters`, `broker clients`, and serial counters.
4. Open a second browser/private session. It should connect as an observer, receive the same UART output, and keep terminal input disabled.
5. Release control in the first browser, request it in the second, and verify the role badges, broker writer ID, and actual serial input ownership change together.
6. Open native USB while a web writer exists, then repeat with USB owning the lease. Confirm each losing transport remains an observer and cannot inject bytes.
7. Close/reload a browser and verify its broker client disappears, the writer lease is released when applicable, and reconnect uses a fresh ticket.
8. Run `web credentials rotate --force`; existing browsers should disconnect and old credentials must no longer mint tickets.
9. Exercise `web stop`, `web start`, and reboot while confirming UART0/native USB recovery remains available and no stale web broker clients survive.
Vendored browser sources, versions, hashes/provenance, deterministic gzip artifacts, and MIT license notices are recorded under [`web_assets/`](web_assets/SOURCES.md). Third-party code remains under its upstream license; project firmware code remains GPL-3.0-only.
HTTPS permits up to six simultaneous client sockets: two bounded persistent WebSocket terminals plus parallel browser asset, ticket, and status requests. ESP-IDF documents approximately 40 KiB per active TLS socket, so this is a concurrency ceiling rather than preallocated per-socket memory. WebSocket serial sessions themselves remain fixed at two. Basic authentication is acceptable here only because plaintext HTTP is disabled. It is an initial administration mechanism, not the final authorization design.
**Current security limitation:** the web password and ECDSA private key are stored as plaintext in the application-owned `web_sec/material` NVS blob, just as Wi-Fi credentials are currently plaintext in `wifi_app/config`. The reserved `nvs_key` partition does not activate NVS encryption. ESP-IDF 5.5 also keeps an internal heap copy of the active TLS private key and does not guarantee zeroization when that allocation is freed. Do not treat the current firmware as resistant to physical flash or RAM extraction; NVS encryption, flash encryption, secure boot, protected OTA, secret-aware core-dump handling, and framework-level key zeroization belong to the later hardening phase. **Current security limitation:** the web password and ECDSA private key are stored as plaintext in the application-owned `web_sec/material` NVS blob, just as Wi-Fi credentials are currently plaintext in `wifi_app/config`. The reserved `nvs_key` partition does not activate NVS encryption. ESP-IDF 5.5 also keeps an internal heap copy of the active TLS private key and does not guarantee zeroization when that allocation is freed. Do not treat the current firmware as resistant to physical flash or RAM extraction; NVS encryption, flash encryption, secure boot, protected OTA, secret-aware core-dump handling, and framework-level key zeroization belong to the later hardening phase.
+3
View File
@@ -15,6 +15,9 @@ CONFIG_TINYUSB_CDC_EP_BUFSIZE=512
# Enable the TLS-only administration server; no plaintext HTTP listener is created. # Enable the TLS-only administration server; no plaintext HTTP listener is created.
CONFIG_ESP_HTTPS_SERVER_ENABLE=y CONFIG_ESP_HTTPS_SERVER_ENABLE=y
CONFIG_HTTPD_WS_SUPPORT=y
# Keep work submission bounded; one-second socket timeouts limit shared-task stalls.
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
# Certificate generation and HTTPS startup use nested cryptographic buffers. # Certificate generation and HTTPS startup use nested cryptographic buffers.
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
+3
View File
@@ -16,6 +16,9 @@ idf_component_register(
"usb_cdc_transport.c" "usb_cdc_transport.c"
"usb_console.c" "usb_console.c"
"web_security.c" "web_security.c"
"web_serial_transport.c"
"web_assets_data.c"
"web_ui.c"
"web_server.c" "web_server.c"
"web_console.c" "web_console.c"
"wifi_config.c" "wifi_config.c"
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Generated web-asset data declarations. */
#pragma once
#include <stddef.h>
#include <stdint.h>
extern const uint8_t web_asset_xterm_js_gz[];
extern const size_t web_asset_xterm_js_gz_size;
extern const uint8_t web_asset_xterm_css_gz[];
extern const size_t web_asset_xterm_css_gz_size;
extern const uint8_t web_asset_addon_fit_js_gz[];
extern const size_t web_asset_addon_fit_js_gz_size;
+84 -5
View File
@@ -10,6 +10,7 @@
#include "esp_console.h" #include "esp_console.h"
#include "secure_random.h" #include "secure_random.h"
#include "web_security.h" #include "web_security.h"
#include "web_serial_transport.h"
#include "web_server.h" #include "web_server.h"
static void print_usage(void) static void print_usage(void)
@@ -57,7 +58,37 @@ static int show_status(void)
printf("Authentication material unavailable: %s; use 'web reset --force' to replace it.\n", printf("Authentication material unavailable: %s; use 'web reset --force' to replace it.\n",
esp_err_to_name(security_error)); esp_err_to_name(security_error));
} }
printf("Endpoints: GET / and GET /api/status (authentication required)\n"); printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n");
web_serial_transport_snapshot_t transport;
esp_err_t transport_error = web_serial_transport_get_snapshot(&transport);
if (transport_error != ESP_OK) {
printf("WebSocket serial transport unavailable: %s\n",
esp_err_to_name(snapshot.serial_transport_error));
return 0;
}
printf("WebSocket serial: attached=%s sessions=%" PRIu32 "/%u tickets=%" PRIu32 "\n",
transport.server_attached ? "yes" : "no",
transport.active_sessions,
WEB_SERIAL_TRANSPORT_MAX_SESSIONS,
transport.active_tickets);
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
const web_serial_transport_session_snapshot_t *session =
&transport.sessions[index];
if (!session->active) {
continue;
}
printf(" slot=%u fd=%d generation=%" PRIu32 " broker=%" PRIu32
" role=%s tx-pending=%s closing=%s\n",
(unsigned int)index,
session->socket_fd,
session->generation,
session->broker_client_id,
session->writer ? "writer" : "observer",
session->tx_pending ? "yes" : "no",
session->close_requested ? "yes" : "no");
}
return 0; return 0;
} }
@@ -76,10 +107,50 @@ static int show_counters(void)
counter->starts, counter->start_failures, counter->stops); counter->starts, counter->start_failures, counter->stops);
printf("Requests: total=%" PRIu64 " authenticated=%" PRIu64 printf("Requests: total=%" PRIu64 " authenticated=%" PRIu64
" auth-failures=%" PRIu64 " root=%" PRIu64 " auth-failures=%" PRIu64 " root=%" PRIu64
" status=%" PRIu64 " response-errors=%" PRIu64 "\n", " status=%" PRIu64 " tickets=%" PRIu64 " assets=%" PRIu64
" response-errors=%" PRIu64 "\n",
counter->requests, counter->authenticated_requests, counter->requests, counter->authenticated_requests,
counter->authentication_failures, counter->root_requests, counter->authentication_failures, counter->root_requests,
counter->status_requests, counter->response_errors); counter->status_requests, counter->ticket_requests,
counter->asset_requests, counter->response_errors);
web_serial_transport_snapshot_t transport;
error = web_serial_transport_get_snapshot(&transport);
if (error != ESP_OK) {
printf("WebSocket serial counters unavailable: %s\n", esp_err_to_name(error));
return 0;
}
const web_serial_transport_counters_t *websocket = &transport.counters;
printf("Tickets: issued=%" PRIu64 " consumed=%" PRIu64
" rejected=%" PRIu64 " expired=%" PRIu64 "\n",
websocket->tickets_issued, websocket->tickets_consumed,
websocket->tickets_rejected, websocket->tickets_expired);
printf("WebSocket sessions: connect=%" PRIu64 " failures=%" PRIu64
" disconnect=%" PRIu64 " service-start-failures=%" PRIu64
" broker-failures=%" PRIu64 "\n",
websocket->connections, websocket->connection_failures,
websocket->disconnections, websocket->service_start_failures,
websocket->broker_failures);
printf("WebSocket RX: frames-ok=%" PRIu64 " frames-rejected=%" PRIu64
" bytes-ok=%" PRIu64 " bytes-rejected=%" PRIu64 "\n",
websocket->rx_ws_frames_accepted,
websocket->rx_ws_frames_rejected,
websocket->rx_ws_bytes_accepted,
websocket->rx_ws_bytes_rejected);
printf("WebSocket TX: binary-frames=%" PRIu64 " binary-bytes=%" PRIu64
" control-frames=%" PRIu64 " control-bytes=%" PRIu64 "\n",
websocket->tx_binary_frames, websocket->tx_binary_bytes,
websocket->tx_control_frames, websocket->tx_control_bytes);
printf("WebSocket control: writer-requests=%" PRIu64
" grants=%" PRIu64 " denials=%" PRIu64
" releases=%" PRIu64 " revocations=%" PRIu64 "\n",
websocket->writer_requests, websocket->writer_grants,
websocket->writer_denials, websocket->writer_releases,
websocket->writer_revocations);
printf("WebSocket failures: send=%" PRIu64 " queue=%" PRIu64
" protocol=%" PRIu64 " closes=%" PRIu64 "\n",
websocket->send_failures, websocket->queue_failures,
websocket->protocol_errors, websocket->close_requests);
return 0; return 0;
} }
@@ -160,7 +231,12 @@ static int rotate_credentials(void)
return 1; return 1;
} }
esp_err_t revoke_error = web_serial_transport_revoke_sessions();
printf("Web credentials rotated and persisted. Existing Basic credentials are now invalid.\n"); printf("Web credentials rotated and persisted. Existing Basic credentials are now invalid.\n");
if (revoke_error != ESP_OK && revoke_error != ESP_ERR_INVALID_STATE) {
printf("Warning: existing WebSocket sessions could not be revoked: %s\n",
esp_err_to_name(revoke_error));
}
printf("Username: %.*s\nPassword: %.*s\n", printf("Username: %.*s\nPassword: %.*s\n",
(int)credentials.username_length, credentials.username, (int)credentials.username_length, credentials.username,
(int)credentials.password_length, credentials.password); (int)credentials.password_length, credentials.password);
@@ -247,11 +323,14 @@ static int command_web(int argc, char **argv)
} }
if (argc == 2 && strcmp(argv[1], "clear-counters") == 0) { if (argc == 2 && strcmp(argv[1], "clear-counters") == 0) {
esp_err_t error = web_server_clear_counters(); esp_err_t error = web_server_clear_counters();
if (error == ESP_OK) {
error = web_serial_transport_clear_counters();
}
if (error != ESP_OK) { if (error != ESP_OK) {
printf("Could not clear HTTPS counters: %s\n", esp_err_to_name(error)); printf("Could not clear web counters: %s\n", esp_err_to_name(error));
return 1; return 1;
} }
printf("HTTPS counters cleared.\n"); printf("HTTPS and WebSocket counters cleared.\n");
return 0; return 0;
} }
if (argc == 3 && strcmp(argv[1], "credentials") == 0 && if (argc == 3 && strcmp(argv[1], "credentials") == 0 &&
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Authenticated, bounded WebSocket transport for the serial session broker. */
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "esp_http_server.h"
#include "session_broker.h"
#ifdef __cplusplus
extern "C" {
#endif
#define WEB_SERIAL_TRANSPORT_MAX_SESSIONS 2U
#define WEB_SERIAL_TRANSPORT_MAX_TICKETS 4U
#define WEB_SERIAL_TRANSPORT_TICKET_LENGTH 32U
#define WEB_SERIAL_TRANSPORT_TICKET_CAPACITY \
(WEB_SERIAL_TRANSPORT_TICKET_LENGTH + 1U)
#define WEB_SERIAL_TRANSPORT_TICKET_LIFETIME_SECONDS 30U
#define WEB_SERIAL_TRANSPORT_MAX_RX_PAYLOAD 1024U
#define WEB_SERIAL_TRANSPORT_TX_PAYLOAD_SIZE 512U
#define WEB_SERIAL_TRANSPORT_TICKET_URI "/api/ws-ticket"
#define WEB_SERIAL_TRANSPORT_WS_URI "/ws/serial"
#define WEB_SERIAL_TRANSPORT_TICKET_QUERY_KEY "ticket"
typedef struct {
uint64_t tickets_issued;
uint64_t tickets_consumed;
uint64_t tickets_rejected;
uint64_t tickets_expired;
uint64_t connections;
uint64_t connection_failures;
uint64_t disconnections;
uint64_t service_start_failures;
uint64_t broker_failures;
uint64_t writer_requests;
uint64_t writer_grants;
uint64_t writer_denials;
uint64_t writer_releases;
uint64_t writer_revocations;
uint64_t rx_ws_frames_accepted;
uint64_t rx_ws_frames_rejected;
uint64_t rx_ws_bytes_accepted;
uint64_t rx_ws_bytes_rejected;
uint64_t tx_binary_frames;
uint64_t tx_binary_bytes;
uint64_t tx_control_frames;
uint64_t tx_control_bytes;
uint64_t send_failures;
uint64_t queue_failures;
uint64_t protocol_errors;
uint64_t close_requests;
} web_serial_transport_counters_t;
typedef struct {
bool active;
bool writer;
bool tx_pending;
bool close_requested;
int socket_fd;
uint32_t generation;
session_broker_client_id_t broker_client_id;
} web_serial_transport_session_snapshot_t;
typedef struct {
bool initialized;
bool server_attached;
uint32_t active_sessions;
uint32_t active_tickets;
web_serial_transport_session_snapshot_t
sessions[WEB_SERIAL_TRANSPORT_MAX_SESSIONS];
web_serial_transport_counters_t counters;
} web_serial_transport_snapshot_t;
/*
* Allocate no per-session heap objects and start the permanent transport task.
* CONFIG_HTTPD_WS_SUPPORT must be enabled. CONFIG_HTTPD_QUEUE_WORK_BLOCKING must
* be disabled because that IDF mode can wait forever inside httpd_queue_work().
*/
esp_err_t web_serial_transport_init(void);
/* Attach after httpd start; detach as part of stopping that same server. */
esp_err_t web_serial_transport_attach_server(httpd_handle_t server);
esp_err_t web_serial_transport_detach_server(httpd_handle_t server);
/*
* Mint a one-time bearer ticket for an already-authenticated caller. The output
* is exactly 32 Base64URL characters plus a terminator and expires after 30
* monotonic seconds. Never log or persist the returned value.
*/
esp_err_t web_serial_transport_mint_ticket(char *ticket, size_t capacity);
/*
* Convenience POST response helper for /api/ws-ticket. Authentication is
* intentionally outside this module: call this only after Basic authentication
* has already succeeded. Register it as HTTP_POST, not as a public handler.
*/
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
httpd_req_t *request);
/*
* Handler for /ws/serial. Register as HTTP_GET with is_websocket=true and
* handle_ws_control_frames=false. The initial upgraded GET authenticates the
* ticket; later invocations process one complete data frame.
*/
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request);
esp_err_t web_serial_transport_get_snapshot(
web_serial_transport_snapshot_t *snapshot);
/* Clearing counters does not alter tickets, sessions, ownership, or queued data. */
esp_err_t web_serial_transport_clear_counters(void);
/* Invalidate outstanding tickets and close authenticated web serial sessions. */
esp_err_t web_serial_transport_revoke_sessions(void);
#ifdef __cplusplus
}
#endif
+185 -33
View File
@@ -21,28 +21,24 @@
#include "session_broker.h" #include "session_broker.h"
#include "usb_cdc_transport.h" #include "usb_cdc_transport.h"
#include "web_security.h" #include "web_security.h"
#include "web_serial_transport.h"
#include "web_ui.h"
#include "wifi_manager.h" #include "wifi_manager.h"
#define WEB_SERVER_PORT 443U #define WEB_SERVER_PORT 443U
#define WEB_SERVER_MAX_AUTHORIZATION 128U #define WEB_SERVER_MAX_AUTHORIZATION 128U
#define WEB_SERVER_MAX_BASIC_DECODED 64U #define WEB_SERVER_MAX_BASIC_DECODED 64U
#define WEB_SERVER_STATUS_JSON_CAPACITY 2304U #define WEB_SERVER_STATUS_JSON_CAPACITY 3072U
static const char s_index_html[] =
"<!doctype html><html lang=en><meta charset=utf-8>"
"<meta name=viewport content=\"width=device-width,initial-scale=1\">"
"<title>ESP32 Serial Swiss Army Knife</title>"
"<style>body{font:16px system-ui;max-width:54rem;margin:3rem auto;padding:0 1rem}"
"pre{background:#171717;color:#eee;padding:1rem;overflow:auto}</style>"
"<h1>ESP32 Serial Swiss Army Knife</h1>"
"<p>Authenticated HTTPS is operational. Interactive web serial access is not enabled yet.</p>"
"<p><a href=/api/status>JSON status</a></p></html>";
static SemaphoreHandle_t s_server_mutex; static SemaphoreHandle_t s_server_mutex;
static httpd_handle_t s_server; static httpd_handle_t s_server;
static bool s_initialized; static bool s_initialized;
static bool s_transitioning; static bool s_transitioning;
static bool s_serial_transport_init_attempted;
static bool s_serial_transport_initialized;
static bool s_serial_transport_attached;
static esp_err_t s_last_error = ESP_ERR_INVALID_STATE; static esp_err_t s_last_error = ESP_ERR_INVALID_STATE;
static esp_err_t s_serial_transport_error = ESP_ERR_INVALID_STATE;
static web_server_counters_t s_counters; static web_server_counters_t s_counters;
static esp_err_t ensure_mutex(void) static esp_err_t ensure_mutex(void)
@@ -167,33 +163,62 @@ static esp_err_t authorize_or_respond(httpd_req_t *request, bool *authorized)
return *authorized ? ESP_OK : send_authentication_required(request); return *authorized ? ESP_OK : send_authentication_required(request);
} }
static esp_err_t root_handler(httpd_req_t *request) static esp_err_t send_authenticated_ui(httpd_req_t *request,
web_ui_resource_t resource,
uint64_t *counter)
{ {
bool authorized = false; bool authorized = false;
esp_err_t error = authorize_or_respond(request, &authorized); esp_err_t error = authorize_or_respond(request, &authorized);
if (error != ESP_OK || !authorized) { if (error != ESP_OK || !authorized) {
return error; return error;
} }
increment_counter(&s_counters.root_requests); increment_counter(counter);
error = httpd_resp_set_type(request, "text/html; charset=utf-8"); error = web_ui_send_response(request, resource);
if (error == ESP_OK) {
error = set_common_headers(request);
}
if (error == ESP_OK) {
error = httpd_resp_set_hdr(
request, "Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'");
}
if (error == ESP_OK) {
error = httpd_resp_send(request, s_index_html, HTTPD_RESP_USE_STRLEN);
}
if (error != ESP_OK) { if (error != ESP_OK) {
increment_counter(&s_counters.response_errors); increment_counter(&s_counters.response_errors);
} }
return error; return error;
} }
static esp_err_t root_handler(httpd_req_t *request)
{
return send_authenticated_ui(request, WEB_UI_RESOURCE_INDEX,
&s_counters.root_requests);
}
static esp_err_t asset_handler(httpd_req_t *request)
{
web_ui_resource_t resource = (web_ui_resource_t)(uintptr_t)request->user_ctx;
return send_authenticated_ui(request, resource, &s_counters.asset_requests);
}
static esp_err_t ticket_handler(httpd_req_t *request)
{
bool authorized = false;
esp_err_t error = authorize_or_respond(request, &authorized);
if (error != ESP_OK || !authorized) {
return error;
}
increment_counter(&s_counters.ticket_requests);
if (request->content_len != 0U) {
return send_plain_error(request, "400 Bad Request",
"Ticket requests must have an empty body.\n");
}
error = web_serial_transport_handle_authenticated_ticket_request(request);
if (error == ESP_OK) {
return ESP_OK;
}
if (error == ESP_ERR_INVALID_ARG) {
return send_plain_error(request, "400 Bad Request",
"Invalid web-terminal ticket request.\n");
}
increment_counter(&s_counters.response_errors);
return send_plain_error(request, "503 Service Unavailable",
"Web terminal transport unavailable.\n");
}
static const char *safe_string(const char *value) static const char *safe_string(const char *value)
{ {
return value != NULL ? value : "unknown"; return value != NULL ? value : "unknown";
@@ -242,6 +267,7 @@ static esp_err_t status_handler(httpd_req_t *request)
session_broker_global_snapshot_t broker = {0}; session_broker_global_snapshot_t broker = {0};
usb_cdc_transport_snapshot_t usb = {0}; usb_cdc_transport_snapshot_t usb = {0};
web_server_snapshot_t web = {0}; web_server_snapshot_t web = {0};
web_serial_transport_snapshot_t web_serial = {0};
web_security_certificate_metadata_t certificate = {0}; web_security_certificate_metadata_t certificate = {0};
char ipv4[16] = {0}; char ipv4[16] = {0};
char fingerprint[WEB_SECURITY_SHA256_LENGTH * 3U] = {0}; char fingerprint[WEB_SECURITY_SHA256_LENGTH * 3U] = {0};
@@ -253,6 +279,8 @@ static esp_err_t status_handler(httpd_req_t *request)
bool broker_available = session_broker_get_global_snapshot(&broker) == ESP_OK; bool broker_available = session_broker_get_global_snapshot(&broker) == ESP_OK;
bool usb_available = usb_cdc_transport_get_snapshot(&usb) == ESP_OK; bool usb_available = usb_cdc_transport_get_snapshot(&usb) == ESP_OK;
bool web_available = web_server_get_snapshot(&web) == ESP_OK; bool web_available = web_server_get_snapshot(&web) == ESP_OK;
bool web_serial_available =
web_serial_transport_get_snapshot(&web_serial) == ESP_OK;
bool certificate_available = bool certificate_available =
web_security_get_certificate_metadata(&certificate) == ESP_OK; web_security_get_certificate_metadata(&certificate) == ESP_OK;
@@ -278,7 +306,10 @@ static esp_err_t status_handler(httpd_req_t *request)
" \"usb\":{\"available\":%s,\"attached\":%s,\"host_open\":%s,\"writer\":%s},\n" " \"usb\":{\"available\":%s,\"attached\":%s,\"host_open\":%s,\"writer\":%s},\n"
" \"https\":{\"running\":%s,\"requests\":%" PRIu64 "," " \"https\":{\"running\":%s,\"requests\":%" PRIu64 ","
"\"authenticated_requests\":%" PRIu64 ",\"authentication_failures\":%" PRIu64 "," "\"authenticated_requests\":%" PRIu64 ",\"authentication_failures\":%" PRIu64 ","
"\"certificate_sha256\":\"%s\"}\n" "\"certificate_sha256\":\"%s\"},\n"
" \"websocket\":{\"available\":%s,\"sessions\":%" PRIu32 ","
"\"active_tickets\":%" PRIu32 ",\"rx_bytes\":%" PRIu64 ","
"\"rx_rejected\":%" PRIu64 ",\"tx_bytes\":%" PRIu64 "}\n"
"}\n", "}\n",
(uint64_t)(esp_timer_get_time() / 1000), (uint64_t)(esp_timer_get_time() / 1000),
wifi_available ? "true" : "false", wifi_available ? "true" : "false",
@@ -309,7 +340,16 @@ static esp_err_t status_handler(httpd_req_t *request)
web_available ? web.counters.requests : 0U, web_available ? web.counters.requests : 0U,
web_available ? web.counters.authenticated_requests : 0U, web_available ? web.counters.authenticated_requests : 0U,
web_available ? web.counters.authentication_failures : 0U, web_available ? web.counters.authentication_failures : 0U,
fingerprint); fingerprint,
web_serial_available ? "true" : "false",
web_serial_available ? web_serial.active_sessions : 0U,
web_serial_available ? web_serial.active_tickets : 0U,
web_serial_available ? web_serial.counters.rx_ws_bytes_accepted : 0U,
web_serial_available ? web_serial.counters.rx_ws_bytes_rejected : 0U,
web_serial_available
? web_serial.counters.tx_binary_bytes +
web_serial.counters.tx_control_bytes
: 0U);
if (written < 0 || (size_t)written >= sizeof(response)) { if (written < 0 || (size_t)written >= sizeof(response)) {
return send_plain_error(request, "500 Internal Server Error", return send_plain_error(request, "500 Internal Server Error",
@@ -342,18 +382,93 @@ static const httpd_uri_t s_status_uri = {
.user_ctx = NULL, .user_ctx = NULL,
}; };
static const httpd_uri_t s_ticket_uri = {
.uri = WEB_SERIAL_TRANSPORT_TICKET_URI,
.method = HTTP_POST,
.handler = ticket_handler,
.user_ctx = NULL,
};
static const httpd_uri_t s_websocket_uri = {
.uri = WEB_SERIAL_TRANSPORT_WS_URI,
.method = HTTP_GET,
.handler = web_serial_transport_ws_handler,
.user_ctx = NULL,
.is_websocket = true,
.handle_ws_control_frames = false,
};
static const httpd_uri_t s_xterm_js_uri = {
.uri = "/assets/xterm.js",
.method = HTTP_GET,
.handler = asset_handler,
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_XTERM_JS,
};
static const httpd_uri_t s_xterm_css_uri = {
.uri = "/assets/xterm.css",
.method = HTTP_GET,
.handler = asset_handler,
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_XTERM_CSS,
};
static const httpd_uri_t s_addon_fit_js_uri = {
.uri = "/assets/addon-fit.js",
.method = HTTP_GET,
.handler = asset_handler,
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_ADDON_FIT_JS,
};
static const httpd_uri_t s_app_js_uri = {
.uri = "/assets/app.js",
.method = HTTP_GET,
.handler = asset_handler,
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_APP_JS,
};
static const httpd_uri_t *const s_uri_handlers[] = {
&s_root_uri,
&s_status_uri,
&s_ticket_uri,
&s_websocket_uri,
&s_xterm_js_uri,
&s_xterm_css_uri,
&s_addon_fit_js_uri,
&s_app_js_uri,
};
esp_err_t web_server_init(void) esp_err_t web_server_init(void)
{ {
esp_err_t error = ensure_mutex(); esp_err_t error = ensure_mutex();
if (error != ESP_OK) { if (error != ESP_OK) {
return error; return error;
} }
bool initialize_serial_transport = false;
xSemaphoreTake(s_server_mutex, portMAX_DELAY); xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (!s_serial_transport_init_attempted) {
s_serial_transport_init_attempted = true;
initialize_serial_transport = true;
}
xSemaphoreGive(s_server_mutex);
esp_err_t serial_transport_error = ESP_OK;
if (initialize_serial_transport) {
serial_transport_error = web_serial_transport_init();
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (initialize_serial_transport) {
s_serial_transport_error = serial_transport_error;
s_serial_transport_initialized = serial_transport_error == ESP_OK;
}
s_initialized = true; s_initialized = true;
if (s_last_error == ESP_ERR_INVALID_STATE) { if (s_last_error == ESP_ERR_INVALID_STATE) {
s_last_error = ESP_OK; s_last_error = ESP_OK;
} }
xSemaphoreGive(s_server_mutex); xSemaphoreGive(s_server_mutex);
/* The Phase 5A HTTPS recovery surface remains available if WebSocket setup fails. */
return ESP_OK; return ESP_OK;
} }
@@ -364,12 +479,14 @@ esp_err_t web_server_start(void)
return error; return error;
} }
bool serial_transport_ready;
xSemaphoreTake(s_server_mutex, portMAX_DELAY); xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (s_server != NULL || s_transitioning) { if (s_server != NULL || s_transitioning) {
xSemaphoreGive(s_server_mutex); xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
} }
s_transitioning = true; s_transitioning = true;
serial_transport_ready = s_serial_transport_initialized;
xSemaphoreGive(s_server_mutex); xSemaphoreGive(s_server_mutex);
uint8_t certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0}; uint8_t certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0};
@@ -383,9 +500,13 @@ esp_err_t web_server_start(void)
private_key, sizeof(private_key), &private_key_length); private_key, sizeof(private_key), &private_key_length);
if (error == ESP_OK) { if (error == ESP_OK) {
httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT(); httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT();
config.httpd.max_open_sockets = 2; /* Two browser terminals retain room for parallel assets and status fetches. */
config.httpd.max_uri_handlers = 2; config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]);
config.httpd.lru_purge_enable = true; config.httpd.lru_purge_enable = true;
config.httpd.recv_wait_timeout = 1;
config.httpd.send_wait_timeout = 1;
config.servercert = certificate; config.servercert = certificate;
config.servercert_len = certificate_length; config.servercert_len = certificate_length;
config.prvtkey_pem = private_key; config.prvtkey_pem = private_key;
@@ -397,11 +518,18 @@ esp_err_t web_server_start(void)
secure_wipe(certificate, sizeof(certificate)); secure_wipe(certificate, sizeof(certificate));
secure_wipe(private_key, sizeof(private_key)); secure_wipe(private_key, sizeof(private_key));
if (error == ESP_OK) { for (size_t index = 0U;
error = httpd_register_uri_handler(server, &s_root_uri); error == ESP_OK &&
index < sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]);
++index) {
error = httpd_register_uri_handler(server, s_uri_handlers[index]);
} }
if (error == ESP_OK) {
error = httpd_register_uri_handler(server, &s_status_uri); bool serial_transport_attached = false;
esp_err_t attach_error = s_serial_transport_error;
if (error == ESP_OK && serial_transport_ready) {
attach_error = web_serial_transport_attach_server(server);
serial_transport_attached = attach_error == ESP_OK;
} }
if (error != ESP_OK && server != NULL) { if (error != ESP_OK && server != NULL) {
(void)httpd_ssl_stop(server); (void)httpd_ssl_stop(server);
@@ -411,6 +539,8 @@ esp_err_t web_server_start(void)
xSemaphoreTake(s_server_mutex, portMAX_DELAY); xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false; s_transitioning = false;
s_last_error = error; s_last_error = error;
s_serial_transport_error = attach_error;
s_serial_transport_attached = serial_transport_attached;
if (error == ESP_OK) { if (error == ESP_OK) {
s_server = server; s_server = server;
++s_counters.starts; ++s_counters.starts;
@@ -433,14 +563,35 @@ esp_err_t web_server_stop(void)
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
} }
httpd_handle_t server = s_server; httpd_handle_t server = s_server;
bool serial_transport_attached = s_serial_transport_attached;
esp_err_t serial_transport_error = s_serial_transport_error;
s_transitioning = true; s_transitioning = true;
xSemaphoreGive(s_server_mutex); xSemaphoreGive(s_server_mutex);
if (serial_transport_attached) {
esp_err_t detach_error = web_serial_transport_detach_server(server);
if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) {
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
s_last_error = detach_error;
s_serial_transport_error = detach_error;
xSemaphoreGive(s_server_mutex);
return detach_error;
}
serial_transport_error = detach_error;
}
esp_err_t error = httpd_ssl_stop(server); esp_err_t error = httpd_ssl_stop(server);
if (error != ESP_OK && serial_transport_attached) {
/* Stay detached: old HTTPD work may still be reading static TX storage. */
serial_transport_error = ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY); xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false; s_transitioning = false;
s_last_error = error; s_last_error = error;
s_serial_transport_error = serial_transport_error;
s_serial_transport_attached = false;
if (error == ESP_OK) { if (error == ESP_OK) {
s_server = NULL; s_server = NULL;
++s_counters.stops; ++s_counters.stops;
@@ -465,6 +616,7 @@ esp_err_t web_server_get_snapshot(web_server_snapshot_t *snapshot)
snapshot->transitioning = s_transitioning; snapshot->transitioning = s_transitioning;
snapshot->port = WEB_SERVER_PORT; snapshot->port = WEB_SERVER_PORT;
snapshot->last_error = s_last_error; snapshot->last_error = s_last_error;
snapshot->serial_transport_error = s_serial_transport_error;
snapshot->counters = s_counters; snapshot->counters = s_counters;
xSemaphoreGive(s_server_mutex); xSemaphoreGive(s_server_mutex);
return ESP_OK; return ESP_OK;
+3
View File
@@ -21,6 +21,8 @@ typedef struct {
uint64_t authentication_failures; uint64_t authentication_failures;
uint64_t root_requests; uint64_t root_requests;
uint64_t status_requests; uint64_t status_requests;
uint64_t ticket_requests;
uint64_t asset_requests;
uint64_t response_errors; uint64_t response_errors;
} web_server_counters_t; } web_server_counters_t;
@@ -30,6 +32,7 @@ typedef struct {
bool transitioning; bool transitioning;
uint16_t port; uint16_t port;
esp_err_t last_error; esp_err_t last_error;
esp_err_t serial_transport_error;
web_server_counters_t counters; web_server_counters_t counters;
} web_server_snapshot_t; } web_server_snapshot_t;
+511
View File
@@ -0,0 +1,511 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Offline browser UI response helpers for authenticated HTTPS routes. */
#include "web_ui.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "web_assets_data.h"
#define WEB_UI_DOCUMENT_CACHE_CONTROL "private, max-age=300"
#define WEB_UI_ASSET_CACHE_CONTROL "private, max-age=604800"
static const char s_index_html[] =
"<!doctype html>\n"
"<html lang=\"en\">\n"
"<head>\n"
"<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
"<meta name=\"color-scheme\" content=\"dark\">\n"
"<title>ESP32 Serial Console</title>\n"
"<style>\n"
":root{color-scheme:dark;--bg:#090d14;--panel:#111824;--panel-2:#161f2e;"
"--line:#29364a;--text:#e8eef8;--muted:#91a0b5;--accent:#55c2ff;"
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
"*{box-sizing:border-box}\n"
"html,body{height:100%;margin:0}\n"
"body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);"
"color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"
"\"Segoe UI\",sans-serif}\n"
"button{font:inherit}\n"
".page{min-height:100%;max-width:1440px;margin:auto;padding:clamp(14px,2.5vw,32px);"
"display:grid;grid-template-rows:auto auto minmax(360px,1fr);gap:16px}\n"
".topbar{display:flex;align-items:center;justify-content:space-between;gap:16px}\n"
".brand{display:flex;align-items:center;gap:12px;min-width:0}\n"
".logo{width:42px;height:42px;border:1px solid #347ba5;border-radius:12px;"
"display:grid;place-items:center;background:#10273a;color:var(--accent);"
"font-weight:800;letter-spacing:.06em;box-shadow:0 0 30px #1c8fc333}\n"
"h1{font-size:clamp(18px,3vw,24px);line-height:1.15;margin:0}\n"
".subtitle{color:var(--muted);margin:3px 0 0;font-size:13px}\n"
".badge{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;"
"border:1px solid var(--line);border-radius:999px;padding:6px 10px;"
"background:#121a26;color:var(--muted);font-weight:700;font-size:12px}\n"
".badge:before{content:\"\";width:7px;height:7px;border-radius:50%;background:currentColor;"
"box-shadow:0 0 10px currentColor}\n"
".badge[data-tone=good]{color:var(--good);border-color:#245a42}\n"
".badge[data-tone=warn]{color:var(--warn);border-color:#675526}\n"
".badge[data-tone=bad]{color:var(--bad);border-color:#64313b}\n"
".dashboard{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(280px,.65fr);gap:16px}\n"
".panel{background:linear-gradient(145deg,#151e2cdd,#0f1621ee);border:1px solid var(--line);"
"border-radius:var(--radius);box-shadow:0 16px 45px #0005}\n"
".status-grid{padding:16px;display:grid;grid-template-columns:repeat(4,minmax(105px,1fr));gap:12px}\n"
".status-item{min-width:0;padding:10px 12px;background:#0b111b99;border:1px solid #202c3e;"
"border-radius:10px}\n"
".status-item.wide{grid-column:span 2}\n"
".label{display:block;color:var(--muted);font-size:11px;font-weight:700;"
"letter-spacing:.08em;text-transform:uppercase;margin-bottom:6px}\n"
".value{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}\n"
".controls{padding:16px;display:flex;flex-direction:column;justify-content:center;gap:12px}\n"
".button-row{display:flex;flex-wrap:wrap;gap:9px}\n"
".button{min-height:38px;border:1px solid #36506d;border-radius:9px;padding:8px 13px;"
"background:#18263a;color:var(--text);font-weight:750;cursor:pointer;"
"transition:background .15s,border-color .15s,transform .15s}\n"
".button:hover:not(:disabled){background:#203652;border-color:#4b789f;transform:translateY(-1px)}\n"
".button.primary{background:#126390;border-color:#278abd}\n"
".button.danger{background:#512631;border-color:#81404e}\n"
".button:disabled{cursor:not-allowed;opacity:.42}\n"
".input-state{margin:0;color:var(--warn);font-size:13px}\n"
".input-state[data-enabled=true]{color:var(--good)}\n"
".connection-detail{margin:0;color:var(--muted);font-size:12px;min-height:1.45em}\n"
".terminal-panel{min-height:0;padding:10px;display:flex;flex-direction:column;overflow:hidden}\n"
".terminal-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;"
"padding:1px 5px 9px;color:var(--muted);font-size:12px}\n"
".terminal-title{color:var(--text);font-weight:750;letter-spacing:.02em}\n"
"#terminal{flex:1;min-height:0;border-radius:9px;overflow:hidden;background:#080c12;padding:8px}\n"
"#terminal .xterm{height:100%}\n"
"#terminal .xterm-viewport{border-radius:7px}\n"
"@media(max-width:850px){.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
".status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}\n"
"@media(max-width:480px){.page{padding:10px;gap:10px}.topbar{align-items:flex-start}"
".logo{width:36px;height:36px}.status-grid{padding:10px;gap:8px}"
".status-item{padding:9px}.controls{padding:12px}.terminal-panel{padding:7px}"
".button-row{display:grid;grid-template-columns:1fr 1fr;width:100%}"
".button:last-child{grid-column:1/-1}.page{grid-template-rows:auto auto minmax(420px,1fr)}}\n"
"</style>\n"
"<link rel=\"stylesheet\" href=\"/assets/xterm.css\">\n"
"<script defer src=\"/assets/xterm.js\"></script>\n"
"<script defer src=\"/assets/addon-fit.js\"></script>\n"
"<script defer src=\"/assets/app.js\"></script>\n"
"</head>\n"
"<body>\n"
"<main class=\"page\">\n"
"<header class=\"topbar\">\n"
"<div class=\"brand\"><div class=\"logo\" aria-hidden=\"true\">SAK</div><div>"
"<h1>ESP32 Serial Console</h1><p class=\"subtitle\">Secure local serial workspace</p></div></div>\n"
"<span id=\"connection-status\" class=\"badge\" data-tone=\"warn\">Connecting</span>\n"
"</header>\n"
"<section class=\"dashboard\" aria-label=\"Connection and device status\">\n"
"<div class=\"panel status-grid\">\n"
"<div class=\"status-item\"><span class=\"label\">Role</span>"
"<span id=\"role-status\" class=\"badge\" data-tone=\"warn\">Observer</span></div>\n"
"<div class=\"status-item\"><span class=\"label\">Broker client</span>"
"<span id=\"client-id\" class=\"value\">—</span></div>\n"
"<div class=\"status-item\"><span class=\"label\">Active writer</span>"
"<span id=\"writer-id\" class=\"value\">None</span></div>\n"
"<div class=\"status-item\"><span class=\"label\">Broker clients</span>"
"<span id=\"broker-clients\" class=\"value\">—</span></div>\n"
"<div class=\"status-item wide\"><span class=\"label\">Wi-Fi</span>"
"<span id=\"wifi-summary\" class=\"value\">Loading…</span></div>\n"
"<div class=\"status-item wide\"><span class=\"label\">Serial</span>"
"<span id=\"serial-summary\" class=\"value\">Loading…</span></div>\n"
"</div>\n"
"<div class=\"panel controls\">\n"
"<div class=\"button-row\">\n"
"<button id=\"request-control\" class=\"button primary\" type=\"button\" disabled>Request control</button>\n"
"<button id=\"release-control\" class=\"button danger\" type=\"button\" disabled>Release control</button>\n"
"<button id=\"reconnect\" class=\"button\" type=\"button\">Reconnect</button>\n"
"</div>\n"
"<p id=\"input-state\" class=\"input-state\" data-enabled=\"false\" aria-live=\"polite\">"
"Observer mode — terminal input is disabled.</p>\n"
"<p id=\"connection-detail\" class=\"connection-detail\" aria-live=\"polite\">"
"Requesting a one-time connection ticket…</p>\n"
"</div>\n"
"</section>\n"
"<section class=\"panel terminal-panel\" aria-label=\"Serial terminal\">\n"
"<div class=\"terminal-toolbar\"><span class=\"terminal-title\">Live serial stream</span>"
"<span>Binary, unmodified device output</span></div>\n"
"<div id=\"terminal\"></div>\n"
"</section>\n"
"</main>\n"
"</body>\n"
"</html>\n";
static const char s_app_js[] =
"(() => {\n"
"'use strict';\n"
"const element = (id) => document.getElementById(id);\n"
"const connectionStatus = element('connection-status');\n"
"const roleStatus = element('role-status');\n"
"const clientIdField = element('client-id');\n"
"const writerIdField = element('writer-id');\n"
"const brokerClientsField = element('broker-clients');\n"
"const wifiSummary = element('wifi-summary');\n"
"const serialSummary = element('serial-summary');\n"
"const inputState = element('input-state');\n"
"const connectionDetail = element('connection-detail');\n"
"const requestControl = element('request-control');\n"
"const releaseControl = element('release-control');\n"
"const reconnectButton = element('reconnect');\n"
"const terminalHost = element('terminal');\n"
"const terminal = new Terminal({\n"
" allowProposedApi: false, convertEol: false, cursorBlink: true, disableStdin: true,\n"
" fontFamily: '\"SFMono-Regular\",Consolas,\"Liberation Mono\",monospace',\n"
" fontSize: 14, scrollback: 5000, theme: {\n"
" background: '#080c12', foreground: '#dce7f5', cursor: '#55c2ff',\n"
" selectionBackground: '#285173', black: '#18202b', red: '#ff6b7a',\n"
" green: '#52d68b', yellow: '#ffc857', blue: '#55aaff',\n"
" magenta: '#c792ea', cyan: '#55d6be', white: '#e8eef8'\n"
" }\n"
"});\n"
"const fitAddon = new FitAddon.FitAddon();\n"
"terminal.loadAddon(fitAddon);\n"
"terminal.open(terminalHost);\n"
"const encoder = new TextEncoder();\n"
"let socket = null;\n"
"let ticketAbort = null;\n"
"let reconnectTimer = null;\n"
"let reconnectDelay = 1000;\n"
"let connectionGeneration = 0;\n"
"let role = 'observer';\n"
"let clientId = null;\n"
"let writerId = 0;\n"
"let unloading = false;\n"
"let fitFrame = 0;\n"
"let statusInFlight = false;\n"
"let statusTimer = null;\n"
"const setBadge = (target, text, tone) => {\n"
" target.textContent = text;\n"
" target.dataset.tone = tone;\n"
"};\n"
"const validId = (value) => Number.isSafeInteger(value) && value >= 0;\n"
"const displayId = (value, noneText) => validId(value) && value !== 0 ? String(value) : noneText;\n"
"const socketOpen = () => socket !== null && socket.readyState === WebSocket.OPEN;\n"
"const updateControls = () => {\n"
" const writer = role === 'writer';\n"
" terminal.options.disableStdin = !writer;\n"
" requestControl.disabled = !socketOpen() || writer;\n"
" releaseControl.disabled = !socketOpen() || !writer;\n"
" inputState.dataset.enabled = writer ? 'true' : 'false';\n"
" inputState.textContent = writer\n"
" ? 'Writer mode — terminal input is enabled.'\n"
" : 'Observer mode — terminal input is disabled.';\n"
" setBadge(roleStatus, writer ? 'Writer' : 'Observer', writer ? 'good' : 'warn');\n"
"};\n"
"const setRole = (nextRole) => {\n"
" role = nextRole === 'writer' ? 'writer' : 'observer';\n"
" updateControls();\n"
"};\n"
"const setConnection = (text, tone, detail) => {\n"
" setBadge(connectionStatus, text, tone);\n"
" connectionDetail.textContent = detail;\n"
" updateControls();\n"
"};\n"
"const clearReconnectTimer = () => {\n"
" if (reconnectTimer !== null) {\n"
" window.clearTimeout(reconnectTimer);\n"
" reconnectTimer = null;\n"
" }\n"
"};\n"
"const scheduleReconnect = () => {\n"
" if (unloading || reconnectTimer !== null) return;\n"
" const delay = reconnectDelay;\n"
" reconnectDelay = Math.min(reconnectDelay * 2, 10000);\n"
" setConnection('Disconnected', 'bad', `Reconnecting in ${Math.ceil(delay / 1000)} second(s)…`);\n"
" reconnectTimer = window.setTimeout(() => {\n"
" reconnectTimer = null;\n"
" connect();\n"
" }, delay);\n"
"};\n"
"const acceptBrokerMessage = (message) => {\n"
" if (message === null || typeof message !== 'object') return;\n"
" if (message.type !== 'hello' && message.type !== 'writer') return;\n"
" if (message.role !== 'writer' && message.role !== 'observer') return;\n"
" if (!validId(message.writerId)) return;\n"
" if (message.type === 'hello') {\n"
" if (!validId(message.clientId) || message.clientId === 0) return;\n"
" clientId = message.clientId;\n"
" clientIdField.textContent = String(clientId);\n"
" reconnectDelay = 1000;\n"
" }\n"
" writerId = message.writerId;\n"
" writerIdField.textContent = displayId(writerId, 'None');\n"
" setRole(message.role);\n"
"};\n"
"const handleSocketMessage = (event) => {\n"
" if (typeof event.data === 'string') {\n"
" try {\n"
" acceptBrokerMessage(JSON.parse(event.data));\n"
" } catch (_) {\n"
" setConnection('Protocol error', 'bad', 'The device sent an invalid control message.');\n"
" }\n"
" return;\n"
" }\n"
" if (event.data instanceof ArrayBuffer) {\n"
" terminal.write(new Uint8Array(event.data));\n"
" }\n"
"};\n"
"async function requestTicket(signal) {\n"
" const response = await fetch('/api/ws-ticket', {\n"
" method: 'POST', credentials: 'same-origin', cache: 'no-store', signal\n"
" });\n"
" if (!response.ok) throw new Error('ticket request failed');\n"
" const payload = await response.json();\n"
" if (payload === null || typeof payload !== 'object' ||\n"
" typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n"
" throw new Error('invalid ticket response');\n"
" }\n"
" const ticket = payload.ticket;\n"
" payload.ticket = '';\n"
" return ticket;\n"
"}\n"
"async function connect() {\n"
" if (unloading) return;\n"
" clearReconnectTimer();\n"
" const generation = ++connectionGeneration;\n"
" if (ticketAbort !== null) ticketAbort.abort();\n"
" ticketAbort = new AbortController();\n"
" if (socket !== null) {\n"
" const previous = socket;\n"
" socket = null;\n"
" previous.close();\n"
" }\n"
" clientId = null;\n"
" clientIdField.textContent = '—';\n"
" setRole('observer');\n"
" setConnection('Connecting', 'warn', 'Requesting a one-time connection ticket…');\n"
" try {\n"
" const ticket = await requestTicket(ticketAbort.signal);\n"
" if (unloading || generation !== connectionGeneration) return;\n"
" ticketAbort = null;\n"
" const url = new URL('/ws/serial', window.location.origin);\n"
" url.protocol = 'wss:';\n"
" url.searchParams.set('ticket', ticket);\n"
" const nextSocket = new WebSocket(url.toString());\n"
" url.search = '';\n"
" nextSocket.binaryType = 'arraybuffer';\n"
" socket = nextSocket;\n"
" nextSocket.addEventListener('open', () => {\n"
" if (socket !== nextSocket) return;\n"
" setConnection('Connected', 'good', 'Connected; waiting for broker role information.');\n"
" });\n"
" nextSocket.addEventListener('message', (event) => {\n"
" if (socket === nextSocket) handleSocketMessage(event);\n"
" });\n"
" nextSocket.addEventListener('error', () => {\n"
" if (socket === nextSocket) {\n"
" setConnection('Connection error', 'bad', 'The WebSocket connection failed.');\n"
" }\n"
" });\n"
" nextSocket.addEventListener('close', () => {\n"
" if (socket !== nextSocket) return;\n"
" socket = null;\n"
" clientId = null;\n"
" clientIdField.textContent = '—';\n"
" setRole('observer');\n"
" scheduleReconnect();\n"
" });\n"
" } catch (error) {\n"
" if (generation !== connectionGeneration || unloading || error.name === 'AbortError') return;\n"
" ticketAbort = null;\n"
" scheduleReconnect();\n"
" }\n"
"}\n"
"terminal.onData((data) => {\n"
" if (role !== 'writer' || !socketOpen()) return;\n"
" const bytes = encoder.encode(data);\n"
" for (let offset = 0; offset < bytes.length; offset += 1024) {\n"
" socket.send(bytes.subarray(offset, Math.min(offset + 1024, bytes.length)));\n"
" }\n"
"});\n"
"requestControl.addEventListener('click', () => {\n"
" if (role !== 'writer' && socketOpen()) socket.send('request-writer');\n"
"});\n"
"releaseControl.addEventListener('click', () => {\n"
" if (role === 'writer' && socketOpen()) socket.send('release-writer');\n"
"});\n"
"reconnectButton.addEventListener('click', () => {\n"
" reconnectDelay = 1000;\n"
" connect();\n"
"});\n"
"const scheduleFit = () => {\n"
" if (fitFrame !== 0) return;\n"
" fitFrame = window.requestAnimationFrame(() => {\n"
" fitFrame = 0;\n"
" try { fitAddon.fit(); } catch (_) {}\n"
" });\n"
"};\n"
"if ('ResizeObserver' in window) new ResizeObserver(scheduleFit).observe(terminalHost);\n"
"window.addEventListener('resize', scheduleFit);\n"
"const textValue = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback;\n"
"const updateStatus = (status) => {\n"
" const wifi = status !== null && typeof status === 'object' ? status.wifi : null;\n"
" if (wifi && wifi.available) {\n"
" const parts = [textValue(wifi.state, 'unknown')];\n"
" if (typeof wifi.sta_ipv4 === 'string' && wifi.sta_ipv4 !== '0.0.0.0') parts.push(wifi.sta_ipv4);\n"
" if (Number.isFinite(wifi.rssi)) parts.push(`${wifi.rssi} dBm`);\n"
" if (Number.isFinite(wifi.channel) && wifi.channel > 0) parts.push(`channel ${wifi.channel}`);\n"
" if (wifi.ap_running && Number.isFinite(wifi.ap_clients)) parts.push(`AP clients ${wifi.ap_clients}`);\n"
" wifiSummary.textContent = parts.join(' · ');\n"
" } else {\n"
" wifiSummary.textContent = 'Unavailable';\n"
" }\n"
" const serial = status !== null && typeof status === 'object' ? status.serial : null;\n"
" if (serial && typeof serial === 'object') {\n"
" const parts = [serial.running ? 'Running' : 'Stopped'];\n"
" if (serial.config_available) {\n"
" if (Number.isFinite(serial.baud)) parts.push(`${serial.baud} baud`);\n"
" parts.push(`${textValue(serial.data_bits, '?')} data`);\n"
" parts.push(`${textValue(serial.parity, '?')} parity`);\n"
" parts.push(`${textValue(serial.stop_bits, '?')} stop`);\n"
" parts.push(`${textValue(serial.flow, '?')} flow`);\n"
" }\n"
" serialSummary.textContent = parts.join(' · ');\n"
" } else {\n"
" serialSummary.textContent = 'Unavailable';\n"
" }\n"
" const broker = status !== null && typeof status === 'object' ? status.broker : null;\n"
" if (broker && broker.available) {\n"
" brokerClientsField.textContent = validId(broker.clients) ? String(broker.clients) : '—';\n"
" if (validId(broker.writer)) {\n"
" writerId = broker.writer;\n"
" writerIdField.textContent = displayId(writerId, 'None');\n"
" }\n"
" } else {\n"
" brokerClientsField.textContent = '—';\n"
" }\n"
"};\n"
"async function pollStatus() {\n"
" if (unloading || statusInFlight) return;\n"
" statusInFlight = true;\n"
" try {\n"
" const response = await fetch('/api/status', {credentials: 'same-origin', cache: 'no-store'});\n"
" if (!response.ok) throw new Error('status request failed');\n"
" updateStatus(await response.json());\n"
" } catch (_) {\n"
" wifiSummary.textContent = 'Unavailable';\n"
" serialSummary.textContent = 'Unavailable';\n"
" brokerClientsField.textContent = '—';\n"
" } finally {\n"
" statusInFlight = false;\n"
" }\n"
"}\n"
"const shutdown = () => {\n"
" if (unloading) return;\n"
" unloading = true;\n"
" ++connectionGeneration;\n"
" clearReconnectTimer();\n"
" if (statusTimer !== null) window.clearInterval(statusTimer);\n"
" if (ticketAbort !== null) ticketAbort.abort();\n"
" if (socket !== null) socket.close();\n"
" socket = null;\n"
"};\n"
"window.addEventListener('pagehide', shutdown, {once: true});\n"
"updateControls();\n"
"scheduleFit();\n"
"pollStatus();\n"
"statusTimer = window.setInterval(pollStatus, 5000);\n"
"connect();\n"
"})();\n";
typedef struct {
const char *content_type;
const char *cache_control;
const char *content_encoding;
const uint8_t *data;
size_t length;
bool content_security_policy;
} web_ui_response_t;
static esp_err_t describe_resource(web_ui_resource_t resource,
web_ui_response_t *response)
{
response->cache_control = WEB_UI_ASSET_CACHE_CONTROL;
response->content_encoding = NULL;
response->content_security_policy = false;
switch (resource) {
case WEB_UI_RESOURCE_INDEX:
response->content_type = "text/html; charset=utf-8";
response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL;
response->data = (const uint8_t *)s_index_html;
response->length = sizeof(s_index_html) - 1U;
response->content_security_policy = true;
return ESP_OK;
case WEB_UI_RESOURCE_XTERM_JS:
response->content_type = "text/javascript; charset=utf-8";
response->content_encoding = "gzip";
response->data = web_asset_xterm_js_gz;
response->length = web_asset_xterm_js_gz_size;
return ESP_OK;
case WEB_UI_RESOURCE_XTERM_CSS:
response->content_type = "text/css; charset=utf-8";
response->content_encoding = "gzip";
response->data = web_asset_xterm_css_gz;
response->length = web_asset_xterm_css_gz_size;
return ESP_OK;
case WEB_UI_RESOURCE_ADDON_FIT_JS:
response->content_type = "text/javascript; charset=utf-8";
response->content_encoding = "gzip";
response->data = web_asset_addon_fit_js_gz;
response->length = web_asset_addon_fit_js_gz_size;
return ESP_OK;
case WEB_UI_RESOURCE_APP_JS:
response->content_type = "text/javascript; charset=utf-8";
response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL;
response->data = (const uint8_t *)s_app_js;
response->length = sizeof(s_app_js) - 1U;
return ESP_OK;
default:
return ESP_ERR_INVALID_ARG;
}
}
static esp_err_t set_response_headers(httpd_req_t *request,
const web_ui_response_t *response)
{
esp_err_t result = httpd_resp_set_type(request, response->content_type);
if (result == ESP_OK) {
result = httpd_resp_set_hdr(request, "Cache-Control",
response->cache_control);
}
if (result == ESP_OK) {
result = httpd_resp_set_hdr(request, "X-Content-Type-Options",
"nosniff");
}
if (result == ESP_OK) {
result = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
}
if (result == ESP_OK && response->content_encoding != NULL) {
result = httpd_resp_set_hdr(request, "Content-Encoding",
response->content_encoding);
}
if (result == ESP_OK && response->content_security_policy) {
result = httpd_resp_set_hdr(
request, "Content-Security-Policy",
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"connect-src 'self'; base-uri 'none'; form-action 'none'; "
"frame-ancestors 'none'");
}
return result;
}
esp_err_t web_ui_send_response(httpd_req_t *request,
web_ui_resource_t resource)
{
if (request == NULL) {
return ESP_ERR_INVALID_ARG;
}
web_ui_response_t response = {0};
esp_err_t result = describe_resource(resource, &response);
if (result == ESP_OK) {
result = set_response_headers(request, &response);
}
if (result == ESP_OK) {
result = httpd_resp_send(request, (const char *)response.data,
(ssize_t)response.length);
}
return result;
}
+30
View File
@@ -0,0 +1,30 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Offline browser UI response helpers for authenticated HTTPS routes. */
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
WEB_UI_RESOURCE_INDEX = 0,
WEB_UI_RESOURCE_XTERM_JS,
WEB_UI_RESOURCE_XTERM_CSS,
WEB_UI_RESOURCE_ADDON_FIT_JS,
WEB_UI_RESOURCE_APP_JS,
} web_ui_resource_t;
/*
* Send one UI resource after the caller has authenticated the request.
* This module deliberately performs no authentication or URI dispatch.
*/
esp_err_t web_ui_send_response(httpd_req_t *request,
web_ui_resource_t resource);
#ifdef __cplusplus
}
#endif
+29
View File
@@ -0,0 +1,29 @@
# Vendored web assets
These browser distributions were downloaded from the exact-version npm packages via unpkg.
| Package | Version | License | Local files | Upstream URLs |
| --- | --- | --- | --- | --- |
| `@xterm/xterm` | `5.5.0` | MIT (`xterm.LICENSE`) | `xterm.js`, `xterm.css` | <https://unpkg.com/@xterm/xterm@5.5.0/lib/xterm.js>, <https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css>, <https://unpkg.com/@xterm/xterm@5.5.0/LICENSE> |
| `@xterm/addon-fit` | `0.10.0` | MIT (`addon-fit.LICENSE`) | `addon-fit.js` | <https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js>, <https://unpkg.com/@xterm/addon-fit@0.10.0/LICENSE> |
Package metadata: <https://unpkg.com/@xterm/xterm@5.5.0/package.json> and <https://unpkg.com/@xterm/addon-fit@0.10.0/package.json>.
The `.gz` files are deterministic build artifacts generated from the corresponding JS/CSS files with `gzip -9 -n -c`. The `-n` option omits source names and timestamps. PlatformIO's ESP-IDF/SCons bridge cannot reliably compile ESP-IDF's generated `BUILD_DIR/*.S` files, so `generate_embedded_assets.py` converts the compressed files into the deterministic `src/web_assets_data.c` byte arrays used by the firmware:
```sh
python3 web_assets/generate_embedded_assets.py
```
## SHA-256 verification
| File | SHA-256 |
| --- | --- |
| `xterm.js` | `1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495` |
| `xterm.css` | `ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6` |
| `addon-fit.js` | `bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089` |
| `xterm.LICENSE` | `b569f629d00f2626a8100df2a1798210535621e42164dfd426a6fe5aac7b0ccd` |
| `addon-fit.LICENSE` | `e256f01188af527e4d06d21d06fbf785ae9c50d4b328bf03cbe0ba7f0aa4228f` |
| `xterm.js.gz` | `042c744ad77ddeda439cc095a70a9b29c62eca541b18cd0d3ac80081cc492f50` |
| `xterm.css.gz` | `876ead49256d30169786511ff27300116634d21951e46f373d19191928d385f3` |
| `addon-fit.js.gz` | `163634a1eb3c4d7ec77faeb1bd60255872f2558e5c28756bfabb48cc00feafc5` |
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-only
"""Generate deterministic C arrays for PlatformIO-compatible asset embedding."""
from pathlib import Path
ASSETS = (
("xterm.js.gz", "web_asset_xterm_js_gz"),
("xterm.css.gz", "web_asset_xterm_css_gz"),
("addon-fit.js.gz", "web_asset_addon_fit_js_gz"),
)
ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "web_assets"
HEADER_PATH = ROOT / "src" / "web_assets_data.h"
SOURCE_PATH = ROOT / "src" / "web_assets_data.c"
def format_array(data: bytes) -> str:
lines = []
for offset in range(0, len(data), 12):
chunk = data[offset : offset + 12]
lines.append(" " + ", ".join(f"0x{value:02x}" for value in chunk) + ",")
return "\n".join(lines)
def main() -> None:
header = """/* SPDX-License-Identifier: GPL-3.0-only */
/* Generated web-asset data declarations. */
#pragma once
#include <stddef.h>
#include <stdint.h>
"""
source = """/* SPDX-License-Identifier: MIT */
/*
* Deterministically generated from the vendored MIT-licensed gzip files in
* web_assets/. See web_assets/SOURCES.md and the accompanying license files.
* Regenerate with: python3 web_assets/generate_embedded_assets.py
*/
#include "web_assets_data.h"
"""
for filename, symbol in ASSETS:
data = (ASSET_DIR / filename).read_bytes()
header += f"extern const uint8_t {symbol}[];\n"
header += f"extern const size_t {symbol}_size;\n\n"
source += (
f"const uint8_t {symbol}[] __attribute__((aligned(4))) = {{\n"
f"{format_array(data)}\n"
"};\n"
f"const size_t {symbol}_size = sizeof({symbol});\n\n"
)
HEADER_PATH.write_text(header, encoding="utf-8", newline="\n")
SOURCE_PATH.write_text(source, encoding="utf-8", newline="\n")
if __name__ == "__main__":
main()
+21
View File
@@ -0,0 +1,21 @@
Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.