From 3176ffdc4cce36b80dc2d71928f01c2a7b46d08a Mon Sep 17 00:00:00 2001 From: weidu12123 Date: Thu, 15 Oct 2026 01:47:00 +0800 Subject: [PATCH] fix: MCP bridge fallback, loopback-exempt API key, terminal reconnect, and probe status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stdio bridge now falls back to in-process handling unless the forward returns a real JSON-RPC response. A 401 from an API-key / data-dir mismatch used to be printed verbatim as the tool result, so clients never completed the handshake - MCP API key is enforced only when bound to a non-loopback address; on 127.0.0.1 it is exempt (local-first, like 1Shell). The remote toggle still governs HOST=0.0.0.0 - vps_terminal "key" action now passes through the command denylist; its fallback branch could previously carry arbitrary text and bypass the guard - vps_exec interactive-prompt early-exit now matches only explicit keywords, so ordinary commands whose last line ends in ':' / '?' are no longer aborted mid-run - terminal: add a "重新连接" button that recreates a pane in place after the session ends - file browser: downloads go through the resumable + SHA-256 transfer engine (default local path = Downloads, runs in background, non-blocking) - standalone terminal window: hide the dead 文件管理 / 详情图表 buttons - probe status: an SSH connection means online (probe failure no longer flips to red); a connect timeout maps to unknown instead of offline; latency now measures the connect only, not the probe - transfer: a resumed download fails instead of silently accepting when the remote hash is unavailable; secret.key is no longer overwritten when corrupt; a per-host lock serializes pane creation --- frontend/src/App.vue | 37 ++++++++ frontend/src/components/FileBrowserPanel.vue | 45 +++++++--- frontend/src/components/TerminalPaneView.vue | 34 ++++--- frontend/src/components/TerminalStage.vue | 4 + src/api.rs | 94 +++++++++++++++----- src/crypto.rs | 20 ++++- src/db.rs | 14 ++- src/files.rs | 12 +++ src/inspector.rs | 56 +++++++----- src/main.rs | 6 ++ src/mcp.rs | 64 +++++++++---- src/ssh.rs | 4 +- src/terminal.rs | 32 ++++++- 13 files changed, 328 insertions(+), 94 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e16746d..9a26ad7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -763,6 +763,8 @@ async function checkSingleHost(host: PublicHost) { } if (data.host.status === 'online') { showToast(`${data.host.name} 在线 (${data.host.latencyMs ?? 0} ms)`); + } else if (data.host.status === 'unknown') { + showToast(`${data.host.name} 连接超时,未能判定(可能只是线路慢)`, 'err'); } else { showToast(`${data.host.name} 离线`, 'err'); } @@ -1003,6 +1005,39 @@ async function handleClosePane(paneId: string) { } } +// 终端会话结束后原地重连:为该主机新建一个 pane 顶替退出那个的位置。 +// 先建后删——后端 create_pane 会顺手回收同主机已退出的旧 pane,且该主机没有活终端时 +// 会释放旧的终端连接、重新登录(横幅照常出现)。 +async function handleReconnectPane(paneId: string) { + const tab = openTabs.value.find((t) => t.panes.some((p) => p.id === paneId)); + if (!tab || paneCreationInFlight.has(tab.hostId)) return; + const idx = tab.panes.findIndex((p) => p.id === paneId); + const cwd = tab.panes[idx]?.cwd || undefined; + paneCreationInFlight.add(tab.hostId); + paneCreationCount.value += 1; + try { + const res = await fetch('/api/terminals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostId: tab.hostId, cols: 100, rows: 30, cwd: cwd || undefined }), + }); + const data = await res.json(); + if (!data.ok || !data.pane) { + showToast(data.error || '重连失败', 'err'); + return; + } + void fetch(`/api/terminals/${encodeURIComponent(paneId)}`, { method: 'DELETE' }); + const at = tab.panes.findIndex((p) => p.id === paneId); + tab.panes.splice(at >= 0 ? at : idx, 1, data.pane as PaneMeta); + showToast('终端已重新连接'); + } catch (e) { + showToast(`重连失败: ${(e as Error).message}`, 'err'); + } finally { + paneCreationInFlight.delete(tab.hostId); + paneCreationCount.value = Math.max(0, paneCreationCount.value - 1); + } +} + function handlePaneMetaUpdate(updated: PaneMeta) { for (const tab of openTabs.value) { const idx = tab.panes.findIndex((p) => p.id === updated.id); @@ -1292,6 +1327,7 @@ onBeforeUnmount(() => { :standalone="true" @split="handleSplitTerminal" @close-pane="handleClosePane" + @reconnect-pane="handleReconnectPane" @toggle-dark="toggleTheme" @meta-update="handlePaneMetaUpdate" /> @@ -2490,6 +2526,7 @@ onBeforeUnmount(() => { :standalone="false" @split="handleSplitTerminal" @close-pane="handleClosePane" + @reconnect-pane="handleReconnectPane" @toggle-dark="terminalDark = !terminalDark" @toggle-right-wing="toggleRightWing" @collapse-stage="switchToHomeView" diff --git a/frontend/src/components/FileBrowserPanel.vue b/frontend/src/components/FileBrowserPanel.vue index f3d2326..6b616e5 100644 --- a/frontend/src/components/FileBrowserPanel.vue +++ b/frontend/src/components/FileBrowserPanel.vue @@ -109,9 +109,9 @@ const editorContent = ref(''); const editorLoading = ref(false); const editorSaving = ref(false); -// 新建/重命名/删除确认条 +// 新建/重命名/删除确认条 / 下载落点 const inlinePrompt = ref<{ - kind: 'mkdir' | 'touch' | 'rename' | 'delete'; + kind: 'mkdir' | 'touch' | 'rename' | 'delete' | 'download'; title: string; value: string; target?: FileEntry; @@ -510,14 +510,8 @@ async function saveFileEditor() { function triggerDownload(item: FileEntry) { if (item.isDir) return; - ensureTransferPolling(); - const url = `/api/files/download?hostId=${encodeURIComponent(props.host.id)}&path=${encodeURIComponent(item.path)}`; - const a = document.createElement('a'); - a.href = url; - a.download = item.name; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + // 改走带断点续传的传输引擎(见 openInlineAction('download')),不再用浏览器直下 + openInlineAction('download', item); } function copyEntryPath(path: string) { @@ -578,7 +572,7 @@ function onDropFiles(e: DragEvent) { } function openInlineAction( - kind: 'mkdir' | 'touch' | 'rename' | 'delete', + kind: 'mkdir' | 'touch' | 'rename' | 'delete' | 'download', target?: FileEntry ) { if (kind === 'mkdir') { @@ -594,6 +588,15 @@ function openInlineAction( value: target.name, target, }; + } else if (kind === 'download' && target) { + // 走带断点续传 + SHA-256 校验的传输引擎(不再是浏览器直下、断了只能从头)。 + // 留空则落到系统下载目录。 + inlinePrompt.value = { + kind, + title: `下载「${target.name}」到本机路径(留空 = 系统下载目录)`, + value: '', + target, + }; } } @@ -670,6 +673,26 @@ async function submitInlineAction() { editorOpen.value = false; } emit('toast', `已删除 ${act.target.name}`); + } else if (act.kind === 'download' && act.target) { + const res = await fetch('/api/transfers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + hostId: props.host.id, + direction: 'download', + localPath: val, + remotePath: act.target.path, + }), + }); + const data = await res.json(); + if (!data.ok) throw new Error(data.error || '发起下载失败'); + inlinePrompt.value = null; + ensureTransferPolling(); + emit( + 'toast', + `开始下载到 ${data.localPath || '系统下载目录'}(队列中看进度;中断后重下会自动续传)` + ); + return; } inlinePrompt.value = null; invalidateCache(props.host.id, currentPath.value); diff --git a/frontend/src/components/TerminalPaneView.vue b/frontend/src/components/TerminalPaneView.vue index fb24919..9c24d58 100644 --- a/frontend/src/components/TerminalPaneView.vue +++ b/frontend/src/components/TerminalPaneView.vue @@ -18,6 +18,7 @@ const emit = defineEmits<{ focus: []; closePane: [paneId: string]; metaUpdate: [meta: PaneMeta]; + reconnect: [paneId: string]; }>(); const containerRef = ref(null); @@ -331,20 +332,31 @@ onBeforeUnmount(() => { - +
- 终端进程已退出 - + 终端会话已结束(远端断开或进程退出) +
+ + +
diff --git a/frontend/src/components/TerminalStage.vue b/frontend/src/components/TerminalStage.vue index 55c8a2c..1e1085c 100644 --- a/frontend/src/components/TerminalStage.vue +++ b/frontend/src/components/TerminalStage.vue @@ -16,6 +16,7 @@ const props = defineProps<{ const emit = defineEmits<{ split: [direction: 'row' | 'col']; closePane: [paneId: string]; + reconnectPane: [paneId: string]; toggleDark: []; toggleRightWing: [mode: 'files' | 'detail']; collapseStage: []; @@ -123,6 +124,7 @@ const activePane = computed(() => {