/* SPDX-License-Identifier: GPL-3.0-only */ /* Offline browser UI response helpers for authenticated HTTPS routes. */ #include "web_ui.h" #include #include #include #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[] = "\n" "\n" "\n" "\n" "\n" "\n" "ESP32 Serial Console\n" "\n" "\n" "\n" "\n" "\n" "\n" "\n" "\n" "
\n" "
\n" "
SAK
" "

ESP32 Serial Console

Secure local serial workspace

\n" "Connecting\n" "
\n" "
\n" "
\n" "
Role" "Observer
\n" "
Broker client" "
\n" "
Active writer" "None
\n" "
Broker clients" "
\n" "
Wi-Fi" "Loading…
\n" "
Serial" "Loading…
\n" "
\n" "
\n" "
\n" "\n" "\n" "\n" "
\n" "

" "Observer mode — terminal input is disabled.

\n" "

" "Requesting a one-time connection ticket…

\n" "
\n" "
\n" "
\n" "
Live serial stream" "Binary, unmodified device output
\n" "
\n" "
\n" "
\n" "\n" "\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 lastFitWidth = 0;\n" "let lastFitHeight = 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 fitTerminal = () => {\n" " fitFrame = 0;\n" " const bounds = terminalHost.getBoundingClientRect();\n" " const width = Math.floor(bounds.width);\n" " const height = Math.floor(bounds.height);\n" " if (width < 1 || height < 1 || (width === lastFitWidth && height === lastFitHeight)) return;\n" " lastFitWidth = width;\n" " lastFitHeight = height;\n" " try {\n" " const dimensions = fitAddon.proposeDimensions();\n" " if (dimensions && dimensions.cols > 0 && dimensions.rows > 0 &&\n" " (dimensions.cols !== terminal.cols || dimensions.rows !== terminal.rows)) {\n" " terminal.resize(dimensions.cols, dimensions.rows);\n" " }\n" " } catch (_) {}\n" "};\n" "const scheduleFit = () => {\n" " if (fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n" "};\n" "const resizeObserver = 'ResizeObserver' in window ? new ResizeObserver(scheduleFit) : null;\n" "if (resizeObserver !== null) resizeObserver.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 (fitFrame !== 0) window.cancelAnimationFrame(fitFrame);\n" " if (resizeObserver !== null) resizeObserver.disconnect();\n" " window.removeEventListener('resize', scheduleFit);\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'; script-src-elem 'self'; " "style-src 'self' 'unsafe-inline'; img-src data:; 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; }