|
|
|
|
@ -28,6 +28,18 @@ from aiohttp import web, WSMsgType
|
|
|
|
|
|
|
|
|
|
LOG = logging.getLogger("dos_httpd")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _env_bool(name: str, default: bool) -> bool:
|
|
|
|
|
value = os.environ.get(name)
|
|
|
|
|
if value is None:
|
|
|
|
|
return default
|
|
|
|
|
value = value.strip().lower()
|
|
|
|
|
if value in {"1", "true", "yes", "on"}:
|
|
|
|
|
return True
|
|
|
|
|
if value in {"0", "false", "no", "off"}:
|
|
|
|
|
return False
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
HTML_PAGE = """<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
@ -36,12 +48,18 @@ HTML_PAGE = """<!DOCTYPE html>
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
|
|
|
|
<style>
|
|
|
|
|
@font-face {
|
|
|
|
|
font-family: "DOSVGA";
|
|
|
|
|
src: url("/assets/WebPlus_IBM_VGA_8x16.woff") format("woff");
|
|
|
|
|
font-display: swap;
|
|
|
|
|
}
|
|
|
|
|
:root, body { background: #1e1e1e; color: #f0f0f0; height: 100%; }
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
font-family: system-ui, sans-serif;
|
|
|
|
|
font-family: "DOSVGA", "Fira Code", "Cascadia Mono", "Hack", monospace;
|
|
|
|
|
line-height: 1;
|
|
|
|
|
}
|
|
|
|
|
header {
|
|
|
|
|
padding: 0.6rem 1rem;
|
|
|
|
|
@ -50,90 +68,274 @@ HTML_PAGE = """<!DOCTYPE html>
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
#terminal {
|
|
|
|
|
#workspace {
|
|
|
|
|
flex: 1 1 auto;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding: 32px 24px 40px;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
}
|
|
|
|
|
#terminal .xterm-viewport {
|
|
|
|
|
background-color: #000;
|
|
|
|
|
#terminal {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
background: rgb(12 12 12);
|
|
|
|
|
border: 1px solid #2b2b2b;
|
|
|
|
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
|
|
|
|
|
padding: 12px;
|
|
|
|
|
transform-origin: center center;
|
|
|
|
|
position: relative;
|
|
|
|
|
box-sizing: content-box;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
.crt-display {
|
|
|
|
|
filter: saturate(1.05) contrast(1.08) brightness(0.95);
|
|
|
|
|
--crt-line-step: 1;
|
|
|
|
|
}
|
|
|
|
|
.crt-display::before {
|
|
|
|
|
content: "";
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
background-image:
|
|
|
|
|
repeating-linear-gradient(
|
|
|
|
|
to bottom,
|
|
|
|
|
rgba(255, 255, 255, 0.06) 0px,
|
|
|
|
|
rgba(255, 255, 255, 0.06) calc(var(--crt-line-step) * 1px),
|
|
|
|
|
rgba(0, 0, 0, 0) calc(var(--crt-line-step) * 1px),
|
|
|
|
|
rgba(0, 0, 0, 0) calc(var(--crt-line-step) * 2px)
|
|
|
|
|
),
|
|
|
|
|
repeating-linear-gradient(
|
|
|
|
|
to right,
|
|
|
|
|
rgba(255, 255, 255, 0.025) 0px,
|
|
|
|
|
rgba(255, 255, 255, 0.025) calc(var(--crt-line-step) * 1px),
|
|
|
|
|
rgba(0, 0, 0, 0) calc(var(--crt-line-step) * 1px),
|
|
|
|
|
rgba(0, 0, 0, 0) calc(var(--crt-line-step) * 2px)
|
|
|
|
|
);
|
|
|
|
|
mix-blend-mode: screen;
|
|
|
|
|
opacity: 0.4;
|
|
|
|
|
animation: crt-scan 6s linear infinite;
|
|
|
|
|
}
|
|
|
|
|
.crt-display::after {
|
|
|
|
|
content: "";
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: -0.1%;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.08) 0%, rgba(0, 0, 0, 0.45) 70%, rgba(0, 0, 0, 0.75) 100%);
|
|
|
|
|
mix-blend-mode: multiply;
|
|
|
|
|
animation: crt-flicker 3s infinite;
|
|
|
|
|
}
|
|
|
|
|
@keyframes crt-scan {
|
|
|
|
|
0% { transform: translateY(-2%); }
|
|
|
|
|
100% { transform: translateY(2%); }
|
|
|
|
|
}
|
|
|
|
|
@keyframes crt-flicker {
|
|
|
|
|
0%, 100% { opacity: 0.35; }
|
|
|
|
|
45% { opacity: 0.45; }
|
|
|
|
|
50% { opacity: 0.3; }
|
|
|
|
|
55% { opacity: 0.4; }
|
|
|
|
|
90% { opacity: 0.33; }
|
|
|
|
|
}
|
|
|
|
|
#terminal .xterm {
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
height: 100% !important;
|
|
|
|
|
}
|
|
|
|
|
#terminal .xterm .xterm-helper-textarea {
|
|
|
|
|
width: 0 !important;
|
|
|
|
|
height: 0 !important;
|
|
|
|
|
}
|
|
|
|
|
#terminal .xterm-viewport,
|
|
|
|
|
#terminal .xterm-screen,
|
|
|
|
|
#terminal .xterm-rows,
|
|
|
|
|
#terminal .xterm-scroll-area {
|
|
|
|
|
width: 640px !important;
|
|
|
|
|
height: 400px !important;
|
|
|
|
|
padding: 0 !important;
|
|
|
|
|
}
|
|
|
|
|
.xterm-dom-renderer-owner-1 .xterm-bg-0 {
|
|
|
|
|
background-color: rgb(14 14 14) !important;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<header>SvarBox DOS Console</header>
|
|
|
|
|
<div id="terminal"></div>
|
|
|
|
|
<main id="workspace">
|
|
|
|
|
<div id="terminal"></div>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
<script type="module">
|
|
|
|
|
import { Terminal } from "https://cdn.jsdelivr.net/npm/xterm@5.3.0/+esm";
|
|
|
|
|
import { FitAddon } from "https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/+esm";
|
|
|
|
|
const CRT_ENABLED = __CRT_ENABLED__;
|
|
|
|
|
|
|
|
|
|
const term = new Terminal({
|
|
|
|
|
cursorBlink: true,
|
|
|
|
|
convertEol: true,
|
|
|
|
|
theme: {
|
|
|
|
|
background: "#000000",
|
|
|
|
|
foreground: "#f5f5f5"
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const fitAddon = new FitAddon();
|
|
|
|
|
(async () => {
|
|
|
|
|
const COLS = 80;
|
|
|
|
|
const ROWS = 25;
|
|
|
|
|
const CHAR_HEIGHT = 16;
|
|
|
|
|
const VIEWPORT_WIDTH = 640;
|
|
|
|
|
const VIEWPORT_HEIGHT = 400;
|
|
|
|
|
const BASE_VERTICAL_STRETCH = 1.2;
|
|
|
|
|
const TARGET_ROW_HEIGHT = VIEWPORT_HEIGHT / ROWS;
|
|
|
|
|
|
|
|
|
|
term.loadAddon(fitAddon);
|
|
|
|
|
term.open(document.getElementById("terminal"));
|
|
|
|
|
|
|
|
|
|
function fitLater() {
|
|
|
|
|
requestAnimationFrame(() => fitAddon.fit());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fitLater();
|
|
|
|
|
window.addEventListener("resize", fitLater);
|
|
|
|
|
|
|
|
|
|
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
|
|
|
const socket = new WebSocket(`${wsProtocol}://${window.location.host}/ws`);
|
|
|
|
|
const textDecoder = new TextDecoder("utf-8", { fatal: false });
|
|
|
|
|
|
|
|
|
|
function decodeBase64ToString(b64) {
|
|
|
|
|
const binary = atob(b64);
|
|
|
|
|
const bytes = new Uint8Array(binary.length);
|
|
|
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
|
|
|
bytes[i] = binary.charCodeAt(i);
|
|
|
|
|
const terminalHost = document.getElementById("terminal");
|
|
|
|
|
if (CRT_ENABLED) {
|
|
|
|
|
terminalHost.classList.add("crt-display");
|
|
|
|
|
} else {
|
|
|
|
|
terminalHost.classList.remove("crt-display");
|
|
|
|
|
}
|
|
|
|
|
return textDecoder.decode(bytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
socket.addEventListener("open", () => {
|
|
|
|
|
fitLater();
|
|
|
|
|
term.focus();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.addEventListener("message", (event) => {
|
|
|
|
|
try {
|
|
|
|
|
const payload = JSON.parse(event.data);
|
|
|
|
|
if (payload.type === "output") {
|
|
|
|
|
term.write(decodeBase64ToString(payload.data));
|
|
|
|
|
} else if (payload.type === "exit") {
|
|
|
|
|
term.write("\\r\\n[dos-shell exited]\\r\\n");
|
|
|
|
|
async function waitForDosvgaFont() {
|
|
|
|
|
if (!document.fonts || typeof document.fonts.load !== "function") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await Promise.race([
|
|
|
|
|
document.fonts.load(`${CHAR_HEIGHT}px "DOSVGA"`),
|
|
|
|
|
new Promise((resolve) => setTimeout(resolve, 2000)),
|
|
|
|
|
]);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.debug("DOSVGA font load race rejected", err);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("failed to process message", err);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.addEventListener("close", (event) => {
|
|
|
|
|
term.write(`\\r\\n[connection closed: ${event.code}]\\r\\n`);
|
|
|
|
|
});
|
|
|
|
|
function applyViewportClamp() {
|
|
|
|
|
terminalHost.style.width = `${VIEWPORT_WIDTH}px`;
|
|
|
|
|
terminalHost.style.height = `${VIEWPORT_HEIGHT}px`;
|
|
|
|
|
|
|
|
|
|
socket.addEventListener("error", (event) => {
|
|
|
|
|
console.error("websocket error", event);
|
|
|
|
|
term.write("\\r\\n[websocket error]\\r\\n");
|
|
|
|
|
});
|
|
|
|
|
const viewport = terminalHost.querySelector(".xterm-viewport");
|
|
|
|
|
if (viewport) {
|
|
|
|
|
viewport.style.width = `${VIEWPORT_WIDTH}px`;
|
|
|
|
|
viewport.style.height = `${VIEWPORT_HEIGHT}px`;
|
|
|
|
|
viewport.style.overflow = "hidden";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
term.onData((data) => {
|
|
|
|
|
socket.send(JSON.stringify({ type: "input", data }));
|
|
|
|
|
});
|
|
|
|
|
const scrollArea = terminalHost.querySelector(".xterm-scroll-area");
|
|
|
|
|
if (scrollArea) {
|
|
|
|
|
scrollArea.style.width = `${VIEWPORT_WIDTH}px`;
|
|
|
|
|
scrollArea.style.height = `${VIEWPORT_HEIGHT}px`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
term.onResize(({ cols, rows }) => {
|
|
|
|
|
socket.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
|
|
|
});
|
|
|
|
|
const screen = terminalHost.querySelector(".xterm-screen");
|
|
|
|
|
if (screen) {
|
|
|
|
|
screen.style.width = `${VIEWPORT_WIDTH}px`;
|
|
|
|
|
screen.style.height = `${VIEWPORT_HEIGHT}px`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rows = terminalHost.querySelector(".xterm-rows");
|
|
|
|
|
if (rows) {
|
|
|
|
|
rows.style.width = `${VIEWPORT_WIDTH}px`;
|
|
|
|
|
rows.style.height = `${VIEWPORT_HEIGHT}px`;
|
|
|
|
|
const rowDivs = rows.querySelectorAll("div");
|
|
|
|
|
rowDivs.forEach((div) => {
|
|
|
|
|
div.style.height = `${TARGET_ROW_HEIGHT}px`;
|
|
|
|
|
div.style.lineHeight = `${TARGET_ROW_HEIGHT}px`;
|
|
|
|
|
div.style.width = `${VIEWPORT_WIDTH}px`;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateScale() {
|
|
|
|
|
const maxWidth = window.innerWidth * 0.85;
|
|
|
|
|
const maxHeight = window.innerHeight * 0.85;
|
|
|
|
|
|
|
|
|
|
// reset transform to measure base stretched size
|
|
|
|
|
terminalHost.style.transform = `scaleY(${BASE_VERTICAL_STRETCH})`;
|
|
|
|
|
terminalHost.style.transformOrigin = "center center";
|
|
|
|
|
|
|
|
|
|
const rect = terminalHost.getBoundingClientRect();
|
|
|
|
|
const scaleForWidth = maxWidth / rect.width;
|
|
|
|
|
const scaleForHeight = maxHeight / rect.height;
|
|
|
|
|
const uniformScale = Math.min(scaleForWidth, scaleForHeight);
|
|
|
|
|
if (CRT_ENABLED) {
|
|
|
|
|
terminalHost.style.setProperty('--crt-line-step', (1 / uniformScale).toString());
|
|
|
|
|
} else {
|
|
|
|
|
terminalHost.style.removeProperty('--crt-line-step');
|
|
|
|
|
}
|
|
|
|
|
terminalHost.style.transform = `scale(${uniformScale}) scaleY(${BASE_VERTICAL_STRETCH})`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await waitForDosvgaFont();
|
|
|
|
|
|
|
|
|
|
const term = new Terminal({
|
|
|
|
|
cursorBlink: true,
|
|
|
|
|
convertEol: true,
|
|
|
|
|
fontFamily: "DOSVGA, Fira Code, Cascadia Mono, Hack, monospace",
|
|
|
|
|
fontSize: CHAR_HEIGHT,
|
|
|
|
|
lineHeight: 1,
|
|
|
|
|
cols: COLS,
|
|
|
|
|
rows: ROWS,
|
|
|
|
|
theme: {
|
|
|
|
|
background: "#000000",
|
|
|
|
|
foreground: "#f5f5f5"
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
term.open(terminalHost);
|
|
|
|
|
term.resize(COLS, ROWS);
|
|
|
|
|
applyViewportClamp();
|
|
|
|
|
updateScale();
|
|
|
|
|
|
|
|
|
|
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
|
|
|
const socket = new WebSocket(`${wsProtocol}://${window.location.host}/ws`);
|
|
|
|
|
const textDecoder = new TextDecoder("utf-8", { fatal: false });
|
|
|
|
|
|
|
|
|
|
function decodeBase64ToString(b64) {
|
|
|
|
|
const binary = atob(b64);
|
|
|
|
|
const bytes = new Uint8Array(binary.length);
|
|
|
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
|
|
|
bytes[i] = binary.charCodeAt(i);
|
|
|
|
|
}
|
|
|
|
|
return textDecoder.decode(bytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
socket.addEventListener("open", () => {
|
|
|
|
|
socket.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
|
|
|
|
term.focus();
|
|
|
|
|
applyViewportClamp();
|
|
|
|
|
updateScale();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.addEventListener("message", (event) => {
|
|
|
|
|
try {
|
|
|
|
|
const payload = JSON.parse(event.data);
|
|
|
|
|
if (payload.type === "output") {
|
|
|
|
|
term.write(decodeBase64ToString(payload.data));
|
|
|
|
|
} else if (payload.type === "exit") {
|
|
|
|
|
term.write("\\r\\n[dos-shell exited]\\r\\n");
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("failed to process message", err);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.addEventListener("close", (event) => {
|
|
|
|
|
term.write(`\\r\\n[connection closed: ${event.code}]\\r\\n`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.addEventListener("error", (event) => {
|
|
|
|
|
console.error("websocket error", event);
|
|
|
|
|
term.write("\\r\\n[websocket error]\\r\\n");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
term.onData((data) => {
|
|
|
|
|
socket.send(JSON.stringify({ type: "input", data }));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
term.onResize(({ cols, rows }) => {
|
|
|
|
|
socket.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
|
|
|
applyViewportClamp();
|
|
|
|
|
updateScale();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
term.onRender(() => {
|
|
|
|
|
applyViewportClamp();
|
|
|
|
|
updateScale();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
window.addEventListener("resize", () => {
|
|
|
|
|
applyViewportClamp();
|
|
|
|
|
updateScale();
|
|
|
|
|
}, { passive: true });
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
@ -308,13 +510,22 @@ class ShellSession:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def index_handler(_request: web.Request) -> web.StreamResponse:
|
|
|
|
|
return web.Response(text=HTML_PAGE, content_type="text/html", headers={"Cache-Control": "no-store"})
|
|
|
|
|
cfg: "AppConfig" = _request.app["config"]
|
|
|
|
|
page = HTML_PAGE.replace("__CRT_ENABLED__", "true" if cfg.crt_effect else "false")
|
|
|
|
|
return web.Response(text=page, content_type="text/html", headers={"Cache-Control": "no-store"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def health_handler(_request: web.Request) -> web.Response:
|
|
|
|
|
return web.Response(text="ok\n", content_type="text/plain")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def font_handler(request: web.Request) -> web.StreamResponse:
|
|
|
|
|
cfg: "AppConfig" = request.app["config"]
|
|
|
|
|
if not os.path.exists(cfg.font_path):
|
|
|
|
|
raise web.HTTPNotFound()
|
|
|
|
|
return web.FileResponse(path=cfg.font_path, headers={"Cache-Control": "public, max-age=86400"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
|
|
|
|
|
ws = web.WebSocketResponse(heartbeat=30)
|
|
|
|
|
await ws.prepare(request)
|
|
|
|
|
@ -347,13 +558,17 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AppConfig:
|
|
|
|
|
def __init__(self, user: str, shell_path: str, host: str, port: int, rows: int, cols: int):
|
|
|
|
|
FONT_PATH = "/usr/local/share/dos-httpd/WebPlus_IBM_VGA_8x16.woff"
|
|
|
|
|
|
|
|
|
|
def __init__(self, user: str, shell_path: str, host: str, port: int, rows: int, cols: int, crt_effect: bool):
|
|
|
|
|
self.user = user
|
|
|
|
|
self.shell_path = shell_path
|
|
|
|
|
self.host = host
|
|
|
|
|
self.port = port
|
|
|
|
|
self.default_rows = rows
|
|
|
|
|
self.default_cols = cols
|
|
|
|
|
self.font_path = self.FONT_PATH
|
|
|
|
|
self.crt_effect = crt_effect
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_argument_parser() -> argparse.ArgumentParser:
|
|
|
|
|
@ -378,6 +593,10 @@ def build_argument_parser() -> argparse.ArgumentParser:
|
|
|
|
|
choices=["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"],
|
|
|
|
|
help="Log verbosity",
|
|
|
|
|
)
|
|
|
|
|
default_crt = _env_bool("DOS_HTTP_CRT", True)
|
|
|
|
|
parser.set_defaults(crt_effect=default_crt)
|
|
|
|
|
parser.add_argument("--crt", dest="crt_effect", action="store_true", help="Enable CRT overlay")
|
|
|
|
|
parser.add_argument("--no-crt", dest="crt_effect", action="store_false", help="Disable CRT overlay")
|
|
|
|
|
return parser
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -392,6 +611,7 @@ async def create_app(cfg: AppConfig) -> web.Application:
|
|
|
|
|
[
|
|
|
|
|
web.get("/", index_handler),
|
|
|
|
|
web.get("/healthz", health_handler),
|
|
|
|
|
web.get("/assets/WebPlus_IBM_VGA_8x16.woff", font_handler),
|
|
|
|
|
web.get("/ws", websocket_handler),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
@ -410,6 +630,7 @@ def main() -> None:
|
|
|
|
|
port=args.port,
|
|
|
|
|
rows=args.rows,
|
|
|
|
|
cols=args.cols,
|
|
|
|
|
crt_effect=args.crt_effect,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
app = asyncio.run(create_app(cfg))
|
|
|
|
|
|