diff --git a/desktop/main.js b/desktop/main.js index f1e8ba1..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 }) => { @@ -2848,6 +2882,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 = normalizeMainWindowTitle(value); + 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..dc6c443 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,26 @@ 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(); + renderWorkspaceGroupBar(); + revealWorkspaceTab(key); + if (!window.restoringTabs) saveTabsState(); +} + updateWorkspaceTabScrollControls = function(paneId=currentWorkspacePaneId()) { const pane = workspacePaneElement(paneId); const container = pane?.querySelector(".tabs"); @@ -818,8 +838,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(); @@ -943,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); @@ -969,11 +989,17 @@ 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}); + 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-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..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,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()) browserReservationCommitted = await openVncInNewWindow(profile.id, key, {closeDetectionTab:true, browserReservation}); + else if (embeddedVnc) await openEmbeddedVncDesktop(profile.id, key); else await launchRemoteDesktop(profile.id, key); } }); @@ -663,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-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() {