From 0ef5d1be489e58043e1e74ccd26fb1f5604eaf00 Mon Sep 17 00:00:00 2001 From: victorwon2001 <192616110+victorwon2001@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:00:10 +0900 Subject: [PATCH] feat: add Windows Remote Desktop client option --- src-tauri/src/cmd/rdp.rs | 82 ++++++++++++++++++- src-tauri/src/config/settings/general.rs | 26 ++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/storage/mod.rs | 6 ++ src/App.tsx | 56 +++++++++++++ src/components/settings/GeneralTab.test.tsx | 90 +++++++++++++++++++++ src/components/settings/GeneralTab.tsx | 24 ++++++ src/context/AppProvider.tsx | 1 + src/context/ChildAppProvider.tsx | 1 + src/hooks/useConnectAfterEdit.test.tsx | 29 +++++++ src/hooks/useConnectAfterEdit.ts | 24 +++++- src/hooks/useTerminalWindowLayout.test.tsx | 1 + src/i18n/locales/en.json | 4 + src/i18n/locales/ko.json | 4 + src/i18n/locales/zh-CN.json | 4 + src/i18n/locales/zh-TW.json | 4 + src/lib/appSessionFactory.test.ts | 54 ++++++++++++- src/lib/appSessionFactory.ts | 22 +++++ src/types/global.d.ts | 1 + 19 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 src/components/settings/GeneralTab.test.tsx diff --git a/src-tauri/src/cmd/rdp.rs b/src-tauri/src/cmd/rdp.rs index b308fc74..c644dbfb 100644 --- a/src-tauri/src/cmd/rdp.rs +++ b/src-tauri/src/cmd/rdp.rs @@ -1,9 +1,63 @@ +use crate::config::ConnectionType; use crate::core::rdp::{self, RdpInputEvent, RdpSessionManager}; -use crate::error::AppResult; +use crate::error::{AppError, AppResult}; +#[cfg(windows)] +use std::process::Command; use std::sync::Arc; use tauri::Emitter; use tauri::ipc::{Channel, InvokeResponseBody}; +const DEFAULT_RDP_PORT: u16 = 3389; + +fn mstsc_target(host: &str, port: u16) -> String { + let host = host.trim(); + let server = if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) { + format!("[{host}]") + } else { + host.to_string() + }; + if port == DEFAULT_RDP_PORT { + server + } else { + format!("{server}:{port}") + } +} + +#[cfg(windows)] +fn spawn_windows_rdp(target: &str) -> AppResult<()> { + Command::new("mstsc.exe") + .arg(format!("/v:{target}")) + .spawn()?; + Ok(()) +} + +#[cfg(not(windows))] +fn spawn_windows_rdp(_target: &str) -> AppResult<()> { + Err(AppError::Unsupported( + "Windows Remote Desktop is only available on Windows".to_string(), + )) +} + +#[tauri::command] +pub fn launch_windows_rdp(app: tauri::AppHandle, connection_id: String) -> AppResult<()> { + let connection = crate::storage::get_connection_without_secret(&connection_id)? + .ok_or_else(|| AppError::Config("RDP connection not found".to_string()))?; + let ConnectionType::Rdp { host, port, .. } = connection.config else { + return Err(AppError::Config( + "Connection is not an RDP connection".to_string(), + )); + }; + let target = mstsc_target(&host, port); + spawn_windows_rdp(&target)?; + + if let Err(error) = crate::storage::mark_connection_used(&connection_id) { + tracing::warn!(connection_id, %error, "Failed to mark RDP connection as recently used"); + } else { + let _ = app.emit("connections-changed", ()); + } + Ok(()) +} + #[tauri::command] pub async fn create_rdp_session( app: tauri::AppHandle, @@ -99,3 +153,29 @@ pub async fn respond_rdp_certificate( .respond_certificate(&request_id, accepted, remember) .await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mstsc_target_formats_hosts_and_non_default_ports() { + assert_eq!( + mstsc_target("server.example.com", 3389), + "server.example.com" + ); + assert_eq!(mstsc_target("192.0.2.10", 3390), "192.0.2.10:3390"); + assert_eq!(mstsc_target("2001:db8::1", 3389), "[2001:db8::1]"); + assert_eq!(mstsc_target("2001:db8::1", 3390), "[2001:db8::1]:3390"); + assert_eq!(mstsc_target("[2001:db8::1]", 3390), "[2001:db8::1]:3390"); + } + + #[cfg(not(windows))] + #[test] + fn system_rdp_launch_is_explicitly_unsupported_off_windows() { + assert!(matches!( + spawn_windows_rdp("server.example.com"), + Err(AppError::Unsupported(_)) + )); + } +} diff --git a/src-tauri/src/config/settings/general.rs b/src-tauri/src/config/settings/general.rs index 1b4332ea..b9ae78d2 100644 --- a/src-tauri/src/config/settings/general.rs +++ b/src-tauri/src/config/settings/general.rs @@ -1,6 +1,10 @@ use super::super::default_true; use serde::{Deserialize, Serialize}; +fn default_rdp_client_mode() -> String { + "builtin".to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeneralSettings { #[serde(default = "default_true")] @@ -13,6 +17,8 @@ pub struct GeneralSettings { pub boss_key: Option, #[serde(default = "default_true")] pub confirm_on_close: bool, + #[serde(default = "default_rdp_client_mode")] + pub rdp_client_mode: String, } impl Default for GeneralSettings { @@ -23,6 +29,26 @@ impl Default for GeneralSettings { minimize_to_tray: false, boss_key: None, confirm_on_close: true, + rdp_client_mode: default_rdp_client_mode(), } } } + +#[cfg(test)] +mod tests { + use super::GeneralSettings; + + #[test] + fn missing_rdp_client_mode_defaults_to_builtin() { + let settings: GeneralSettings = serde_json::from_value(serde_json::json!({ + "startup_restore": true, + "startup_restore_window_layout": true, + "minimize_to_tray": false, + "boss_key": null, + "confirm_on_close": true + })) + .expect("general settings"); + + assert_eq!(settings.rdp_client_mode, "builtin"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e8d9f36..648aea6d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -261,6 +261,7 @@ pub fn run() { cmd::session::create_telnet_session, cmd::session::create_serial_session, cmd::rdp::create_rdp_session, + cmd::rdp::launch_windows_rdp, cmd::rdp::rdp_attach_frame_channel, cmd::rdp::rdp_input_batch, cmd::rdp::rdp_set_keyboard_capture, diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 46560121..f3f203c4 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -152,6 +152,12 @@ pub(crate) fn get_connection( storage()?.get_connection_with_secret(connection_id) } +pub(crate) fn get_connection_without_secret( + connection_id: &str, +) -> AppResult> { + storage()?.get_connection(connection_id) +} + pub(crate) fn mark_connection_used(connection_id: &str) -> AppResult<()> { storage()?.mark_connection_used(connection_id) } diff --git a/src/App.tsx b/src/App.tsx index 3011d78e..831fa29d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -55,6 +55,7 @@ import { getRemoteDesktopPaneDisplay, getTemporaryLinkSessionType, isSessionCreationCancelled, + launchSavedRdpWithSystemClient, type StartupCommandRequest, sendStartupCommandToSession, } from "./lib/appSessionFactory"; @@ -480,6 +481,7 @@ function App() { setActiveTabId, updatePaneSession, updateTabSession, + rdpClientMode: appSettings.general.rdp_client_mode, setTerminalWindows, updateAutoIconForSessionStart, }); @@ -626,6 +628,33 @@ function App() { onSuccess?: (sessionId: string) => void; }, ) => { + try { + if ( + await launchSavedRdpWithSystemClient( + connection, + appSettings.general.rdp_client_mode, + ) + ) { + recordRecentConnection(connection.id); + return; + } + } catch (error) { + const errorMessage = getErrorMessage(error); + logger.error({ + domain: "session.lifecycle", + event: "connection.external_rdp_open_failed", + message: + options?.failureContext ?? "Windows Remote Desktop launch failed", + ids: { connection_id: connection.id }, + error, + }); + toast.error( + t("savedConnections.connectionFailed", { error: errorMessage }), + ); + if (options?.propagateError) throw error; + return; + } + const pending = addPendingTab( connection.name, getConnectionSessionType(connection), @@ -680,6 +709,7 @@ function App() { }, [ addPendingTab, + appSettings.general.rdp_client_mode, hasTab, markTabConnectionFailed, maybePromptConnectionEdit, @@ -1051,6 +1081,31 @@ function App() { const anchorTabId = targetLeaf?.activeTabId ?? targetLeaf?.tabIds[targetLeaf.tabIds.length - 1] ?? null; + try { + if ( + await launchSavedRdpWithSystemClient( + connection, + appSettings.general.rdp_client_mode, + ) + ) { + recordRecentConnection(connection.id); + return; + } + } catch (error) { + const errorMessage = getErrorMessage(error); + logger.error({ + domain: "session.lifecycle", + event: "connection.external_rdp_open_failed", + message: "Windows Remote Desktop launch failed from tab menu", + ids: { connection_id: connection.id }, + error, + }); + toast.error( + t("savedConnections.connectionFailed", { error: errorMessage }), + ); + return; + } + if (targetLeaf?.activeTabId) { handleSelectLeafTab(leafId, targetLeaf.activeTabId); } @@ -1112,6 +1167,7 @@ function App() { }, [ addPendingTab, + appSettings.general.rdp_client_mode, hasTab, handleSelectLeafTab, setTerminalWindows, diff --git a/src/components/settings/GeneralTab.test.tsx b/src/components/settings/GeneralTab.test.tsx new file mode 100644 index 00000000..e9155cd5 --- /dev/null +++ b/src/components/settings/GeneralTab.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GeneralTab } from "./GeneralTab"; + +const state = vi.hoisted(() => ({ + isWindows: true, + updateAppSettings: vi.fn(), + updateUi: vi.fn(), +})); + +vi.mock("@/lib/platform", () => ({ + get isWindows() { + return state.isWindows; + }, +})); + +vi.mock("@/context/AppContext", () => ({ + useApp: () => ({ + appSettings: { + general: { + startup_restore: true, + startup_restore_window_layout: true, + minimize_to_tray: false, + boss_key: null, + confirm_on_close: true, + rdp_client_mode: "builtin", + }, + ui: { + language: "en", + header_status_visible: true, + header_status_mode: "session", + }, + diagnostics: { level: "info", retention_days: 7 }, + }, + updateAppSettings: state.updateAppSettings, + updateUi: state.updateUi, + }), +})); + +vi.mock("@/hooks/useConfigTransfer", () => ({ + useConfigTransfer: () => ({ + handleExportDiagnostics: vi.fn(), + handleOpenLogs: vi.fn(), + }), +})); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ + i18n: { changeLanguage: vi.fn() }, + t: (key: string) => key, + }), +})); + +describe("GeneralTab RDP client setting", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.isWindows = true; + }); + + it("shows the RDP client selector on Windows and saves Windows Remote Desktop", () => { + render(); + const field = + screen.getByText("settings.rdpClient").parentElement?.parentElement; + expect(field).not.toBeNull(); + const combo = within(field as HTMLElement).getByRole("combobox"); + + fireEvent.keyDown(combo, { key: "ArrowDown" }); + fireEvent.click( + screen.getByRole("option", { name: "settings.rdpClientWindows" }), + ); + + expect(state.updateAppSettings).toHaveBeenCalledWith({ + general: { + startup_restore: true, + startup_restore_window_layout: true, + minimize_to_tray: false, + boss_key: null, + confirm_on_close: true, + rdp_client_mode: "windows", + }, + }); + }); + + it("does not show the Windows-only RDP client selector off Windows", () => { + state.isWindows = false; + render(); + expect(screen.queryByText("settings.rdpClient")).toBeNull(); + }); +}); diff --git a/src/components/settings/GeneralTab.tsx b/src/components/settings/GeneralTab.tsx index 612e9283..f387eaa8 100644 --- a/src/components/settings/GeneralTab.tsx +++ b/src/components/settings/GeneralTab.tsx @@ -5,6 +5,7 @@ import { useApp } from "@/context/AppContext"; import { useConfigTransfer } from "@/hooks/useConfigTransfer"; import { AVAILABLE_LANGUAGES } from "@/i18n"; import { HEADER_STATUS_MODES, normalizeHeaderStatusMode } from "@/lib/headerStatus"; +import { isWindows } from "@/lib/platform"; import { SettingFieldGrid, SettingRow, @@ -69,6 +70,29 @@ export function GeneralTab() { + {isWindows && ( + + updateAppSettings({ + general: { + ...appSettings.general, + rdp_client_mode: value === "windows" ? "windows" : "builtin", + }, + }) + } + > + + {t("settings.rdpClientBuiltin")} + + + {t("settings.rdpClientWindows")} + + + )} + ({ invoke: vi.fn(), closeStaleCreatedSession: vi.fn(), focusTerminalSession: vi.fn(), + launchSavedRdpWithSystemClient: vi.fn(), openNewSession: vi.fn(), })); vi.mock("@/lib/invoke", () => ({ invoke: mocks.invoke })); @@ -17,6 +18,7 @@ vi.mock("@/lib/appSessionFactory", async (importOriginal) => ({ ...(await importOriginal()), closeStaleCreatedSession: mocks.closeStaleCreatedSession, focusTerminalSession: mocks.focusTerminalSession, + launchSavedRdpWithSystemClient: mocks.launchSavedRdpWithSystemClient, })); function deferred() { @@ -45,6 +47,7 @@ function renderConnect(overrides: Partial setActiveTabId: vi.fn(), updatePaneSession: vi.fn(), updateTabSession: vi.fn(), + rdpClientMode: "builtin", setTerminalWindows: vi.fn(), updateAutoIconForSessionStart: vi.fn(), ...overrides, @@ -62,6 +65,7 @@ beforeEach(() => { command === "get_saved_connections" ? [connection] : "new-session", ); mocks.closeStaleCreatedSession.mockResolvedValue(undefined); + mocks.launchSavedRdpWithSystemClient.mockResolvedValue(false); }); describe("useConnectAfterEdit", () => { @@ -183,6 +187,31 @@ describe("useConnectAfterEdit", () => { }); }); + it("launches a system RDP connection before creating a pending pane", async () => { + const rdpConnection = { ...connection, type: "rdp" as const }; + mocks.invoke.mockResolvedValueOnce([rdpConnection]); + mocks.launchSavedRdpWithSystemClient.mockResolvedValueOnce(true); + const { result, options } = renderConnect({ rdpClientMode: "windows" }); + + await act(() => result.current({ connectionId: "conn-1" })); + + expect(mocks.launchSavedRdpWithSystemClient).toHaveBeenCalledWith( + rdpConnection, + "windows", + ); + expect(options.addPendingTab).not.toHaveBeenCalled(); + expect(options.markPaneConnecting).not.toHaveBeenCalled(); + expect(options.updatePaneSession).not.toHaveBeenCalled(); + expect(options.updateTabSession).not.toHaveBeenCalled(); + expect(options.updateAutoIconForSessionStart).not.toHaveBeenCalled(); + expect(options.recordRecentConnection).toHaveBeenCalledWith("conn-1"); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "create_rdp_session", + expect.anything(), + ); + }); + it("preserves the ID fallback when the saved connection is missing", async () => { mocks.invoke.mockResolvedValueOnce([]); const { result, options } = renderConnect(); diff --git a/src/hooks/useConnectAfterEdit.ts b/src/hooks/useConnectAfterEdit.ts index b6fe857a..667bf93e 100644 --- a/src/hooks/useConnectAfterEdit.ts +++ b/src/hooks/useConnectAfterEdit.ts @@ -1,4 +1,6 @@ import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import type { AppContextType } from "@/context/AppContext"; import { closeStaleCreatedSession, @@ -6,13 +8,14 @@ import { getConnectionSessionType, getRemoteDesktopPaneDisplay, isSessionCreationCancelled, + launchSavedRdpWithSystemClient, } from "@/lib/appSessionFactory"; import { getErrorMessage, shouldPromptConnectionEditOnFailure } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; import { insertTabIntoLeaf, type TerminalWindowNode } from "@/lib/tabWindows"; import { openNewSession } from "@/lib/windowManager"; import { findSessionPaneById, getActivePane } from "@/lib/workspaceTabs"; -import type { SavedConnection } from "@/types/global"; +import type { GeneralSettings, SavedConnection } from "@/types/global"; export interface SessionConnectAfterEditPayload { connectionId: string; @@ -41,6 +44,7 @@ interface ConnectAfterEditOptions > { setTerminalWindows: Dispatch>; updateAutoIconForSessionStart: (connectionId: string, sessionId: string) => void; + rdpClientMode: GeneralSettings["rdp_client_mode"]; } export function useConnectAfterEdit({ @@ -58,7 +62,9 @@ export function useConnectAfterEdit({ updateTabSession, setTerminalWindows, updateAutoIconForSessionStart, + rdpClientMode, }: ConnectAfterEditOptions) { + const { t } = useTranslation(); const tabsRef = useRef(tabs); useEffect(() => { tabsRef.current = tabs; @@ -71,6 +77,20 @@ export function useConnectAfterEdit({ const conns = await invoke("get_saved_connections"); const conn = conns.find((c) => c.id === connectionId); const connName = conn?.name ?? connectionId; + if (conn) { + try { + if (await launchSavedRdpWithSystemClient(conn, rdpClientMode)) { + recordRecentConnection(connectionId); + return; + } + } catch (error) { + const errorMessage = getErrorMessage(error); + toast.error( + t("savedConnections.connectionFailed", { error: errorMessage }), + ); + return; + } + } const sessionType = getConnectionSessionType(conn); const sourceTab = sourceTabId ? (tabsRef.current.find((item) => item.id === sourceTabId) ?? null) @@ -200,12 +220,14 @@ export function useConnectAfterEdit({ markPaneConnectionFailed, markTabConnectionFailed, recordRecentConnection, + rdpClientMode, setActivePane, setActiveTabId, updatePaneSession, updateTabSession, setTerminalWindows, updateAutoIconForSessionStart, + t, ], ); } diff --git a/src/hooks/useTerminalWindowLayout.test.tsx b/src/hooks/useTerminalWindowLayout.test.tsx index 4fb29152..0fd6bd09 100644 --- a/src/hooks/useTerminalWindowLayout.test.tsx +++ b/src/hooks/useTerminalWindowLayout.test.tsx @@ -22,6 +22,7 @@ function renderLayout(tabs: Tab[], savedLayout: RestorableTerminalWindowNode | n minimize_to_tray: false, boss_key: null, confirm_on_close: true, + rdp_client_mode: "builtin", }, savedLayout, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a273ad34..5005260e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2500,6 +2500,10 @@ "rdpCertificateVerifyNew": "New RDP certificate. Verify the fingerprint below.", "rdpCertificateVerifyTitle": "RDP Certificate Verification", "rdpCertificateVerifyWarning": "WARNING: The RDP certificate changed. Accept only if you trust this server.", + "rdpClient": "RDP client", + "rdpClientBuiltin": "NyaTerm built-in RDP", + "rdpClientDesc": "Choose how saved RDP connections open on Windows.", + "rdpClientWindows": "Windows Remote Desktop", "reconnectRestoreCwd": "Restore last working directory on reconnect", "reconnectRestoreCwdDesc": "When enabled, an SSH session reconnects to the last known working directory. Requires shell integration (bash/zsh/fish); falls back to the default behavior when no directory was recorded.", "recordingAutoStart": "Auto-record new sessions", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 3c13245e..4d2aede3 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2498,6 +2498,10 @@ "rdpCertificateVerifyNew": "새 RDP 인증서입니다. 아래 지문을 확인하세요.", "rdpCertificateVerifyTitle": "RDP 인증서 검증", "rdpCertificateVerifyWarning": "경고: RDP 인증서가 변경되었습니다. 이 서버를 신뢰할 때만 수락하세요.", + "rdpClient": "RDP 클라이언트", + "rdpClientBuiltin": "NyaTerm 내장 RDP", + "rdpClientDesc": "Windows에서 저장된 RDP 연결을 여는 방식을 선택합니다.", + "rdpClientWindows": "Windows 원격 데스크톱", "reconnectRestoreCwd": "재연결 시 마지막 작업 디렉터리 복원", "reconnectRestoreCwdDesc": "활성화하면 SSH 세션 재연결 시 연결이 끊어지기 전 작업 디렉터리로 cd 합니다. 셸 통합(bash/zsh/fish)이 필요하며 기록이 없으면 기존 동작을 유지합니다.", "recordingAutoStart": "새 세션 자동 녹화", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9417b44a..395a0716 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2498,6 +2498,10 @@ "rdpCertificateVerifyNew": "新的 RDP 证书,请验证以下指纹。", "rdpCertificateVerifyTitle": "RDP 证书验证", "rdpCertificateVerifyWarning": "警告:RDP 证书已更改。请仅在你信任此服务器时接受。", + "rdpClient": "RDP 客户端", + "rdpClientBuiltin": "NyaTerm 内置 RDP", + "rdpClientDesc": "选择在 Windows 上打开已保存 RDP 连接的方式。", + "rdpClientWindows": "Windows 远程桌面", "reconnectRestoreCwd": "重连后恢复上次工作目录", "reconnectRestoreCwdDesc": "启用后,SSH 会话重连时自动 cd 回断开前的工作目录。需要 shell 集成(bash/zsh/fish)支持,无记录时保持原有行为。", "recordingAutoStart": "自动录制新会话", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 5af8cc24..8304e707 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2493,6 +2493,10 @@ "rdpCertificateVerifyNew": "新的 RDP 憑證,請驗證以下指紋。", "rdpCertificateVerifyTitle": "RDP 憑證驗證", "rdpCertificateVerifyWarning": "警告:RDP 憑證已更改。請僅在你信任此伺服器時接受。", + "rdpClient": "RDP 用戶端", + "rdpClientBuiltin": "NyaTerm 內建 RDP", + "rdpClientDesc": "選擇在 Windows 上開啟已儲存 RDP 連線的方式。", + "rdpClientWindows": "Windows 遠端桌面", "reconnectRestoreCwd": "重連後恢復上次工作目錄", "reconnectRestoreCwdDesc": "啟用後,SSH 工作階段重連時自動 cd 回中斷前的工作目錄。需要 shell 整合(bash/zsh/fish)支援,無記錄時保持原有行為。", "recordingAutoStart": "自動錄製新工作階段", diff --git a/src/lib/appSessionFactory.test.ts b/src/lib/appSessionFactory.test.ts index edade5cd..8dd31689 100644 --- a/src/lib/appSessionFactory.test.ts +++ b/src/lib/appSessionFactory.test.ts @@ -1,7 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { setOwnerMainWindowLabel } from "./windowManager"; import type { SavedConnection, TerminalSessionPane } from "@/types/global"; -import { createSessionForConnection, createSessionForPane } from "./appSessionFactory"; +import { + createSessionForConnection, + createSessionForPane, + launchSavedRdpWithSystemClient, + shouldLaunchSavedRdpWithSystemClient, +} from "./appSessionFactory"; const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); @@ -94,3 +99,50 @@ describe("VNC owner window routing", () => { }, ); }); + +describe("Windows system RDP routing", () => { + beforeEach(() => { + invokeMock.mockReset().mockResolvedValue(undefined); + }); + + it("launches a saved RDP connection externally only on Windows system mode", async () => { + const connection = { id: "rdp-1", type: "rdp" } as SavedConnection; + + expect( + shouldLaunchSavedRdpWithSystemClient(connection, "windows", true), + ).toBe(true); + expect( + await launchSavedRdpWithSystemClient(connection, "windows", true), + ).toBe(true); + expect(invokeMock).toHaveBeenCalledExactlyOnceWith("launch_windows_rdp", { + connectionId: "rdp-1", + }); + }); + + it("keeps built-in and non-Windows RDP on the existing session path", async () => { + const connection = { id: "rdp-1", type: "rdp" } as SavedConnection; + + expect( + shouldLaunchSavedRdpWithSystemClient(connection, "builtin", true), + ).toBe(false); + expect( + shouldLaunchSavedRdpWithSystemClient(connection, "windows", false), + ).toBe(false); + expect( + await launchSavedRdpWithSystemClient(connection, "builtin", true), + ).toBe(false); + expect( + await launchSavedRdpWithSystemClient(connection, "windows", false), + ).toBe(false); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it("does not route non-RDP saved connections to Windows Remote Desktop", async () => { + const connection = { id: "ssh-1", type: "ssh" } as SavedConnection; + + expect( + await launchSavedRdpWithSystemClient(connection, "windows", true), + ).toBe(false); + expect(invokeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/appSessionFactory.ts b/src/lib/appSessionFactory.ts index e1b9319c..beeda4e4 100644 --- a/src/lib/appSessionFactory.ts +++ b/src/lib/appSessionFactory.ts @@ -5,6 +5,7 @@ import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; import { getOwnerMainWindowLabel } from "@/lib/windowManager"; import { logger } from "@/lib/logger"; +import { isWindows } from "@/lib/platform"; import { buildTerminalCommandInput, clearSessionCommandHistory, @@ -13,6 +14,7 @@ import { import type { TemporaryLinkConfig } from "@/lib/temporaryLink"; import { captureTerminalReconnectContent } from "@/lib/terminalReconnectHistory"; import type { + GeneralSettings, SavedConnection, SessionPane, SessionType, @@ -63,6 +65,26 @@ export function getRemoteDesktopPaneDisplay( return undefined; } +export function shouldLaunchSavedRdpWithSystemClient( + connection: Pick | null | undefined, + mode: GeneralSettings["rdp_client_mode"] | undefined, + windows = isWindows, +) { + return windows && mode === "windows" && connection?.type === "rdp"; +} + +export async function launchSavedRdpWithSystemClient( + connection: Pick, + mode: GeneralSettings["rdp_client_mode"] | undefined, + windows = isWindows, +) { + if (!shouldLaunchSavedRdpWithSystemClient(connection, mode, windows)) { + return false; + } + await invoke("launch_windows_rdp", { connectionId: connection.id }); + return true; +} + export function isSessionCreationCancelled(error: unknown) { return getErrorMessage(error) .toLowerCase() diff --git a/src/types/global.d.ts b/src/types/global.d.ts index cdac0cb9..eb0a3e1b 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1197,6 +1197,7 @@ export interface GeneralSettings { minimize_to_tray: boolean; boss_key: string | null; confirm_on_close: boolean; + rdp_client_mode: "builtin" | "windows"; } export type BackgroundImageFit = "cover" | "contain" | "stretch" | "tile";