Files

259 lines
7.3 KiB
JavaScript
Raw Permalink Normal View History

2026-05-13 22:32:49 -05:00
import { Terminal } from "/vendor/xterm/xterm.mjs";
import { FitAddon } from "/vendor/xterm/addon-fit.mjs";
import { WebLinksAddon } from "/vendor/xterm/addon-web-links.mjs";
2026-03-05 00:04:26 -06:00
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
2026-04-13 17:15:06 -05:00
import { checkFeatureAccess } from "./feature-gate.js";
2026-05-13 22:32:49 -05:00
const containerEl = document.getElementById("terminal");
2026-03-05 00:04:26 -06:00
const statusPill = document.getElementById("status-pill");
const clientLabel = document.getElementById("client-label");
const hostLabel = document.getElementById("host-label");
const userLabel = document.getElementById("user-label");
const osLabel = document.getElementById("os-label");
2026-05-13 22:32:49 -05:00
const reconnectBtn = document.getElementById("reconnect-btn");
const clearBtn = document.getElementById("clear-btn");
2026-05-19 20:31:36 -05:00
const interruptBtn = document.getElementById("interrupt-btn");
2026-03-05 00:04:26 -06:00
const clientId = decodeURIComponent(
location.pathname.split("/").filter(Boolean)[0] || "",
);
const wsProto = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${wsProto}://${location.host}/api/clients/${encodeURIComponent(clientId)}/console/ws`;
2026-05-13 22:32:49 -05:00
2026-05-19 20:31:36 -05:00
const THEME = {
background: "#050913",
foreground: "#e8edf2",
cursor: "#6ee7b7",
cursorAccent: "#050913",
selectionBackground: "rgba(110, 231, 183, 0.25)",
black: "#1a1d29",
red: "#ef4444",
green: "#22c55e",
yellow: "#eab308",
blue: "#60a5fa",
magenta: "#c084fc",
cyan: "#22d3ee",
white: "#e8edf2",
brightBlack: "#475569",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#facc15",
brightBlue: "#93c5fd",
brightMagenta: "#d8b4fe",
brightCyan: "#67e8f9",
brightWhite: "#f8fafc",
};
2026-05-13 22:32:49 -05:00
2026-05-19 20:31:36 -05:00
let term = null;
let fit = null;
2026-03-05 00:04:26 -06:00
let ws = null;
let connected = false;
2026-05-13 22:32:49 -05:00
let sessionClosed = false;
let offlineNotified = false;
let lastSize = { cols: 0, rows: 0 };
let resizeTimer = 0;
2026-05-19 20:31:36 -05:00
let pendingSystem = [];
function isWindowsOs(os) {
return typeof os === "string" && /win/i.test(os);
}
function initTerminal(os) {
if (term) return;
const opts = {
cursorBlink: true,
fontFamily: '"JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',
fontSize: 13,
scrollback: 10000,
allowProposedApi: true,
theme: THEME,
};
if (isWindowsOs(os)) {
opts.windowsPty = { backend: "conpty", buildNumber: 22621 };
}
term = new Terminal(opts);
fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(containerEl);
term.onData((d) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (sessionClosed) return;
ws.send(encodeMsgpack({ type: "input", data: d }));
});
term.onResize(() => {
sendResize();
});
for (const msg of pendingSystem) term.writeln(msg);
pendingSystem.length = 0;
tryFit();
}
2026-03-05 00:04:26 -06:00
2026-05-13 22:32:49 -05:00
function tryFit() {
2026-05-19 20:31:36 -05:00
if (!fit) return;
2026-05-13 22:32:49 -05:00
try {
fit.fit();
} catch {
/* ignore: container not yet measured */
}
2026-03-05 00:04:26 -06:00
}
function setStatus(label, tone = "pill-offline") {
if (!statusPill) return;
statusPill.className = `pill ${tone}`;
2026-08-24 09:35:03 -05:00
statusPill.innerHTML = '<i class="fa-solid fa-circle"></i>';
statusPill.appendChild(document.createTextNode(` ${String(label || "")}`));
2026-03-05 00:04:26 -06:00
}
2026-05-13 22:32:49 -05:00
function writeSystem(msg) {
2026-05-19 20:31:36 -05:00
const line = `\x1b[2m[${msg}]\x1b[0m`;
if (term) {
term.writeln(line);
} else {
pendingSystem.push(line);
}
2026-03-05 00:04:26 -06:00
}
2026-05-13 22:32:49 -05:00
function sendResize() {
if (sessionClosed) return;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
2026-05-19 20:31:36 -05:00
if (!term) return;
2026-05-13 22:32:49 -05:00
const cols = term.cols;
const rows = term.rows;
if (!cols || !rows) return;
if (cols === lastSize.cols && rows === lastSize.rows) return;
lastSize = { cols, rows };
ws.send(encodeMsgpack({ type: "resize", cols, rows }));
2026-03-05 00:04:26 -06:00
}
2026-05-13 22:32:49 -05:00
function applyOutput(data) {
2026-05-19 20:31:36 -05:00
if (!data || !term) return;
2026-05-13 22:32:49 -05:00
if (data instanceof Uint8Array) {
term.write(data);
} else if (typeof data === "string") {
term.write(data);
} else if (data?.buffer instanceof ArrayBuffer) {
term.write(new Uint8Array(data.buffer, data.byteOffset ?? 0, data.byteLength));
}
2026-03-05 00:04:26 -06:00
}
function connect() {
2026-05-13 22:32:49 -05:00
sessionClosed = false;
setStatus("Connecting...", "pill-ghost");
writeSystem("Connecting...");
2026-03-05 00:04:26 -06:00
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.addEventListener("open", () => {
connected = true;
setStatus("Connected", "pill-online");
});
ws.addEventListener("message", (event) => {
const payload = decodeMsgpack(event.data);
if (!payload) return;
switch (payload.type) {
case "ready":
2026-05-19 20:31:36 -05:00
if (clientLabel) {
const full = payload.clientId || "unknown";
clientLabel.textContent = full.length > 7 ? full.slice(0, 7) + "…" : full;
clientLabel.title = full;
}
2026-03-05 00:04:26 -06:00
if (hostLabel) hostLabel.textContent = payload.host || "unknown";
if (userLabel) userLabel.textContent = payload.user || "unknown";
if (osLabel) osLabel.textContent = payload.os || "unknown";
2026-05-19 20:31:36 -05:00
initTerminal(payload.os);
if (term) term.options.disableStdin = false;
tryFit();
sendResize();
2026-05-13 22:32:49 -05:00
setStatus("Live", "pill-online");
2026-03-05 00:04:26 -06:00
break;
case "status":
if (payload.status === "offline") {
setStatus("Offline", "pill-offline");
2026-05-13 22:32:49 -05:00
if (!offlineNotified) {
offlineNotified = true;
writeSystem(payload.reason || "Client offline");
}
sessionClosed = true;
2026-05-19 20:31:36 -05:00
if (term) term.options.disableStdin = true;
2026-03-05 00:04:26 -06:00
} else if (payload.status === "closed") {
setStatus("Closed", "pill-offline");
2026-05-13 22:32:49 -05:00
writeSystem(payload.reason || "Console closed");
sessionClosed = true;
2026-05-19 20:31:36 -05:00
if (term) term.options.disableStdin = true;
2026-05-13 22:32:49 -05:00
} else if (payload.status === "online") {
offlineNotified = false;
2026-03-05 00:04:26 -06:00
}
break;
2026-05-13 22:32:49 -05:00
case "output":
applyOutput(payload.data);
if (payload.error) writeSystem(payload.error);
2026-03-05 00:04:26 -06:00
if (typeof payload.exitCode === "number") {
2026-05-19 20:31:36 -05:00
if (term) term.writeln(`\r\n\x1b[33m[Process exited (${payload.exitCode})]\x1b[0m`);
2026-03-05 00:04:26 -06:00
setStatus("Closed", "pill-offline");
2026-05-13 22:32:49 -05:00
sessionClosed = true;
2026-05-19 20:31:36 -05:00
if (term) term.options.disableStdin = true;
2026-03-05 00:04:26 -06:00
}
break;
default:
break;
}
});
ws.addEventListener("close", () => {
if (!connected) return;
connected = false;
setStatus("Disconnected", "pill-offline");
2026-05-13 22:32:49 -05:00
if (!sessionClosed) writeSystem("Connection closed");
2026-05-19 20:31:36 -05:00
if (term) term.options.disableStdin = true;
2026-03-05 00:04:26 -06:00
});
ws.addEventListener("error", () => {
setStatus("Error", "pill-offline");
});
}
2026-05-13 22:32:49 -05:00
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(tryFit, 120);
});
reconnectBtn?.addEventListener("click", () => {
try { ws?.close(); } catch { /* ignore */ }
2026-05-19 20:31:36 -05:00
if (term) term.reset();
lastSize = { cols: 0, rows: 0 };
2026-05-13 22:32:49 -05:00
connect();
});
clearBtn?.addEventListener("click", () => {
2026-05-19 20:31:36 -05:00
if (term) term.clear();
});
interruptBtn?.addEventListener("click", () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (sessionClosed) return;
ws.send(encodeMsgpack({ type: "input", data: "\x03" }));
if (term) term.focus();
2026-05-13 22:32:49 -05:00
});
requestAnimationFrame(() => {
checkFeatureAccess("console", clientId).then((ok) => ok && connect());
});
const prefilledCommand = new URLSearchParams(window.location.search).get("cmd");
if (prefilledCommand) {
setTimeout(() => {
if (ws?.readyState === WebSocket.OPEN && !sessionClosed) {
ws.send(encodeMsgpack({ type: "input", data: prefilledCommand + "\r" }));
2026-03-05 00:04:26 -06:00
}
2026-05-13 22:32:49 -05:00
}, 1200);
2026-03-05 00:04:26 -06:00
}