From 95c8b4ab482dedc3867bb764a677aad257fd8edd Mon Sep 17 00:00:00 2001 From: Kang Date: Thu, 8 Oct 2026 13:23:39 +0800 Subject: [PATCH] feat(rdp): enhance RDP client selection and connection handling - Added options for selecting between built-in RDP and Windows Remote Desktop in the settings and connection menus. - Updated connection handling to respect user preferences for RDP client mode during connection attempts. - Introduced context menu actions for opening RDP connections with specified client modes. - Enhanced documentation for RDP session types to clarify client selection behavior. - Added tests to ensure correct behavior of RDP client mode selection and connection handling across different platforms. --- docs-site/docs/guide/session-types.md | 6 + .../current/guide/session-types.md | 6 + .../nyaterm-core/src/config/connection.rs | 35 ++++ .../src/core/portable_snapshot/tests.rs | 31 ++++ src-tauri/crates/nyaterm-core/src/services.rs | 7 + src-tauri/src/cmd/connection.rs | 1 + src/App.tsx | 4 +- .../saved-connections/ConnectionItem.test.tsx | 56 +++++- .../saved-connections/ConnectionItem.tsx | 23 +++ .../panel/saved-connections/context.tsx | 3 +- .../panel/saved-connections/index.test.tsx | 160 ++++++++++++++++++ .../panel/saved-connections/index.tsx | 11 +- src/components/sessions/RdpForm.test.tsx | 104 ++++++++++++ src/components/sessions/RdpForm.tsx | 52 ++++++ src/components/settings/GeneralTab.test.tsx | 33 +++- src/components/settings/GeneralTab.tsx | 24 --- src/components/settings/RemoteDesktopTab.tsx | 39 +++++ src/hooks/useConnectAfterEdit.test.tsx | 15 ++ src/lib/appSessionFactory.test.ts | 96 ++++++++++- src/lib/appSessionFactory.ts | 46 ++++- src/lib/backend/runtime.test.ts | 33 ++++ src/lib/backend/runtime.ts | 7 + src/pages/NewSessionPage.test.tsx | 48 ++++++ src/pages/NewSessionPage.tsx | 8 + src/pages/SettingsPage.tsx | 17 +- src/types/global.d.ts | 5 +- 26 files changed, 814 insertions(+), 56 deletions(-) create mode 100644 src/components/panel/saved-connections/index.test.tsx create mode 100644 src/components/sessions/RdpForm.test.tsx create mode 100644 src/components/settings/RemoteDesktopTab.tsx create mode 100644 src/lib/backend/runtime.test.ts diff --git a/docs-site/docs/guide/session-types.md b/docs-site/docs/guide/session-types.md index d2a2a36e..2c181024 100644 --- a/docs-site/docs/guide/session-types.md +++ b/docs-site/docs/guide/session-types.md @@ -107,6 +107,12 @@ Telnet 会话适合: ## RDP +Windows 桌面端可以在 **设置 → 连接 → 远程桌面** 中选择默认 RDP 客户端,初始为 NyaTerm 内置 RDP。在新建或编辑 RDP 连接时,“打开方式”可以跟随默认设置,也可以单独选择内置 RDP 或 Windows 远程桌面。已有连接会继续跟随原来的全局偏好。 + +已保存的 RDP 连接右键菜单还提供“使用内置 RDP 打开”和“使用 Windows 远程桌面打开”,仅影响当前连接的本次打开,不修改默认设置或保存的连接。批量连接按每个连接的偏好打开。 + +Windows 远程桌面使用系统独立窗口,仅接收地址和端口;用户名、密码、域、代理、SSH 跳板机及其他参数由系统客户端管理。NyaTerm 中保存的参数仍可用于内置 RDP。已存在的内置会话重连、分屏和启动恢复继续使用内置客户端;非 Windows 桌面端使用内置 RDP,Web 不提供 RDP。 + RDP 会话适合连接 Windows 主机或提供 RDP 服务的远程桌面环境。它和终端会话共享同一套标签页、分屏和保存连接体系,但底层是图形远程桌面,而不是文本终端。 创建 RDP 会话时可以配置: diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guide/session-types.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guide/session-types.md index da6be23a..2b849243 100644 --- a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guide/session-types.md +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guide/session-types.md @@ -105,6 +105,12 @@ Serial sessions still live inside NyaTerm's tabbed and split workspace, so you c ## RDP +On Windows desktop, choose the default RDP client in **Settings → Connections → Remote Desktop**. The initial default is NyaTerm built-in RDP. When creating or editing an RDP connection, **Open with** can follow the default or select either built-in RDP or Windows Remote Desktop. Existing connections continue to follow your previous global preference. + +The saved RDP connection context menu also offers **Open with built-in RDP** and **Open with Windows Remote Desktop**. These actions apply only to that connection's current launch and do not change saved preferences, even with multiple connections selected. Batch launches use each connection's preference. + +Windows Remote Desktop opens in a separate system window and receives only the address and port. Credentials, domain, proxies, SSH jump hosts, and other options are managed by the system client. Parameters saved in NyaTerm remain available for built-in RDP. Existing built-in sessions keep using built-in RDP for reconnects, splits, and startup restoration. Other desktop platforms use built-in RDP; Web does not offer RDP. + RDP sessions are for Windows hosts or other environments that expose a Remote Desktop endpoint. They share NyaTerm's saved-connection, tab, and split-pane workspace model, but the underlying session is a graphical desktop instead of a text terminal. When creating an RDP session, you can configure: diff --git a/src-tauri/crates/nyaterm-core/src/config/connection.rs b/src-tauri/crates/nyaterm-core/src/config/connection.rs index d1a348f6..6e075e46 100644 --- a/src-tauri/crates/nyaterm-core/src/config/connection.rs +++ b/src-tauri/crates/nyaterm-core/src/config/connection.rs @@ -717,6 +717,8 @@ pub enum ConnectionType { encoding: String, }, Rdp { + #[serde(default, skip_serializing_if = "Option::is_none")] + rdp_client_mode: Option, host: String, #[serde(default = "default_rdp_port")] port: u16, @@ -2755,6 +2757,7 @@ mod tests { let ConnectionType::Rdp { port, + rdp_client_mode, security, display, clipboard, @@ -2766,6 +2769,7 @@ mod tests { }; assert_eq!(port, 3389); + assert_eq!(rdp_client_mode, None); assert!(security.use_nla); assert_eq!(security.certificate_policy, "prompt"); assert_eq!(display.width, 1920); @@ -2776,6 +2780,37 @@ mod tests { assert_eq!(reconnect.max_attempts, 5); } + #[test] + fn rdp_client_mode_roundtrip_and_validation() { + for mode in [None, Some("builtin"), Some("windows"), Some("invalid")] { + let mut value = serde_json::json!({ + "id": "rdp-client", "name": "RDP", "type": "rdp", + "host": "rdp.example.com", "username": "Administrator" + }); + if let Some(mode) = mode { + value["rdp_client_mode"] = serde_json::json!(mode); + } + let connection: SavedConnection = serde_json::from_value(value).expect("connection"); + assert_eq!( + crate::services::validate_rdp_config(&connection).is_ok(), + mode != Some("invalid") + ); + let serialized = serde_json::to_value(&connection).expect("serialize"); + assert_eq!( + serialized.get("rdp_client_mode").and_then(|v| v.as_str()), + mode + ); + let restored: SavedConnection = serde_json::from_value(serialized).expect("restore"); + let ConnectionType::Rdp { + rdp_client_mode, .. + } = restored.config + else { + panic!("expected RDP"); + }; + assert_eq!(rdp_client_mode.as_deref(), mode); + } + } + #[test] fn vnc_connection_defaults_to_standard_mvp_options() { let connection: SavedConnection = serde_json::from_value(serde_json::json!({ diff --git a/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/tests.rs b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/tests.rs index 8a573fbc..56dec6a0 100644 --- a/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/tests.rs +++ b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/tests.rs @@ -839,6 +839,37 @@ mod tests { } } + #[test] + fn rdp_client_mode_survives_portable_snapshot_roundtrip() { + for mode in [None, Some("builtin"), Some("windows")] { + let mut connection = serde_json::json!({ + "id": "rdp-client", "name": "RDP", "type": "rdp", + "host": "rdp.example.com", "username": "Administrator" + }); + if let Some(mode) = mode { + connection["rdp_client_mode"] = serde_json::json!(mode); + } + let mut snapshot = sample_snapshot(); + snapshot.sessions.connections = + vec![serde_json::from_value(connection).expect("connection")]; + strip_device_local_sessions(&mut snapshot.sessions); + snapshot.payload_hash = calculate_payload_hash(&snapshot).expect("hash"); + let encoded = encode_portable_snapshot(&snapshot).expect("export"); + let mut restored = super::decode_portable_snapshot(&encoded).expect("import"); + preserve_device_local_sessions( + &mut restored.sessions, + &config::SessionsConfig::default(), + ); + let config::ConnectionType::Rdp { + rdp_client_mode, .. + } = &restored.sessions.connections[0].config + else { + panic!("expected RDP"); + }; + assert_eq!(rdp_client_mode.as_deref(), mode); + } + } + #[test] fn portable_snapshot_zip_roundtrip() { let mut snapshot = sample_snapshot(); diff --git a/src-tauri/crates/nyaterm-core/src/services.rs b/src-tauri/crates/nyaterm-core/src/services.rs index c9f8c68c..305b21cd 100644 --- a/src-tauri/crates/nyaterm-core/src/services.rs +++ b/src-tauri/crates/nyaterm-core/src/services.rs @@ -130,6 +130,7 @@ pub fn validate_sftp_settings_config(connection: &SavedConnection) -> AppResult< pub fn validate_rdp_config(connection: &SavedConnection) -> AppResult<()> { let config::ConnectionType::Rdp { + rdp_client_mode, host, port, username, @@ -143,6 +144,12 @@ pub fn validate_rdp_config(connection: &SavedConnection) -> AppResult<()> { return Ok(()); }; + if let Some(mode) = rdp_client_mode + && !matches!(mode.as_str(), "builtin" | "windows") + { + return Err(AppError::Config("RDP client mode is invalid".to_string())); + } + if host.trim().is_empty() { return Err(AppError::Config("RDP host is required".to_string())); } diff --git a/src-tauri/src/cmd/connection.rs b/src-tauri/src/cmd/connection.rs index e78c1380..271ae1af 100644 --- a/src-tauri/src/cmd/connection.rs +++ b/src-tauri/src/cmd/connection.rs @@ -628,6 +628,7 @@ e+JpiSq66Z6GIt0801skPh20jxOO3F52SoX1IeO5D5PXfZrfSZlw6S8c7bwyp2FHxDewRx id: "rdp-1".to_string(), name: "RDP".to_string(), config: ConnectionType::Rdp { + rdp_client_mode: None, host: "example.com".to_string(), port: 3389, username: "administrator".to_string(), diff --git a/src/App.tsx b/src/App.tsx index e187dd85..424add3b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,6 +57,7 @@ import { getTemporaryLinkSessionType, isSessionCreationCancelled, launchSavedRdpWithSystemClient, + type SavedConnectionOpenOptions, type StartupCommandRequest, sendStartupCommandToSession, } from "./lib/appSessionFactory"; @@ -636,7 +637,7 @@ function App() { const connectSavedConnection = useCallback( async ( connection: SavedConnection, - options?: { + options?: SavedConnectionOpenOptions & { failureContext?: string; runtimeModeOverride?: SshRuntimeMode; propagateError?: boolean; @@ -652,6 +653,7 @@ function App() { await launchSavedRdpWithSystemClient( connection, appSettings.general.rdp_client_mode, + { rdpClientModeOverride: options?.rdpClientModeOverride }, ) ) { recordRecentConnection(connection.id); diff --git a/src/components/panel/saved-connections/ConnectionItem.test.tsx b/src/components/panel/saved-connections/ConnectionItem.test.tsx index 0a0b1ee5..d0aa1c48 100644 --- a/src/components/panel/saved-connections/ConnectionItem.test.tsx +++ b/src/components/panel/saved-connections/ConnectionItem.test.tsx @@ -6,6 +6,12 @@ import type { SavedConnection } from "@/types/global"; import ConnectionItem from "./ConnectionItem"; import { SavedConnectionsContext, type SavedConnectionsContextValue } from "./context"; +const platformState = vi.hoisted(() => ({ windowsDesktop: true })); +vi.mock("@/lib/backend/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + canUseWindowsRdpClient: () => platformState.windowsDesktop, +})); + vi.mock("@/components/ui/context-menu", () => ({ ContextMenu: ({ children }: { children: ReactNode }) => <>{children}, ContextMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, @@ -83,9 +89,9 @@ function createContextValue(conn = connection): SavedConnectionsContextValue { }; } -function renderConnectionItem(conn = connection) { +function renderConnectionItem(conn = connection, context = createContextValue(conn)) { return render( - + , ); @@ -99,10 +105,56 @@ function getConnectionTrigger(container: HTMLElement) { describe("ConnectionItem", () => { beforeEach(() => { + platformState.windowsDesktop = true; handleConnectOnlyMock.mockReset(); onEditConnectionMock.mockReset(); }); + it.each(["builtin", "windows"] as const)( + "temporarily opens only the right-clicked RDP with %s despite a multi-selection", + (mode) => { + const rdp = { + ...connection, + type: "rdp" as const, + rdp_client_mode: "windows" as const, + }; + const context = createContextValue(rdp); + context.selectedConnectionIds.add("other-connection"); + renderConnectionItem(rdp, context); + fireEvent.click( + screen.getByRole("button", { + name: + mode === "builtin" + ? "savedConnections.openWithBuiltinRdp" + : "savedConnections.openWithWindowsRdp", + }), + ); + expect(handleConnectOnlyMock).toHaveBeenCalledExactlyOnceWith(rdp, { + rdpClientModeOverride: mode, + }); + expect(context.handleConnectSelected).not.toHaveBeenCalled(); + expect(rdp.rdp_client_mode).toBe("windows"); + }, + ); + + it("hides temporary RDP actions on non-Windows desktops and Web", () => { + platformState.windowsDesktop = false; + renderConnectionItem({ ...connection, type: "rdp" }); + expect( + screen.queryByText("savedConnections.openWithBuiltinRdp"), + ).toBeNull(); + expect( + screen.queryByText("savedConnections.openWithWindowsRdp"), + ).toBeNull(); + }); + + it("hides temporary RDP actions for other protocols", () => { + renderConnectionItem(); + expect( + screen.queryByText("savedConnections.openWithBuiltinRdp"), + ).toBeNull(); + }); + it("connects exactly once when the focused connection receives Enter", async () => { const user = userEvent.setup(); const { container } = renderConnectionItem(); diff --git a/src/components/panel/saved-connections/ConnectionItem.tsx b/src/components/panel/saved-connections/ConnectionItem.tsx index d0853300..6a6e8525 100644 --- a/src/components/panel/saved-connections/ConnectionItem.tsx +++ b/src/components/panel/saved-connections/ConnectionItem.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/context-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { canOpenSavedConnectionWithSftp } from "@/lib/sftpRuntime"; +import { canUseWindowsRdpClient } from "@/lib/backend/runtime"; import type { SavedConnection } from "@/types/global"; import { resolveConnectionIcon } from "../../icons"; import { useSavedConnectionsContext } from "./context"; @@ -682,6 +683,28 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection {connectLabel} + {conn.type === "rdp" && canUseWindowsRdpClient() && ( + <> + { + closeAndSuppressDetails(); + handleConnectOnly(conn, { rdpClientModeOverride: "builtin" }); + }} + > + + {t("savedConnections.openWithBuiltinRdp")} + + { + closeAndSuppressDetails(); + handleConnectOnly(conn, { rdpClientModeOverride: "windows" }); + }} + > + + {t("savedConnections.openWithWindowsRdp")} + + + )} {canOpenSavedConnectionWithSftp(conn) ? ( { diff --git a/src/components/panel/saved-connections/context.tsx b/src/components/panel/saved-connections/context.tsx index 4f75dbf0..1018f42d 100644 --- a/src/components/panel/saved-connections/context.tsx +++ b/src/components/panel/saved-connections/context.tsx @@ -2,6 +2,7 @@ import type { TFunction } from "i18next"; import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react"; import { createContext, useContext } from "react"; import type { NewSessionTarget } from "@/lib/windowManager"; +import type { SavedConnectionOpenOptions } from "@/lib/appSessionFactory"; import type { Group, SavedConnection } from "@/types/global"; // ── Component-local types ───────────────────────────────────────────────── @@ -40,7 +41,7 @@ export interface SavedConnectionsContextValue { // List actions toggleGroup: (id: string) => void; handleConnect: (conn: SavedConnection) => void; - handleConnectOnly: (conn: SavedConnection) => void; + handleConnectOnly: (conn: SavedConnection, options?: SavedConnectionOpenOptions) => void; handleOpenSftp: (conn: SavedConnection) => void; handleConnectSelected: () => void; handleCopyConnection: (conn: SavedConnection) => void; diff --git a/src/components/panel/saved-connections/index.test.tsx b/src/components/panel/saved-connections/index.test.tsx new file mode 100644 index 00000000..e66b1c6e --- /dev/null +++ b/src/components/panel/saved-connections/index.test.tsx @@ -0,0 +1,160 @@ +import { act, render } from "@testing-library/react"; +import type { MouseEvent } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SavedConnection } from "@/types/global"; +import type { SavedConnectionsContextValue } from "./context"; +import SavedConnections from "./index"; + +const state = vi.hoisted(() => ({ + context: null as SavedConnectionsContextValue | null, + connections: [] as SavedConnection[], + invoke: vi.fn(), + updateUi: vi.fn(), + refresh: vi.fn(), +})); +vi.mock("@/lib/invoke", () => ({ invoke: state.invoke })); +vi.mock("@/context/AppContext", () => ({ + useApp: () => ({ + savedConnections: state.connections, + savedGroups: [], + refreshConnections: state.refresh, + appSettings: { ui: {}, keybindings: {} }, + updateUi: state.updateUi, + }), +})); +vi.mock("@/hooks/useConfigTransfer", () => ({ + useConfigTransfer: () => ({ handleExport: vi.fn(), passwordAlert: null }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("./ConnectionItem", async () => { + const { useSavedConnectionsContext } = await import("./context"); + return { + default: () => { + state.context = useSavedConnectionsContext(); + return null; + }, + }; +}); +vi.mock("@/components/dialog/connections/ClearAllDialog", () => ({ + default: () => null, +})); +vi.mock("@/components/dialog/connections/DeleteConnectionDialog", () => ({ + default: () => null, +})); +vi.mock("@/components/dialog/connections/DeleteFolderDialog", () => ({ + default: () => null, +})); +vi.mock("@/components/dialog/connections/FolderDialog", () => ({ + default: () => null, +})); +vi.mock("@/components/dialog/connections/ImportDialog", () => ({ + default: () => null, +})); +vi.mock("@/components/dialog/connections/OpenGroupConnectionsDialog", () => ({ + default: () => null, +})); +vi.mock("@/components/dialog/connections/RenameConnectionDialog", () => ({ + default: () => null, +})); + +function renderPanel(connect = vi.fn().mockResolvedValue(undefined)) { + render( + , + ); + return connect; +} + +describe("saved connection client overrides", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.invoke.mockResolvedValue("copy-id"); + state.connections = [ + { + id: "rdp-1", + name: "RDP", + type: "rdp", + host: "host", + rdp_client_mode: "windows", + }, + { + id: "rdp-2", + name: "Other", + type: "rdp", + host: "other", + rdp_client_mode: "builtin", + }, + ]; + }); + + it("forwards a temporary override and prevents concurrent duplicate opens", async () => { + let finish!: () => void; + const connect = renderPanel( + vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + ); + act(() => { + state.context?.handleConnectOnly(state.connections[0], { + rdpClientModeOverride: "builtin", + }); + state.context?.handleConnectOnly(state.connections[0], { + rdpClientModeOverride: "windows", + }); + }); + expect(connect).toHaveBeenCalledExactlyOnceWith(state.connections[0], { + rdpClientModeOverride: "builtin", + }); + expect(state.invoke).not.toHaveBeenCalled(); + expect(state.connections[0].rdp_client_mode).toBe("windows"); + await act(async () => finish()); + act(() => + state.context?.handleConnectOnly(state.connections[0], { + rdpClientModeOverride: "windows", + }), + ); + expect(connect).toHaveBeenCalledTimes(2); + await act(async () => finish()); + }); + + it("passes each selected connection's preference without a temporary override", async () => { + const connect = renderPanel(); + for (const connection of state.connections) { + act(() => + state.context?.handleConnectionSelectionStart(connection, { + button: 0, + ctrlKey: true, + } as MouseEvent), + ); + } + await act(async () => state.context?.handleConnectSelected()); + expect(connect).toHaveBeenNthCalledWith(1, state.connections[0], undefined); + expect(connect).toHaveBeenNthCalledWith(2, state.connections[1], undefined); + }); + + it("preserves the client preference when copying a saved connection", async () => { + renderPanel(); + await act(async () => + state.context?.handleCopyConnection(state.connections[0]), + ); + expect(state.invoke).toHaveBeenCalledWith("save_connection", { + connection: { + ...state.connections[0], + id: "", + name: "RDP (copy)", + password: undefined, + }, + }); + expect(state.refresh).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/panel/saved-connections/index.tsx b/src/components/panel/saved-connections/index.tsx index bb342d82..fb330676 100644 --- a/src/components/panel/saved-connections/index.tsx +++ b/src/components/panel/saved-connections/index.tsx @@ -50,6 +50,7 @@ import { logger } from "@/lib/logger"; import { openSavedConnectionWithSftp } from "@/lib/sftpRuntime"; import { matchesKeyEvent } from "@/lib/shortcutRegistry"; import type { NewSessionTarget } from "@/lib/windowManager"; +import type { SavedConnectionOpenOptions } from "@/lib/appSessionFactory"; import type { Group, SavedConnection } from "@/types/global"; import ConnectionItem from "./ConnectionItem"; import { matchesConnectionSearch } from "./connectionSearch"; @@ -72,7 +73,7 @@ interface SavedConnectionsProps { autoConnect?: boolean, target?: NewSessionTarget, ) => void; - onConnectConnection: (connection: SavedConnection) => Promise | void; + onConnectConnection: (connection: SavedConnection, options?: SavedConnectionOpenOptions) => Promise | void; onOpenSftpConnection: (connection: SavedConnection) => Promise | void; } @@ -578,11 +579,11 @@ export default function SavedConnections({ }); }; - const connectConnection = async (conn: SavedConnection) => { + const connectConnection = async (conn: SavedConnection, options?: SavedConnectionOpenOptions) => { if (connectingIdsRef.current.has(conn.id)) return; connectingIdsRef.current.add(conn.id); try { - await onConnectConnection(conn); + await onConnectConnection(conn, options); } catch (e) { toast.error(t("savedConnections.connectionFailed", { error: getErrorMessage(e) })); } finally { @@ -601,8 +602,8 @@ export default function SavedConnections({ openConnections(selectedConnections); }; - const handleConnectOnly = (conn: SavedConnection) => { - openConnections([conn]); + const handleConnectOnly = (conn: SavedConnection, options?: SavedConnectionOpenOptions) => { + void connectConnection(conn, options); }; const handleOpenSftp = (conn: SavedConnection) => { diff --git a/src/components/sessions/RdpForm.test.tsx b/src/components/sessions/RdpForm.test.tsx new file mode 100644 index 00000000..5089d577 --- /dev/null +++ b/src/components/sessions/RdpForm.test.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RdpForm } from "./RdpForm"; + +const platform = vi.hoisted(() => ({ windowsDesktop: true })); +vi.mock("@/lib/backend/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + canUseWindowsRdpClient: () => platform.windowsDesktop, +})); +vi.mock("@/lib/invoke", () => ({ invoke: vi.fn().mockResolvedValue([]) })); +vi.mock("@/components/sessions/SessionNetworkSection", () => ({ + SessionNetworkSection: () => null, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderForm(overrides: Partial> = {}) { + const props: ComponentProps = { + clientMode: "default", + defaultClientMode: "windows", + setClientMode: vi.fn(), + host: "rdp.example.com", + setHost: vi.fn(), + port: 3389, + setPort: vi.fn(), + username: "Administrator", + setUsername: vi.fn(), + domain: "", + setDomain: vi.fn(), + passwordId: "", + setPasswordId: vi.fn(), + password: "", + setPassword: vi.fn(), + hasPassword: false, + setHasPassword: vi.fn(), + useNla: true, + setUseNla: vi.fn(), + certificatePolicy: "prompt", + setCertificatePolicy: vi.fn(), + displayWidth: 1920, + setDisplayWidth: vi.fn(), + displayHeight: 1080, + setDisplayHeight: vi.fn(), + displayMode: "fit-window", + setDisplayMode: vi.fn(), + clipboardMode: "text-only", + setClipboardMode: vi.fn(), + reconnectEnabled: true, + setReconnectEnabled: vi.fn(), + reconnectMaxAttempts: 5, + setReconnectMaxAttempts: vi.fn(), + proxyId: "", + setProxyId: vi.fn(), + proxies: [], + jumpHostId: "", + setJumpHostId: vi.fn(), + jumpHostOptions: [], + ...overrides, + }; + render(); + return props; +} + +describe("RDP open method", () => { + beforeEach(() => { + platform.windowsDesktop = true; + }); + + it("shows the effective default and system-client parameter explanation", () => { + renderForm(); + expect( + screen.getByRole("combobox", { name: "dialog.rdpOpenWith" }).textContent, + ).toContain("settings.rdpClientWindows"); + expect(screen.queryByText("settings.rdpSystemClientDesc")).not.toBeNull(); + }); + + it("allows selecting built-in RDP", () => { + const props = renderForm(); + fireEvent.keyDown( + screen.getByRole("combobox", { name: "dialog.rdpOpenWith" }), + { key: "ArrowDown" }, + ); + fireEvent.click( + screen.getByRole("option", { name: "settings.rdpClientBuiltin" }), + ); + expect(props.setClientMode).toHaveBeenCalledWith("builtin"); + }); + + it("hides the system-client explanation for an explicit built-in choice", () => { + renderForm({ clientMode: "builtin" }); + expect(screen.queryByText("settings.rdpSystemClientDesc")).toBeNull(); + }); + + it("hides the selector without rewriting a stored Windows preference on other platforms", () => { + platform.windowsDesktop = false; + const props = renderForm({ clientMode: "windows" }); + expect( + screen.queryByRole("combobox", { name: "dialog.rdpOpenWith" }), + ).toBeNull(); + expect(props.setClientMode).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/sessions/RdpForm.tsx b/src/components/sessions/RdpForm.tsx index 274e5d15..bcabb920 100644 --- a/src/components/sessions/RdpForm.tsx +++ b/src/components/sessions/RdpForm.tsx @@ -18,15 +18,20 @@ import { import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { invoke } from "@/lib/invoke"; +import { canUseWindowsRdpClient } from "@/lib/backend/runtime"; import type { ProxyConfig, RdpCertificatePolicy, + RdpClientMode, RdpClipboardMode, RdpDisplayMode, SavedPassword, } from "@/types/global"; interface RdpFormProps { + clientMode: RdpClientMode | "default"; + setClientMode: (value: RdpClientMode | "default") => void; + defaultClientMode: RdpClientMode; host: string; setHost: (value: string) => void; port: number; @@ -75,6 +80,9 @@ function RequiredMark() { } export function RdpForm({ + clientMode, + setClientMode, + defaultClientMode, host, setHost, port, @@ -198,6 +206,50 @@ export function RdpForm({ + {canUseWindowsRdpClient() && ( +
+ + + {(clientMode === "windows" || + (clientMode === "default" && defaultClientMode === "windows")) && ( +

+ {t("settings.rdpSystemClientDesc")} +

+ )} +
+ )} +