Update embedded app debugging workflow

This commit is contained in:
zoukai committed 2026-10-22 14:09:49 +08:00
1 parent d50be29246
commit 38807e5f97
13 files changed
+846 -122

No files matched your search

+70 -14
View File
@@ -53,6 +53,9 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
tab-size: 4;
user-select: text;
-webkit-user-select: text;
cursor: text;
font: 13px/1.42 Consolas, "Cascadia Mono", "Microsoft YaHei UI", monospace;
}
.console {
@@ -176,6 +179,8 @@
let sendBusy = false;
let promptMode = 'unknown';
let pageClosing = false;
let livenessConnection = null;
let selectingLog = false;
commandPromptView.textContent = `${configuredPort} >`;
@@ -184,6 +189,29 @@
return `${path}${separator}client=${encodeURIComponent(clientId)}`;
}
function connectLiveness() {
if (pageClosing || livenessConnection || typeof EventSource !== 'function') return;
// 页面在后台时 setInterval 可能被节流;独立的原生持续连接才是页面存活依据。
// 它只维护当前页面的租约,不打开串口、不发送命令,也不会重启已经退出的服务。
livenessConnection = new EventSource(`${clientUrl('/api/client/live')}&token=${encodeURIComponent(token)}`);
livenessConnection.onerror = () => {
if (!pageClosing) refreshStatus();
};
}
async function fetchLocal(path) {
const controller = new AbortController();
// 请求失败后必须放开 polling 标志;否则一个悬挂请求会让页面永久停止更新。
const timeout = setTimeout(() => controller.abort(), 2500);
try {
const response = await fetch(path, { cache: 'no-store', signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.arrayBuffer();
} finally {
clearTimeout(timeout);
}
}
// 仅清理终端显示控制字符,不修改设备输出中的普通调试内容。
function removeTerminalControls(text) {
text = text.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '');
@@ -250,42 +278,57 @@
updateInputControls();
}
function isLogSelectionActive() {
// 拖选开始时选区可能仍为空,也不能替换文本节点;否则拖选会被轮询打断。
if (selectingLog) return true;
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return false;
for (let index = 0; index < selection.rangeCount; index++) {
if (selection.getRangeAt(index).intersectsNode(logView)) return true;
}
return false;
}
async function refreshLog() {
if (logPolling) return;
if (pageClosing || logPolling) return;
logPolling = true;
const nearBottom = logView.scrollHeight - logView.scrollTop - logView.clientHeight < 100;
const shouldFollowLatest = followView.checked && (!hasRenderedLog || nearBottom);
try {
const response = await fetch(`${clientUrl('/api/log')}&t=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
const bytes = new Uint8Array(await fetchLocal(`${clientUrl('/api/log')}&t=${Date.now()}`));
const fullText = removeTerminalControls(decoder.decode(bytes));
updateSessionState(fullText);
const visibleText = fullText.length > 500000
? `[前面的日志已在页面中省略,完整数据仍保存在文件中]\n${fullText.slice(-500000)}`
: fullText;
logView.textContent = visibleText || `日志文件当前为空,正在等待 ${configuredPort} 数据…`;
// 只暂停页面绘制,不停串口采集、文件落盘或页面存活连接。
// 必须在请求完成后检查选区,防止等待响应期间用户刚开始选择文字。
if (isLogSelectionActive()) return;
const nextText = visibleText || `日志文件当前为空,正在等待 ${configuredPort} 数据…`;
if (logView.textContent === nextText) return;
const nearBottom = logView.scrollHeight - logView.scrollTop - logView.clientHeight < 100;
const shouldFollowLatest = followView.checked && (!hasRenderedLog || nearBottom);
logView.textContent = nextText;
// 首次打开必须显示最新日志;等待浏览器完成布局后再滚动到底部,避免大日志仍停在开头。
if (shouldFollowLatest) {
requestAnimationFrame(() => {
logView.scrollTop = logView.scrollHeight;
if (!isLogSelectionActive()) logView.scrollTop = logView.scrollHeight;
});
}
hasRenderedLog = true;
} catch (error) {
logView.textContent = `${logView.textContent}\n[网页] 日志读取失败:${error.message}`;
if (!isLogSelectionActive()) {
logView.textContent = `${logView.textContent}\n[网页] 日志读取失败:${error.message}`;
}
} finally {
logPolling = false;
}
}
async function refreshStatus() {
if (statusPolling) return;
if (pageClosing || statusPolling) return;
statusPolling = true;
try {
const response = await fetch(`${clientUrl('/api/status')}&t=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const status = await response.json();
const bytes = await fetchLocal(`${clientUrl('/api/status')}&t=${Date.now()}`);
const status = JSON.parse(decoder.decode(bytes));
applyConnectionState(Boolean(status.isOpen));
connectionView.textContent = status.isOpen ? `● ${status.port} 已连接` : `● ${status.port} 已断开`;
connectionView.style.color = status.isOpen ? 'var(--ok)' : 'var(--danger)';
@@ -343,6 +386,10 @@
function disconnectClient() {
if (pageClosing) return;
pageClosing = true;
if (livenessConnection) {
livenessConnection.close();
livenessConnection = null;
}
const disconnectUrl = `${clientUrl('/api/client/disconnect')}&token=${encodeURIComponent(token)}`;
// 浏览器关闭标签页时普通异步请求可能被直接取消;sendBeacon 专门用于离页通知。
// 某些内置浏览器未实现 sendBeacon,因此同时保留 keepalive fetch 作为兼容兜底。
@@ -369,10 +416,16 @@
});
sendButton.addEventListener('click', sendCommand);
logView.addEventListener('pointerdown', (event) => {
if (event.button === 0) selectingLog = true;
});
window.addEventListener('pointerup', () => { selectingLog = false; });
window.addEventListener('pointercancel', () => { selectingLog = false; });
window.addEventListener('blur', () => { selectingLog = false; });
followView.addEventListener('change', () => {
if (followView.checked) {
requestAnimationFrame(() => {
logView.scrollTop = logView.scrollHeight;
if (!isLogSelectionActive()) logView.scrollTop = logView.scrollHeight;
});
}
});
@@ -408,9 +461,12 @@
window.addEventListener('beforeunload', disconnectClient);
window.addEventListener('pageshow', () => {
pageClosing = false;
connectLiveness();
refreshLog();
refreshStatus();
});
connectLiveness();
refreshLog();
refreshStatus();
setInterval(refreshLog, 800);