From 3dcf5ff205a72b0fb258ed621ab6d98e7370ca4a Mon Sep 17 00:00:00 2001 From: JunXiaoRuo <47996900+JunXiaoRuo@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:26:02 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E4=B8=8E=20VNC=20=E5=8D=A1=E9=A1=BF=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E7=BB=88=E7=AB=AF=E5=92=8C=20SFTP=20=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工作区标签切换与关闭改为增量激活、清理和工具栏同步,减少源码运行时的重复渲染。 - 增加 VNC 快速打开默认新窗口设置,探测成功后自动转移到独立窗口并关闭探测视图。 - 降低 VNC 剪贴板空闲轮询开销,补充桌面窗口资源标题,并完善远程辅助离线安装所需的本机授权范围。 - 修正终端多行粘贴写入 PTY 时的换行转换,避免 nano 等程序合并行。 - SFTP 编辑器检测并选择换行格式;Shell 脚本保存时统一 Unix LF、移除 UTF-8 BOM、补齐末尾换行,后端写入同步兜底。 - 更新中英文资源与运行时设置,并补充权限、SFTP 后端、工作区和 Electron UI 回归覆盖。 --- desktop/main.js | 6 +++ desktop/preload.js | 3 ++ public/app-docking.js | 30 +++++++++++--- public/app-linux-desktop.js | 13 +++++- public/app-remote-profiles.js | 3 +- public/app-remote-rdp.js | 6 +-- public/app-remote.js | 7 +++- public/app-settings-runtime.js | 18 ++++++++ public/app-settings.js | 4 ++ public/app-sftp.js | 61 +++++++++++++++++++++++++--- public/app-terminal-settings.js | 8 +++- public/app-terminal.js | 2 +- public/app-vnc-clipboard.js | 51 +++++++++++++++++++---- public/app-vnc-window.js | 5 ++- public/app-vnc.js | 15 ++++++- public/app-workspace.js | 33 ++++++++++----- public/locales/en-US/settings.json | 4 ++ public/locales/en-US/sftp.json | 2 + public/locales/zh-CN/settings.json | 4 ++ public/locales/zh-CN/sftp.json | 2 + public/sftp-open-worker.js | 31 +++++++++++++- scripts/desktop-startup-check.js | 2 + scripts/i18n-check.js | 2 +- scripts/regression-check.js | 4 +- scripts/remote-install-ui-check.js | 7 +++- scripts/remote-privilege-check.js | 5 +++ scripts/runtime-settings-check.js | 12 +++++- scripts/sftp-backend-check.js | 15 +++++++ scripts/ui-smoke-electron.js | 40 +++++++++++++++--- src/routes/sftp-transfer-routes.ts | 10 ++--- src/routes/storage-routes.ts | 2 + src/runtime-settings.ts | 8 +++- src/services/sftp-content-service.ts | 32 +++++++++++++-- 33 files changed, 380 insertions(+), 67 deletions(-) diff --git a/desktop/main.js b/desktop/main.js index f1e8ba1..1ae735d 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -2848,6 +2848,12 @@ function writeDesktopClipboardImage(value) { } function registerDesktopClipboardHandlers() { + ipcMain.on("terma:set-window-title", (event, value) => { + const window = desktopWindowForSender(event); + if (!window || window !== mainWindow || window.isDestroyed()) return; + const title = sanitizeAuxiliaryWindowTitle(String(value || PRODUCT_NAME), PRODUCT_NAME); + window.setTitle(title); + }); ipcMain.handle("terma:clipboard-read", event => { assertDesktopClipboardSender(event); return clipboard.readText(); diff --git a/desktop/preload.js b/desktop/preload.js index da921cc..1aea7be 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -13,6 +13,9 @@ contextBridge.exposeInMainWorld("termaDesktop", { setInterfaceLanguage(language) { if (language === "zh-CN" || language === "en-US") ipcRenderer.send("terma:set-interface-language", language); }, + setWindowTitle(title) { + ipcRenderer.send("terma:set-window-title", String(title || "Terma")); + }, readClipboardText() { return ipcRenderer.invoke("terma:clipboard-read"); }, diff --git a/public/app-docking.js b/public/app-docking.js index 4ba8371..6271ec7 100644 --- a/public/app-docking.js +++ b/public/app-docking.js @@ -522,11 +522,11 @@ function workspaceTabHtml(tab, pane) { return ``; } -renderTabs = function() { +renderTabs = function(options={}) { if (!workspaceDockElement()) return legacyWorkspaceApi.renderTabs(); if (typeof syncSftpTabTitles === "function") syncSftpTabTitles(); reconcileWorkspaceLayoutTabs(); - renderWorkspaceLayout(); + if (options.rebuildLayout !== false) renderWorkspaceLayout(); for (const pane of workspaceVisiblePanes()) { const paneElement = workspacePaneElement(pane.id); if (!paneElement) continue; @@ -550,6 +550,25 @@ renderTabs = function() { if (!window.restoringTabs) saveTabsState(); }; +function syncWorkspaceTabActivation(pane, key) { + for (const visiblePane of workspaceVisiblePanes()) { + const paneElement = workspacePaneElement(visiblePane.id); + if (!paneElement) continue; + paneElement.classList.toggle("focused", visiblePane.id === focusedPaneId); + for (const button of paneElement.querySelectorAll(".tabs .tab[data-tab-key]")) { + const active = button.dataset.tabKey === visiblePane.activeTabKey; + button.classList.toggle("active", active); + button.setAttribute("aria-selected", String(active)); + if (active) button.classList.remove("activity-info", "activity-success", "activity-error"); + } + } + const tab = tabs.find(item => item.key === key); + if (tab) tab.activityState = ""; + syncWorkspaceLegacyTabIds(); + revealWorkspaceTab(key); + if (!window.restoringTabs) saveTabsState(); +} + updateWorkspaceTabScrollControls = function(paneId=currentWorkspacePaneId()) { const pane = workspacePaneElement(paneId); const container = pane?.querySelector(".tabs"); @@ -818,8 +837,7 @@ activateTab = function(key) { activeTabKey = key; activeView = tab.viewName || tab.kind || "welcome"; if (typeof restoreSftpRuntimeForTab === "function" && tab.kind === "sftp") restoreSftpRuntimeForTab(tab.key); - renderTabs(); - revealWorkspaceTab(key); + syncWorkspaceTabActivation(pane, key); renderWorkspacePaneContent(pane.id); syncFocusedWorkspaceClasses(); syncWorkspaceToolbarPlacements(); @@ -969,11 +987,13 @@ closeTabsByKey = function(keys, anchorKey="") { trimWorkspacePaneTabHistory(pane); if (pane.activeTabKey) rememberWorkspacePaneTab(pane, pane.activeTabKey, pane.activeTabKey); } + const paneIdsBeforeNormalize = workspaceLeaves().map(pane => pane.id).join("\0"); normalizeWorkspaceLayoutAfterMutation(anchorPane?.id || focusedPaneId); + const paneIdsAfterNormalize = workspaceLeaves().map(pane => pane.id).join("\0"); const focusedPane = workspaceFindPane(focusedPaneId) || workspaceLeaves()[0]; focusedPaneId = focusedPane.id; activeTabKey = focusedPane.activeTabKey || ""; - renderTabs(); + renderTabs({rebuildLayout:paneIdsBeforeNormalize !== paneIdsAfterNormalize}); if (activeTabKey) { const tab = tabs.find(item => item.key === activeTabKey); activeView = tab?.viewName || tab?.kind || "welcome"; diff --git a/public/app-linux-desktop.js b/public/app-linux-desktop.js index 383f81e..fdd988b 100644 --- a/public/app-linux-desktop.js +++ b/public/app-linux-desktop.js @@ -433,7 +433,12 @@ async function installLinuxDesktop(desktopId, mode="", button=null) { if (!await confirmModal(message, operationTitle, tr("common:actions.install", {defaultValue:"安装"}), tr("common:actions.cancel", {defaultValue:"取消"}), true)) return null; let adminAuth = null; if (!linuxDesktopManagerState.diagnostics?.privileged) { - adminAuth = await requestRemoteAdminAuthorization(id, operationTitle); + const grantScope = normalizedMode === "local-offline" + ? "linux-desktop.install-local-offline" + : normalizedMode === "offline" + ? "linux-desktop.install-offline" + : "linux-desktop.install"; + adminAuth = await requestRemoteAdminAuthorization(id, operationTitle, grantScope); if (!adminAuth) return null; } const task = await api(`/api/connections/${id}/linux-desktop/install`, {method:"POST", body:JSON.stringify({desktop_id:desktopId, mode:normalizedMode, ...(adminAuth ? {admin_auth:adminAuth} : {})})}); @@ -473,7 +478,11 @@ async function uninstallLinuxDesktop(desktopId, button=null) { )) return null; let adminAuth = null; if (!linuxDesktopManagerState.diagnostics?.privileged) { - adminAuth = await requestRemoteAdminAuthorization(id, tr("remote:linux_desktop.uninstall_title", {defaultValue:"卸载 Linux 桌面"})); + adminAuth = await requestRemoteAdminAuthorization( + id, + tr("remote:linux_desktop.uninstall_title", {defaultValue:"卸载 Linux 桌面"}), + "linux-desktop.uninstall" + ); if (!adminAuth) return null; } const task = await api(`/api/connections/${id}/linux-desktop/uninstall`, {method:"POST", body:JSON.stringify({desktop_id:desktopId, ...(adminAuth ? {admin_auth:adminAuth} : {})})}); diff --git a/public/app-remote-profiles.js b/public/app-remote-profiles.js index bf477ef..5a2eda0 100644 --- a/public/app-remote-profiles.js +++ b/public/app-remote-profiles.js @@ -652,7 +652,8 @@ async function openRemoteDesktop(id, updateTab=true, showManagement=false) { if (embeddedVnc && existingVncSession?.presentation === "management") syncEmbeddedVncManagementControls(existingVncSession, activeView); const xdmcpDirectReady = !embeddedXdmcp || serverState?.ready_for_login || serverState?.management_available === false || Boolean(serverState?.error) || Boolean(serverState?.endpoint_probe?.ok); if (updateTab && remoteDesktopQuickOpen && clientLaunchable && xdmcpDirectReady && rdpEndpointReady && vncReadyForLaunch) { - if (embeddedVnc) await openEmbeddedVncDesktop(profile.id, key); + if (embeddedVnc && vncQuickOpenUsesNewWindow()) await openVncInNewWindow(profile.id, key, {closeDetectionTab:true}); + else if (embeddedVnc) await openEmbeddedVncDesktop(profile.id, key); else await launchRemoteDesktop(profile.id, key); } }); diff --git a/public/app-remote-rdp.js b/public/app-remote-rdp.js index 9aae405..e8a0ce7 100644 --- a/public/app-remote-rdp.js +++ b/public/app-remote-rdp.js @@ -313,16 +313,16 @@ async function installRdpServerImpl(profileId, key, button=null, mode="online") : tr("remote:rdp_status.confirm_online_install", {defaultValue:"将通过远端软件源在线安装 xrdp 和对应的 Xorg 后端。是否继续?"}); const operationTitle = tr("remote:rdp_status.install_action_title", {mode:modeLabel, defaultValue:`${modeLabel}安装 RDP 服务`}); if (!await confirmModal(message, operationTitle, tr("common:actions.install", {defaultValue:"安装"}), tr("common:actions.cancel", {defaultValue:"取消"}), true)) return null; + const action = normalizedMode === "local-offline" ? "install-local-offline" : normalizedMode === "offline" ? "install-offline" : "install"; const sourceId = Number(diagnostics.connection?.id || linuxDesktopManagerConnectionIdForProfile(profile) || 0); let adminAuth = null; if (diagnostics.privileged !== true) { if (!sourceId) return notify(tr("remote:rdp_status.ssh_management_missing", {defaultValue:"该 RDP 连接没有关联的 SSH 管理连接"}), "error"); - adminAuth = await requestRemoteAdminAuthorization(sourceId, operationTitle); + adminAuth = await requestRemoteAdminAuthorization(sourceId, operationTitle, `rdp.server.${action}`); if (!adminAuth) return null; } if (button && document.contains(button)) setButtonBusy(button, true, tr("common:auto.installing", {defaultValue:"安装中..."})); try { - const action = normalizedMode === "local-offline" ? "install-local-offline" : normalizedMode === "offline" ? "install-offline" : "install"; const result = await api(`/api/remote-profiles/${Number(profileId)}/rdp/server`, {method:"POST", body:JSON.stringify({action, ...(adminAuth ? {admin_auth:adminAuth} : {})})}); if (result.task) { const taskContainer = $("rdpServerState"); @@ -401,7 +401,7 @@ async function runRdpServerActionImpl(profileId, key, action, button=null) { let adminAuth = null; if (diagnostics.privileged !== true) { if (!sourceId) return notify(tr("remote:rdp_status.ssh_management_missing", {defaultValue:"该 RDP 连接没有关联的 SSH 管理连接"}), "error"); - adminAuth = await requestRemoteAdminAuthorization(sourceId, label); + adminAuth = await requestRemoteAdminAuthorization(sourceId, label, `rdp.server.${action}`); if (!adminAuth) return null; } if (button && document.contains(button)) setButtonBusy(button, true, busyRdpServerActionLabel(action)); diff --git a/public/app-remote.js b/public/app-remote.js index 71e5cb8..f9c7e0d 100644 --- a/public/app-remote.js +++ b/public/app-remote.js @@ -35,7 +35,8 @@ const vncSessions = new Map(); let vncFullscreenSessionKey = ""; let remoteDesktopRenderSerial = 0; const VNC_CLIPBOARD_POLL_INTERVAL_MS = 900; -const VNC_CLIPBOARD_IMAGE_POLL_INTERVAL_MS = 2600; +const VNC_CLIPBOARD_IMAGE_POLL_INTERVAL_MS = 8000; +const VNC_CLIPBOARD_IMAGE_INPUT_IDLE_MS = 1400; const VNC_CLIPBOARD_ECHO_GUARD_MS = 3000; const remoteAdminGrantCache = new Map(); let noVncRfbPromise = null; @@ -45,6 +46,10 @@ let linuxDesktopTaskLogView = {taskId:"", expanded:false, follow:true, scrollTop const linuxDesktopTaskMonitors = new Map(); let pendingRemoteGroupSelectValue = "默认分组"; +function vncQuickOpenUsesNewWindow() { + return runtimeSettings?.saved?.vnc_quick_open_new_window !== false; +} + function rdpServerActionKey(profileId) { return `rdp-server:${Number(profileId || 0)}`; } diff --git a/public/app-settings-runtime.js b/public/app-settings-runtime.js index 95fced7..bdbace1 100644 --- a/public/app-settings-runtime.js +++ b/public/app-settings-runtime.js @@ -110,6 +110,8 @@ function normalizeRuntimeSettingsResponse(value={}) { sftp_download_concurrency: Math.max(1, Math.min(8, Number(savedSource.sftp_download_concurrency) || 3)), sftp_upload_concurrency: Math.max(1, Math.min(8, Number(savedSource.sftp_upload_concurrency) || 3)), restore_workspace_tabs: savedSource.restore_workspace_tabs !== false, + remote_desktop_quick_open_enabled: savedSource.remote_desktop_quick_open_enabled === true, + vnc_quick_open_new_window: savedSource.vnc_quick_open_new_window !== false, workspace_toolbar_placement: normalizeWorkspaceToolbarPlacement(savedSource.workspace_toolbar_placement), saved: { ...savedSource, @@ -130,6 +132,8 @@ function normalizeRuntimeSettingsResponse(value={}) { sftp_upload_concurrency: Math.max(1, Math.min(8, Number(savedSource.sftp_upload_concurrency) || 3)), sftp_download_directory: String(savedSource.sftp_download_directory || ""), restore_workspace_tabs: savedSource.restore_workspace_tabs !== false, + remote_desktop_quick_open_enabled: savedSource.remote_desktop_quick_open_enabled === true, + vnc_quick_open_new_window: savedSource.vnc_quick_open_new_window !== false, workspace_toolbar_placement: normalizeWorkspaceToolbarPlacement(savedSource.workspace_toolbar_placement) }, effective: { @@ -151,6 +155,10 @@ async function loadRuntimeSettings(refreshUi=false) { runtimeSettingsCheck = null; try { runtimeSettings = normalizeRuntimeSettingsResponse(await api("/api/runtime-settings")); + const legacyQuickOpen = localStorage.getItem("remoteDesktopQuickOpen"); + remoteDesktopQuickOpen = legacyQuickOpen === null + ? runtimeSettings.saved.remote_desktop_quick_open_enabled === true + : legacyQuickOpen === "1"; await setTermaLanguage(runtimeSettings.saved.language, {render:false, emit:false}); } catch (error) { runtimeSettings = normalizeRuntimeSettingsResponse({error:error.message || tr("settings:auto.runtime_load_failed", {defaultValue:"监听配置加载失败"})}); @@ -584,15 +592,21 @@ async function saveWorkspaceSettings() { const vnc_fullscreen_toolbar = ["always", "never", "edge"].includes($("generalVncFullscreenToolbar")?.value) ? $("generalVncFullscreenToolbar").value : "always"; + const remote_desktop_quick_open_enabled = $("generalRemoteDesktopQuickOpen")?.checked === true; + const vnc_quick_open_new_window = $("generalVncQuickOpenNewWindow")?.checked !== false; const result = await api("/api/runtime-settings", { method:"PUT", body:JSON.stringify({ restore_workspace_tabs:input.checked, + remote_desktop_quick_open_enabled, + vnc_quick_open_new_window, workspace_toolbar_placement, vnc_fullscreen_toolbar }) }); runtimeSettings = normalizeRuntimeSettingsResponse({...runtimeSettings, ...result}); + remoteDesktopQuickOpen = runtimeSettings.saved.remote_desktop_quick_open_enabled === true; + localStorage.removeItem("remoteDesktopQuickOpen"); await setTermaLanguage(runtimeSettings.saved.language); inPane(() => { renderSettings(); @@ -605,6 +619,10 @@ async function saveWorkspaceSettings() { syncWorkspaceToolbarPlacementInputs(runtimeSettings?.saved?.workspace_toolbar_placement); const vncToolbar = $("generalVncFullscreenToolbar"); if (vncToolbar) vncToolbar.value = runtimeSettings?.saved?.vnc_fullscreen_toolbar || "always"; + const quickOpen = $("generalRemoteDesktopQuickOpen"); + if (quickOpen) quickOpen.checked = remoteDesktopQuickOpen; + const newWindow = $("generalVncQuickOpenNewWindow"); + if (newWindow) newWindow.checked = runtimeSettings?.saved?.vnc_quick_open_new_window !== false; }); notify(error.message || tr("settings:auto.workspace_save_failed", {defaultValue:"工作区设置保存失败"}), "error"); } finally { diff --git a/public/app-settings.js b/public/app-settings.js index 25cfa89..681d6d5 100644 --- a/public/app-settings.js +++ b/public/app-settings.js @@ -100,6 +100,10 @@ function renderSettings() {

${esc(tr("settings:auto.workspace"))}

${esc(tr("settings:auto.restore_tabs_hint"))}
+ +
${esc(tr("settings:auto.remote_desktop_quick_open_hint", {defaultValue:"终端、SFTP 和远程连接列表中的远程桌面入口使用同一设置。"}))}
+ +
${esc(tr("settings:auto.vnc_quick_open_new_window_hint", {defaultValue:"先进入探测界面,确认服务可用后打开独立 VNC 窗口并关闭探测标签。"}))}
${sftpTextEncodingOptions.map(([value,label]) => ``).join("")}
`; + modal.innerHTML = ``; modal.hidden = false; modal.onclick = null; let finished = false; @@ -1045,8 +1087,9 @@ function sftpTextModal(title, content, size=0, limit=5*1024*1024, encoding="utf8 }; $("sftpTextSave").onclick = () => { const value = getValue(); - if (!updateStats(true, value)) return notify(tr("sftp:editor.content_too_large", {limit:formatBytes(limit), defaultValue:`在线编辑内容不能超过 ${formatBytes(limit)}`}), "error"); - finish({action:"save", content:value, changed:contentModified, backup:$("sftpBackupBeforeSave").checked, encoding:$("sftpTextEncoding").value, persist_default:$("sftpPersistEncoding").checked}); + const prepared = prepareSftpEditorSave(title, value, $("sftpTextEncoding").value, $("sftpLineEnding").value); + if (!updateStats(true, prepared.content)) return notify(tr("sftp:editor.content_too_large", {limit:formatBytes(limit), defaultValue:`在线编辑内容不能超过 ${formatBytes(limit)}`}), "error"); + finish({action:"save", content:prepared.content, changed:contentModified || prepared.changed || scriptNeedsFormatRepair, backup:$("sftpBackupBeforeSave").checked, encoding:prepared.encoding, line_ending:prepared.lineEnding, normalized_script:prepared.unixScript, persist_default:$("sftpPersistEncoding").checked}); }; $("sftpTextClose").onclick = async () => { if (contentModified && !await confirmModal( @@ -1105,6 +1148,9 @@ async function previewSftpText(id, path) { const editorPromise = sftpTextModal(path, data.content || "", data.size || 0, data.limit || 50*1024*1024, data.encoding || "utf8", data.preferred_encoding || "auto", { editorKind:data.editor_kind || "ace", lineCount:data.line_count, + lineEnding:data.line_ending, + finalNewline:data.final_newline, + bom:data.bom, loadVersions:() => api(`/api/connections/${id}/sftp/versions?path=${encodeURIComponent(path)}&limit=10`).catch(() => ({versions:[]})), onReady:() => data.progress?.finish(tr("sftp:editor.opened", {size:formatBytes(data.size || 0), defaultValue:`已打开 · ${formatBytes(data.size || 0)}`})), loadVersion:async (version, versionEncoding) => { @@ -1121,14 +1167,17 @@ async function previewSftpText(id, path) { continue; } if (!next.changed && !(next.persist_default && data.preferred_encoding !== next.encoding)) return notify(tr("sftp:editor.no_changes", {defaultValue:"文件内容没有变化"}), "info"); - await api(`/api/connections/${id}/sftp/write`, {method:"POST", body:JSON.stringify({path, content:next.content, backup:next.backup, encoding:next.encoding, persist_default:next.persist_default})}); + const saved = await api(`/api/connections/${id}/sftp/write`, {method:"POST", body:JSON.stringify({path, content:next.content, backup:next.backup, encoding:next.encoding, line_ending:next.line_ending, persist_default:next.persist_default})}); const connection = connections.find(item => item.id === id); - if (connection && next.persist_default) connection.sftp_text_encoding = next.encoding; + const savedEncoding = saved?.encoding || next.encoding; + if (connection && next.persist_default) connection.sftp_text_encoding = savedEncoding; if (typeof queueSftpDirectoryRefresh === "function") { queueSftpDirectoryRefresh(id); flushPendingSftpDirectoryRefresh(); } - notify(tr("sftp:editor.saved_with_encoding", {encoding:sftpTextEncodingLabel(next.encoding), defaultValue:`文件已按 ${sftpTextEncodingLabel(next.encoding)} 保存`}), "success"); + notify(saved?.normalized_script || next.normalized_script + ? tr("sftp:editor.saved_shell_script", {encoding:sftpTextEncodingLabel(savedEncoding), defaultValue:`脚本已按 ${sftpTextEncodingLabel(savedEncoding)}、Unix LF、无 BOM 保存`}) + : tr("sftp:editor.saved_with_encoding", {encoding:sftpTextEncodingLabel(savedEncoding), defaultValue:`文件已按 ${sftpTextEncodingLabel(savedEncoding)} 保存`}), "success"); return; } } catch (error) { diff --git a/public/app-terminal-settings.js b/public/app-terminal-settings.js index c8a3c36..2481b5c 100644 --- a/public/app-terminal-settings.js +++ b/public/app-terminal-settings.js @@ -215,6 +215,10 @@ function terminalSingleLinePaste(text) { return String(text || "").replace(/\r\n?/g, "\n").split("\n").map(line => line.trim()).filter(Boolean).join(" "); } +function terminalPasteInput(text) { + return String(text || "").replace(/\r\n|\r|\n/g, "\r"); +} + function editTerminalMultilinePaste(initialText) { return new Promise(resolve => { const modal = $("modal"); @@ -268,7 +272,7 @@ async function sendTerminalPasteText(key, text) { const lineCount = normalized.split("\n").length; const mode = currentTerminalGlobalSettings().multiline_paste_mode; if (lineCount <= 1 || mode === "paste") { - return sendTerminalData(key, value, {trackCommand:true}); + return sendTerminalData(key, terminalPasteInput(value), {trackCommand:true}); } if (mode === "single_line") { return sendTerminalData(key, terminalSingleLinePaste(value), {trackCommand:true}); @@ -283,7 +287,7 @@ async function sendTerminalPasteText(key, text) { focusTerminalSession(key); return false; } - return sendTerminalData(key, edited, {trackCommand:true}); + return sendTerminalData(key, terminalPasteInput(edited), {trackCommand:true}); } function terminalOpenLink(text) { diff --git a/public/app-terminal.js b/public/app-terminal.js index 7ca564e..f1cec2c 100644 --- a/public/app-terminal.js +++ b/public/app-terminal.js @@ -297,7 +297,7 @@ function openTerminalConnection(c, updateTab=true, existingKey="", existingTitle const reconnectShortText = tr("terminal:toolbar.reconnect_short", {defaultValue:"重连"}); const commandPlaceholder = tr("terminal:toolbar.command_placeholder", {defaultValue:"输入命令"}); const sendText = tr("terminal:toolbar.send_command", {defaultValue:"发送命令"}); - terminalView.innerHTML = `
${esc(connectionAddress)}
${quick ? `${esc(tr("terminal:toolbar.temporary", {defaultValue:"临时"}))}` : ""}${terminalLatencyHtml(key)}
${savedConnectionActions}${terminalFontSizeForCurrentLayout(c)}px${savedDisplayActions}${quickCommandButton}${forwardListButton}${forwardButton}
${renderTerminalKeys(key)}
${quickCommandBar}
`; + terminalView.innerHTML = `
${esc(connectionAddress)}
${quick ? `${esc(tr("terminal:toolbar.temporary", {defaultValue:"临时"}))}` : ""}${terminalLatencyHtml(key)}
${savedConnectionActions}${terminalFontSizeForCurrentLayout(c)}px${savedDisplayActions}${quickCommandButton}${forwardListButton}${forwardButton}
${renderTerminalKeys(key)}
${quickCommandBar}
`; const sftpToolbarButton = terminalView.querySelector(".terminal-action-sftp"); if (sftpToolbarButton) { sftpToolbarButton.innerHTML = `${icon("folder-sync")}SFTP`; diff --git a/public/app-vnc-clipboard.js b/public/app-vnc-clipboard.js index 233b338..bc41a9e 100644 --- a/public/app-vnc-clipboard.js +++ b/public/app-vnc-clipboard.js @@ -395,7 +395,11 @@ async function installVncClipboardHelper(key, mode="online", button=null) { await persistVncClipboardSshSelection(session, connectionId); session.clipboardHelperModalFinish?.({installing:true}); if (!diagnostics.root) { - adminAuth = await requestRemoteAdminAuthorization(sourceId, tr("remote:clipboard.install_unicode_action", {mode:modeLabel, defaultValue:`${modeLabel}安装 Unicode 剪贴板辅助工具`})); + adminAuth = await requestRemoteAdminAuthorization( + sourceId, + tr("remote:clipboard.install_unicode_action", {mode:modeLabel, defaultValue:`${modeLabel}安装 Unicode 剪贴板辅助工具`}), + `vnc.clipboard-helper.${action}` + ); if (!adminAuth) return; } const result = await api(`/api/remote-profiles/${session.profile.id}/vnc-clipboard/helper`, {method:"POST", body:JSON.stringify({action, connection_id:connectionId, ...(adminAuth ? {admin_auth:adminAuth} : {})})}); @@ -456,7 +460,11 @@ async function uninstallVncClipboardHelper(key, button=null) { ); if (!confirmed) return null; if (!diagnostics.root) { - adminAuth = await requestRemoteAdminAuthorization(sourceId, tr("remote:clipboard.uninstall_unicode_action", {defaultValue:"卸载 Unicode 剪贴板辅助工具"})); + adminAuth = await requestRemoteAdminAuthorization( + sourceId, + tr("remote:clipboard.uninstall_unicode_action", {defaultValue:"卸载 Unicode 剪贴板辅助工具"}), + "vnc.clipboard-helper.uninstall" + ); if (!adminAuth) return null; } const result = await api(`/api/remote-profiles/${session.profile.id}/vnc-clipboard/helper`, {method:"POST", body:JSON.stringify({action:"uninstall", connection_id:connectionId, ...(adminAuth ? {admin_auth:adminAuth} : {})})}); @@ -523,10 +531,13 @@ async function configureVncClipboardSsh(key) { function stopVncClipboardPolling(session) { if (!session) return; + session.clipboardPollGeneration = Number(session.clipboardPollGeneration || 0) + 1; if (session.clipboardPollTimer) clearInterval(session.clipboardPollTimer); - if (session.clipboardImagePollTimer) clearInterval(session.clipboardImagePollTimer); + if (session.clipboardImagePollTimer) clearTimeout(session.clipboardImagePollTimer); + if (session.clipboardImageIdleHandle && typeof cancelIdleCallback === "function") cancelIdleCallback(session.clipboardImageIdleHandle); session.clipboardPollTimer = null; session.clipboardImagePollTimer = null; + session.clipboardImageIdleHandle = null; } function resetVncClipboardBridgeWriteState(session) { @@ -1036,18 +1047,40 @@ async function syncVncClipboardFromLocal(session) { function startVncClipboardPolling(session) { stopVncClipboardPolling(session); if (!session?.clipboardAutoSync || !session.connected) return; + const generation = Number(session.clipboardPollGeneration || 0); void ensureVncClipboardTransport(session); session.clipboardPollTimer = setInterval(() => { void syncVncClipboardFromLocal(session); void pollVncRemoteClipboardBridge(session); }, VNC_CLIPBOARD_POLL_INTERVAL_MS); - if (session.clipboardAutoSyncImages) { - const pollImages = () => { - void syncVncClipboardImageFromLocal(session).then(sent => sent ? false : pollVncRemoteClipboardImageBridge(session)); + if (session.clipboardAutoSyncImages) scheduleVncClipboardImagePoll(session, VNC_CLIPBOARD_IMAGE_POLL_INTERVAL_MS, generation); +} + +function scheduleVncClipboardImagePoll(session, delay=VNC_CLIPBOARD_IMAGE_POLL_INTERVAL_MS, generation=Number(session?.clipboardPollGeneration || 0)) { + if (!session?.clipboardAutoSyncImages || !session.clipboardAutoSync || !session.connected || generation !== Number(session.clipboardPollGeneration || 0)) return; + if (session.clipboardImagePollTimer) clearTimeout(session.clipboardImagePollTimer); + session.clipboardImagePollTimer = setTimeout(() => { + session.clipboardImagePollTimer = null; + const recentlyActiveFor = Date.now() - Number(session.lastInteractionAt || 0); + if (recentlyActiveFor < VNC_CLIPBOARD_IMAGE_INPUT_IDLE_MS) { + scheduleVncClipboardImagePoll(session, VNC_CLIPBOARD_IMAGE_INPUT_IDLE_MS - recentlyActiveFor, generation); + return; + } + const run = async () => { + session.clipboardImageIdleHandle = null; + try { + const sent = await syncVncClipboardImageFromLocal(session); + if (!sent) await pollVncRemoteClipboardImageBridge(session); + } finally { + scheduleVncClipboardImagePoll(session, VNC_CLIPBOARD_IMAGE_POLL_INTERVAL_MS, generation); + } }; - pollImages(); - session.clipboardImagePollTimer = setInterval(pollImages, VNC_CLIPBOARD_IMAGE_POLL_INTERVAL_MS); - } + if (typeof requestIdleCallback === "function") { + session.clipboardImageIdleHandle = requestIdleCallback(() => { void run(); }, {timeout:2500}); + } else { + void run(); + } + }, Math.max(0, Number(delay || 0))); } async function pollVncRemoteClipboardBridge(session, force=false) { diff --git a/public/app-vnc-window.js b/public/app-vnc-window.js index acb7ee3..a6b5e4a 100644 --- a/public/app-vnc-window.js +++ b/public/app-vnc-window.js @@ -40,7 +40,7 @@ async function closeVncDetachedWindowForProfile(profileId) { return {ok:true, profileId:id, closed:true}; } -async function openVncInNewWindow(profileId, key="") { +async function openVncInNewWindow(profileId, key="", options={}) { const id = Number(profileId || 0); if (!Number.isInteger(id) || id <= 0) return notify(tr("remote:vnc_ui.detached_profile_missing", {defaultValue:"VNC 连接不存在"}), "error"); try { @@ -48,6 +48,7 @@ async function openVncInNewWindow(profileId, key="") { if (window.termaDesktop?.openVncWindow) { await window.termaDesktop.openVncWindow(id, {key}); await managementReady; + if (options.closeDetectionTab && key && tabs.some(tab => tab.key === key)) closeTabsByKey([key], key); return true; } const url = new URL(location.href); @@ -56,12 +57,14 @@ async function openVncInNewWindow(profileId, key="") { if (child && !child.closed) { child.focus(); await managementReady; + if (options.closeDetectionTab && key && tabs.some(tab => tab.key === key)) closeTabsByKey([key], key); return true; } child = window.open(url.href, `terma-vnc-${id}`, "popup,width=1280,height=820"); if (!child) throw new Error(tr("remote:vnc_ui.detached_open_failed", {defaultValue:"无法打开新窗口,请检查浏览器弹窗权限"})); browserDetachedVncWindows.set(id, child); await managementReady; + if (options.closeDetectionTab && key && tabs.some(tab => tab.key === key)) closeTabsByKey([key], key); return true; } catch (error) { notify(window.termaDesktop?.openVncWindow diff --git a/public/app-vnc.js b/public/app-vnc.js index 61215b8..a4c9991 100644 --- a/public/app-vnc.js +++ b/public/app-vnc.js @@ -571,7 +571,7 @@ async function runVncServerActionImpl(profileId, key, action, button=null) { if (!await confirmModal(message, actionLabel, confirmLabel, tr("remote:vnc_ui.cancel", {defaultValue:"取消"}), uninstall || stop)) return null; let adminAuth = null; if (diagnostics.privileged !== true) { - adminAuth = await requestRemoteAdminAuthorization(sourceId, actionLabel); + adminAuth = await requestRemoteAdminAuthorization(sourceId, actionLabel, `vnc.server.${action}`); if (!adminAuth) return null; } if (button) setButtonBusy(button, true, isInstall ? tr("remote:vnc_ui.installing_mode", {mode:installModeLabel, defaultValue:`${installModeLabel}安装中...`}) : uninstall ? tr("remote:vnc_ui.uninstalling", {defaultValue:"卸载中..."}) : stop ? tr("remote:vnc_ui.stopping", {defaultValue:"停止中..."}) : tr("remote:vnc_ui.starting", {defaultValue:"启动中..."})); @@ -705,6 +705,7 @@ function renderEmbeddedVnc(profile, key, diagnostics=null, targetView=null, deta session.remotePlatform = diagnostics?.platform || diagnostics?.os_id || (["macos", "darwin"].includes(sourcePlatform) ? "macos" : "") || session.remotePlatform || ""; session.viewport = session.workspace.querySelector("#vncViewport"); session.screen = session.workspace.querySelector(".vnc-screen") || session.screen; + bindVncInteractionTracking(session); applyVncDisplayMode(session); applyVncCursorPolicy(session); session.status = session.workspace.querySelector("#vncStatus"); @@ -814,6 +815,7 @@ function renderEmbeddedVnc(profile, key, diagnostics=null, targetView=null, deta session.viewport = viewport; session.help = viewport.querySelector("#vncConnectionHelp"); viewport.appendChild(session.screen); + bindVncInteractionTracking(session); applyVncDisplayMode(session); if (session.helpState) showVncConnectionHelp(session, session.helpState.serviceAvailable, session.helpState.detail, session.helpState.diagnostics); vncSessionStatus(session, session.statusText || tr("remote:vnc_ui.connecting_endpoint", {endpoint:remoteProfileEndpoint(profile), defaultValue:`正在连接 ${remoteProfileEndpoint(profile)}`}), session.statusState || "connecting"); @@ -822,6 +824,17 @@ function renderEmbeddedVnc(profile, key, diagnostics=null, targetView=null, deta if (!session.rfb && !session.connecting) connectEmbeddedVnc(profile, key); } +function bindVncInteractionTracking(session) { + const screen = session?.screen; + if (!screen || session.interactionTrackingScreen === screen) return; + session.interactionTrackingScreen = screen; + const markInteraction = () => { session.lastInteractionAt = Date.now(); }; + screen.addEventListener("pointerdown", markInteraction, {passive:true}); + screen.addEventListener("pointermove", markInteraction, {passive:true}); + screen.addEventListener("wheel", markInteraction, {passive:true}); + screen.addEventListener("keydown", markInteraction); +} + function syncEmbeddedVncManagementControls(session, view=$("view-remote-desktop")) { if (!session || !view) return; const launchButton = view.querySelector("#remoteDesktopLaunchButton"); diff --git a/public/app-workspace.js b/public/app-workspace.js index 10b2cfe..5be29a7 100644 --- a/public/app-workspace.js +++ b/public/app-workspace.js @@ -815,7 +815,10 @@ function syncWorkspaceDocumentTitle(title, subtitle, viewName, key=viewName, met "remote-desktop":protocol || tr("remote:auto.remote_desktop", {defaultValue:"远程桌面"}) }[kind] || ""; const endpoint = workspaceDocumentEndpoint(subtitle || tab.subtitle || ""); - document.title = label && endpoint ? `Terma · ${endpoint} · ${label}` : "Terma"; + const resource = String(title || tab.title || "").trim(); + const parts = ["Terma", endpoint, label, resource && resource !== endpoint && resource !== label ? resource : ""].filter(Boolean); + document.title = parts.join(" · "); + window.termaDesktop?.setWindowTitle?.(document.title); } function setWorkspace(title, subtitle, viewName, key=viewName, updateTab=true, closable=true, meta={}) { @@ -1103,15 +1106,25 @@ function renderExplorerTools() { `; } -function toggleRemoteDesktopQuickOpen() { - remoteDesktopQuickOpen = !remoteDesktopQuickOpen; - localStorage.setItem("remoteDesktopQuickOpen", remoteDesktopQuickOpen ? "1" : "0"); - renderExplorerTools(); - notify(tr(remoteDesktopQuickOpen ? "remote:auto.quick_open_enabled_notice" : "remote:auto.quick_open_disabled_notice", { - defaultValue:remoteDesktopQuickOpen - ? "已开启快捷打开:远程桌面探测通过后会自动启动" - : "已关闭快捷打开:远程桌面默认停留在探测界面" - }), "info"); +async function toggleRemoteDesktopQuickOpen() { + const nextValue = !remoteDesktopQuickOpen; + try { + const result = await api("/api/runtime-settings", { + method:"PUT", + body:JSON.stringify({remote_desktop_quick_open_enabled:nextValue}) + }); + runtimeSettings = normalizeRuntimeSettingsResponse({...runtimeSettings, ...result}); + remoteDesktopQuickOpen = runtimeSettings.saved.remote_desktop_quick_open_enabled === true; + localStorage.removeItem("remoteDesktopQuickOpen"); + renderExplorerTools(); + notify(tr(remoteDesktopQuickOpen ? "remote:auto.quick_open_enabled_notice" : "remote:auto.quick_open_disabled_notice", { + defaultValue:remoteDesktopQuickOpen + ? "已开启快捷打开:远程桌面探测通过后会自动启动" + : "已关闭快捷打开:远程桌面默认停留在探测界面" + }), "info"); + } catch (error) { + notify(error.message || tr("settings:auto.workspace_save_failed", {defaultValue:"工作区设置保存失败"}), "error"); + } } function showConnectionExplorerMenu(event) { diff --git a/public/locales/en-US/settings.json b/public/locales/en-US/settings.json index c3dd5f4..38dd720 100644 --- a/public/locales/en-US/settings.json +++ b/public/locales/en-US/settings.json @@ -212,6 +212,10 @@ "terminal_latency_hint": "Enabled by default. Latency starts when an actual key is sent and ends when the remote terminal first returns data. No probe commands are sent, and Tab completion is never triggered. This setting is stored on the current device.", "restore_tabs": "Restore tabs left open last time", "restore_tabs_hint": "Enabled by default. Restarting Terma restores all open terminal, SFTP, tunnel, settings, logs, import, and export tabs.", + "remote_desktop_quick_open": "Open Remote Desktop after detection succeeds", + "remote_desktop_quick_open_hint": "Remote Desktop entries in Terminal, SFTP, and the remote connection list use the same setting.", + "vnc_quick_open_new_window": "Use a separate window for VNC quick open", + "vnc_quick_open_new_window_hint": "Open the detection view first, then open a separate VNC window and close the detection tab after the service is ready.", "action_location": "Action button location", "action_location_hint": "The desktop app can place terminal and SFTP actions independently. When “Workspace header” is selected, only actions for the focused tab are shown. Mobile continues to use the compact layout.", "single_terminal": "Single pane · Terminal", diff --git a/public/locales/en-US/sftp.json b/public/locales/en-US/sftp.json index ea1f3ae..3f4d01e 100644 --- a/public/locales/en-US/sftp.json +++ b/public/locales/en-US/sftp.json @@ -647,6 +647,7 @@ "no_comparable_backups": "No backups available for comparison", "file_limit": "{{size}} · Limit {{limit}}", "text_encoding": "Text encoding", + "line_ending": "Line ending", "language": "Language", "plain_text": "Plain text", "ini_configuration": "INI / Configuration", @@ -690,6 +691,7 @@ "opened_backup": "Backup opened · {{size}}", "no_changes": "The file content has not changed", "saved_with_encoding": "File saved using {{encoding}}", + "saved_shell_script": "Script saved as {{encoding}}, Unix LF, without BOM", "image_preview_failed": "Image preview failed", "parse_light": "Parsing with the lightweight editor...", "parse_ace": "Parsing with Ace Editor (select the lightweight editor in SFTP settings if needed)...", diff --git a/public/locales/zh-CN/settings.json b/public/locales/zh-CN/settings.json index 01620a8..c7045f9 100644 --- a/public/locales/zh-CN/settings.json +++ b/public/locales/zh-CN/settings.json @@ -212,6 +212,10 @@ "terminal_latency_hint": "默认开启。延迟从实际按键发送开始,到远端终端首次返回数据为止;不会额外发送探测命令,也不会触发 Tab 补全。此选项保存在当前设备。", "restore_tabs": "恢复上次未关闭的标签", "restore_tabs_hint": "默认开启。重新启动 Terma 后会恢复终端、SFTP、转发、设置、日志、导入导出等所有未关闭的工作区标签。", + "remote_desktop_quick_open": "远程桌面探测通过后自动打开", + "remote_desktop_quick_open_hint": "终端、SFTP 和远程连接列表中的远程桌面入口使用同一设置。", + "vnc_quick_open_new_window": "VNC 快速打开时使用独立窗口", + "vnc_quick_open_new_window_hint": "先进入探测界面,确认服务可用后打开独立 VNC 窗口并关闭探测标签。", "action_location": "操作按钮位置", "action_location_hint": "桌面端可以分别设置终端和 SFTP 的操作按钮。选择“工作区标题栏”时,只显示当前焦点标签的按钮;移动端仍使用紧凑布局。", "single_terminal": "未分屏 · 终端", diff --git a/public/locales/zh-CN/sftp.json b/public/locales/zh-CN/sftp.json index 067c7fd..f93cdf9 100644 --- a/public/locales/zh-CN/sftp.json +++ b/public/locales/zh-CN/sftp.json @@ -647,6 +647,7 @@ "no_comparable_backups": "没有可比较的备份", "file_limit": "{{size}} · 上限 {{limit}}", "text_encoding": "文本编码", + "line_ending": "换行符", "language": "语言", "plain_text": "纯文本", "ini_configuration": "INI / 配置", @@ -690,6 +691,7 @@ "opened_backup": "已打开备份 · {{size}}", "no_changes": "文件内容没有变化", "saved_with_encoding": "文件已按 {{encoding}} 保存", + "saved_shell_script": "脚本已按 {{encoding}}、Unix LF、无 BOM 保存", "image_preview_failed": "图片预览失败", "parse_light": "正在使用轻量编辑器解析...", "parse_ace": "正在使用 Ace 编辑器解析(可在 SFTP 设置中选择轻量编辑器)...", diff --git a/public/sftp-open-worker.js b/public/sftp-open-worker.js index 5031a51..bc24b51 100644 --- a/public/sftp-open-worker.js +++ b/public/sftp-open-worker.js @@ -30,10 +30,37 @@ self.addEventListener("message", event => { content = new TextDecoder(labels[encoding] || encoding).decode(source); } let lineCount = 1; + let crlfCount = 0; + let lfCount = 0; + let crCount = 0; for (let index = 0; index < content.length; index += 1) { - if (content.charCodeAt(index) === 10) lineCount += 1; + const code = content.charCodeAt(index); + if (code === 13 && content.charCodeAt(index + 1) === 10) { + crlfCount += 1; + lineCount += 1; + index += 1; + } else if (code === 10) { + lfCount += 1; + lineCount += 1; + } else if (code === 13) { + crCount += 1; + lineCount += 1; + } } - self.postMessage({ok:true, content, encoding, bom:encoding === "utf8bom" && hasBom, line_count:lineCount}); + const lineEnding = crlfCount >= lfCount && crlfCount >= crCount && crlfCount > 0 + ? "crlf" + : crCount > lfCount && crCount > 0 + ? "cr" + : "lf"; + self.postMessage({ + ok:true, + content, + encoding, + bom:hasBom, + line_count:lineCount, + line_ending:lineEnding, + final_newline:/[\r\n]$/.test(content) + }); } catch (error) { self.postMessage({ok:false, error_code:error?.code || "decode_failed"}); } diff --git a/scripts/desktop-startup-check.js b/scripts/desktop-startup-check.js index 76f8899..c03f8e0 100644 --- a/scripts/desktop-startup-check.js +++ b/scripts/desktop-startup-check.js @@ -22,6 +22,8 @@ assert.match(desktopMainSource, /startDesktopNotificationBridge\(\)/, "desktop s assert.match(desktopMainSource, /terma:notification-event/, "desktop notification events must be forwarded to the renderer"); assert.match(desktopPreloadSource, /onNotification\(callback\)/, "preload must expose the notification event bridge"); assert.match(desktopPreloadSource, /onNotificationAction\(callback\)/, "preload must expose notification actions without Node access"); +assert.match(desktopPreloadSource, /setWindowTitle\(title\)[\s\S]*?terma:set-window-title/, "preload must expose the current resource title to the desktop window"); +assert.match(desktopMainSource, /terma:set-window-title[\s\S]*?window !== mainWindow[\s\S]*?window\.setTitle\(title\)/, "desktop window titles must stay scoped to the main renderer"); assert.match(appSource, /if \(!window\.termaDesktop\) pollNotifications\(\)/, "desktop renderer must not duplicate the main-process notification poll"); assert.match(desktopMainSource, /termaDisplaySession:\s*localLinuxDisplaySession/, "Linux second launches must report their graphical session with the Terma field"); assert.match(desktopMainSource, /additionalData\?\.termaDisplaySession\s*\|\|\s*additionalData\?\.tunneldeskDisplaySession/, "Linux second launches must still accept the legacy TunnelDesk field"); diff --git a/scripts/i18n-check.js b/scripts/i18n-check.js index 9b923cb..afaf0b2 100644 --- a/scripts/i18n-check.js +++ b/scripts/i18n-check.js @@ -554,7 +554,7 @@ const apiAt = html.indexOf('/app-api.js'); assert.ok(vendorAt >= 0 && bootstrapAt > vendorAt && apiAt > bootstrapAt, "i18next vendor and bootstrap scripts must load before application modules"); assert.ok(staticContent.includes('["/vendor/i18next/i18next.min.js", vendorFile("i18next", "dist/umd/i18next.min.js")]')); const runtimeSettings = read("src/runtime-settings.ts"); -assert.ok(runtimeSettings.includes("schema_version: 15") && runtimeSettings.includes("language: normalizeLanguage") && runtimeSettings.includes("language_onboarding_version") && runtimeSettings.includes("vnc_fullscreen_toolbar")); +assert.ok(runtimeSettings.includes("schema_version: 16") && runtimeSettings.includes("language: normalizeLanguage") && runtimeSettings.includes("language_onboarding_version") && runtimeSettings.includes("vnc_fullscreen_toolbar")); const i18nBootstrap = read("public/app-i18n.js"); const frontend = `${i18nBootstrap}\n${read("public/app-settings-runtime.js")}\n${read("public/app-settings.js")}`; for (const token of ["setTermaLanguage", "registerTermaI18nRenderer", "toggleTermaLanguage", "syncTermaLanguageControls", "termaI18nPhraseTemplates"]) { diff --git a/scripts/regression-check.js b/scripts/regression-check.js index 8e73695..a024559 100644 --- a/scripts/regression-check.js +++ b/scripts/regression-check.js @@ -429,7 +429,7 @@ async function main() { ok("全局终端设置独立持久化并应用到当前和未来会话", runtimeSettingsSource.includes("DEFAULT_TERMINAL_SETTINGS") && runtimeSettingsSource.includes("normalizeTerminalSettings") - && runtimeSettingsSource.includes("schema_version: 15") + && runtimeSettingsSource.includes("schema_version: 16") && runtimeSettingsSource.includes("language: normalizeLanguage") && runtimeSettingsSource.includes("language_onboarding_version") && runtimeSettingsSource.includes('background_mode: "theme"') @@ -494,7 +494,7 @@ async function main() { ok("终端默认显示真实交互响应延迟且可在通用设置关闭", frontend.includes('localStorage.getItem("terminalLatencyVisible") !== "0"') && terminalFrontend.includes("startTerminalLatencySample") && terminalFrontend.includes("finishTerminalLatencySample") && terminalFrontend.includes('tr("terminal:latency.hint"') && terminalFrontend.includes('tr("terminal:latency.latest"') && settingsFrontend.includes('id="terminalLatencyVisible"') && settingsFrontend.includes('tr("settings:auto.terminal_latency_hint"')); ok("终端连接状态省略时悬停显示完整地址与状态", terminalFrontend.includes("updateTerminalConnectionStatus") && terminalFrontend.includes("updateTerminalStatusForLayout") && terminalFrontend.includes("status.title = `${address}${state ? ` · ${state}` : \"\"}`") && terminalFrontend.includes('title="${esc(connectionAddress)}"')); ok("终端工具栏桌面端使用纯图标并在窄屏分行保留全部按钮", appCss.includes("container-name:terminal-view") && appCss.includes("@container terminal-view (max-width:1080px)") && appCss.includes("@container terminal-toolbar (max-width:1080px)") && appCss.includes("@media (min-width:761px) and (hover:hover) and (pointer:fine)") && appCss.includes(".terminal-actions > button > span:not(.composite-icon)") && terminalFrontend.includes('tr("terminal:toolbar.forward_list"') && terminalFrontend.includes('class="terminal-action-forward-list"') && terminalFrontend.includes('icon("earth")')); - ok("X11、连接快捷入口和窗口标题保持统一", utilsFrontend.includes('name === "x11"') && settingsFrontend.includes('icon("x11")') && terminalFrontend.includes('icon("x11")') && productivityFrontend.includes('class="xserver-x-icon"') && productivityFrontend.includes('= 0 ? insertion + 1 : tabs.length") && dockingFrontend.includes("pane.tabs.splice(insertion >= 0 ? insertion + 1 : pane.tabs.length") && appEntry.includes("workspaceRestorePending = true") && workspaceFrontend.includes("if (window.workspaceRestorePending) return") && dockingFrontend.includes("if (window.workspaceRestorePending) return") && appEntry.includes("const restored = restoreTabsState()")); ok("SFTP 任务中心宽高跨重启持久化", sftpTasksFrontend.includes('SFTP_TASK_CENTER_SIZE_STORAGE_KEY = "sftpTaskCenterSizeV1"') && sftpTasksFrontend.includes("persistSftpTaskCenterSize") && sftpTasksFrontend.includes("restoreSftpTaskCenterSize") && sftpTasksFrontend.includes("localStorage.removeItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY)")); ok("移动端终端 SFTP 按钮保留完整文字宽度", appCss.includes("button.terminal-action-sftp { width:auto; min-width:84px; padding-inline:10px; }")); diff --git a/scripts/remote-install-ui-check.js b/scripts/remote-install-ui-check.js index 87bab01..7c939d4 100644 --- a/scripts/remote-install-ui-check.js +++ b/scripts/remote-install-ui-check.js @@ -7,6 +7,7 @@ const { readSources } = require("./backend-source"); const root = path.resolve(__dirname, ".."); const remote = readFrontendDomain(root, "remote"); +const settings = readFrontendDomain(root, "settings"); const app = ["app-state.js", "app.js"] .map(file => fs.readFileSync(path.join(root, "public", file), "utf8")) .join("\n"); @@ -67,10 +68,12 @@ assert.match(remote, /3389 未监听/); assert.match(remote, /remoteDesktopProtocolGuideMarkup\("rdp", diagnostics, profile\)/, "RDP status must explain the desktop login account and preferred backend"); assert.match(remote, /xrdp 使用 Xorg\/xorgxrdp/); assert.match(remote, /临时管理员授权(?:的 root )?密码、VNC 密码(?:或|和) Windows 当前账号/); -assert.match(app, /remoteDesktopQuickOpen = localStorage\.getItem\("remoteDesktopQuickOpen"\) === "1"/, "remote desktop quick open must default to off"); -assert.match(workspace, /function toggleRemoteDesktopQuickOpen\(\)[\s\S]*?localStorage\.setItem\("remoteDesktopQuickOpen"/, "quick open preference must persist"); +assert.match(app, /remoteDesktopQuickOpen = localStorage\.getItem\("remoteDesktopQuickOpen"\) === "1"/, "legacy quick open preference must remain available for migration"); +assert.match(settings, /remoteDesktopQuickOpen = legacyQuickOpen === null[\s\S]*?remote_desktop_quick_open_enabled/, "global quick open preference must load with the legacy fallback"); +assert.match(workspace, /async function toggleRemoteDesktopQuickOpen\(\)[\s\S]*?\/api\/runtime-settings[\s\S]*?remote_desktop_quick_open_enabled/, "quick open preference must persist in global runtime settings"); assert.match(workspace, /quickOpenButton[\s\S]*?aria-pressed[\s\S]*?icon\("zap"\)/, "Other Connections toolbar must expose a stateful quick-open button"); assert.match(remote, /updateTab && remoteDesktopQuickOpen && clientLaunchable/, "automatic launch must be gated by the quick-open preference"); +assert.match(remote, /vncQuickOpenUsesNewWindow\(\)[\s\S]*?openVncInNewWindow\(profile\.id, key, \{closeDetectionTab:true\}\)/, "VNC quick open must honor the global new-window preference and close the detection tab"); assert.match(remote, /async function openRemoteDesktop[\s\S]*?captureRemoteDesktopRenderScope\(profile\.id, key, view\)[\s\S]*?await withRemoteDesktopRenderScope\(renderScope,[\s\S]*?catch \(error\) \{[\s\S]*?withRemoteDesktopRenderScope\(renderScope,/, "remote desktop diagnostics must ignore stale async results instead of touching a missing status element"); assert.match(workspace, /tab\.kind === "linux-desktop"[\s\S]{0,220}openLinuxDesktopManager\(connectionId, false\)/, "legacy workspace restoration must render Linux desktop manager tabs"); assert.match(remote, /id="vncServerState"[\s\S]*?正在探测远端 VNC 服务/, "VNC must enter the shared detection workspace before connecting"); diff --git a/scripts/remote-privilege-check.js b/scripts/remote-privilege-check.js index 8678cd6..6442a54 100644 --- a/scripts/remote-privilege-check.js +++ b/scripts/remote-privilege-check.js @@ -33,6 +33,11 @@ assert.match(serverSource, /xdmcp\.configure/); assert.match(serverSource, /x11\.remote-install/); assert.match(serverSource, /vnc\.server\.\$\{action\}/); assert.match(remoteFrontend, /requestRemoteAdminAuthorization/); +assert.match(remoteFrontend, /vnc\.clipboard-helper\.\$\{action\}/); +assert.match(remoteFrontend, /vnc\.server\.\$\{action\}/); +assert.match(remoteFrontend, /rdp\.server\.\$\{action\}/); +assert.match(remoteFrontend, /linux-desktop\.install-local-offline/); +assert.match(remoteFrontend, /linux-desktop\.uninstall/); assert.match(remoteFrontend, /临时授权后/); assert.match(remoteFrontend, /installVncServer/); diff --git a/scripts/runtime-settings-check.js b/scripts/runtime-settings-check.js index 99be451..55c29a2 100644 --- a/scripts/runtime-settings-check.js +++ b/scripts/runtime-settings-check.js @@ -91,7 +91,9 @@ async function main() { "toolbarPlacementUnsplitTerminal", "toolbarPlacementUnsplitSftp", "toolbarPlacementSplitTerminal", - "toolbarPlacementSplitSftp" + "toolbarPlacementSplitSftp", + "generalRemoteDesktopQuickOpen", + "generalVncQuickOpenNewWindow" ]) assert.equal(settingsFrontend.includes(`id=\"${controlId}\"`), true, `${controlId} setting is missing`); assert.equal(settingsFrontend.includes("syncWorkspaceToolbarPlacements()"), true); assert.equal(DEFAULT_TERMINAL_SETTINGS.url_links_enabled, true); @@ -103,7 +105,7 @@ async function main() { assert.match(DEFAULT_TERMINAL_SETTINGS.font_family, /monospace/); assert.deepEqual(normalizeListenHosts(["127.0.0.1", "0.0.0.0", "127.0.0.1"]), ["0.0.0.0"]); assert.deepEqual(normalizeRuntimeSettings({ listen_hosts: "127.0.0.1,127.0.0.2", listen_port: "8123" }), { - schema_version: 15, + schema_version: 16, language: "zh-CN", language_onboarding_version: 0, vnc_fullscreen_toolbar: "always", @@ -126,6 +128,8 @@ async function main() { sftp_upload_concurrency: 3, sftp_download_directory: "", restore_workspace_tabs: true, + remote_desktop_quick_open_enabled: false, + vnc_quick_open_new_window: true, workspace_toolbar_placement: { unsplit: {terminal:"header", sftp:"header"}, split: {terminal:"header", sftp:"header"} @@ -426,6 +430,8 @@ async function main() { const workspaceSaved = await request(base, "/api/runtime-settings", { method: "PUT", body: JSON.stringify({ + remote_desktop_quick_open_enabled: true, + vnc_quick_open_new_window: false, workspace_toolbar_placement: { unsplit:{terminal:"tab", sftp:"header"}, split:{terminal:"header", sftp:"tab"} @@ -433,6 +439,8 @@ async function main() { }) }); assert.equal(workspaceSaved.response.ok, true); + assert.equal(workspaceSaved.body.saved.remote_desktop_quick_open_enabled, true); + assert.equal(workspaceSaved.body.saved.vnc_quick_open_new_window, false); assert.deepEqual(workspaceSaved.body.saved.workspace_toolbar_placement, { unsplit:{terminal:"tab", sftp:"header"}, split:{terminal:"header", sftp:"tab"} diff --git a/scripts/sftp-backend-check.js b/scripts/sftp-backend-check.js index 2f6799d..d387534 100644 --- a/scripts/sftp-backend-check.js +++ b/scripts/sftp-backend-check.js @@ -46,6 +46,21 @@ async function main() { const reread = server.prepareSftpWriteContent("a".repeat(1536 * 1024), "utf8"); assert.equal(reread.maximum_bytes, 2 * 1024 * 1024, "runtime settings must be reread without restarting"); + const shellScript = server.prepareSftpWriteContent( + "\uFEFF#!/bin/bash\r\necho restart", + "utf8bom", + "/tmp/restart_Pms.sh", + "crlf" + ); + assert.equal(shellScript.encoding, "utf8", "shell scripts must not retain a UTF-8 BOM"); + assert.equal(shellScript.line_ending, "lf", "shell scripts must use Unix line endings"); + assert.equal(shellScript.normalized_script, true); + assert.equal(shellScript.content.toString("utf8"), "#!/bin/bash\necho restart\n", "shell scripts must end with an LF"); + assert.notDeepEqual([...shellScript.content.subarray(0, 3)], [0xef, 0xbb, 0xbf], "shell scripts must not start with a BOM"); + + const windowsText = server.prepareSftpWriteContent("first\nsecond\n", "utf8", "/tmp/readme.txt", "crlf"); + assert.equal(windowsText.content.toString("utf8"), "first\r\nsecond\r\n", "non-script files must honor the selected line ending"); + const dragRoot = path.join(dataDir, "sftp-drag"); const oldDirectory = path.join(dragRoot, "old-stage"); const recentDirectory = path.join(dragRoot, "recent-stage"); diff --git a/scripts/ui-smoke-electron.js b/scripts/ui-smoke-electron.js index d73186b..154ff41 100644 --- a/scripts/ui-smoke-electron.js +++ b/scripts/ui-smoke-electron.js @@ -2664,11 +2664,23 @@ app.whenReady().then(async () => { }); await runI18nScenario('quick-open-notice', async () => { const previousQuickOpen = remoteDesktopQuickOpen; + const quickOpenApi = api; remoteDesktopQuickOpen = false; + api = async (path, options={}) => { + if (String(path) === '/api/runtime-settings' && String(options.method || 'GET').toUpperCase() === 'PUT') { + const body = JSON.parse(options.body || '{}'); + return normalizeRuntimeSettingsResponse({ + ...runtimeSettings, + saved:{...runtimeSettings.saved, remote_desktop_quick_open_enabled:body.remote_desktop_quick_open_enabled === true} + }); + } + return quickOpenApi(path, options); + }; try { - toggleRemoteDesktopQuickOpen(); + await toggleRemoteDesktopQuickOpen(); await collectTranslatedHan('quick-open-notice-open', true, document.getElementById('toast')); } finally { + api = quickOpenApi; remoteDesktopQuickOpen = previousQuickOpen; localStorage.setItem('remoteDesktopQuickOpen', previousQuickOpen ? '1' : '0'); renderExplorerTools(); @@ -4133,7 +4145,7 @@ app.whenReady().then(async () => { const connectionAddress=first.ssh_user+'@'+first.ssh_host+':'+first.ssh_port; document.querySelector('#view-terminal').innerHTML='
'; setWorkspace('终端测试',connectionAddress,'terminal',key,false,true,{kind:'terminal',id:first.id}); - const resourceWindowTitle = document.title === 'Terma · '+first.ssh_host+':'+first.ssh_port+' · 终端'; + const resourceWindowTitle = document.title === 'Terma · '+first.ssh_host+':'+first.ssh_port+' · 终端 · 终端测试'; activeTabKey = key; updateTerminalConnectionStatus(first, key, 'connected'); const statusIndicator = document.querySelector('#terminalStatus'); @@ -4524,13 +4536,14 @@ app.whenReady().then(async () => { fakeInputHandler?.('\\r'); const bufferedPasteRecordedOnEnter=recentTerminalCommands[0]==='echo pasted-two'&&pasteSession.commandBuffer===''; await sendTerminalPasteText(key,'printf first\\nprintf second\\npartial third'); + const multilinePasteUsesPtyReturns=reconnectedFakeSocket.sent.at(-1)==='printf first\\rprintf second\\rpartial third'; const multilinePasteRecorded=recentTerminalCommands.includes('printf first')&&recentTerminalCommands.includes('printf second')&&!recentTerminalCommands.includes('partial third')&&pasteSession.commandBuffer==='partial third'; fakeInputHandler?.('\\r'); const trailingPasteRecordedOnEnter=recentTerminalCommands[0]==='partial third'; pasteSession.sensitiveInput=true; await sendTerminalPasteText(key,'very-secret-command\\r'); pasteSession.sensitiveInput=false; - terminalSettingsUi.pasteCommandHistory=Boolean(completedPasteRecorded&&incompletePasteBuffered&&bufferedPasteRecordedOnEnter&&multilinePasteRecorded&&trailingPasteRecordedOnEnter&&!recentTerminalCommands.includes('very-secret-command')&&pasteSession.commandBuffer===''); + terminalSettingsUi.pasteCommandHistory=Boolean(completedPasteRecorded&&incompletePasteBuffered&&bufferedPasteRecordedOnEnter&&multilinePasteUsesPtyReturns&&multilinePasteRecorded&&trailingPasteRecordedOnEnter&&!recentTerminalCommands.includes('very-secret-command')&&pasteSession.commandBuffer===''); recentTerminalCommands=previousRecentCommandsForPaste; if(previousRecentCommandStorage===null)localStorage.removeItem('recentTerminalCommands');else localStorage.setItem('recentTerminalCommands',previousRecentCommandStorage); fakeTerm.buffer.active.getLine=()=>({translateToString:()=> 'open https://example.test/path.',length:32}); @@ -4553,7 +4566,7 @@ app.whenReady().then(async () => { const pasteSummaryUpdated=document.querySelector('#terminalPasteSummary')?.textContent.includes('2 行'); document.querySelector('#terminalPasteConfirm')?.click(); const pasteSent=await pastePromise; - terminalSettingsUi.editablePaste=Boolean(pasteBackdropIgnored&&pasteEditable&&pasteSummaryUpdated&&pasteModalRect&&pasteModalRect.left>=-0.5&&pasteModalRect.right<=innerWidth+0.5&&pasteModalRect.top>=-0.5&&pasteModalRect.bottom<=innerHeight+0.5&&pasteSent&&reconnectedFakeSocket.sent.at(-1)==='edited command\\nsecond command'); + terminalSettingsUi.editablePaste=Boolean(pasteBackdropIgnored&&pasteEditable&&pasteSummaryUpdated&&pasteModalRect&&pasteModalRect.left>=-0.5&&pasteModalRect.right<=innerWidth+0.5&&pasteModalRect.top>=-0.5&&pasteModalRect.bottom<=innerHeight+0.5&&pasteSent&&reconnectedFakeSocket.sent.at(-1)==='edited command\\rsecond command'); const toolbarFixture=document.createElement('div'); toolbarFixture.className='terminal-toolbar'; toolbarFixture.style.width='100%'; @@ -7830,7 +7843,9 @@ app.whenReady().then(async () => { json5FormattingHidden:!isSftpJsonFileName('/tmp/example.json5'), wordWrap:Boolean(document.querySelector('#sftpEditorWordWrap')?.checked), persistDefault:Boolean(document.querySelector('#sftpPersistEncoding')), - backup:Boolean(document.querySelector('#sftpBackupBeforeSave')?.checked) + backup:Boolean(document.querySelector('#sftpBackupBeforeSave')?.checked), + lineEndings:[...document.querySelectorAll('#sftpLineEnding option')].map(option=>option.value), + shellFormat:false }; if (languageSelect) { languageSelect.value='markdown'; @@ -7846,6 +7861,19 @@ app.whenReady().then(async () => { } document.querySelector('#sftpTextClose')?.click(); await editorPromise; + const shellEditorPromise=sftpTextModal('/tmp/restart_Pms.sh','\uFEFF#!/bin/bash\\r\\necho restart',31,512*1024,'utf8bom','auto',{lineEnding:'crlf',bom:true,finalNewline:false}); + await new Promise(resolve=>setTimeout(resolve,20)); + const shellLineEnding=document.querySelector('#sftpLineEnding'); + const shellEncoding=document.querySelector('#sftpTextEncoding'); + const shellControlsReady=Boolean(shellLineEnding?.value==='lf'&&shellLineEnding.disabled&&shellEncoding?.value==='utf8'); + document.querySelector('#sftpTextSave')?.click(); + const shellSave=await shellEditorPromise; + textEncodingUi.shellFormat=Boolean(shellControlsReady + &&shellSave?.changed + &&shellSave?.normalized_script + &&shellSave?.encoding==='utf8' + &&shellSave?.line_ending==='lf' + &&shellSave?.content==='#!/bin/bash\\necho restart\\n'); const lightFixture='x'.repeat(1024*1024+37); const lightEditorPromise=sftpTextModal('/tmp/large.log',lightFixture,lightFixture.length,2*1024*1024,'utf8','auto',{editorKind:'light',lineCount:1}); await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); @@ -10046,7 +10074,7 @@ app.whenReady().then(async () => { const globalSettingsUi = sftpUi.globalSettingsUi || {}; const downloadNoticeUi = sftpUi.downloadNoticeUi || {}; const jobUiFailed = !jobUi.found || !jobUi.singleGlobalEntry || !jobUi.noPaneTaskRegions || !jobUi.failedStatusVisible || !jobUi.totalProgressVisible || !jobUi.totalProgressIndeterminate || !jobUi.totalProgressHidesWhenIdle || !jobUi.floatingVisibleBelowHeader || !jobUi.floatingActions || !jobUi.floatingResumeAction || !jobUi.floatingProgress || !jobUi.floatingOpensTaskCenter || !jobUi.floatingCloseHidesCurrent || !jobUi.floatingNewTaskReopens || !jobUi.floatingMutePersists || !jobUi.floatingSettingRestores || !jobUi.drawerOpened || !jobUi.drawerDefaultCompact || !jobUi.currentOnly || !jobUi.currentActions || !jobUi.failedOnly || !jobUi.failedActions || !jobUi.failedClearAvailable || !jobUi.currentProgress || !jobUi.drawerResizable || !jobUi.drawerResizeAdaptive || !jobUi.drawerResizePersists || !jobUi.drawerResizeReset || !jobUi.deleteDuplicateBlocked || !jobUi.deleteKeepsDrawerOpen || !jobUi.taskLogInitialOpen || !jobUi.taskLogInitialBottom || !jobUi.taskLogRefreshKeepsOpen || !jobUi.taskLogRefreshShowsLatest || !jobUi.taskLogRefreshFollowsBottom || !jobUi.drawerFitsViewport || !jobUi.historyOnly || !jobUi.historyCounts || !jobUi.historyActions || !jobUi.outsideClickCloses || !jobUi.escapeCloses || !jobUi.runningStatusVisible || !jobUi.nativeDragTaskStopHidden || !jobUi.itemProgress || !jobUi.staleJobResponseIgnored || !jobUi.toastIconsAligned || !jobUi.toastOrderPreserved || !jobUi.toastStackedDown || !jobUi.toastAvoidsFloatingTask || !jobUi.toastExitAnimated || !jobUi.toastReflowAnimated || !jobUi.toastMovedUp; - const textEncodingUiFailed = !textEncodingUi.opened || !textEncodingUi.aceLoaded || textEncodingUi.selected !== 'gbk' || !textEncodingUi.manualLanguage || !textEncodingUi.nonJsonFormattingHidden || !textEncodingUi.lightPaged || !textEncodingUi.lightNextPage || !textEncodingUi.jsonFormatting || !textEncodingUi.jsonHiddenAfterLanguageChange || !textEncodingUi.json5FormattingHidden || !textEncodingUi.wordWrap || !textEncodingUi.persistDefault || !textEncodingUi.backup || !['utf8','utf8bom','gb18030','gbk','big5','shift_jis','euc-kr','latin1'].every(value=>textEncodingUi.options?.includes(value)) || !['auto','json','yaml','xml','sh','batchfile','powershell','javascript','java','c_cpp','sql','markdown'].every(value=>textEncodingUi.languageOptions?.includes(value)); + const textEncodingUiFailed = !textEncodingUi.opened || !textEncodingUi.aceLoaded || textEncodingUi.selected !== 'gbk' || !textEncodingUi.manualLanguage || !textEncodingUi.nonJsonFormattingHidden || !textEncodingUi.lightPaged || !textEncodingUi.lightNextPage || !textEncodingUi.jsonFormatting || !textEncodingUi.jsonHiddenAfterLanguageChange || !textEncodingUi.json5FormattingHidden || !textEncodingUi.wordWrap || !textEncodingUi.persistDefault || !textEncodingUi.backup || !textEncodingUi.shellFormat || !['lf','crlf','cr'].every(value=>textEncodingUi.lineEndings?.includes(value)) || !['utf8','utf8bom','gb18030','gbk','big5','shift_jis','euc-kr','latin1'].every(value=>textEncodingUi.options?.includes(value)) || !['auto','json','yaml','xml','sh','batchfile','powershell','javascript','java','c_cpp','sql','markdown'].every(value=>textEncodingUi.languageOptions?.includes(value)); const nativeDragUiFailed = !nativeDragUi.found || !nativeDragUi.webExternalDragBlocked || !nativeDragUi.linuxFallbackNoticeOnce || !nativeDragUi.linuxFallbackUsesCompatibilityMode || !nativeDragUi.streamingPreparesOnPointerDown || !nativeDragUi.streamingThresholdActivatesOnce || !nativeDragUi.streamingCaptureCancelSurvives || !nativeDragUi.pointerUpCancelsPending || !nativeDragUi.streamingSkipsStage || !nativeDragUi.streamingNativeBlocksParallelBrowserDrag || !nativeDragUi.nativeIdleHintStable || !nativeDragUi.nativeOutsideHintStaysStable || !nativeDragUi.nativeMotionTargetsSftp || !nativeDragUi.nativeTransientMissKeepsTarget || !nativeDragUi.nativeFinalTransientMissKeepsTarget || !nativeDragUi.nativeReleasedClearsStaleTarget || !nativeDragUi.nativeResultCopiesOnce || !nativeDragUi.firstDragOnlyStages || !nativeDragUi.firstDragReset || !nativeDragUi.cacheReused || !nativeDragUi.cachedUnarmedStaysInternal || !nativeDragUi.sameWindowDropDoesNotArm || !nativeDragUi.armedDragStartsSynchronously || !nativeDragUi.failureRearmed || !nativeDragUi.successClearsState || !nativeDragUi.finderRenameNoticeShown; const sftpUiFailed = Boolean(sftpUi.error) || !connectionSessionUi.found || !connectionSessionUi.addressIncludesPort || !connectionSessionUi.disconnectedAction || !connectionSessionUi.disconnectedBanner || !connectionSessionUi.connectedAction || !connectionSessionUi.preservedWhileDisconnected || !connectionSessionUi.automaticConnectShared || !connectionSessionUi.manualDisconnectAutoReconnect || !connectionSessionUi.disconnectedTabSwitchDoesNotReconnect || !connectionSessionUi.disconnectedFolderOperationReconnects || !connectionSessionUi.dragFeedbackVisible || !connectionSessionUi.dragTargetViewActivated || !connectionSessionUi.targetListDropPrompt || !connectionSessionUi.targetListDropPromptStable || !connectionSessionUi.crossHostListDropCopies || !connectionSessionUi.crossHostPreviewHandoffSurvives || !connectionSessionUi.crossHostDropHasNoUploadToast || !connectionSessionUi.sameHostListDropCopies || !connectionSessionUi.terminalTabPreviewActivated || !connectionSessionUi.invalidTerminalDropRestoresSource || !connectionSessionUi.invalidSftpDropRestoresSource || !connectionSessionUi.acceptedTerminalDropStays || !connectionSessionUi.ownDragUploadSuppressed || !connectionSessionUi.armedPointerCancelClearsRequest || !connectionSessionUi.armedDragAllowsExternalUpload || !connectionSessionUi.staleInternalDragAllowsExternalUpload || !connectionSessionUi.desktopUriListDragAccepted || !connectionSessionUi.releasedDragAllowsExternalUpload || !connectionSessionUi.externalFileDropDetected || !connectionSessionUi.externalFileDropCollected || !connectionSessionUi.externalDropPromptIsSingle || !connectionSessionUi.externalDropPromptAvoidsWorkspaceChrome || !connectionSessionUi.externalDropPromptListCentered || !connectionSessionUi.externalDropSurfaceFillsWorkspace || !connectionSessionUi.externalDropPromptScrollClamped || !connectionSessionUi.externalDropPromptHorizontalClamped || !connectionSessionUi.externalDropPromptClears || nativeDragUiFailed || jobUiFailed || textEncodingUiFailed || !downloadNoticeUi.oncePerMode || !downloadNoticeUi.desktopPath || !downloadNoticeUi.browserDevice || !downloadNoticeUi.batchUsesSharedNotice || !downloadNoticeUi.browserSeparateChoice || !downloadNoticeUi.browserSeparateQueued || !downloadNoticeUi.noDuplicateBatchNotice || !globalSettingsUi.found || !globalSettingsUi.globalScope || !globalSettingsUi.controls || !globalSettingsUi.floatingProgressDefaultOn || !globalSettingsUi.floatingProgressCanRestore || !globalSettingsUi.downloadBehavior || !globalSettingsUi.defaultLimit || !globalSettingsUi.backdropIgnored || !globalSettingsUi.withinViewport || !globalSettingsUi.classicSurface || !globalSettingsUi.themedField || !directorySizeUi.idleButton || !directorySizeUi.requestedOnce || !directorySizeUi.exactBytes || !directorySizeUi.formatted || !directorySizeUi.refreshable || !sftpUi.fileOpenFeedback?.busy || !sftpUi.fileOpenFeedback?.duplicateBlocked || !sftpUi.fileOpenFeedback?.restored || !sftpUi.fileOpenFeedback?.interruptedRetry || !directoryCacheBehavior.sameResponseUntouched || !directoryCacheBehavior.changedResponseRendered || !directoryCacheBehavior.permissionFailureRestored || !sftpUi.searchKeyboardUi?.opened || !sftpUi.searchKeyboardUi?.closed || !sftpUi.searchKeyboardUi?.recursive || !sftpUi.searchKeyboardUi?.feedback || !sftpUi.syncIndicatorFollowsScroll || !sftpUi.diffComparisonUi || !sftpUi.columnLayoutUi?.order || !sftpUi.columnLayoutUi?.persisted || !sftpUi.columnLayoutUi?.resized || !sftpUi.columnLayoutUi?.pointerStable || !sftpUi.columnLayoutUi?.pairOnly || !sftpUi.columnLayoutUi?.adjacentResizeStable || !sftpUi.columnLayoutUi?.dividerUniform || !sftpUi.columnLayoutUi?.localNarrowResizable || !sftpUi.columnLayoutUi?.openButtonStable || !sftpUi.columnLayoutUi?.selectionToolbarStable || !sftpUi.columnLayoutUi?.scrollbarUnified || !sftpUi.columnLayoutUi?.globalCss || !directoryActionsUi.found || directoryActionsUi.stickyPosition !== 'sticky' || !directoryActionsUi.toolbarInHeader || !directoryActionsUi.navigationBeforeFavorites || !directoryActionsUi.reusedWithoutDirectoryReload || !expectedSftpToolActions.every(action=>directoryActionsUi.actionTitles?.includes(action)) || !directoryActionsUi.searchHidden || !directoryActionsUi.pathEditorHidden || !directoryActionsUi.emptyClipboardHidden || !directoryActionsUi.copyQueueVisible || !directoryActionsUi.copyCancelled || !directoryActionsUi.moveQueueVisible || !directoryActionsUi.moveCancelled || !directoryActionsUi.crossHostCopyEnabled || !directoryActionsUi.crossHostMoveDisabled || !directoryActionsUi.crossHostClipboardConflict || !directoryActionsUi.filenameEncodingMenu || !directoryActionsUi.emptyFavoritesCompact || !directoryActionsUi.wideNavigationCompact || !directoryActionsUi.narrowNavigationCompact || !directoryActionsUi.terminalJump || !directoryActionsUi.terminalJumpFirst || !sftpUi.folderOpened || !sftpUi.fileOpened || !sftpUi.unknownAction || sftpUi.stickyPosition !== "sticky" || !sftpUi.breadcrumbScrollable || !sftpUi.singlePathPresentation || sftpUi.breadcrumbLabels?.join('/') !== '根目录/Users/demo/Public' || sftpUi.breadcrumbText.includes('//') || !sftpUi.selectionShown || !sftpUi.selectionActionsShown || !sftpUi.multiNameAddsSelection || !sftpUi.multiNameCancelsSelection || !sftpUi.singleNameReplacesSelection || !sftpUi.specialSelectionExact || sftpUi.selectedRows !== 2 || !sftpUi.dragSelectionSynchronized || !sftpUi.selectionCleared || !sftpUi.fileHasCompression || !sftpUi.permissionOwnerColumn || !sftpUi.permissionOwnerTitle || !sftpUi.symlinkUsesTargetSize || !sftpUi.symlinkExplainsBothSizes || !sftpUi.symlinkMarked || !sftpUi.wideColumnAlignment || !sftpUi.wideActionsFit || !sftpUi.compactSizeVisible || !sftpUi.compactTimeVisible || !sftpUi.compactAccessVisible || !sftpUi.compactMediumHidden || !sftpUi.compactCoreVisible || !sftpUi.compactHorizontalScroll || !sftpUi.permissionModeSync || !sftpUi.recursiveVisible || sftpUi.compactRowHeight > 48 || !sftpUi.moreMenuOpened || !sftpUi.contextMenuOpened || !sftpUi.directoryDownloadMenu || !sftpUi.narrowLayoutClass || !sftpUi.narrowCoreHidden || !sftpUi.narrowMoreVisible || !sftpUi.narrowMetaVisible || !sftpUi.narrowAccessHidden || !sftpUi.narrowHeaderNameVisible || !sftpUi.narrowHeaderSummaryVisible || !sftpUi.narrowCompactActions || !sftpUi.completedMutationDetected || !sftpUi.desktopPagerSingleRow || !sftpUi.pagerFloatsAtWorkspaceBottom || !sftpUi.pagerOpaqueAndElevated || !sftpUi.pagerDockSealsBottom || !sftpUi.pagerPinnedToViewport || !sftpUi.scrollCueVisibleAboveContent || !sftpUi.scrollCueHidesAtEnd || !sftpUi.narrowPagerWraps || sftpUi.pageRows !== 50 || !sftpUi.pagerVisible || !sftpUi.pagerText.includes('第 1/2 页') || !sftpUi.previousDisabled || !sftpUi.nextEnabled; const sftpToolbarRecoveryFailed = !directoryActionsUi.recoveredMissingToolbar || !directoryActionsUi.duplicateSftpToolbarsFollowActiveTab; diff --git a/src/routes/sftp-transfer-routes.ts b/src/routes/sftp-transfer-routes.ts index 2f1bf88..c9fc9e0 100644 --- a/src/routes/sftp-transfer-routes.ts +++ b/src/routes/sftp-transfer-routes.ts @@ -29,7 +29,7 @@ interface SftpTransferRouteDependencies { moveRemotePaths(connectionId: number, paths: string[], target: string): Promise; normalizeRemotePermissionRequest(paths: any, mode: any, recursive: any, owner: any, group: any): any; planRemoteUploads(connectionId: number, remotePath: string, filenames: string[]): Promise; - prepareSftpWriteContent(content: string, encoding: string): {content: Buffer}; + prepareSftpWriteContent(content: string, encoding: string, remotePath?: string, lineEnding?: string): {content: Buffer; encoding?: string; line_ending?: string | null; normalized_script?: boolean}; readJson(request: IncomingMessage): Promise; readRemoteBinaryFile(connectionId: number, remotePath: string, maximumBytes: number): Promise; readRemoteDirectorySize(connectionId: number, remotePath: string): Promise; @@ -381,11 +381,11 @@ export async function handleSftpTransferRoutes( return true; } if (method === "POST" && parts[4] === "write") { - const {content} = dependencies.prepareSftpWriteContent(data.content, data.encoding || "utf8"); - const result = await dependencies.writeRemoteFile(connectionId, data.path, content, {backup:Boolean(data.backup)}); - if (data.persist_default) dependencies.updateSftpTextEncoding(connectionId, data.encoding || "utf8"); + const prepared = dependencies.prepareSftpWriteContent(data.content, data.encoding || "utf8", data.path, data.line_ending); + const result = await dependencies.writeRemoteFile(connectionId, data.path, prepared.content, {backup:Boolean(data.backup)}); + if (data.persist_default) dependencies.updateSftpTextEncoding(connectionId, prepared.encoding || data.encoding || "utf8"); dependencies.invalidateRemoteDirectoryCache(connectionId); - dependencies.sendJson(response, {...result, encoding:data.encoding || "utf8"}); + dependencies.sendJson(response, {...result, encoding:prepared.encoding || data.encoding || "utf8", line_ending:prepared.line_ending, normalized_script:prepared.normalized_script}); return true; } diff --git a/src/routes/storage-routes.ts b/src/routes/storage-routes.ts index 80b45e2..212a05c 100644 --- a/src/routes/storage-routes.ts +++ b/src/routes/storage-routes.ts @@ -139,6 +139,8 @@ export async function handleStorageRoutes( sftp_upload_concurrency:data.sftp_upload_concurrency ?? current.sftp_upload_concurrency, sftp_download_directory:data.sftp_download_directory ?? current.sftp_download_directory, restore_workspace_tabs:data.restore_workspace_tabs ?? current.restore_workspace_tabs, + remote_desktop_quick_open_enabled:data.remote_desktop_quick_open_enabled ?? current.remote_desktop_quick_open_enabled, + vnc_quick_open_new_window:data.vnc_quick_open_new_window ?? current.vnc_quick_open_new_window, workspace_toolbar_placement:data.workspace_toolbar_placement ?? current.workspace_toolbar_placement, terminal: data.terminal ?? current.terminal }); diff --git a/src/runtime-settings.ts b/src/runtime-settings.ts index 7a212f4..30d5e75 100644 --- a/src/runtime-settings.ts +++ b/src/runtime-settings.ts @@ -210,7 +210,7 @@ function normalizeRuntimeSettings(value: any = {}, fallback: any = {}) { : (value.hosts !== undefined ? value.hosts : value.host); const portValue = value.listen_port !== undefined ? value.listen_port : value.port; return { - schema_version: 15, + schema_version: 16, language: normalizeLanguage(value.language, fallback.language), language_onboarding_version: Math.max(0, Math.min(1, Number.isInteger(Number(value.language_onboarding_version ?? fallback.language_onboarding_version)) ? Number(value.language_onboarding_version ?? fallback.language_onboarding_version) @@ -264,6 +264,12 @@ function normalizeRuntimeSettings(value: any = {}, fallback: any = {}) { restore_workspace_tabs: value.restore_workspace_tabs === undefined ? fallback.restore_workspace_tabs !== false : value.restore_workspace_tabs !== false, + remote_desktop_quick_open_enabled: value.remote_desktop_quick_open_enabled === undefined + ? fallback.remote_desktop_quick_open_enabled === true + : value.remote_desktop_quick_open_enabled === true, + vnc_quick_open_new_window: value.vnc_quick_open_new_window === undefined + ? fallback.vnc_quick_open_new_window !== false + : value.vnc_quick_open_new_window !== false, workspace_toolbar_placement: normalizeWorkspaceToolbarPlacement( value.workspace_toolbar_placement, fallback.workspace_toolbar_placement diff --git a/src/services/sftp-content-service.ts b/src/services/sftp-content-service.ts index 236cf29..cfc2679 100644 --- a/src/services/sftp-content-service.ts +++ b/src/services/sftp-content-service.ts @@ -1,13 +1,37 @@ const { RUNTIME_SETTINGS_FILE } = require("../config"); const { readRuntimeSettings } = require("../runtime-settings"); -const { encodeRemoteText } = require("../sftp"); +const { encodeRemoteText, normalizeTextEncoding } = require("../sftp"); -function prepareSftpWriteContent(content, encoding = "utf8") { +function isUnixScript(remotePath, content) { + const basename = String(remotePath || "").replace(/\\/g, "/").split("/").pop().toLowerCase(); + if (/\.(?:sh|bash|zsh|ksh|dash|fish)$/.test(basename)) return true; + if ([".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".kshrc"].includes(basename)) return true; + return /^\uFEFF?#!/.test(String(content || "")); +} + +function normalizeLineEndings(content, lineEnding) { + const normalized = String(content || "").replace(/\r\n|\r|\n/g, "\n"); + if (lineEnding === "crlf") return normalized.replace(/\n/g, "\r\n"); + if (lineEnding === "cr") return normalized.replace(/\n/g, "\r"); + return normalized; +} + +function prepareSftpWriteContent(content, encoding = "utf8", remotePath = "", requestedLineEnding = "") { const maximumMb = readRuntimeSettings(RUNTIME_SETTINGS_FILE).sftp_max_open_file_size_mb; const maximumBytes = maximumMb * 1024 * 1024; - const encoded = encodeRemoteText(content, encoding); + const unixScript = isUnixScript(remotePath, content); + const lineEnding = unixScript ? "lf" : (["lf", "crlf", "cr"].includes(requestedLineEnding) ? requestedLineEnding : ""); + let text = String(content || ""); + let selectedEncoding = normalizeTextEncoding(encoding, "utf8"); + if (unixScript) { + text = text.replace(/^\uFEFF/, ""); + if (selectedEncoding === "utf8bom") selectedEncoding = "utf8"; + } + if (lineEnding) text = normalizeLineEndings(text, lineEnding); + if (unixScript && text && !text.endsWith("\n")) text += "\n"; + const encoded = encodeRemoteText(text, selectedEncoding); if (encoded.length > maximumBytes) throw new Error(`在线编辑内容不能超过 ${maximumMb} MB`); - return {content:encoded, maximum_bytes:maximumBytes}; + return {content:encoded, maximum_bytes:maximumBytes, encoding:selectedEncoding, line_ending:lineEnding || null, normalized_script:unixScript}; } module.exports = { prepareSftpWriteContent }; From b020a3f023a672fb056d88a4c9d44ea500eaf908 Mon Sep 17 00:00:00 2001 From: JunXiaoRuo <47996900+JunXiaoRuo@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:22:02 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E5=B9=B6=E5=8E=BB=E9=99=A4=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/main.js | 36 ++++++++++++- public/app-docking.js | 6 +++ public/app-remote-profiles.js | 8 ++- public/app-sftp.js | 25 +++++++-- public/app-vnc-window.js | 36 ++++++++++--- public/app-workspace.js | 54 ++++++++++++------- scripts/desktop-startup-check.js | 23 +++++++- scripts/regression-check.js | 2 +- scripts/remote-install-ui-check.js | 2 +- scripts/ui-smoke-electron.js | 78 +++++++++++++++++++++++++--- scripts/vnc-detached-window-check.js | 4 ++ scripts/workspace-docking-check.js | 41 +++++++++++++++ 12 files changed, 274 insertions(+), 41 deletions(-) diff --git a/desktop/main.js b/desktop/main.js index 1ae735d..d75e35a 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -1253,6 +1253,40 @@ function sanitizeAuxiliaryWindowTitle(value, fallback = PRODUCT_NAME) { return title || fallback; } +function desktopWindowTitleEndpointIdentity(value) { + let identity = String(value || "").trim().toLowerCase(); + if (!identity) return ""; + identity = identity.replace(/^\w+:\/\//, "").replace(/\/$/, ""); + if (identity.includes("@")) identity = identity.slice(identity.lastIndexOf("@") + 1); + if (/^\[[^\]]+\](?::\d+)?$/.test(identity)) return identity.replace(/^\[|\](?::\d+)?$/g, ""); + if ((identity.match(/:/g) || []).length === 1) identity = identity.replace(/:\d+$/, ""); + return identity; +} + +function normalizeMainWindowTitle(value) { + const sanitized = sanitizeAuxiliaryWindowTitle(value, PRODUCT_NAME); + const parts = sanitized.split(/\s*·\s*/).map(part => part.trim()).filter(Boolean); + if (parts.length < 4 || parts[0].toLowerCase() !== PRODUCT_NAME.toLowerCase()) return sanitized; + + const [product, endpoint, originalLabel, ...originalResourceParts] = parts; + const resourceParts = [...originalResourceParts]; + let label = originalLabel; + const generatedProtocolTitle = resourceParts.at(-1) || ""; + const generatedProtocolMatch = generatedProtocolTitle.match(/^(.*?)(?:\s+#(\d+))?$/); + if (generatedProtocolMatch?.[1]?.trim().toLowerCase() === originalLabel.toLowerCase()) { + resourceParts.pop(); + if (generatedProtocolMatch[2]) label = `${originalLabel} #${generatedProtocolMatch[2]}`; + } + + const resource = resourceParts.join(" · ").trim(); + const uniqueResource = resource + && desktopWindowTitleEndpointIdentity(resource) !== desktopWindowTitleEndpointIdentity(endpoint) + && resource.toLowerCase() !== label.toLowerCase() + ? resource + : ""; + return sanitizeAuxiliaryWindowTitle([product, endpoint, label, uniqueResource].filter(Boolean).join(" · "), PRODUCT_NAME); +} + function configureAuxiliaryDesktopWindow(window) { if (!window || window.isDestroyed()) return; window.webContents.setWindowOpenHandler(({ url }) => { @@ -2851,7 +2885,7 @@ function registerDesktopClipboardHandlers() { ipcMain.on("terma:set-window-title", (event, value) => { const window = desktopWindowForSender(event); if (!window || window !== mainWindow || window.isDestroyed()) return; - const title = sanitizeAuxiliaryWindowTitle(String(value || PRODUCT_NAME), PRODUCT_NAME); + const title = normalizeMainWindowTitle(value); window.setTitle(title); }); ipcMain.handle("terma:clipboard-read", event => { diff --git a/public/app-docking.js b/public/app-docking.js index 6271ec7..dc6c443 100644 --- a/public/app-docking.js +++ b/public/app-docking.js @@ -565,6 +565,7 @@ function syncWorkspaceTabActivation(pane, key) { const tab = tabs.find(item => item.key === key); if (tab) tab.activityState = ""; syncWorkspaceLegacyTabIds(); + renderWorkspaceGroupBar(); revealWorkspaceTab(key); if (!window.restoringTabs) saveTabsState(); } @@ -961,6 +962,7 @@ closeTabsByKey = function(keys, anchorKey="") { } if (typeof rememberClosedWorkspaceTabs === "function") rememberClosedWorkspaceTabs([...targets]); const anchorPane = workspaceFindPaneForTab(anchorKey) || workspaceFindPane(focusedPaneId); + const paneActiveKeysBeforeClose = new Map(workspaceLeaves().map(pane => [pane.id, pane.activeTabKey])); for (const key of targets) { const tab = tabs.find(item => item.key === key); closeTerminalSession(key); @@ -994,6 +996,10 @@ closeTabsByKey = function(keys, anchorKey="") { focusedPaneId = focusedPane.id; activeTabKey = focusedPane.activeTabKey || ""; renderTabs({rebuildLayout:paneIdsBeforeNormalize !== paneIdsAfterNormalize}); + for (const pane of workspaceVisiblePanes()) { + if (pane.id === focusedPane.id || !pane.activeTabKey) continue; + if (paneActiveKeysBeforeClose.get(pane.id) !== pane.activeTabKey) renderWorkspacePaneContent(pane.id); + } if (activeTabKey) { const tab = tabs.find(item => item.key === activeTabKey); activeView = tab?.viewName || tab?.kind || "welcome"; diff --git a/public/app-remote-profiles.js b/public/app-remote-profiles.js index 5a2eda0..1a34161 100644 --- a/public/app-remote-profiles.js +++ b/public/app-remote-profiles.js @@ -544,6 +544,10 @@ async function openRemoteDesktop(id, updateTab=true, showManagement=false) { } const view = $("view-remote-desktop"); if (!view) return; + const browserReservation = updateTab && !showManagement && remoteDesktopQuickOpen && embeddedVnc && vncQuickOpenUsesNewWindow() + ? reserveVncDetachedBrowserWindow(profile.id) + : null; + let browserReservationCommitted = false; const renderScope = captureRemoteDesktopRenderScope(profile.id, key, view); const embeddedXdmcp = profile.protocol === "xdmcp"; const managedRdp = profile.protocol === "rdp"; @@ -652,7 +656,7 @@ async function openRemoteDesktop(id, updateTab=true, showManagement=false) { if (embeddedVnc && existingVncSession?.presentation === "management") syncEmbeddedVncManagementControls(existingVncSession, activeView); const xdmcpDirectReady = !embeddedXdmcp || serverState?.ready_for_login || serverState?.management_available === false || Boolean(serverState?.error) || Boolean(serverState?.endpoint_probe?.ok); if (updateTab && remoteDesktopQuickOpen && clientLaunchable && xdmcpDirectReady && rdpEndpointReady && vncReadyForLaunch) { - if (embeddedVnc && vncQuickOpenUsesNewWindow()) await openVncInNewWindow(profile.id, key, {closeDetectionTab:true}); + if (embeddedVnc && vncQuickOpenUsesNewWindow()) browserReservationCommitted = await openVncInNewWindow(profile.id, key, {closeDetectionTab:true, browserReservation}); else if (embeddedVnc) await openEmbeddedVncDesktop(profile.id, key); else await launchRemoteDesktop(profile.id, key); } @@ -664,5 +668,7 @@ async function openRemoteDesktop(id, updateTab=true, showManagement=false) { status.className = "connection-test-status error"; status.textContent = error.message; }); + } finally { + if (!browserReservationCommitted) cancelReservedVncDetachedBrowserWindow(browserReservation); } } diff --git a/public/app-sftp.js b/public/app-sftp.js index 209cc49..cdbf9b8 100644 --- a/public/app-sftp.js +++ b/public/app-sftp.js @@ -653,6 +653,13 @@ function prepareSftpEditorSave(title, content, encoding="utf8", lineEnding="lf") }; } +function sftpEditorByteMeasurement(content, encoding="utf8") { + const selectedEncoding = String(encoding || "utf8").toLowerCase(); + const exact = selectedEncoding === "utf8" || selectedEncoding === "utf8bom"; + const bytes = new Blob([String(content || "")]).size + (selectedEncoding === "utf8bom" ? 3 : 0); + return {bytes, exact}; +} + const sftpEditorLanguageOptions = [ ["plain_text","纯文本"], ["yaml","YAML"], ["json","JSON"], ["xml","XML"], ["ini","INI / 配置"], ["properties","Properties"], ["toml","TOML"], ["sh","Shell"], ["batchfile","BAT / CMD"], ["powershell","PowerShell"], @@ -965,14 +972,26 @@ function sftpTextModal(title, content, size=0, limit=5*1024*1024, encoding="utf8 $("sftpTextFormatJson").hidden = useLightEditor || !isSftpJsonFileName(title) || selectedLanguage() !== "json"; }; let contentModified = false; - const updateStats = (force=false, providedValue=null) => { + const updateStats = (force=false, providedValue=null, providedEncoding="") => { if (useLightEditor && contentModified && !force) { $("sftpEditorStats").textContent = tr("sftp:editor.modified_check_size", {defaultValue:"已修改 · 保存时检查大小"}); + $("sftpEditorStats").classList.remove("limit-exceeded"); + saveButton.disabled = false; return true; } const initial = !contentModified; const value = initial && useLightEditor ? "" : (providedValue === null ? getValue() : providedValue); - const bytes = initial ? Number(size || 0) : new Blob([value]).size; + const measurement = initial + ? {bytes:Number(size || 0), exact:true} + : sftpEditorByteMeasurement(value, providedEncoding || $("sftpTextEncoding")?.value || encoding); + if (!measurement.exact) { + const stats = $("sftpEditorStats"); + stats.textContent = tr("sftp:editor.modified_check_size", {defaultValue:"已修改 · 保存时检查大小"}); + stats.classList.remove("limit-exceeded"); + saveButton.disabled = false; + return true; + } + const bytes = measurement.bytes; const tooLarge = bytes > limit; const stats = $("sftpEditorStats"); const lines = initial && Number(diffOptions.lineCount) > 0 ? Number(diffOptions.lineCount) : value.split("\n").length; @@ -1088,7 +1107,7 @@ function sftpTextModal(title, content, size=0, limit=5*1024*1024, encoding="utf8 $("sftpTextSave").onclick = () => { const value = getValue(); const prepared = prepareSftpEditorSave(title, value, $("sftpTextEncoding").value, $("sftpLineEnding").value); - if (!updateStats(true, prepared.content)) return notify(tr("sftp:editor.content_too_large", {limit:formatBytes(limit), defaultValue:`在线编辑内容不能超过 ${formatBytes(limit)}`}), "error"); + if (!updateStats(true, prepared.content, prepared.encoding)) return notify(tr("sftp:editor.content_too_large", {limit:formatBytes(limit), defaultValue:`在线编辑内容不能超过 ${formatBytes(limit)}`}), "error"); finish({action:"save", content:prepared.content, changed:contentModified || prepared.changed || scriptNeedsFormatRepair, backup:$("sftpBackupBeforeSave").checked, encoding:prepared.encoding, line_ending:prepared.lineEnding, normalized_script:prepared.unixScript, persist_default:$("sftpPersistEncoding").checked}); }; $("sftpTextClose").onclick = async () => { diff --git a/public/app-vnc-window.js b/public/app-vnc-window.js index a6b5e4a..879bd0f 100644 --- a/public/app-vnc-window.js +++ b/public/app-vnc-window.js @@ -4,6 +4,25 @@ function isDetachedVncWindow() { const browserDetachedVncWindows = new Map(); +function reserveVncDetachedBrowserWindow(profileId) { + const id = Number(profileId || 0); + if (!Number.isInteger(id) || id <= 0 || window.termaDesktop?.openVncWindow) return null; + const existing = browserDetachedVncWindows.get(id); + if (existing && !existing.closed) return {profileId:id, child:existing, created:false, blocked:false}; + const child = window.open("", `terma-vnc-${id}`, "popup,width=1280,height=820"); + if (!child) return {profileId:id, child:null, created:false, blocked:true}; + browserDetachedVncWindows.set(id, child); + return {profileId:id, child, created:true, blocked:false}; +} + +function cancelReservedVncDetachedBrowserWindow(reservation) { + if (!reservation?.created) return; + const id = Number(reservation.profileId || 0); + const child = reservation.child; + if (child && !child.closed) child.close(); + if (browserDetachedVncWindows.get(id) === child) browserDetachedVncWindows.delete(id); +} + function embeddedVncSessionKeysForProfile(profileId, preferredKey="") { const id = Number(profileId || 0); const keys = new Set(); @@ -44,8 +63,8 @@ async function openVncInNewWindow(profileId, key="", options={}) { const id = Number(profileId || 0); if (!Number.isInteger(id) || id <= 0) return notify(tr("remote:vnc_ui.detached_profile_missing", {defaultValue:"VNC 连接不存在"}), "error"); try { - const managementReady = prepareVncManagementForDetachedWindow(id, key); if (window.termaDesktop?.openVncWindow) { + const managementReady = prepareVncManagementForDetachedWindow(id, key); await window.termaDesktop.openVncWindow(id, {key}); await managementReady; if (options.closeDetectionTab && key && tabs.some(tab => tab.key === key)) closeTabsByKey([key], key); @@ -53,20 +72,25 @@ async function openVncInNewWindow(profileId, key="", options={}) { } const url = new URL(location.href); url.searchParams.set("termaVncWindow", String(id)); - let child = browserDetachedVncWindows.get(id); - if (child && !child.closed) { + const reservation = Number(options.browserReservation?.profileId || 0) === id ? options.browserReservation : null; + if (reservation?.blocked) throw new Error(tr("remote:vnc_ui.detached_open_failed", {defaultValue:"无法打开新窗口,请检查浏览器弹窗权限"})); + let child = reservation?.child || browserDetachedVncWindows.get(id); + if (child && !child.closed && !reservation?.created) { child.focus(); - await managementReady; + await prepareVncManagementForDetachedWindow(id, key); if (options.closeDetectionTab && key && tabs.some(tab => tab.key === key)) closeTabsByKey([key], key); return true; } - child = window.open(url.href, `terma-vnc-${id}`, "popup,width=1280,height=820"); + if (!child || child.closed) child = window.open(url.href, `terma-vnc-${id}`, "popup,width=1280,height=820"); if (!child) throw new Error(tr("remote:vnc_ui.detached_open_failed", {defaultValue:"无法打开新窗口,请检查浏览器弹窗权限"})); browserDetachedVncWindows.set(id, child); - await managementReady; + if (reservation?.created) child.location.replace(url.href); + child.focus(); + await prepareVncManagementForDetachedWindow(id, key); if (options.closeDetectionTab && key && tabs.some(tab => tab.key === key)) closeTabsByKey([key], key); return true; } catch (error) { + cancelReservedVncDetachedBrowserWindow(options.browserReservation); notify(window.termaDesktop?.openVncWindow ? tr("remote:vnc_ui.detached_open_failed", {defaultValue:"无法打开 VNC 新窗口,请重试"}) : error.message || tr("remote:vnc_ui.detached_open_failed", {defaultValue:"无法打开 VNC 新窗口"}), "error"); diff --git a/public/app-workspace.js b/public/app-workspace.js index 5be29a7..3e856c0 100644 --- a/public/app-workspace.js +++ b/public/app-workspace.js @@ -1079,7 +1079,7 @@ function renderExplorerTools() { const quickOpenTitle = tr(remoteDesktopQuickOpen ? "remote:auto.quick_open_enabled_title" : "remote:auto.quick_open_disabled_title", { defaultValue:remoteDesktopQuickOpen ? "快捷打开:已开启,探测通过后自动打开远程桌面" : "快捷打开:已关闭,只进入探测界面" }); - const quickOpenButton = ``; + const quickOpenButton = ``; tools.innerHTML = `
${icon("search")}
@@ -1106,25 +1106,41 @@ function renderExplorerTools() {
`; } +let remoteDesktopQuickOpenToggleQueue = Promise.resolve(); +let remoteDesktopQuickOpenToggleTarget = null; +let remoteDesktopQuickOpenTogglePending = 0; + async function toggleRemoteDesktopQuickOpen() { - const nextValue = !remoteDesktopQuickOpen; - try { - const result = await api("/api/runtime-settings", { - method:"PUT", - body:JSON.stringify({remote_desktop_quick_open_enabled:nextValue}) - }); - runtimeSettings = normalizeRuntimeSettingsResponse({...runtimeSettings, ...result}); - remoteDesktopQuickOpen = runtimeSettings.saved.remote_desktop_quick_open_enabled === true; - localStorage.removeItem("remoteDesktopQuickOpen"); - renderExplorerTools(); - notify(tr(remoteDesktopQuickOpen ? "remote:auto.quick_open_enabled_notice" : "remote:auto.quick_open_disabled_notice", { - defaultValue:remoteDesktopQuickOpen - ? "已开启快捷打开:远程桌面探测通过后会自动启动" - : "已关闭快捷打开:远程桌面默认停留在探测界面" - }), "info"); - } catch (error) { - notify(error.message || tr("settings:auto.workspace_save_failed", {defaultValue:"工作区设置保存失败"}), "error"); - } + const nextValue = !(remoteDesktopQuickOpenToggleTarget ?? remoteDesktopQuickOpen); + remoteDesktopQuickOpenToggleTarget = nextValue; + remoteDesktopQuickOpenTogglePending += 1; + renderExplorerTools(); + const operation = remoteDesktopQuickOpenToggleQueue.then(async () => { + try { + const result = await api("/api/runtime-settings", { + method:"PUT", + body:JSON.stringify({remote_desktop_quick_open_enabled:nextValue}) + }); + runtimeSettings = normalizeRuntimeSettingsResponse({...runtimeSettings, ...result}); + remoteDesktopQuickOpen = runtimeSettings.saved.remote_desktop_quick_open_enabled === true; + localStorage.removeItem("remoteDesktopQuickOpen"); + notify(tr(remoteDesktopQuickOpen ? "remote:auto.quick_open_enabled_notice" : "remote:auto.quick_open_disabled_notice", { + defaultValue:remoteDesktopQuickOpen + ? "已开启快捷打开:远程桌面探测通过后会自动启动" + : "已关闭快捷打开:远程桌面默认停留在探测界面" + }), "info"); + return remoteDesktopQuickOpen; + } catch (error) { + notify(error.message || tr("settings:auto.workspace_save_failed", {defaultValue:"工作区设置保存失败"}), "error"); + return remoteDesktopQuickOpen; + } finally { + remoteDesktopQuickOpenTogglePending = Math.max(0, remoteDesktopQuickOpenTogglePending - 1); + if (!remoteDesktopQuickOpenTogglePending) remoteDesktopQuickOpenToggleTarget = null; + renderExplorerTools(); + } + }); + remoteDesktopQuickOpenToggleQueue = operation.catch(() => {}); + return operation; } function showConnectionExplorerMenu(event) { diff --git a/scripts/desktop-startup-check.js b/scripts/desktop-startup-check.js index c03f8e0..e1df729 100644 --- a/scripts/desktop-startup-check.js +++ b/scripts/desktop-startup-check.js @@ -23,7 +23,7 @@ assert.match(desktopMainSource, /terma:notification-event/, "desktop notificatio assert.match(desktopPreloadSource, /onNotification\(callback\)/, "preload must expose the notification event bridge"); assert.match(desktopPreloadSource, /onNotificationAction\(callback\)/, "preload must expose notification actions without Node access"); assert.match(desktopPreloadSource, /setWindowTitle\(title\)[\s\S]*?terma:set-window-title/, "preload must expose the current resource title to the desktop window"); -assert.match(desktopMainSource, /terma:set-window-title[\s\S]*?window !== mainWindow[\s\S]*?window\.setTitle\(title\)/, "desktop window titles must stay scoped to the main renderer"); +assert.match(desktopMainSource, /terma:set-window-title[\s\S]*?window !== mainWindow[\s\S]*?normalizeMainWindowTitle\(value\)[\s\S]*?window\.setTitle\(title\)/, "desktop window titles must stay scoped to the main renderer and normalize structural duplicates"); assert.match(appSource, /if \(!window\.termaDesktop\) pollNotifications\(\)/, "desktop renderer must not duplicate the main-process notification poll"); assert.match(desktopMainSource, /termaDisplaySession:\s*localLinuxDisplaySession/, "Linux second launches must report their graphical session with the Terma field"); assert.match(desktopMainSource, /additionalData\?\.termaDisplaySession\s*\|\|\s*additionalData\?\.tunneldeskDisplaySession/, "Linux second launches must still accept the legacy TunnelDesk field"); @@ -89,6 +89,7 @@ globalThis.__desktopStartupTestApi = { isWindowsPortable, desktopStartupFailurePresentation, desktopNotificationAllowed, + normalizeMainWindowTitle, userRuntimeRoot, legacyPackagedRoot, resolveRuntimePaths, @@ -1264,4 +1265,24 @@ check("Desktop background notifications respect global mode and severity switche assert.equal(api.desktopNotificationAllowed({level:"success"}, {mode:"off", success:true}), false); }); +check("Desktop resource titles remove only structural endpoint and protocol duplication", () => { + const { api } = createHarness(); + assert.equal( + api.normalizeMainWindowTitle("Terma · 210.10.1.134:22 · SFTP · 210.10.1.134 · SFTP"), + "Terma · 210.10.1.134:22 · SFTP" + ); + assert.equal( + api.normalizeMainWindowTitle("Terma · server.example:22 · SFTP · Production · SFTP #2"), + "Terma · server.example:22 · SFTP #2 · Production" + ); + assert.equal( + api.normalizeMainWindowTitle("Terma · 210.10.1.134:5900 · VNC · 210.10.1.134"), + "Terma · 210.10.1.134:5900 · VNC" + ); + assert.equal( + api.normalizeMainWindowTitle("Terma · server.example:22 · Terminal · Backup Backup"), + "Terma · server.example:22 · Terminal · Backup Backup" + ); +}); + console.log("Desktop startup semantics passed."); diff --git a/scripts/regression-check.js b/scripts/regression-check.js index a024559..c20a940 100644 --- a/scripts/regression-check.js +++ b/scripts/regression-check.js @@ -494,7 +494,7 @@ async function main() { ok("终端默认显示真实交互响应延迟且可在通用设置关闭", frontend.includes('localStorage.getItem("terminalLatencyVisible") !== "0"') && terminalFrontend.includes("startTerminalLatencySample") && terminalFrontend.includes("finishTerminalLatencySample") && terminalFrontend.includes('tr("terminal:latency.hint"') && terminalFrontend.includes('tr("terminal:latency.latest"') && settingsFrontend.includes('id="terminalLatencyVisible"') && settingsFrontend.includes('tr("settings:auto.terminal_latency_hint"')); ok("终端连接状态省略时悬停显示完整地址与状态", terminalFrontend.includes("updateTerminalConnectionStatus") && terminalFrontend.includes("updateTerminalStatusForLayout") && terminalFrontend.includes("status.title = `${address}${state ? ` · ${state}` : \"\"}`") && terminalFrontend.includes('title="${esc(connectionAddress)}"')); ok("终端工具栏桌面端使用纯图标并在窄屏分行保留全部按钮", appCss.includes("container-name:terminal-view") && appCss.includes("@container terminal-view (max-width:1080px)") && appCss.includes("@container terminal-toolbar (max-width:1080px)") && appCss.includes("@media (min-width:761px) and (hover:hover) and (pointer:fine)") && appCss.includes(".terminal-actions > button > span:not(.composite-icon)") && terminalFrontend.includes('tr("terminal:toolbar.forward_list"') && terminalFrontend.includes('class="terminal-action-forward-list"') && terminalFrontend.includes('icon("earth")')); - ok("X11、连接快捷入口和窗口标题保持统一", utilsFrontend.includes('name === "x11"') && settingsFrontend.includes('icon("x11")') && terminalFrontend.includes('icon("x11")') && productivityFrontend.includes('class="xserver-x-icon"') && productivityFrontend.includes('= 0 ? insertion + 1 : tabs.length") && dockingFrontend.includes("pane.tabs.splice(insertion >= 0 ? insertion + 1 : pane.tabs.length") && appEntry.includes("workspaceRestorePending = true") && workspaceFrontend.includes("if (window.workspaceRestorePending) return") && dockingFrontend.includes("if (window.workspaceRestorePending) return") && appEntry.includes("const restored = restoreTabsState()")); ok("SFTP 任务中心宽高跨重启持久化", sftpTasksFrontend.includes('SFTP_TASK_CENTER_SIZE_STORAGE_KEY = "sftpTaskCenterSizeV1"') && sftpTasksFrontend.includes("persistSftpTaskCenterSize") && sftpTasksFrontend.includes("restoreSftpTaskCenterSize") && sftpTasksFrontend.includes("localStorage.removeItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY)")); ok("移动端终端 SFTP 按钮保留完整文字宽度", appCss.includes("button.terminal-action-sftp { width:auto; min-width:84px; padding-inline:10px; }")); diff --git a/scripts/remote-install-ui-check.js b/scripts/remote-install-ui-check.js index 7c939d4..86bbaad 100644 --- a/scripts/remote-install-ui-check.js +++ b/scripts/remote-install-ui-check.js @@ -73,7 +73,7 @@ assert.match(settings, /remoteDesktopQuickOpen = legacyQuickOpen === null[\s\S]* assert.match(workspace, /async function toggleRemoteDesktopQuickOpen\(\)[\s\S]*?\/api\/runtime-settings[\s\S]*?remote_desktop_quick_open_enabled/, "quick open preference must persist in global runtime settings"); assert.match(workspace, /quickOpenButton[\s\S]*?aria-pressed[\s\S]*?icon\("zap"\)/, "Other Connections toolbar must expose a stateful quick-open button"); assert.match(remote, /updateTab && remoteDesktopQuickOpen && clientLaunchable/, "automatic launch must be gated by the quick-open preference"); -assert.match(remote, /vncQuickOpenUsesNewWindow\(\)[\s\S]*?openVncInNewWindow\(profile\.id, key, \{closeDetectionTab:true\}\)/, "VNC quick open must honor the global new-window preference and close the detection tab"); +assert.match(remote, /vncQuickOpenUsesNewWindow\(\)[\s\S]*?openVncInNewWindow\(profile\.id, key, \{closeDetectionTab:true, browserReservation\}\)/, "VNC quick open must honor the global new-window preference, reuse its reserved popup and close the detection tab"); assert.match(remote, /async function openRemoteDesktop[\s\S]*?captureRemoteDesktopRenderScope\(profile\.id, key, view\)[\s\S]*?await withRemoteDesktopRenderScope\(renderScope,[\s\S]*?catch \(error\) \{[\s\S]*?withRemoteDesktopRenderScope\(renderScope,/, "remote desktop diagnostics must ignore stale async results instead of touching a missing status element"); assert.match(workspace, /tab\.kind === "linux-desktop"[\s\S]{0,220}openLinuxDesktopManager\(connectionId, false\)/, "legacy workspace restoration must render Linux desktop manager tabs"); assert.match(remote, /id="vncServerState"[\s\S]*?正在探测远端 VNC 服务/, "VNC must enter the shared detection workspace before connecting"); diff --git a/scripts/ui-smoke-electron.js b/scripts/ui-smoke-electron.js index 154ff41..0d603c4 100644 --- a/scripts/ui-smoke-electron.js +++ b/scripts/ui-smoke-electron.js @@ -1085,6 +1085,7 @@ app.whenReady().then(async () => { const previousStoredTabHeight = localStorage.getItem('workspaceTabHeight'); const previousHeaderHeight = workspaceHeaderHeight; const previousTabHeight = workspaceTabHeight; + const previousRenderWorkspacePaneContent = renderWorkspacePaneContent; const nextFrame = () => new Promise(resolve => requestAnimationFrame(() => resolve())); const nearly = (value, expected, tolerance=0.6) => Math.abs(value - expected) <= tolerance; const dragChromeHandle = async (handle, pointerId, deltaY, kind) => { @@ -1307,12 +1308,34 @@ app.whenReady().then(async () => { && restoredTabSnapshot.aria.every(value => value === 45); const tabStorageIndependent = localStorage.getItem('workspaceHeaderHeight') === headerStorageBeforeTabResize; + const inactivePane = workspaceFindPaneForTab('dock-b'); + const movedIntoInactivePane = Boolean(inactivePane && sourcePane) + && applyWorkspaceTabDrop({key:'dock-d',sourcePaneId:sourcePane.id}, {paneId:inactivePane.id,zone:'tabs',index:inactivePane.tabs.length}); + const closeRenderCalls = []; + if (movedIntoInactivePane) { + inactivePane.activeTabKey = 'dock-b'; + focusedPaneId = sourcePane.id; + activeTabKey = sourcePane.activeTabKey; + renderWorkspacePaneContent = paneId => { + closeRenderCalls.push(paneId); + return previousRenderWorkspacePaneContent(paneId); + }; + try { + closeTabsByKey(['dock-b'], 'dock-b'); + } finally { + renderWorkspacePaneContent = previousRenderWorkspacePaneContent; + } + } + const inactiveCloseRerendered = movedIntoInactivePane + && inactivePane.activeTabKey === 'dock-d' + && closeRenderCalls.includes(inactivePane.id); + const cPane = workspaceFindPaneForTab('dock-c'); const mergedNested = Boolean(cPane && sourcePane) && applyWorkspaceTabDrop({key:'dock-c',sourcePaneId:cPane.id}, {paneId:sourcePane.id,zone:'tabs',index:sourcePane.tabs.length}); - const bPane = workspaceFindPaneForTab('dock-b'); - const mergedAll = Boolean(bPane && sourcePane) - && applyWorkspaceTabDrop({key:'dock-b',sourcePaneId:bPane.id}, {paneId:sourcePane.id,zone:'tabs',index:sourcePane.tabs.length}); + const dPane = workspaceFindPaneForTab('dock-d'); + const mergedAll = Boolean(dPane && sourcePane) + && applyWorkspaceTabDrop({key:'dock-d',sourcePaneId:dPane.id}, {paneId:sourcePane.id,zone:'tabs',index:sourcePane.tabs.length}); const collapsedToSinglePane = workspaceLayout.type === 'pane' && workspaceLeaves().length === 1 && document.querySelectorAll('#workspaceDock .workspace-pane').length === 1; document.querySelectorAll('.ui-smoke-tab-connection-dot').forEach(dot => dot.remove()); @@ -1343,6 +1366,7 @@ app.whenReady().then(async () => { tabDoubleClickResets, tabHeightRestored, tabStorageIndependent, + inactiveCloseRerendered, tabMin, tabMax, mergedNested, @@ -1350,6 +1374,7 @@ app.whenReady().then(async () => { collapsedToSinglePane }; } finally { + renderWorkspacePaneContent = previousRenderWorkspacePaneContent; if (workspaceChromeResize) endWorkspaceChromeResize(null, true); if (previousStoredHeaderHeight === null) localStorage.removeItem('workspaceHeaderHeight'); else localStorage.setItem('workspaceHeaderHeight', previousStoredHeaderHeight); @@ -2049,8 +2074,25 @@ app.whenReady().then(async () => { collectVisibleHan('vnc'); collectVisibleHan('document', true); const vncWindowBridgeDescriptor = Object.getOwnPropertyDescriptor(window, 'termaDesktop'); + const originalWindowOpen = window.open; const vncWindowLifecycle = []; try { + Object.defineProperty(window, 'termaDesktop', {configurable:true,writable:true,value:undefined}); + const browserPopup = { + closed:false, + location:{href:'about:blank',replace(value){this.href=String(value);}}, + focus(){}, + close(){this.closed=true;} + }; + window.open = () => browserPopup; + browserDetachedVncWindows.delete(Number(languageVncProfile.id)); + const browserReservation = reserveVncDetachedBrowserWindow(languageVncProfile.id); + if (!browserReservation?.created || browserReservation.child !== browserPopup || browserDetachedVncWindows.get(Number(languageVncProfile.id)) !== browserPopup) { + throw new Error('Web VNC popup was not reserved synchronously'); + } + cancelReservedVncDetachedBrowserWindow(browserReservation); + if (!browserPopup.closed || browserDetachedVncWindows.has(Number(languageVncProfile.id))) throw new Error('Unused Web VNC popup was not cleaned up'); + window.open = originalWindowOpen; Object.defineProperty(window, 'termaDesktop', { configurable:true, writable:true, @@ -2071,6 +2113,8 @@ app.whenReady().then(async () => { if (vncWindowLifecycle[0]?.action !== 'open-detached' || vncWindowLifecycle[0]?.embeddedActive) throw new Error('Detached VNC opened before the built-in session was closed'); if (!embeddedPrepared || vncWindowLifecycle[1]?.action !== 'close-detached') throw new Error('Switching to built-in VNC did not close the detached window'); } finally { + window.open = originalWindowOpen; + browserDetachedVncWindows.delete(Number(languageVncProfile.id)); if (vncWindowBridgeDescriptor) Object.defineProperty(window, 'termaDesktop', vncWindowBridgeDescriptor); else delete window.termaDesktop; } @@ -2664,11 +2708,15 @@ app.whenReady().then(async () => { }); await runI18nScenario('quick-open-notice', async () => { const previousQuickOpen = remoteDesktopQuickOpen; + const previousRuntimeSettings = runtimeSettings; const quickOpenApi = api; + const writes = []; remoteDesktopQuickOpen = false; api = async (path, options={}) => { if (String(path) === '/api/runtime-settings' && String(options.method || 'GET').toUpperCase() === 'PUT') { const body = JSON.parse(options.body || '{}'); + writes.push(body.remote_desktop_quick_open_enabled === true); + await Promise.resolve(); return normalizeRuntimeSettingsResponse({ ...runtimeSettings, saved:{...runtimeSettings.saved, remote_desktop_quick_open_enabled:body.remote_desktop_quick_open_enabled === true} @@ -2677,10 +2725,13 @@ app.whenReady().then(async () => { return quickOpenApi(path, options); }; try { - await toggleRemoteDesktopQuickOpen(); + await Promise.all([toggleRemoteDesktopQuickOpen(), toggleRemoteDesktopQuickOpen()]); + if (JSON.stringify(writes) !== JSON.stringify([true,false])) throw new Error('quick-open toggles were not serialized against the latest requested state'); + if (remoteDesktopQuickOpen || remoteDesktopQuickOpenTogglePending !== 0 || remoteDesktopQuickOpenToggleTarget !== null) throw new Error('quick-open double toggle did not restore the original state'); await collectTranslatedHan('quick-open-notice-open', true, document.getElementById('toast')); } finally { api = quickOpenApi; + runtimeSettings = previousRuntimeSettings; remoteDesktopQuickOpen = previousQuickOpen; localStorage.setItem('remoteDesktopQuickOpen', previousQuickOpen ? '1' : '0'); renderExplorerTools(); @@ -7826,7 +7877,7 @@ app.whenReady().then(async () => { && downloadRequests.some(request=>request.pathname.endsWith('/sftp/download')&&request.body.path==='/fixture/'+specialName), noDuplicateBatchNotice:noticeCalls===2 }; - const editorPromise = sftpTextModal('/tmp/gbk.txt', '中文内容', 8, 512*1024, 'gbk', 'auto'); + const editorPromise = sftpTextModal('/tmp/gbk.txt', 'A', 1, 2, 'gbk', 'auto'); await new Promise(resolve=>setTimeout(resolve,20)); const editorHost=document.querySelector('#sftpTextEditor'); const languageSelect=document.querySelector('#sftpEditorLanguage'); @@ -7847,6 +7898,15 @@ app.whenReady().then(async () => { lineEndings:[...document.querySelectorAll('#sftpLineEnding option')].map(option=>option.value), shellFormat:false }; + const gbkEditor=window.ace&&editorHost?ace.edit(editorHost):null; + gbkEditor?.setValue('中',-1); + await new Promise(resolve=>setTimeout(resolve,20)); + textEncodingUi.nonUtf8SaveAllowed=Boolean(!document.querySelector('#sftpTextSave')?.disabled + && document.querySelector('#sftpEditorStats')?.textContent.includes('保存时检查大小')); + const utf8Measurement=sftpEditorByteMeasurement('中','utf8'); + const utf8BomMeasurement=sftpEditorByteMeasurement('A','utf8bom'); + textEncodingUi.utf8LimitEnforced=utf8Measurement.exact&&utf8Measurement.bytes===3&&utf8Measurement.bytes>2; + textEncodingUi.utf8BomIncludesPrefix=utf8BomMeasurement.exact&&utf8BomMeasurement.bytes===4; if (languageSelect) { languageSelect.value='markdown'; languageSelect.dispatchEvent(new Event('change',{bubbles:true})); @@ -7859,8 +7919,9 @@ app.whenReady().then(async () => { textEncodingUi.nonJsonFormattingHidden=Boolean(nonJsonFormatButton?.hidden) && getComputedStyle(nonJsonFormatButton).display==='none'; } - document.querySelector('#sftpTextClose')?.click(); - await editorPromise; + document.querySelector('#sftpTextSave')?.click(); + const gbkSave=await editorPromise; + textEncodingUi.nonUtf8SaveSubmitted=Boolean(gbkSave?.action==='save'&&gbkSave?.encoding==='gbk'&&gbkSave?.content==='中'); const shellEditorPromise=sftpTextModal('/tmp/restart_Pms.sh','\uFEFF#!/bin/bash\\r\\necho restart',31,512*1024,'utf8bom','auto',{lineEnding:'crlf',bom:true,finalNewline:false}); await new Promise(resolve=>setTimeout(resolve,20)); const shellLineEnding=document.querySelector('#sftpLineEnding'); @@ -9917,6 +9978,7 @@ app.whenReady().then(async () => { || !workspaceDockingUi.tabDoubleClickResets || !workspaceDockingUi.tabHeightRestored || !workspaceDockingUi.tabStorageIndependent + || !workspaceDockingUi.inactiveCloseRerendered || !workspaceDockingUi.mergedNested || !workspaceDockingUi.mergedAll || !workspaceDockingUi.collapsedToSinglePane; @@ -10074,7 +10136,7 @@ app.whenReady().then(async () => { const globalSettingsUi = sftpUi.globalSettingsUi || {}; const downloadNoticeUi = sftpUi.downloadNoticeUi || {}; const jobUiFailed = !jobUi.found || !jobUi.singleGlobalEntry || !jobUi.noPaneTaskRegions || !jobUi.failedStatusVisible || !jobUi.totalProgressVisible || !jobUi.totalProgressIndeterminate || !jobUi.totalProgressHidesWhenIdle || !jobUi.floatingVisibleBelowHeader || !jobUi.floatingActions || !jobUi.floatingResumeAction || !jobUi.floatingProgress || !jobUi.floatingOpensTaskCenter || !jobUi.floatingCloseHidesCurrent || !jobUi.floatingNewTaskReopens || !jobUi.floatingMutePersists || !jobUi.floatingSettingRestores || !jobUi.drawerOpened || !jobUi.drawerDefaultCompact || !jobUi.currentOnly || !jobUi.currentActions || !jobUi.failedOnly || !jobUi.failedActions || !jobUi.failedClearAvailable || !jobUi.currentProgress || !jobUi.drawerResizable || !jobUi.drawerResizeAdaptive || !jobUi.drawerResizePersists || !jobUi.drawerResizeReset || !jobUi.deleteDuplicateBlocked || !jobUi.deleteKeepsDrawerOpen || !jobUi.taskLogInitialOpen || !jobUi.taskLogInitialBottom || !jobUi.taskLogRefreshKeepsOpen || !jobUi.taskLogRefreshShowsLatest || !jobUi.taskLogRefreshFollowsBottom || !jobUi.drawerFitsViewport || !jobUi.historyOnly || !jobUi.historyCounts || !jobUi.historyActions || !jobUi.outsideClickCloses || !jobUi.escapeCloses || !jobUi.runningStatusVisible || !jobUi.nativeDragTaskStopHidden || !jobUi.itemProgress || !jobUi.staleJobResponseIgnored || !jobUi.toastIconsAligned || !jobUi.toastOrderPreserved || !jobUi.toastStackedDown || !jobUi.toastAvoidsFloatingTask || !jobUi.toastExitAnimated || !jobUi.toastReflowAnimated || !jobUi.toastMovedUp; - const textEncodingUiFailed = !textEncodingUi.opened || !textEncodingUi.aceLoaded || textEncodingUi.selected !== 'gbk' || !textEncodingUi.manualLanguage || !textEncodingUi.nonJsonFormattingHidden || !textEncodingUi.lightPaged || !textEncodingUi.lightNextPage || !textEncodingUi.jsonFormatting || !textEncodingUi.jsonHiddenAfterLanguageChange || !textEncodingUi.json5FormattingHidden || !textEncodingUi.wordWrap || !textEncodingUi.persistDefault || !textEncodingUi.backup || !textEncodingUi.shellFormat || !['lf','crlf','cr'].every(value=>textEncodingUi.lineEndings?.includes(value)) || !['utf8','utf8bom','gb18030','gbk','big5','shift_jis','euc-kr','latin1'].every(value=>textEncodingUi.options?.includes(value)) || !['auto','json','yaml','xml','sh','batchfile','powershell','javascript','java','c_cpp','sql','markdown'].every(value=>textEncodingUi.languageOptions?.includes(value)); + const textEncodingUiFailed = !textEncodingUi.opened || !textEncodingUi.aceLoaded || textEncodingUi.selected !== 'gbk' || !textEncodingUi.manualLanguage || !textEncodingUi.nonJsonFormattingHidden || !textEncodingUi.nonUtf8SaveAllowed || !textEncodingUi.nonUtf8SaveSubmitted || !textEncodingUi.utf8LimitEnforced || !textEncodingUi.utf8BomIncludesPrefix || !textEncodingUi.lightPaged || !textEncodingUi.lightNextPage || !textEncodingUi.jsonFormatting || !textEncodingUi.jsonHiddenAfterLanguageChange || !textEncodingUi.json5FormattingHidden || !textEncodingUi.wordWrap || !textEncodingUi.persistDefault || !textEncodingUi.backup || !textEncodingUi.shellFormat || !['lf','crlf','cr'].every(value=>textEncodingUi.lineEndings?.includes(value)) || !['utf8','utf8bom','gb18030','gbk','big5','shift_jis','euc-kr','latin1'].every(value=>textEncodingUi.options?.includes(value)) || !['auto','json','yaml','xml','sh','batchfile','powershell','javascript','java','c_cpp','sql','markdown'].every(value=>textEncodingUi.languageOptions?.includes(value)); const nativeDragUiFailed = !nativeDragUi.found || !nativeDragUi.webExternalDragBlocked || !nativeDragUi.linuxFallbackNoticeOnce || !nativeDragUi.linuxFallbackUsesCompatibilityMode || !nativeDragUi.streamingPreparesOnPointerDown || !nativeDragUi.streamingThresholdActivatesOnce || !nativeDragUi.streamingCaptureCancelSurvives || !nativeDragUi.pointerUpCancelsPending || !nativeDragUi.streamingSkipsStage || !nativeDragUi.streamingNativeBlocksParallelBrowserDrag || !nativeDragUi.nativeIdleHintStable || !nativeDragUi.nativeOutsideHintStaysStable || !nativeDragUi.nativeMotionTargetsSftp || !nativeDragUi.nativeTransientMissKeepsTarget || !nativeDragUi.nativeFinalTransientMissKeepsTarget || !nativeDragUi.nativeReleasedClearsStaleTarget || !nativeDragUi.nativeResultCopiesOnce || !nativeDragUi.firstDragOnlyStages || !nativeDragUi.firstDragReset || !nativeDragUi.cacheReused || !nativeDragUi.cachedUnarmedStaysInternal || !nativeDragUi.sameWindowDropDoesNotArm || !nativeDragUi.armedDragStartsSynchronously || !nativeDragUi.failureRearmed || !nativeDragUi.successClearsState || !nativeDragUi.finderRenameNoticeShown; const sftpUiFailed = Boolean(sftpUi.error) || !connectionSessionUi.found || !connectionSessionUi.addressIncludesPort || !connectionSessionUi.disconnectedAction || !connectionSessionUi.disconnectedBanner || !connectionSessionUi.connectedAction || !connectionSessionUi.preservedWhileDisconnected || !connectionSessionUi.automaticConnectShared || !connectionSessionUi.manualDisconnectAutoReconnect || !connectionSessionUi.disconnectedTabSwitchDoesNotReconnect || !connectionSessionUi.disconnectedFolderOperationReconnects || !connectionSessionUi.dragFeedbackVisible || !connectionSessionUi.dragTargetViewActivated || !connectionSessionUi.targetListDropPrompt || !connectionSessionUi.targetListDropPromptStable || !connectionSessionUi.crossHostListDropCopies || !connectionSessionUi.crossHostPreviewHandoffSurvives || !connectionSessionUi.crossHostDropHasNoUploadToast || !connectionSessionUi.sameHostListDropCopies || !connectionSessionUi.terminalTabPreviewActivated || !connectionSessionUi.invalidTerminalDropRestoresSource || !connectionSessionUi.invalidSftpDropRestoresSource || !connectionSessionUi.acceptedTerminalDropStays || !connectionSessionUi.ownDragUploadSuppressed || !connectionSessionUi.armedPointerCancelClearsRequest || !connectionSessionUi.armedDragAllowsExternalUpload || !connectionSessionUi.staleInternalDragAllowsExternalUpload || !connectionSessionUi.desktopUriListDragAccepted || !connectionSessionUi.releasedDragAllowsExternalUpload || !connectionSessionUi.externalFileDropDetected || !connectionSessionUi.externalFileDropCollected || !connectionSessionUi.externalDropPromptIsSingle || !connectionSessionUi.externalDropPromptAvoidsWorkspaceChrome || !connectionSessionUi.externalDropPromptListCentered || !connectionSessionUi.externalDropSurfaceFillsWorkspace || !connectionSessionUi.externalDropPromptScrollClamped || !connectionSessionUi.externalDropPromptHorizontalClamped || !connectionSessionUi.externalDropPromptClears || nativeDragUiFailed || jobUiFailed || textEncodingUiFailed || !downloadNoticeUi.oncePerMode || !downloadNoticeUi.desktopPath || !downloadNoticeUi.browserDevice || !downloadNoticeUi.batchUsesSharedNotice || !downloadNoticeUi.browserSeparateChoice || !downloadNoticeUi.browserSeparateQueued || !downloadNoticeUi.noDuplicateBatchNotice || !globalSettingsUi.found || !globalSettingsUi.globalScope || !globalSettingsUi.controls || !globalSettingsUi.floatingProgressDefaultOn || !globalSettingsUi.floatingProgressCanRestore || !globalSettingsUi.downloadBehavior || !globalSettingsUi.defaultLimit || !globalSettingsUi.backdropIgnored || !globalSettingsUi.withinViewport || !globalSettingsUi.classicSurface || !globalSettingsUi.themedField || !directorySizeUi.idleButton || !directorySizeUi.requestedOnce || !directorySizeUi.exactBytes || !directorySizeUi.formatted || !directorySizeUi.refreshable || !sftpUi.fileOpenFeedback?.busy || !sftpUi.fileOpenFeedback?.duplicateBlocked || !sftpUi.fileOpenFeedback?.restored || !sftpUi.fileOpenFeedback?.interruptedRetry || !directoryCacheBehavior.sameResponseUntouched || !directoryCacheBehavior.changedResponseRendered || !directoryCacheBehavior.permissionFailureRestored || !sftpUi.searchKeyboardUi?.opened || !sftpUi.searchKeyboardUi?.closed || !sftpUi.searchKeyboardUi?.recursive || !sftpUi.searchKeyboardUi?.feedback || !sftpUi.syncIndicatorFollowsScroll || !sftpUi.diffComparisonUi || !sftpUi.columnLayoutUi?.order || !sftpUi.columnLayoutUi?.persisted || !sftpUi.columnLayoutUi?.resized || !sftpUi.columnLayoutUi?.pointerStable || !sftpUi.columnLayoutUi?.pairOnly || !sftpUi.columnLayoutUi?.adjacentResizeStable || !sftpUi.columnLayoutUi?.dividerUniform || !sftpUi.columnLayoutUi?.localNarrowResizable || !sftpUi.columnLayoutUi?.openButtonStable || !sftpUi.columnLayoutUi?.selectionToolbarStable || !sftpUi.columnLayoutUi?.scrollbarUnified || !sftpUi.columnLayoutUi?.globalCss || !directoryActionsUi.found || directoryActionsUi.stickyPosition !== 'sticky' || !directoryActionsUi.toolbarInHeader || !directoryActionsUi.navigationBeforeFavorites || !directoryActionsUi.reusedWithoutDirectoryReload || !expectedSftpToolActions.every(action=>directoryActionsUi.actionTitles?.includes(action)) || !directoryActionsUi.searchHidden || !directoryActionsUi.pathEditorHidden || !directoryActionsUi.emptyClipboardHidden || !directoryActionsUi.copyQueueVisible || !directoryActionsUi.copyCancelled || !directoryActionsUi.moveQueueVisible || !directoryActionsUi.moveCancelled || !directoryActionsUi.crossHostCopyEnabled || !directoryActionsUi.crossHostMoveDisabled || !directoryActionsUi.crossHostClipboardConflict || !directoryActionsUi.filenameEncodingMenu || !directoryActionsUi.emptyFavoritesCompact || !directoryActionsUi.wideNavigationCompact || !directoryActionsUi.narrowNavigationCompact || !directoryActionsUi.terminalJump || !directoryActionsUi.terminalJumpFirst || !sftpUi.folderOpened || !sftpUi.fileOpened || !sftpUi.unknownAction || sftpUi.stickyPosition !== "sticky" || !sftpUi.breadcrumbScrollable || !sftpUi.singlePathPresentation || sftpUi.breadcrumbLabels?.join('/') !== '根目录/Users/demo/Public' || sftpUi.breadcrumbText.includes('//') || !sftpUi.selectionShown || !sftpUi.selectionActionsShown || !sftpUi.multiNameAddsSelection || !sftpUi.multiNameCancelsSelection || !sftpUi.singleNameReplacesSelection || !sftpUi.specialSelectionExact || sftpUi.selectedRows !== 2 || !sftpUi.dragSelectionSynchronized || !sftpUi.selectionCleared || !sftpUi.fileHasCompression || !sftpUi.permissionOwnerColumn || !sftpUi.permissionOwnerTitle || !sftpUi.symlinkUsesTargetSize || !sftpUi.symlinkExplainsBothSizes || !sftpUi.symlinkMarked || !sftpUi.wideColumnAlignment || !sftpUi.wideActionsFit || !sftpUi.compactSizeVisible || !sftpUi.compactTimeVisible || !sftpUi.compactAccessVisible || !sftpUi.compactMediumHidden || !sftpUi.compactCoreVisible || !sftpUi.compactHorizontalScroll || !sftpUi.permissionModeSync || !sftpUi.recursiveVisible || sftpUi.compactRowHeight > 48 || !sftpUi.moreMenuOpened || !sftpUi.contextMenuOpened || !sftpUi.directoryDownloadMenu || !sftpUi.narrowLayoutClass || !sftpUi.narrowCoreHidden || !sftpUi.narrowMoreVisible || !sftpUi.narrowMetaVisible || !sftpUi.narrowAccessHidden || !sftpUi.narrowHeaderNameVisible || !sftpUi.narrowHeaderSummaryVisible || !sftpUi.narrowCompactActions || !sftpUi.completedMutationDetected || !sftpUi.desktopPagerSingleRow || !sftpUi.pagerFloatsAtWorkspaceBottom || !sftpUi.pagerOpaqueAndElevated || !sftpUi.pagerDockSealsBottom || !sftpUi.pagerPinnedToViewport || !sftpUi.scrollCueVisibleAboveContent || !sftpUi.scrollCueHidesAtEnd || !sftpUi.narrowPagerWraps || sftpUi.pageRows !== 50 || !sftpUi.pagerVisible || !sftpUi.pagerText.includes('第 1/2 页') || !sftpUi.previousDisabled || !sftpUi.nextEnabled; const sftpToolbarRecoveryFailed = !directoryActionsUi.recoveredMissingToolbar || !directoryActionsUi.duplicateSftpToolbarsFollowActiveTab; diff --git a/scripts/vnc-detached-window-check.js b/scripts/vnc-detached-window-check.js index ae27a64..f2a41b4 100644 --- a/scripts/vnc-detached-window-check.js +++ b/scripts/vnc-detached-window-check.js @@ -37,6 +37,10 @@ assert.doesNotMatch(preload, /toggleVncWindowMaximize|terma:vnc-window-maximize/ assert.match(app, /termaVncWindow[\s\S]*?initDetachedVncWindow\(termaVncDetachedProfileId\)/, "the detached query must initialize only the VNC workspace"); assert.match(vncWindow, /function initDetachedVncWindow[\s\S]*?vnc-detached-root[\s\S]*?renderEmbeddedVnc\(profile, key, null, root, true\)/); assert.match(vncWindow, /prepareVncManagementForDetachedWindow[\s\S]*?closeRemoteProtocolSession\(key\)[\s\S]*?openRemoteDesktop\(id, false, true\)/, "switching to a detached window must close built-in VNC and restore the detection view"); +assert.match(vncWindow, /function reserveVncDetachedBrowserWindow[\s\S]*?window\.open\("", `terma-vnc-\$\{id\}`/, "web quick-open must reserve a popup synchronously while user activation is available"); +assert.match(vncWindow, /browserReservation[\s\S]*?child\.location\.replace\(url\.href\)/, "a reserved same-origin popup must navigate only after VNC probing succeeds"); +assert.match(remoteProfiles, /browserReservation = updateTab[\s\S]*?reserveVncDetachedBrowserWindow\(profile\.id\)[\s\S]*?await Promise\.all/, "VNC quick-open must reserve the browser window before asynchronous probing"); +assert.match(remoteProfiles, /openVncInNewWindow\(profile\.id, key, \{closeDetectionTab:true, browserReservation\}\)[\s\S]*?finally \{[\s\S]*?cancelReservedVncDetachedBrowserWindow\(browserReservation\)/, "failed or stale probes must close an unused reserved popup"); assert.match(vncWindow, /closeVncDetachedWindowForProfile[\s\S]*?closeVncWindowForProfile/, "switching back to built-in VNC must close the matching detached window"); assert.match(vnc, /openEmbeddedVncDesktop[\s\S]*?prepareEmbeddedVncWindowSwitch\(profile\.id\)/, "built-in VNC must close the detached window before rendering"); assert.match(vnc, /function renderEmbeddedVnc\(profile, key, diagnostics=null, targetView=null, detached=isDetachedVncWindow\(\)\)/, "main and detached windows must reuse the same VNC renderer"); diff --git a/scripts/workspace-docking-check.js b/scripts/workspace-docking-check.js index def324f..ff48a3e 100644 --- a/scripts/workspace-docking-check.js +++ b/scripts/workspace-docking-check.js @@ -131,6 +131,7 @@ function loadDockingModel() { addTab, setWorkspaceTabConnectionStatus, renderTabContent, + syncWorkspaceTabActivation, activateTab, closeTabsByKey, setLayout:value => { workspaceLayout = value; }, @@ -252,6 +253,46 @@ function runWorkspaceDockingChecks({silent=false}={}) { assert.equal(api.workspaceLeaves().map(item => item.id).join(","), "pane-4,pane-1,pane-2,pane-3"); }); + check("closing an inactive pane's active tab rerenders its replacement content", () => { + const left = pane("pane-close-left", ["close-left"]); + const right = pane("pane-close-right", ["close-right-a", "close-right-b"]); + right.activeTabKey = "close-right-a"; + api.setTabs([ + {key:"close-left", title:"Left", kind:"settings", closable:true}, + {key:"close-right-a", title:"Right A", kind:"settings", closable:true}, + {key:"close-right-b", title:"Right B", kind:"settings", closable:true} + ]); + api.setLayout(split("split-close", "row", left, right)); + api.setFocusedPane(left.id); + const rendered = []; + const previousRenderPane = sandbox.renderWorkspacePaneContent; + sandbox.renderWorkspacePaneContent = paneId => rendered.push(paneId); + try { + api.closeTabsByKey(["close-right-a"], "close-right-a"); + } finally { + sandbox.renderWorkspacePaneContent = previousRenderPane; + } + assert.equal(api.workspaceFindPane(right.id).activeTabKey, "close-right-b"); + assert.ok(rendered.includes(right.id), "the non-focused pane must render its newly active tab"); + }); + + check("incremental tab activation refreshes workspace group activity", () => { + const activePane = pane("pane-activity", ["activity-tab"]); + api.setTabs([{key:"activity-tab", title:"Activity", kind:"terminal", closable:true, activityState:"output"}]); + api.setLayout(activePane); + api.setFocusedPane(activePane.id); + let groupRenders = 0; + const previousGroupRender = sandbox.renderWorkspaceGroupBar; + sandbox.renderWorkspaceGroupBar = () => { groupRenders += 1; }; + try { + api.syncWorkspaceTabActivation(activePane, "activity-tab"); + } finally { + sandbox.renderWorkspaceGroupBar = previousGroupRender; + } + assert.equal(api.getTabs()[0].activityState, ""); + assert.equal(groupRenders, 1, "clearing tab activity must refresh the group activity marker"); + }); + check("Linux desktop manager tabs restore their content and legacy connection id", () => { api.renderTabContent({key:"linux-desktop-17", kind:"linux-desktop", id:17}); assert.equal(sandbox.linuxDesktopManagerOpen.connectionId, 17); From 6cbdd9fbea3255fc3181f708d4be4f3f66a6a6b9 Mon Sep 17 00:00:00 2001 From: JunXiaoRuo <47996900+JunXiaoRuo@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:48:20 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E5=AE=8C=E5=96=84VNC=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E4=B8=8ESFTP=E4=BF=9D=E5=AD=98=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/app-sftp.js | 14 ++++-- public/app-vnc-window.js | 75 +++++++++++++++++++++------- public/app-workspace.js | 17 ++++++- public/locales/en-US/sftp.json | 3 ++ public/locales/zh-CN/sftp.json | 3 ++ scripts/regression-check.js | 2 +- scripts/ui-smoke-electron.js | 55 ++++++++++++++++---- scripts/vnc-detached-window-check.js | 4 +- 8 files changed, 137 insertions(+), 36 deletions(-) diff --git a/public/app-sftp.js b/public/app-sftp.js index cdbf9b8..f30a3d4 100644 --- a/public/app-sftp.js +++ b/public/app-sftp.js @@ -615,9 +615,13 @@ const sftpTextEncodingOptions = [ ["big5","Big5"], ["shift_jis","Shift_JIS"], ["euc-kr","EUC-KR"], ["latin1","ISO-8859-1"] ]; -const sftpTextLineEndingOptions = [ - ["lf","LF (Unix/Linux)"], ["crlf","CRLF (Windows)"], ["cr","CR (Classic Mac)"] -]; +function sftpTextLineEndingOptions() { + return [ + ["lf",tr("sftp:editor.line_ending_lf", {defaultValue:"LF (Unix/Linux)"})], + ["crlf",tr("sftp:editor.line_ending_crlf", {defaultValue:"CRLF (Windows)"})], + ["cr",tr("sftp:editor.line_ending_cr", {defaultValue:"CR (Classic Mac)"})] + ]; +} function sftpTextEncodingLabel(value) { return sftpTextEncodingOptions.find(([encoding]) => encoding === value)?.[1] || String(value || "UTF-8"); @@ -830,7 +834,7 @@ function sftpTextModal(title, content, size=0, limit=5*1024*1024, encoding="utf8 ? versions.map((version, index) => ``).join("") : ``; const fileLimit = tr("sftp:editor.file_limit", {size:formatBytes(size), limit:formatBytes(limit), defaultValue:`${formatBytes(size)} · 上限 ${formatBytes(limit)}`}); - modal.innerHTML = ``; + modal.innerHTML = ``; modal.hidden = false; modal.onclick = null; let finished = false; @@ -979,7 +983,7 @@ function sftpTextModal(title, content, size=0, limit=5*1024*1024, encoding="utf8 saveButton.disabled = false; return true; } - const initial = !contentModified; + const initial = !contentModified && providedValue === null; const value = initial && useLightEditor ? "" : (providedValue === null ? getValue() : providedValue); const measurement = initial ? {bytes:Number(size || 0), exact:true} diff --git a/public/app-vnc-window.js b/public/app-vnc-window.js index 879bd0f..fb45211 100644 --- a/public/app-vnc-window.js +++ b/public/app-vnc-window.js @@ -3,24 +3,67 @@ function isDetachedVncWindow() { } const browserDetachedVncWindows = new Map(); +const browserDetachedVncReservations = new Map(); + +function activeVncDetachedBrowserReservation(profileId) { + const id = Number(profileId || 0); + const state = browserDetachedVncReservations.get(id); + if (!state || state.status !== "pending" || !state.child || state.child.closed) { + if (browserDetachedVncReservations.get(id) === state) browserDetachedVncReservations.delete(id); + return null; + } + return state; +} function reserveVncDetachedBrowserWindow(profileId) { const id = Number(profileId || 0); if (!Number.isInteger(id) || id <= 0 || window.termaDesktop?.openVncWindow) return null; + const pending = activeVncDetachedBrowserReservation(id); + if (pending) { + const claim = {}; + pending.claims.add(claim); + return {profileId:id, child:pending.child, created:false, blocked:false, pending:true, state:pending, claim}; + } const existing = browserDetachedVncWindows.get(id); - if (existing && !existing.closed) return {profileId:id, child:existing, created:false, blocked:false}; + if (existing && !existing.closed) return {profileId:id, child:existing, created:false, blocked:false, pending:false, committed:true}; const child = window.open("", `terma-vnc-${id}`, "popup,width=1280,height=820"); if (!child) return {profileId:id, child:null, created:false, blocked:true}; + const claim = {}; + const state = {profileId:id, child, status:"pending", claims:new Set([claim])}; browserDetachedVncWindows.set(id, child); - return {profileId:id, child, created:true, blocked:false}; + browserDetachedVncReservations.set(id, state); + return {profileId:id, child, created:true, blocked:false, pending:true, state, claim}; } function cancelReservedVncDetachedBrowserWindow(reservation) { - if (!reservation?.created) return; + if (!reservation || reservation.released) return; + reservation.released = true; + const id = Number(reservation.profileId || 0); + const state = reservation.state; + if (!state) return; + state.claims.delete(reservation.claim); + if (state.status !== "pending" || state.claims.size > 0) return; + state.status = "cancelled"; + if (browserDetachedVncReservations.get(id) === state) browserDetachedVncReservations.delete(id); + if (state.child && !state.child.closed) state.child.close(); + if (browserDetachedVncWindows.get(id) === state.child) browserDetachedVncWindows.delete(id); +} + +function commitReservedVncDetachedBrowserWindow(reservation, url) { + if (!reservation || reservation.blocked) return null; const id = Number(reservation.profileId || 0); const child = reservation.child; - if (child && !child.closed) child.close(); - if (browserDetachedVncWindows.get(id) === child) browserDetachedVncWindows.delete(id); + if (!child || child.closed) return null; + const state = reservation.state; + if (state?.status === "pending") { + if (browserDetachedVncReservations.get(id) !== state) return null; + state.status = "committed"; + browserDetachedVncReservations.delete(id); + child.location.replace(url); + } else if (state?.status === "cancelled") return null; + browserDetachedVncWindows.set(id, child); + reservation.committed = true; + return child; } function embeddedVncSessionKeysForProfile(profileId, preferredKey="") { @@ -52,8 +95,12 @@ async function closeVncDetachedWindowForProfile(profileId) { const child = browserDetachedVncWindows.get(id); if (!child || child.closed) { browserDetachedVncWindows.delete(id); + browserDetachedVncReservations.delete(id); return {ok:true, profileId:id, closed:false}; } + const pending = browserDetachedVncReservations.get(id); + if (pending) pending.status = "cancelled"; + browserDetachedVncReservations.delete(id); child.close(); browserDetachedVncWindows.delete(id); return {ok:true, profileId:id, closed:true}; @@ -62,6 +109,7 @@ async function closeVncDetachedWindowForProfile(profileId) { async function openVncInNewWindow(profileId, key="", options={}) { const id = Number(profileId || 0); if (!Number.isInteger(id) || id <= 0) return notify(tr("remote:vnc_ui.detached_profile_missing", {defaultValue:"VNC 连接不存在"}), "error"); + let browserReservation = Number(options.browserReservation?.profileId || 0) === id ? options.browserReservation : null; try { if (window.termaDesktop?.openVncWindow) { const managementReady = prepareVncManagementForDetachedWindow(id, key); @@ -72,25 +120,16 @@ async function openVncInNewWindow(profileId, key="", options={}) { } const url = new URL(location.href); url.searchParams.set("termaVncWindow", String(id)); - const reservation = Number(options.browserReservation?.profileId || 0) === id ? options.browserReservation : null; - if (reservation?.blocked) throw new Error(tr("remote:vnc_ui.detached_open_failed", {defaultValue:"无法打开新窗口,请检查浏览器弹窗权限"})); - let child = reservation?.child || browserDetachedVncWindows.get(id); - if (child && !child.closed && !reservation?.created) { - child.focus(); - await prepareVncManagementForDetachedWindow(id, key); - if (options.closeDetectionTab && key && tabs.some(tab => tab.key === key)) closeTabsByKey([key], key); - return true; - } - if (!child || child.closed) child = window.open(url.href, `terma-vnc-${id}`, "popup,width=1280,height=820"); + browserReservation ||= reserveVncDetachedBrowserWindow(id); + if (browserReservation?.blocked) throw new Error(tr("remote:vnc_ui.detached_open_failed", {defaultValue:"无法打开新窗口,请检查浏览器弹窗权限"})); + const child = commitReservedVncDetachedBrowserWindow(browserReservation, url.href); if (!child) throw new Error(tr("remote:vnc_ui.detached_open_failed", {defaultValue:"无法打开新窗口,请检查浏览器弹窗权限"})); - browserDetachedVncWindows.set(id, child); - if (reservation?.created) child.location.replace(url.href); child.focus(); await prepareVncManagementForDetachedWindow(id, key); if (options.closeDetectionTab && key && tabs.some(tab => tab.key === key)) closeTabsByKey([key], key); return true; } catch (error) { - cancelReservedVncDetachedBrowserWindow(options.browserReservation); + cancelReservedVncDetachedBrowserWindow(browserReservation); notify(window.termaDesktop?.openVncWindow ? tr("remote:vnc_ui.detached_open_failed", {defaultValue:"无法打开 VNC 新窗口,请重试"}) : error.message || tr("remote:vnc_ui.detached_open_failed", {defaultValue:"无法打开 VNC 新窗口"}), "error"); diff --git a/public/app-workspace.js b/public/app-workspace.js index 3e856c0..d45c963 100644 --- a/public/app-workspace.js +++ b/public/app-workspace.js @@ -803,6 +803,14 @@ function workspaceDocumentEndpoint(subtitle="") { return address.replace(/^\w+:\/\//, "").replace(/\/$/, ""); } +function workspaceDocumentResourceIdentity(value="") { + let identity = String(value || "").trim().toLowerCase().replace(/^\w+:\/\//, "").replace(/\/$/, ""); + if (identity.includes("@")) identity = identity.slice(identity.lastIndexOf("@") + 1); + if (/^\[[^\]]+\](?::\d+)?$/.test(identity)) return identity.replace(/^\[|\](?::\d+)?$/g, ""); + if ((identity.match(/:/g) || []).length === 1) identity = identity.replace(/:\d+$/, ""); + return identity; +} + function syncWorkspaceDocumentTitle(title, subtitle, viewName, key=viewName, meta={}) { const tab = tabs.find(item => item.key === key) || {}; const kind = String(meta.kind || tab.kind || viewName || ""); @@ -815,8 +823,13 @@ function syncWorkspaceDocumentTitle(title, subtitle, viewName, key=viewName, met "remote-desktop":protocol || tr("remote:auto.remote_desktop", {defaultValue:"远程桌面"}) }[kind] || ""; const endpoint = workspaceDocumentEndpoint(subtitle || tab.subtitle || ""); - const resource = String(title || tab.title || "").trim(); - const parts = ["Terma", endpoint, label, resource && resource !== endpoint && resource !== label ? resource : ""].filter(Boolean); + const resource = workspaceTabPresentation({...tab, ...meta, kind, protocol:protocol.toLowerCase(), title:String(title || tab.title || "").trim()}).title.trim(); + const uniqueResource = resource + && workspaceDocumentResourceIdentity(resource) !== workspaceDocumentResourceIdentity(endpoint) + && resource.toLowerCase() !== label.toLowerCase() + ? resource + : ""; + const parts = ["Terma", endpoint, label, uniqueResource].filter(Boolean); document.title = parts.join(" · "); window.termaDesktop?.setWindowTitle?.(document.title); } diff --git a/public/locales/en-US/sftp.json b/public/locales/en-US/sftp.json index 3f4d01e..dd0ec91 100644 --- a/public/locales/en-US/sftp.json +++ b/public/locales/en-US/sftp.json @@ -648,6 +648,9 @@ "file_limit": "{{size}} · Limit {{limit}}", "text_encoding": "Text encoding", "line_ending": "Line ending", + "line_ending_lf": "LF (Unix/Linux)", + "line_ending_crlf": "CRLF (Windows)", + "line_ending_cr": "CR (Classic Mac)", "language": "Language", "plain_text": "Plain text", "ini_configuration": "INI / Configuration", diff --git a/public/locales/zh-CN/sftp.json b/public/locales/zh-CN/sftp.json index f93cdf9..aa1998f 100644 --- a/public/locales/zh-CN/sftp.json +++ b/public/locales/zh-CN/sftp.json @@ -648,6 +648,9 @@ "file_limit": "{{size}} · 上限 {{limit}}", "text_encoding": "文本编码", "line_ending": "换行符", + "line_ending_lf": "LF(Unix/Linux)", + "line_ending_crlf": "CRLF(Windows)", + "line_ending_cr": "CR(经典 Mac)", "language": "语言", "plain_text": "纯文本", "ini_configuration": "INI / 配置", diff --git a/scripts/regression-check.js b/scripts/regression-check.js index c20a940..751c57f 100644 --- a/scripts/regression-check.js +++ b/scripts/regression-check.js @@ -494,7 +494,7 @@ async function main() { ok("终端默认显示真实交互响应延迟且可在通用设置关闭", frontend.includes('localStorage.getItem("terminalLatencyVisible") !== "0"') && terminalFrontend.includes("startTerminalLatencySample") && terminalFrontend.includes("finishTerminalLatencySample") && terminalFrontend.includes('tr("terminal:latency.hint"') && terminalFrontend.includes('tr("terminal:latency.latest"') && settingsFrontend.includes('id="terminalLatencyVisible"') && settingsFrontend.includes('tr("settings:auto.terminal_latency_hint"')); ok("终端连接状态省略时悬停显示完整地址与状态", terminalFrontend.includes("updateTerminalConnectionStatus") && terminalFrontend.includes("updateTerminalStatusForLayout") && terminalFrontend.includes("status.title = `${address}${state ? ` · ${state}` : \"\"}`") && terminalFrontend.includes('title="${esc(connectionAddress)}"')); ok("终端工具栏桌面端使用纯图标并在窄屏分行保留全部按钮", appCss.includes("container-name:terminal-view") && appCss.includes("@container terminal-view (max-width:1080px)") && appCss.includes("@container terminal-toolbar (max-width:1080px)") && appCss.includes("@media (min-width:761px) and (hover:hover) and (pointer:fine)") && appCss.includes(".terminal-actions > button > span:not(.composite-icon)") && terminalFrontend.includes('tr("terminal:toolbar.forward_list"') && terminalFrontend.includes('class="terminal-action-forward-list"') && terminalFrontend.includes('icon("earth")')); - ok("X11、连接快捷入口和窗口标题保持统一", utilsFrontend.includes('name === "x11"') && settingsFrontend.includes('icon("x11")') && terminalFrontend.includes('icon("x11")') && productivityFrontend.includes('class="xserver-x-icon"') && productivityFrontend.includes('= 0 ? insertion + 1 : tabs.length") && dockingFrontend.includes("pane.tabs.splice(insertion >= 0 ? insertion + 1 : pane.tabs.length") && appEntry.includes("workspaceRestorePending = true") && workspaceFrontend.includes("if (window.workspaceRestorePending) return") && dockingFrontend.includes("if (window.workspaceRestorePending) return") && appEntry.includes("const restored = restoreTabsState()")); ok("SFTP 任务中心宽高跨重启持久化", sftpTasksFrontend.includes('SFTP_TASK_CENTER_SIZE_STORAGE_KEY = "sftpTaskCenterSizeV1"') && sftpTasksFrontend.includes("persistSftpTaskCenterSize") && sftpTasksFrontend.includes("restoreSftpTaskCenterSize") && sftpTasksFrontend.includes("localStorage.removeItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY)")); ok("移动端终端 SFTP 按钮保留完整文字宽度", appCss.includes("button.terminal-action-sftp { width:auto; min-width:84px; padding-inline:10px; }")); diff --git a/scripts/ui-smoke-electron.js b/scripts/ui-smoke-electron.js index 0d603c4..912e845 100644 --- a/scripts/ui-smoke-electron.js +++ b/scripts/ui-smoke-electron.js @@ -2075,6 +2075,9 @@ app.whenReady().then(async () => { collectVisibleHan('document', true); const vncWindowBridgeDescriptor = Object.getOwnPropertyDescriptor(window, 'termaDesktop'); const originalWindowOpen = window.open; + const browserProfileId = Number(languageVncProfile.id); + const previousBrowserDetachedWindow = browserDetachedVncWindows.get(browserProfileId); + const previousBrowserDetachedReservation = browserDetachedVncReservations.get(browserProfileId); const vncWindowLifecycle = []; try { Object.defineProperty(window, 'termaDesktop', {configurable:true,writable:true,value:undefined}); @@ -2085,13 +2088,24 @@ app.whenReady().then(async () => { close(){this.closed=true;} }; window.open = () => browserPopup; - browserDetachedVncWindows.delete(Number(languageVncProfile.id)); - const browserReservation = reserveVncDetachedBrowserWindow(languageVncProfile.id); - if (!browserReservation?.created || browserReservation.child !== browserPopup || browserDetachedVncWindows.get(Number(languageVncProfile.id)) !== browserPopup) { + browserDetachedVncWindows.delete(browserProfileId); + browserDetachedVncReservations.delete(browserProfileId); + const firstBrowserReservation = reserveVncDetachedBrowserWindow(browserProfileId); + const secondBrowserReservation = reserveVncDetachedBrowserWindow(browserProfileId); + if (!firstBrowserReservation?.created || secondBrowserReservation?.created || firstBrowserReservation.child !== browserPopup || secondBrowserReservation?.child !== browserPopup || firstBrowserReservation.state !== secondBrowserReservation?.state || firstBrowserReservation.state?.claims.size !== 2) { throw new Error('Web VNC popup was not reserved synchronously'); } - cancelReservedVncDetachedBrowserWindow(browserReservation); - if (!browserPopup.closed || browserDetachedVncWindows.has(Number(languageVncProfile.id))) throw new Error('Unused Web VNC popup was not cleaned up'); + cancelReservedVncDetachedBrowserWindow(firstBrowserReservation); + if (browserPopup.closed || firstBrowserReservation.state?.claims.size !== 1 || !browserDetachedVncReservations.has(browserProfileId)) throw new Error('A stale VNC probe closed a popup still claimed by another request'); + const reservedUrl = new URL(location.href); + reservedUrl.searchParams.set('termaVncWindow', String(browserProfileId)); + if (commitReservedVncDetachedBrowserWindow(secondBrowserReservation, reservedUrl.href) !== browserPopup || browserPopup.location.href !== reservedUrl.href || browserPopup.closed || browserDetachedVncReservations.has(browserProfileId)) { + throw new Error('Shared Web VNC popup was not committed atomically'); + } + cancelReservedVncDetachedBrowserWindow(secondBrowserReservation); + if (browserPopup.closed) throw new Error('Committed Web VNC popup was closed while releasing its final probe claim'); + browserPopup.close(); + browserDetachedVncWindows.delete(browserProfileId); window.open = originalWindowOpen; Object.defineProperty(window, 'termaDesktop', { configurable:true, @@ -2114,7 +2128,10 @@ app.whenReady().then(async () => { if (!embeddedPrepared || vncWindowLifecycle[1]?.action !== 'close-detached') throw new Error('Switching to built-in VNC did not close the detached window'); } finally { window.open = originalWindowOpen; - browserDetachedVncWindows.delete(Number(languageVncProfile.id)); + if (previousBrowserDetachedWindow) browserDetachedVncWindows.set(browserProfileId, previousBrowserDetachedWindow); + else browserDetachedVncWindows.delete(browserProfileId); + if (previousBrowserDetachedReservation) browserDetachedVncReservations.set(browserProfileId, previousBrowserDetachedReservation); + else browserDetachedVncReservations.delete(browserProfileId); if (vncWindowBridgeDescriptor) Object.defineProperty(window, 'termaDesktop', vncWindowBridgeDescriptor); else delete window.termaDesktop; } @@ -2709,6 +2726,9 @@ app.whenReady().then(async () => { await runI18nScenario('quick-open-notice', async () => { const previousQuickOpen = remoteDesktopQuickOpen; const previousRuntimeSettings = runtimeSettings; + const previousQuickOpenToggleQueue = remoteDesktopQuickOpenToggleQueue; + const previousQuickOpenToggleTarget = remoteDesktopQuickOpenToggleTarget; + const previousQuickOpenTogglePending = remoteDesktopQuickOpenTogglePending; const quickOpenApi = api; const writes = []; remoteDesktopQuickOpen = false; @@ -2733,6 +2753,9 @@ app.whenReady().then(async () => { api = quickOpenApi; runtimeSettings = previousRuntimeSettings; remoteDesktopQuickOpen = previousQuickOpen; + remoteDesktopQuickOpenToggleQueue = previousQuickOpenToggleQueue; + remoteDesktopQuickOpenToggleTarget = previousQuickOpenToggleTarget; + remoteDesktopQuickOpenTogglePending = previousQuickOpenTogglePending; localStorage.setItem('remoteDesktopQuickOpen', previousQuickOpen ? '1' : '0'); renderExplorerTools(); } @@ -4197,6 +4220,11 @@ app.whenReady().then(async () => { document.querySelector('#view-terminal').innerHTML='
'; setWorkspace('终端测试',connectionAddress,'terminal',key,false,true,{kind:'terminal',id:first.id}); const resourceWindowTitle = document.title === 'Terma · '+first.ssh_host+':'+first.ssh_port+' · 终端 · 终端测试'; + const previousDocumentTitle = document.title; + syncWorkspaceDocumentTitle('210.10.1.134 · VNC', '210.10.1.134:5900', 'remote-desktop', 'ui-smoke-vnc-title', {kind:'remote-desktop', protocol:'vnc'}); + const remoteDesktopTitleDedup = document.title === 'Terma · 210.10.1.134:5900 · VNC'; + document.title = previousDocumentTitle; + window.termaDesktop?.setWindowTitle?.(previousDocumentTitle); activeTabKey = key; updateTerminalConnectionStatus(first, key, 'connected'); const statusIndicator = document.querySelector('#terminalStatus'); @@ -4953,7 +4981,7 @@ app.whenReady().then(async () => { terminalLatencyVisible = previousLatencyVisible; if (previousLatencyStored === null) localStorage.removeItem('terminalLatencyVisible'); else localStorage.setItem('terminalLatencyVisible', previousLatencyStored); - return {found:true,labels,metrics,desktopBackHidden,desktopKeysHidden,binaryType,binaryWrite,stableLogId,x11DefaultFallsBack,x11ScopeMenu,ctrlVImageIntercepted,ctrlVEmptyFallsThrough,ctrlVDiagnostics,enterReconnect,reconnectPreservesOutput,inactiveTerminalOutputContinues,fontActionRestoresFocus,recentCommandsRestoreFocus,recentCommandSequenceVisible,resourceWindowTitle,numberingContinuesWithOpenTabs,numberingRestartsAfterAllClosed,encodingMenuOpened,fontMenuOpened,statusHoverShowsFull,desktopStatusAvoidsDuplicate,desktopToolbarInHeader,connectionToggleUsesLinkAction,activeToolbarReplacesPrevious,narrowToolbarFits,narrowToolbarLeftAligned,responsiveToolbarFits,terminalToolbarScrollable,startupCompactIconOnly,desktopActionsIconOnly,terminalToolbarIconSet,terminalFrameLowContrast,terminalFrameColors,terminalBackgroundColor,desktopCursorCopyHintVisible,desktopCursorCopyHintCleansUp,terminalCtrlWheelZooms,terminalCtrlWheelKeepsPosition,terminalPlainWheelScrolls,terminalFontChangePreservesMiddleScroll,terminalFontChangeKeepsWheelContinuity,terminalWheelMetrics,terminalCjkTextDoesNotClip,terminalCjkMetrics,latencyMeasured,latencyCanDisable,latencyCanEnable,zmodemPanelUi,zmodemPanelMetrics,terminalSettingsUi}; + return {found:true,labels,metrics,desktopBackHidden,desktopKeysHidden,binaryType,binaryWrite,stableLogId,x11DefaultFallsBack,x11ScopeMenu,ctrlVImageIntercepted,ctrlVEmptyFallsThrough,ctrlVDiagnostics,enterReconnect,reconnectPreservesOutput,inactiveTerminalOutputContinues,fontActionRestoresFocus,recentCommandsRestoreFocus,recentCommandSequenceVisible,resourceWindowTitle,remoteDesktopTitleDedup,numberingContinuesWithOpenTabs,numberingRestartsAfterAllClosed,encodingMenuOpened,fontMenuOpened,statusHoverShowsFull,desktopStatusAvoidsDuplicate,desktopToolbarInHeader,connectionToggleUsesLinkAction,activeToolbarReplacesPrevious,narrowToolbarFits,narrowToolbarLeftAligned,responsiveToolbarFits,terminalToolbarScrollable,startupCompactIconOnly,desktopActionsIconOnly,terminalToolbarIconSet,terminalFrameLowContrast,terminalFrameColors,terminalBackgroundColor,desktopCursorCopyHintVisible,desktopCursorCopyHintCleansUp,terminalCtrlWheelZooms,terminalCtrlWheelKeepsPosition,terminalPlainWheelScrolls,terminalFontChangePreservesMiddleScroll,terminalFontChangeKeepsWheelContinuity,terminalWheelMetrics,terminalCjkTextDoesNotClip,terminalCjkMetrics,latencyMeasured,latencyCanDisable,latencyCanEnable,zmodemPanelUi,zmodemPanelMetrics,terminalSettingsUi}; })()`); const terminalStartupOriginalContentSize = window.getContentSize(); window.setContentSize(1000, 600); @@ -7896,6 +7924,7 @@ app.whenReady().then(async () => { persistDefault:Boolean(document.querySelector('#sftpPersistEncoding')), backup:Boolean(document.querySelector('#sftpBackupBeforeSave')?.checked), lineEndings:[...document.querySelectorAll('#sftpLineEnding option')].map(option=>option.value), + lineEndingLabelsLocalized:[...document.querySelectorAll('#sftpLineEnding option')].every(option=>option.textContent===sftpTextLineEndingOptions().find(([value])=>value===option.value)?.[1]), shellFormat:false }; const gbkEditor=window.ace&&editorHost?ace.edit(editorHost):null; @@ -7935,6 +7964,14 @@ app.whenReady().then(async () => { &&shellSave?.encoding==='utf8' &&shellSave?.line_ending==='lf' &&shellSave?.content==='#!/bin/bash\\necho restart\\n'); + const normalizationOnlyPromise=sftpTextModal('/tmp/normalize-only.sh','ab',2,2,'utf8','auto',{lineEnding:'lf',bom:false,finalNewline:false}); + await new Promise(resolve=>setTimeout(resolve,20)); + document.querySelector('#sftpTextSave')?.click(); + await new Promise(resolve=>setTimeout(resolve,20)); + textEncodingUi.normalizationOnlyLimitEnforced=Boolean(document.querySelector('#sftpTextSave')?.disabled + &&document.querySelector('#sftpEditorStats')?.classList.contains('limit-exceeded')); + document.querySelector('#sftpTextClose')?.click(); + await normalizationOnlyPromise; const lightFixture='x'.repeat(1024*1024+37); const lightEditorPromise=sftpTextModal('/tmp/large.log',lightFixture,lightFixture.length,2*1024*1024,'utf8','auto',{editorKind:'light',lineCount:1}); await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); @@ -10074,7 +10111,7 @@ app.whenReady().then(async () => { const terminalDropUi = terminalSettingsUi.drop || {}; const mobileTerminalSettingsUi = mobile.terminalGlobalSettings || {}; const terminalStartupUiFailed = !terminalStartupUi.found || !Object.values(terminalStartupUi).every(Boolean); - const terminalUiFailed = !terminalUi.found || !terminalUi.desktopBackHidden || !terminalUi.desktopKeysHidden || terminalUi.binaryType !== 'arraybuffer' || !terminalUi.binaryWrite || !terminalUi.stableLogId || !terminalUi.x11DefaultFallsBack || !terminalUi.x11ScopeMenu || !terminalUi.ctrlVImageIntercepted || !terminalUi.ctrlVEmptyFallsThrough || !terminalUi.enterReconnect || !terminalUi.reconnectPreservesOutput || !terminalUi.inactiveTerminalOutputContinues || !terminalUi.fontActionRestoresFocus || !terminalUi.recentCommandsRestoreFocus || !terminalUi.recentCommandSequenceVisible || !terminalUi.resourceWindowTitle || !terminalUi.numberingContinuesWithOpenTabs || !terminalUi.numberingRestartsAfterAllClosed || !terminalUi.encodingMenuOpened || !terminalUi.fontMenuOpened || !terminalUi.statusHoverShowsFull || !terminalUi.desktopStatusAvoidsDuplicate || !terminalUi.desktopToolbarInHeader || !terminalUi.connectionToggleUsesLinkAction || !terminalUi.activeToolbarReplacesPrevious || !terminalUi.narrowToolbarFits || !terminalUi.narrowToolbarLeftAligned || !terminalUi.responsiveToolbarFits || !terminalUi.terminalToolbarScrollable || !terminalUi.startupCompactIconOnly || !terminalUi.desktopActionsIconOnly || !terminalUi.terminalToolbarIconSet || !terminalUi.terminalFrameLowContrast || !terminalUi.desktopCursorCopyHintVisible || !terminalUi.desktopCursorCopyHintCleansUp || !terminalUi.terminalCtrlWheelZooms || !terminalUi.terminalCtrlWheelKeepsPosition || !terminalUi.terminalPlainWheelScrolls || !terminalUi.terminalFontChangePreservesMiddleScroll || !terminalUi.terminalFontChangeKeepsWheelContinuity || !terminalUi.terminalCjkTextDoesNotClip || !terminalUi.latencyMeasured || !terminalUi.latencyCanDisable || !terminalUi.latencyCanEnable || !terminalUi.zmodemPanelUi || !terminalSettingsUi.open || !terminalSettingsUi.globalScope || !terminalSettingsUi.controls || !terminalSettingsUi.fontInheritance || !terminalDropUi.found || !terminalDropUi.copyFeedbackVisible || !terminalDropUi.sftpCopyToCurrentDirectory || !terminalDropUi.uploadFeedbackVisible || !terminalDropUi.localUploadToCurrentDirectory || !terminalDropUi.singleActiveDropTarget || !terminalDropUi.resizeFeedbackClears || !terminalDropUi.staleFeedbackClears || !terminalDropUi.completionNoticeNotDuplicated || !terminalSettingsUi.withinViewport || !terminalSettingsUi.compact || !terminalSettingsUi.readableWidth || !terminalSettingsUi.noHorizontalOverflow || JSON.stringify(terminalSettingsUi.tabs)!==JSON.stringify(['外观','鼠标与链接','选择与粘贴']) || JSON.stringify(terminalSettingsUi.backgroundModes)!==JSON.stringify(['theme','black','white','custom']) || !terminalSettingsUi.backgroundPreview || !terminalSettingsUi.requestedDefaults || !terminalSettingsUi.editablePasteSetting || !terminalSettingsUi.appliesToAllOpenSessions || !terminalSettingsUi.readableCustomPalette || !terminalSettingsUi.followsTheme || !terminalSettingsUi.copyFormatting || !terminalSettingsUi.singleLinePaste || !terminalSettingsUi.pasteCommandHistory || !terminalSettingsUi.linkProvider || !terminalSettingsUi.editablePaste || !mobileTerminalSettingsUi.buttonHidden || !mobile.terminalLongPress?.menuOnly || !mobile.terminalLongPress?.menuOpened || !mobile.terminalLongPress?.cursorHintStarted || !mobile.terminalLongPress?.cursorStartStored || !mobile.terminalLongPress?.cursorSelectionBlue || !mobile.terminalLongPress?.cursorCopyCompleted || !mobile.terminalLongPress?.clipboardFallback || !mobile.terminalSessionText?.open || !mobile.terminalSessionText?.withinViewport || !mobile.terminalSessionText?.selectable || !mobile.terminalSessionText?.scrollable || !mobile.terminalSessionText?.fullText || !mobile.terminalSessionText?.copyAll || !mobile.terminalSessionText?.copyAllWorks || !mobile.terminalSessionText?.backdropIgnored || !mobile.terminalPasteEditor?.open || !mobile.terminalPasteEditor?.withinViewport || !mobile.terminalPasteEditor?.editable || !mobile.terminalPasteEditor?.actionsVisible || !mobile.terminalPasteEditor?.backdropIgnored || !mobile.terminalPasteEditor?.cancelled || !mobile.terminalBack?.visible || !mobile.terminalBack?.shellOwned || !mobile.terminalBack?.reservedRow || !mobile.terminalBack?.compactToolbar || !mobile.terminalBack?.sftpTextFits || !mobile.terminalBack?.globalSettingsHidden || JSON.stringify(mobile.terminalBack?.priorityOrder)!==JSON.stringify(['reconnect','keys','forward-list','forward','sftp']) || !mobile.terminalBack?.returned || !mobile.terminalFontMenu?.opened || !mobile.terminalFontMenu?.withinViewport || !mobile.terminalFontMenu?.compact || !mobile.terminalFontMenu?.scrollable || !mobile.terminalFontMenu?.closeSticky || !mobile.terminalFontMenu?.touchTargets || !terminalLabels.every(label=>terminalUi.labels.includes(label)) || terminalUi.metrics.some(item=>Math.abs(item.buttonHeight-30)>0.5||Math.abs(item.iconWidth-14)>0.5||Math.abs(item.iconHeight-14)>0.5||item.centerDelta>0.5); + const terminalUiFailed = !terminalUi.found || !terminalUi.desktopBackHidden || !terminalUi.desktopKeysHidden || terminalUi.binaryType !== 'arraybuffer' || !terminalUi.binaryWrite || !terminalUi.stableLogId || !terminalUi.x11DefaultFallsBack || !terminalUi.x11ScopeMenu || !terminalUi.ctrlVImageIntercepted || !terminalUi.ctrlVEmptyFallsThrough || !terminalUi.enterReconnect || !terminalUi.reconnectPreservesOutput || !terminalUi.inactiveTerminalOutputContinues || !terminalUi.fontActionRestoresFocus || !terminalUi.recentCommandsRestoreFocus || !terminalUi.recentCommandSequenceVisible || !terminalUi.resourceWindowTitle || !terminalUi.remoteDesktopTitleDedup || !terminalUi.numberingContinuesWithOpenTabs || !terminalUi.numberingRestartsAfterAllClosed || !terminalUi.encodingMenuOpened || !terminalUi.fontMenuOpened || !terminalUi.statusHoverShowsFull || !terminalUi.desktopStatusAvoidsDuplicate || !terminalUi.desktopToolbarInHeader || !terminalUi.connectionToggleUsesLinkAction || !terminalUi.activeToolbarReplacesPrevious || !terminalUi.narrowToolbarFits || !terminalUi.narrowToolbarLeftAligned || !terminalUi.responsiveToolbarFits || !terminalUi.terminalToolbarScrollable || !terminalUi.startupCompactIconOnly || !terminalUi.desktopActionsIconOnly || !terminalUi.terminalToolbarIconSet || !terminalUi.terminalFrameLowContrast || !terminalUi.desktopCursorCopyHintVisible || !terminalUi.desktopCursorCopyHintCleansUp || !terminalUi.terminalCtrlWheelZooms || !terminalUi.terminalCtrlWheelKeepsPosition || !terminalUi.terminalPlainWheelScrolls || !terminalUi.terminalFontChangePreservesMiddleScroll || !terminalUi.terminalFontChangeKeepsWheelContinuity || !terminalUi.terminalCjkTextDoesNotClip || !terminalUi.latencyMeasured || !terminalUi.latencyCanDisable || !terminalUi.latencyCanEnable || !terminalUi.zmodemPanelUi || !terminalSettingsUi.open || !terminalSettingsUi.globalScope || !terminalSettingsUi.controls || !terminalSettingsUi.fontInheritance || !terminalDropUi.found || !terminalDropUi.copyFeedbackVisible || !terminalDropUi.sftpCopyToCurrentDirectory || !terminalDropUi.uploadFeedbackVisible || !terminalDropUi.localUploadToCurrentDirectory || !terminalDropUi.singleActiveDropTarget || !terminalDropUi.resizeFeedbackClears || !terminalDropUi.staleFeedbackClears || !terminalDropUi.completionNoticeNotDuplicated || !terminalSettingsUi.withinViewport || !terminalSettingsUi.compact || !terminalSettingsUi.readableWidth || !terminalSettingsUi.noHorizontalOverflow || JSON.stringify(terminalSettingsUi.tabs)!==JSON.stringify(['外观','鼠标与链接','选择与粘贴']) || JSON.stringify(terminalSettingsUi.backgroundModes)!==JSON.stringify(['theme','black','white','custom']) || !terminalSettingsUi.backgroundPreview || !terminalSettingsUi.requestedDefaults || !terminalSettingsUi.editablePasteSetting || !terminalSettingsUi.appliesToAllOpenSessions || !terminalSettingsUi.readableCustomPalette || !terminalSettingsUi.followsTheme || !terminalSettingsUi.copyFormatting || !terminalSettingsUi.singleLinePaste || !terminalSettingsUi.pasteCommandHistory || !terminalSettingsUi.linkProvider || !terminalSettingsUi.editablePaste || !mobileTerminalSettingsUi.buttonHidden || !mobile.terminalLongPress?.menuOnly || !mobile.terminalLongPress?.menuOpened || !mobile.terminalLongPress?.cursorHintStarted || !mobile.terminalLongPress?.cursorStartStored || !mobile.terminalLongPress?.cursorSelectionBlue || !mobile.terminalLongPress?.cursorCopyCompleted || !mobile.terminalLongPress?.clipboardFallback || !mobile.terminalSessionText?.open || !mobile.terminalSessionText?.withinViewport || !mobile.terminalSessionText?.selectable || !mobile.terminalSessionText?.scrollable || !mobile.terminalSessionText?.fullText || !mobile.terminalSessionText?.copyAll || !mobile.terminalSessionText?.copyAllWorks || !mobile.terminalSessionText?.backdropIgnored || !mobile.terminalPasteEditor?.open || !mobile.terminalPasteEditor?.withinViewport || !mobile.terminalPasteEditor?.editable || !mobile.terminalPasteEditor?.actionsVisible || !mobile.terminalPasteEditor?.backdropIgnored || !mobile.terminalPasteEditor?.cancelled || !mobile.terminalBack?.visible || !mobile.terminalBack?.shellOwned || !mobile.terminalBack?.reservedRow || !mobile.terminalBack?.compactToolbar || !mobile.terminalBack?.sftpTextFits || !mobile.terminalBack?.globalSettingsHidden || JSON.stringify(mobile.terminalBack?.priorityOrder)!==JSON.stringify(['reconnect','keys','forward-list','forward','sftp']) || !mobile.terminalBack?.returned || !mobile.terminalFontMenu?.opened || !mobile.terminalFontMenu?.withinViewport || !mobile.terminalFontMenu?.compact || !mobile.terminalFontMenu?.scrollable || !mobile.terminalFontMenu?.closeSticky || !mobile.terminalFontMenu?.touchTargets || !terminalLabels.every(label=>terminalUi.labels.includes(label)) || terminalUi.metrics.some(item=>Math.abs(item.buttonHeight-30)>0.5||Math.abs(item.iconWidth-14)>0.5||Math.abs(item.iconHeight-14)>0.5||item.centerDelta>0.5); const logSettingsUiFailed = !logSettingsUi.open || !logSettingsUi.accessible || !logSettingsUi.days || !logSettingsUi.fileMb || !logSettingsUi.totalMb || !logSettingsUi.rotations || !logSettingsUi.cleanup || !logSettingsUi.save || !logSettingsUi.closed || !logSettingsUi.fullTerminalTime || !logSettingsUi.defaultsToLatest || !logSettingsUi.followsTheme; const productivityUiFailed = !productivityUi.quickVisible || productivityUi.actionCount < 7 || !productivityUi.quickConnectionActionsInline || !productivityUi.quickPanelDirect || !productivityUi.workspaceSearchable || !productivityUi.workspacePreviewOpens || !productivityUi.quickButtonPlacement || !productivityUi.quickButtonLightning || !productivityUi.xServerQuickUsesX11 || !productivityUi.xServerUnauthorizedWarning || !productivityUi.xServerLocalDirectReady || !productivityUi.broadcastFromEither || !productivityUi.broadcastTabMarked || !productivityUi.broadcastHeaderGrouped || !productivityUi.broadcastExitCompact || !productivityUi.visibleSplitHasNoActivity || !productivityUi.visibleSplitClearsPriorActivity || !productivityUi.hiddenBinaryOutputMarked || productivityUi.syncRows !== 3 || !productivityUi.conflictSafe || !productivityUi.namedWorkspaceTools || !productivityUi.terminalTools || !productivityUi.quickToolbarIconVisible || !productivityUi.quickToggleStateVisible || !productivityUi.quickCompactWidths || !productivityUi.quickCommandExecutes || !productivityUi.quickContextMenu || !productivityUi.quickDoubleClickCreates || !productivityUi.quickEditorBackCloses || !productivityUi.quickManagerPolished || !productivityUi.quickOrderPersists || !productivityUi.quickHeightAdjustable || !productivityUi.quickToggleHides || !productivityUi.quickWheelScrolls || !productivityUi.quickResponsive; const remoteAdminUiFailed = Boolean(remoteAdminUi.desktop?.error) @@ -10136,7 +10173,7 @@ app.whenReady().then(async () => { const globalSettingsUi = sftpUi.globalSettingsUi || {}; const downloadNoticeUi = sftpUi.downloadNoticeUi || {}; const jobUiFailed = !jobUi.found || !jobUi.singleGlobalEntry || !jobUi.noPaneTaskRegions || !jobUi.failedStatusVisible || !jobUi.totalProgressVisible || !jobUi.totalProgressIndeterminate || !jobUi.totalProgressHidesWhenIdle || !jobUi.floatingVisibleBelowHeader || !jobUi.floatingActions || !jobUi.floatingResumeAction || !jobUi.floatingProgress || !jobUi.floatingOpensTaskCenter || !jobUi.floatingCloseHidesCurrent || !jobUi.floatingNewTaskReopens || !jobUi.floatingMutePersists || !jobUi.floatingSettingRestores || !jobUi.drawerOpened || !jobUi.drawerDefaultCompact || !jobUi.currentOnly || !jobUi.currentActions || !jobUi.failedOnly || !jobUi.failedActions || !jobUi.failedClearAvailable || !jobUi.currentProgress || !jobUi.drawerResizable || !jobUi.drawerResizeAdaptive || !jobUi.drawerResizePersists || !jobUi.drawerResizeReset || !jobUi.deleteDuplicateBlocked || !jobUi.deleteKeepsDrawerOpen || !jobUi.taskLogInitialOpen || !jobUi.taskLogInitialBottom || !jobUi.taskLogRefreshKeepsOpen || !jobUi.taskLogRefreshShowsLatest || !jobUi.taskLogRefreshFollowsBottom || !jobUi.drawerFitsViewport || !jobUi.historyOnly || !jobUi.historyCounts || !jobUi.historyActions || !jobUi.outsideClickCloses || !jobUi.escapeCloses || !jobUi.runningStatusVisible || !jobUi.nativeDragTaskStopHidden || !jobUi.itemProgress || !jobUi.staleJobResponseIgnored || !jobUi.toastIconsAligned || !jobUi.toastOrderPreserved || !jobUi.toastStackedDown || !jobUi.toastAvoidsFloatingTask || !jobUi.toastExitAnimated || !jobUi.toastReflowAnimated || !jobUi.toastMovedUp; - const textEncodingUiFailed = !textEncodingUi.opened || !textEncodingUi.aceLoaded || textEncodingUi.selected !== 'gbk' || !textEncodingUi.manualLanguage || !textEncodingUi.nonJsonFormattingHidden || !textEncodingUi.nonUtf8SaveAllowed || !textEncodingUi.nonUtf8SaveSubmitted || !textEncodingUi.utf8LimitEnforced || !textEncodingUi.utf8BomIncludesPrefix || !textEncodingUi.lightPaged || !textEncodingUi.lightNextPage || !textEncodingUi.jsonFormatting || !textEncodingUi.jsonHiddenAfterLanguageChange || !textEncodingUi.json5FormattingHidden || !textEncodingUi.wordWrap || !textEncodingUi.persistDefault || !textEncodingUi.backup || !textEncodingUi.shellFormat || !['lf','crlf','cr'].every(value=>textEncodingUi.lineEndings?.includes(value)) || !['utf8','utf8bom','gb18030','gbk','big5','shift_jis','euc-kr','latin1'].every(value=>textEncodingUi.options?.includes(value)) || !['auto','json','yaml','xml','sh','batchfile','powershell','javascript','java','c_cpp','sql','markdown'].every(value=>textEncodingUi.languageOptions?.includes(value)); + const textEncodingUiFailed = !textEncodingUi.opened || !textEncodingUi.aceLoaded || textEncodingUi.selected !== 'gbk' || !textEncodingUi.manualLanguage || !textEncodingUi.nonJsonFormattingHidden || !textEncodingUi.nonUtf8SaveAllowed || !textEncodingUi.nonUtf8SaveSubmitted || !textEncodingUi.utf8LimitEnforced || !textEncodingUi.utf8BomIncludesPrefix || !textEncodingUi.normalizationOnlyLimitEnforced || !textEncodingUi.lineEndingLabelsLocalized || !textEncodingUi.lightPaged || !textEncodingUi.lightNextPage || !textEncodingUi.jsonFormatting || !textEncodingUi.jsonHiddenAfterLanguageChange || !textEncodingUi.json5FormattingHidden || !textEncodingUi.wordWrap || !textEncodingUi.persistDefault || !textEncodingUi.backup || !textEncodingUi.shellFormat || !['lf','crlf','cr'].every(value=>textEncodingUi.lineEndings?.includes(value)) || !['utf8','utf8bom','gb18030','gbk','big5','shift_jis','euc-kr','latin1'].every(value=>textEncodingUi.options?.includes(value)) || !['auto','json','yaml','xml','sh','batchfile','powershell','javascript','java','c_cpp','sql','markdown'].every(value=>textEncodingUi.languageOptions?.includes(value)); const nativeDragUiFailed = !nativeDragUi.found || !nativeDragUi.webExternalDragBlocked || !nativeDragUi.linuxFallbackNoticeOnce || !nativeDragUi.linuxFallbackUsesCompatibilityMode || !nativeDragUi.streamingPreparesOnPointerDown || !nativeDragUi.streamingThresholdActivatesOnce || !nativeDragUi.streamingCaptureCancelSurvives || !nativeDragUi.pointerUpCancelsPending || !nativeDragUi.streamingSkipsStage || !nativeDragUi.streamingNativeBlocksParallelBrowserDrag || !nativeDragUi.nativeIdleHintStable || !nativeDragUi.nativeOutsideHintStaysStable || !nativeDragUi.nativeMotionTargetsSftp || !nativeDragUi.nativeTransientMissKeepsTarget || !nativeDragUi.nativeFinalTransientMissKeepsTarget || !nativeDragUi.nativeReleasedClearsStaleTarget || !nativeDragUi.nativeResultCopiesOnce || !nativeDragUi.firstDragOnlyStages || !nativeDragUi.firstDragReset || !nativeDragUi.cacheReused || !nativeDragUi.cachedUnarmedStaysInternal || !nativeDragUi.sameWindowDropDoesNotArm || !nativeDragUi.armedDragStartsSynchronously || !nativeDragUi.failureRearmed || !nativeDragUi.successClearsState || !nativeDragUi.finderRenameNoticeShown; const sftpUiFailed = Boolean(sftpUi.error) || !connectionSessionUi.found || !connectionSessionUi.addressIncludesPort || !connectionSessionUi.disconnectedAction || !connectionSessionUi.disconnectedBanner || !connectionSessionUi.connectedAction || !connectionSessionUi.preservedWhileDisconnected || !connectionSessionUi.automaticConnectShared || !connectionSessionUi.manualDisconnectAutoReconnect || !connectionSessionUi.disconnectedTabSwitchDoesNotReconnect || !connectionSessionUi.disconnectedFolderOperationReconnects || !connectionSessionUi.dragFeedbackVisible || !connectionSessionUi.dragTargetViewActivated || !connectionSessionUi.targetListDropPrompt || !connectionSessionUi.targetListDropPromptStable || !connectionSessionUi.crossHostListDropCopies || !connectionSessionUi.crossHostPreviewHandoffSurvives || !connectionSessionUi.crossHostDropHasNoUploadToast || !connectionSessionUi.sameHostListDropCopies || !connectionSessionUi.terminalTabPreviewActivated || !connectionSessionUi.invalidTerminalDropRestoresSource || !connectionSessionUi.invalidSftpDropRestoresSource || !connectionSessionUi.acceptedTerminalDropStays || !connectionSessionUi.ownDragUploadSuppressed || !connectionSessionUi.armedPointerCancelClearsRequest || !connectionSessionUi.armedDragAllowsExternalUpload || !connectionSessionUi.staleInternalDragAllowsExternalUpload || !connectionSessionUi.desktopUriListDragAccepted || !connectionSessionUi.releasedDragAllowsExternalUpload || !connectionSessionUi.externalFileDropDetected || !connectionSessionUi.externalFileDropCollected || !connectionSessionUi.externalDropPromptIsSingle || !connectionSessionUi.externalDropPromptAvoidsWorkspaceChrome || !connectionSessionUi.externalDropPromptListCentered || !connectionSessionUi.externalDropSurfaceFillsWorkspace || !connectionSessionUi.externalDropPromptScrollClamped || !connectionSessionUi.externalDropPromptHorizontalClamped || !connectionSessionUi.externalDropPromptClears || nativeDragUiFailed || jobUiFailed || textEncodingUiFailed || !downloadNoticeUi.oncePerMode || !downloadNoticeUi.desktopPath || !downloadNoticeUi.browserDevice || !downloadNoticeUi.batchUsesSharedNotice || !downloadNoticeUi.browserSeparateChoice || !downloadNoticeUi.browserSeparateQueued || !downloadNoticeUi.noDuplicateBatchNotice || !globalSettingsUi.found || !globalSettingsUi.globalScope || !globalSettingsUi.controls || !globalSettingsUi.floatingProgressDefaultOn || !globalSettingsUi.floatingProgressCanRestore || !globalSettingsUi.downloadBehavior || !globalSettingsUi.defaultLimit || !globalSettingsUi.backdropIgnored || !globalSettingsUi.withinViewport || !globalSettingsUi.classicSurface || !globalSettingsUi.themedField || !directorySizeUi.idleButton || !directorySizeUi.requestedOnce || !directorySizeUi.exactBytes || !directorySizeUi.formatted || !directorySizeUi.refreshable || !sftpUi.fileOpenFeedback?.busy || !sftpUi.fileOpenFeedback?.duplicateBlocked || !sftpUi.fileOpenFeedback?.restored || !sftpUi.fileOpenFeedback?.interruptedRetry || !directoryCacheBehavior.sameResponseUntouched || !directoryCacheBehavior.changedResponseRendered || !directoryCacheBehavior.permissionFailureRestored || !sftpUi.searchKeyboardUi?.opened || !sftpUi.searchKeyboardUi?.closed || !sftpUi.searchKeyboardUi?.recursive || !sftpUi.searchKeyboardUi?.feedback || !sftpUi.syncIndicatorFollowsScroll || !sftpUi.diffComparisonUi || !sftpUi.columnLayoutUi?.order || !sftpUi.columnLayoutUi?.persisted || !sftpUi.columnLayoutUi?.resized || !sftpUi.columnLayoutUi?.pointerStable || !sftpUi.columnLayoutUi?.pairOnly || !sftpUi.columnLayoutUi?.adjacentResizeStable || !sftpUi.columnLayoutUi?.dividerUniform || !sftpUi.columnLayoutUi?.localNarrowResizable || !sftpUi.columnLayoutUi?.openButtonStable || !sftpUi.columnLayoutUi?.selectionToolbarStable || !sftpUi.columnLayoutUi?.scrollbarUnified || !sftpUi.columnLayoutUi?.globalCss || !directoryActionsUi.found || directoryActionsUi.stickyPosition !== 'sticky' || !directoryActionsUi.toolbarInHeader || !directoryActionsUi.navigationBeforeFavorites || !directoryActionsUi.reusedWithoutDirectoryReload || !expectedSftpToolActions.every(action=>directoryActionsUi.actionTitles?.includes(action)) || !directoryActionsUi.searchHidden || !directoryActionsUi.pathEditorHidden || !directoryActionsUi.emptyClipboardHidden || !directoryActionsUi.copyQueueVisible || !directoryActionsUi.copyCancelled || !directoryActionsUi.moveQueueVisible || !directoryActionsUi.moveCancelled || !directoryActionsUi.crossHostCopyEnabled || !directoryActionsUi.crossHostMoveDisabled || !directoryActionsUi.crossHostClipboardConflict || !directoryActionsUi.filenameEncodingMenu || !directoryActionsUi.emptyFavoritesCompact || !directoryActionsUi.wideNavigationCompact || !directoryActionsUi.narrowNavigationCompact || !directoryActionsUi.terminalJump || !directoryActionsUi.terminalJumpFirst || !sftpUi.folderOpened || !sftpUi.fileOpened || !sftpUi.unknownAction || sftpUi.stickyPosition !== "sticky" || !sftpUi.breadcrumbScrollable || !sftpUi.singlePathPresentation || sftpUi.breadcrumbLabels?.join('/') !== '根目录/Users/demo/Public' || sftpUi.breadcrumbText.includes('//') || !sftpUi.selectionShown || !sftpUi.selectionActionsShown || !sftpUi.multiNameAddsSelection || !sftpUi.multiNameCancelsSelection || !sftpUi.singleNameReplacesSelection || !sftpUi.specialSelectionExact || sftpUi.selectedRows !== 2 || !sftpUi.dragSelectionSynchronized || !sftpUi.selectionCleared || !sftpUi.fileHasCompression || !sftpUi.permissionOwnerColumn || !sftpUi.permissionOwnerTitle || !sftpUi.symlinkUsesTargetSize || !sftpUi.symlinkExplainsBothSizes || !sftpUi.symlinkMarked || !sftpUi.wideColumnAlignment || !sftpUi.wideActionsFit || !sftpUi.compactSizeVisible || !sftpUi.compactTimeVisible || !sftpUi.compactAccessVisible || !sftpUi.compactMediumHidden || !sftpUi.compactCoreVisible || !sftpUi.compactHorizontalScroll || !sftpUi.permissionModeSync || !sftpUi.recursiveVisible || sftpUi.compactRowHeight > 48 || !sftpUi.moreMenuOpened || !sftpUi.contextMenuOpened || !sftpUi.directoryDownloadMenu || !sftpUi.narrowLayoutClass || !sftpUi.narrowCoreHidden || !sftpUi.narrowMoreVisible || !sftpUi.narrowMetaVisible || !sftpUi.narrowAccessHidden || !sftpUi.narrowHeaderNameVisible || !sftpUi.narrowHeaderSummaryVisible || !sftpUi.narrowCompactActions || !sftpUi.completedMutationDetected || !sftpUi.desktopPagerSingleRow || !sftpUi.pagerFloatsAtWorkspaceBottom || !sftpUi.pagerOpaqueAndElevated || !sftpUi.pagerDockSealsBottom || !sftpUi.pagerPinnedToViewport || !sftpUi.scrollCueVisibleAboveContent || !sftpUi.scrollCueHidesAtEnd || !sftpUi.narrowPagerWraps || sftpUi.pageRows !== 50 || !sftpUi.pagerVisible || !sftpUi.pagerText.includes('第 1/2 页') || !sftpUi.previousDisabled || !sftpUi.nextEnabled; const sftpToolbarRecoveryFailed = !directoryActionsUi.recoveredMissingToolbar || !directoryActionsUi.duplicateSftpToolbarsFollowActiveTab; diff --git a/scripts/vnc-detached-window-check.js b/scripts/vnc-detached-window-check.js index f2a41b4..7743117 100644 --- a/scripts/vnc-detached-window-check.js +++ b/scripts/vnc-detached-window-check.js @@ -38,7 +38,9 @@ assert.match(app, /termaVncWindow[\s\S]*?initDetachedVncWindow\(termaVncDetached assert.match(vncWindow, /function initDetachedVncWindow[\s\S]*?vnc-detached-root[\s\S]*?renderEmbeddedVnc\(profile, key, null, root, true\)/); assert.match(vncWindow, /prepareVncManagementForDetachedWindow[\s\S]*?closeRemoteProtocolSession\(key\)[\s\S]*?openRemoteDesktop\(id, false, true\)/, "switching to a detached window must close built-in VNC and restore the detection view"); assert.match(vncWindow, /function reserveVncDetachedBrowserWindow[\s\S]*?window\.open\("", `terma-vnc-\$\{id\}`/, "web quick-open must reserve a popup synchronously while user activation is available"); -assert.match(vncWindow, /browserReservation[\s\S]*?child\.location\.replace\(url\.href\)/, "a reserved same-origin popup must navigate only after VNC probing succeeds"); +assert.match(vncWindow, /const browserDetachedVncReservations = new Map\(\)[\s\S]*?pending\.claims\.add\(claim\)/, "concurrent quick-open requests must share and claim one pending popup"); +assert.match(vncWindow, /function cancelReservedVncDetachedBrowserWindow[\s\S]*?state\.claims\.delete[\s\S]*?state\.claims\.size > 0[\s\S]*?state\.child\.close\(\)/, "one stale quick-open request must not close a popup still claimed by another request"); +assert.match(vncWindow, /function commitReservedVncDetachedBrowserWindow[\s\S]*?state\.status = "committed"[\s\S]*?child\.location\.replace\(url\)/, "a shared reserved popup must navigate atomically only after one VNC probe succeeds"); assert.match(remoteProfiles, /browserReservation = updateTab[\s\S]*?reserveVncDetachedBrowserWindow\(profile\.id\)[\s\S]*?await Promise\.all/, "VNC quick-open must reserve the browser window before asynchronous probing"); assert.match(remoteProfiles, /openVncInNewWindow\(profile\.id, key, \{closeDetectionTab:true, browserReservation\}\)[\s\S]*?finally \{[\s\S]*?cancelReservedVncDetachedBrowserWindow\(browserReservation\)/, "failed or stale probes must close an unused reserved popup"); assert.match(vncWindow, /closeVncDetachedWindowForProfile[\s\S]*?closeVncWindowForProfile/, "switching back to built-in VNC must close the matching detached window");