401 lines
12 KiB
C++
401 lines
12 KiB
C++
#pragma once
|
|
|
|
inline const char* scripts_js = R"rawliteral(
|
|
|
|
let socket = null;
|
|
let pendingEchoLines = 0;
|
|
let reconnectInterval = 1000; // ms
|
|
let responseTimeout = null;
|
|
let responseTimeoutDelay = 6000; // ms
|
|
let bridgeMode = false;
|
|
let fsTotalBytes = 0;
|
|
let fsUsedBytes = 0;
|
|
let isUploading = false;
|
|
const filePanel = document.getElementById("file-panel");
|
|
const filePanelOverlay = document.getElementById("file-panel-overlay");
|
|
|
|
/* =========================
|
|
WebSocket / Terminal
|
|
========================= */
|
|
|
|
function connectSocket() {
|
|
socket = new WebSocket("ws://" + window.location.host + "/ws");
|
|
|
|
socket.onopen = function () {
|
|
hideWsLostPopup();
|
|
bridgeMode = false;
|
|
pendingEchoLines = 0;
|
|
console.log("[WebSocket] Connected");
|
|
};
|
|
|
|
socket.onmessage = function (event) {
|
|
const output = document.getElementById("output");
|
|
const lines = event.data.split("\n");
|
|
|
|
if (event.data.includes("Bridge: Stopped by user.")) {
|
|
bridgeMode = false;
|
|
console.log("[WebSocket] Bridge mode exited.");
|
|
}
|
|
|
|
clearTimeout(responseTimeout);
|
|
hideWsLostPopup();
|
|
|
|
if (pendingEchoLines > 0) {
|
|
pendingEchoLines -= 1;
|
|
return;
|
|
}
|
|
|
|
output.value += lines.join("\n");
|
|
output.scrollTop = output.scrollHeight;
|
|
console.log("[WebSocket] Recv:", event.data);
|
|
};
|
|
|
|
socket.onerror = function (error) {
|
|
console.error("[WebSocket] Error:", error);
|
|
};
|
|
|
|
socket.onclose = function () {
|
|
console.warn("[WebSocket] Disconnected. Retrying in 1s...");
|
|
showWsLostPopup();
|
|
setTimeout(connectSocket, reconnectInterval);
|
|
};
|
|
}
|
|
|
|
function showWsLostPopup() {
|
|
if (bridgeMode) return;
|
|
const popup = document.getElementById("ws-lost-popup");
|
|
if (popup) popup.style.display = "block";
|
|
}
|
|
function hideWsLostPopup() {
|
|
const popup = document.getElementById("ws-lost-popup");
|
|
if (popup) popup.style.display = "none";
|
|
}
|
|
|
|
function sendCommand() {
|
|
const input = document.getElementById("command");
|
|
const output = document.getElementById("output");
|
|
const cmd = input.value.trim();
|
|
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
|
|
if (cmd === "bridge" || cmd === "keyboard") {
|
|
bridgeMode = true;
|
|
clearTimeout(responseTimeout);
|
|
hideWsLostPopup();
|
|
console.log("[WebSocket] Bridge Mode");
|
|
pendingEchoLines = cmd.length
|
|
socket.send(cmd + "\n");
|
|
input.value = "";
|
|
addToHistory(cmd);
|
|
output.value += cmd + "\n";
|
|
return;
|
|
}
|
|
|
|
clearTimeout(responseTimeout);
|
|
responseTimeout = setTimeout(() => {
|
|
console.warn("[WebSocket] No response after command.");
|
|
showWsLostPopup();
|
|
}, responseTimeoutDelay);
|
|
|
|
socket.send(cmd + "\n");
|
|
input.value = "";
|
|
|
|
if (!bridgeMode && !/^\d+$/.test(cmd)) {
|
|
output.value += cmd;
|
|
addToHistory(cmd);
|
|
pendingEchoLines = cmd.length;
|
|
} else {
|
|
pendingEchoLines = 0;
|
|
}
|
|
}
|
|
|
|
window.addEventListener("DOMContentLoaded", function () {
|
|
const input = document.getElementById("command");
|
|
const output = document.getElementById("output");
|
|
|
|
output.value =
|
|
` ____ _ _
|
|
| __ ) _ _ ___ _ __ (_)_ __ __ _| |_ ___
|
|
| _ \\| | | / __| | '_ \\| | '__/ _\` | __/ _ \\
|
|
| |_) | |_| \\__ \\ | |_) | | | | (_| | || __/
|
|
|____/ \\__,_|___/ | .__/|_|_| \\__,_|\\__\\___|
|
|
|_|
|
|
Version 1.3 Ready to board
|
|
|
|
Type 'mode' to start or 'help' for commands
|
|
|
|
HIZ> `;
|
|
|
|
input.addEventListener("keydown", function (event) {
|
|
if (event.key === "Enter") { event.preventDefault(); sendCommand(); return; }
|
|
if (event.key === "Escape") { if (bridgeMode) { event.preventDefault(); socket.send("\x1B"); } return; }
|
|
if (event.ctrlKey && event.key.toLowerCase() === "c") { if (bridgeMode) { event.preventDefault(); socket.send("\x03"); } return; }
|
|
if (event.key === "Tab") { if (bridgeMode) { event.preventDefault(); socket.send("\x09"); } return; }
|
|
if (event.ctrlKey && event.key.toLowerCase() === "d") { if (bridgeMode) { event.preventDefault(); socket.send("\x04"); } return; }
|
|
if (event.ctrlKey && event.key.toLowerCase() === "z") { if (bridgeMode) { event.preventDefault(); socket.send("\x1A"); } return; }
|
|
if (event.ctrlKey && event.key.toLowerCase() === "x") { if (bridgeMode) { event.preventDefault(); socket.send("\x18"); } return; }
|
|
});
|
|
|
|
// Files button
|
|
const filesBtn = document.getElementById("files-btn");
|
|
if (filesBtn) filesBtn.addEventListener("click", openFilePanel);
|
|
|
|
connectSocket();
|
|
});
|
|
|
|
/* =========================
|
|
Command History
|
|
========================= */
|
|
function addToHistory(cmd) {
|
|
if (!isValidCommand(cmd)) return;
|
|
const history = document.getElementById("history");
|
|
const last = history.firstChild;
|
|
if (last && last.textContent === cmd) return;
|
|
|
|
const btn = document.createElement("button");
|
|
const maxLength = 15;
|
|
const displayText = cmd.length > maxLength ? cmd.slice(0, maxLength - 3) + "..." : cmd;
|
|
btn.textContent = displayText;
|
|
btn.title = cmd;
|
|
btn.onclick = () => {
|
|
document.getElementById("command").value = cmd;
|
|
document.getElementById("command").focus();
|
|
};
|
|
history.insertBefore(btn, history.firstChild);
|
|
}
|
|
function isValidCommand(cmd) {
|
|
if (!cmd) return false;
|
|
if (cmd.length < 2) return false;
|
|
if (/^\d+$/.test(cmd)) return false;
|
|
return true;
|
|
}
|
|
|
|
/* =========================
|
|
File Panel
|
|
========================= */
|
|
|
|
function onOverlayClick(e) {
|
|
if (e.target === filePanelOverlay && !isUploading) closeFilePanel();
|
|
}
|
|
|
|
function openFilePanel() {
|
|
document.getElementById("file-panel-overlay").style.display = "block";
|
|
document.getElementById("file-panel").style.display = "flex";
|
|
filePanelOverlay.addEventListener("click", onOverlayClick);
|
|
refreshFileList();
|
|
}
|
|
|
|
function closeFilePanel() {
|
|
document.getElementById("file-panel-overlay").style.display = "none";
|
|
document.getElementById("file-panel").style.display = "none";
|
|
}
|
|
|
|
async function refreshFileList() {
|
|
const listEl = document.getElementById("file-list");
|
|
listEl.innerHTML = "<div class='fp-empty'>Loading...</div>";
|
|
try {
|
|
const r = await fetch("/littlefs/list?dir=/", { cache: "no-store" });
|
|
const j = await r.json();
|
|
fsTotalBytes = Number(j.total) || 0;
|
|
fsUsedBytes = Number(j.used) || 0;
|
|
|
|
// update space info
|
|
const totalKB = Math.floor(j.total / 1024);
|
|
const usedKB = Math.floor(j.used / 1024);
|
|
const percent = totalKB > 0 ? Math.round((usedKB / totalKB) * 100) : 0;
|
|
document.getElementById("fp-space").textContent =
|
|
`Total: ${totalKB} KB — Used: ${usedKB} KB (${percent}%)`;
|
|
|
|
// Get files
|
|
const entries = (j && Array.isArray(j.entries)) ? j.entries : [];
|
|
const files = entries.filter(e => !e.isDir);
|
|
if (files.length === 0) {
|
|
listEl.innerHTML = "<div class='fp-empty'>No files found.</div>";
|
|
} else {
|
|
renderFileList(files);
|
|
}
|
|
} catch (e) {
|
|
listEl.innerHTML = "<div class='fp-empty'>Failed to load.</div>";
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
function renderFileList(entries) {
|
|
const listEl = document.getElementById("file-list");
|
|
listEl.innerHTML = "";
|
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
listEl.innerHTML = "<div class='fp-empty'>No files yet.</div>";
|
|
return;
|
|
}
|
|
entries
|
|
.filter(e => !e.isDir)
|
|
.sort((a,b)=> a.name.localeCompare(b.name))
|
|
.forEach(e => {
|
|
const card = document.createElement("div");
|
|
card.className = "fp-item";
|
|
|
|
const icon = document.createElement("div");
|
|
icon.className = "fp-icon";
|
|
icon.textContent = "📄";
|
|
icon.title = e.name;
|
|
|
|
const name = document.createElement("div");
|
|
name.className = "fp-name";
|
|
name.textContent = e.name;
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "fp-meta";
|
|
meta.textContent = humanSize(e.size);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "fp-actions";
|
|
|
|
const dl = document.createElement("button");
|
|
dl.className = "fp-dl";
|
|
dl.textContent = "💾";
|
|
dl.title = "Download file";
|
|
dl.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
downloadFile(e.name);
|
|
};
|
|
|
|
const del = document.createElement("button");
|
|
del.className = "fp-del";
|
|
del.textContent = "🗑️";
|
|
del.title = "Delete file";
|
|
del.onclick = async (ev) => {
|
|
ev.stopPropagation();
|
|
await deleteFile(e.name);
|
|
};
|
|
|
|
actions.appendChild(dl);
|
|
actions.appendChild(del);
|
|
|
|
const topWrap = document.createElement("div");
|
|
topWrap.style.display = "flex";
|
|
topWrap.style.flexDirection = "column";
|
|
topWrap.style.gap = "4px";
|
|
topWrap.appendChild(icon);
|
|
topWrap.appendChild(name);
|
|
topWrap.appendChild(meta);
|
|
|
|
card.appendChild(topWrap);
|
|
card.appendChild(actions);
|
|
listEl.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function humanSize(n) {
|
|
const units = ["B","KB","MB","GB"];
|
|
let i = 0, s = Number(n);
|
|
while (s >= 1024 && i < units.length - 1) { s /= 1024; i++; }
|
|
return (i===0 ? s.toString() : s.toFixed(1)) + " " + units[i];
|
|
}
|
|
|
|
/* Upload */
|
|
|
|
function onDropOver(e) {
|
|
if (isUploading) return; // ignore
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
document.getElementById("dropzone").classList.add("drag");
|
|
}
|
|
function onDropLeave(e) {
|
|
e.preventDefault();
|
|
document.getElementById("dropzone").classList.remove("drag");
|
|
}
|
|
async function onDrop(e) {
|
|
e.preventDefault();
|
|
if (isUploading) return;
|
|
document.getElementById("dropzone").classList.remove("drag");
|
|
const files = e.dataTransfer.files;
|
|
if (!files || !files.length) return;
|
|
await uploadFile(files[0]);
|
|
}
|
|
|
|
async function onFileInput(e) {
|
|
const f = e.target.files && e.target.files[0];
|
|
if (!f) return;
|
|
await uploadFile(f);
|
|
e.target.value = "";
|
|
}
|
|
|
|
function disableDropArea(disabled) {
|
|
const dz = document.getElementById("dropzone");
|
|
const inp = document.getElementById("file-input");
|
|
if (!dz || !inp) return;
|
|
if (disabled) {
|
|
dz.classList.add("disabled");
|
|
inp.disabled = true;
|
|
} else {
|
|
dz.classList.remove("disabled");
|
|
inp.disabled = false;
|
|
}
|
|
}
|
|
|
|
/* Upload a single file */
|
|
|
|
async function uploadFile(file) {
|
|
const titleEl = document.getElementById("fp-header-title");
|
|
const originalTitle = titleEl.textContent;
|
|
|
|
// Free space check
|
|
const FREE_MARGIN = 1024; // 1 KB de marge
|
|
const freeBytes = Math.max(0, fsTotalBytes - fsUsedBytes - FREE_MARGIN);
|
|
|
|
// Size
|
|
if (file.size > freeBytes) {
|
|
const needKB = Math.ceil(file.size / 1024);
|
|
const freeKB = Math.floor(freeBytes / 1024);
|
|
alert(`Not enough space.\nNeeded: ${needKB} KB\nFree: ${freeKB} KB`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
isUploading = true;
|
|
disableDropArea(true);
|
|
titleEl.textContent = "⏳ Upload... Please wait";
|
|
|
|
const r = await fetch("/littlefs/upload?file=" + encodeURIComponent(file.name), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
body: file
|
|
});
|
|
|
|
if (!r.ok) throw new Error("Upload failed");
|
|
|
|
await refreshFileList();
|
|
} catch (e) {
|
|
alert("Upload error: " + e.message);
|
|
console.error(e);
|
|
} finally {
|
|
titleEl.textContent = originalTitle;
|
|
disableDropArea(false);
|
|
isUploading = false;
|
|
}
|
|
}
|
|
|
|
/* Download */
|
|
|
|
function downloadFile(name) {
|
|
// Content-Type binaire Content-Disposition: attachment
|
|
const url = "/littlefs/download?file=" + encodeURIComponent(name);
|
|
window.open(url, "_blank");
|
|
}
|
|
|
|
/* Delete */
|
|
|
|
async function deleteFile(name) {
|
|
if (!confirm(`Delete '${name}' ?`)) return;
|
|
try {
|
|
const url = "/littlefs/delete?file=" + encodeURIComponent(name);
|
|
const r = await fetch(url, { method: "DELETE" });
|
|
if (!r.ok) throw new Error("Delete failed");
|
|
await refreshFileList();
|
|
} catch (e) {
|
|
alert("Delete error: " + e.message);
|
|
console.error(e);
|
|
}
|
|
}
|
|
)rawliteral";
|