feat: add Windows Remote Desktop client option
This commit is contained in:
1 parent
bc6637f6f1
commit
0ef5d1be48
19 files changed
+431
-3
No files matched your search
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Option<crate::config::SavedConnection>> {
|
||||
storage()?.get_connection(connection_id)
|
||||
}
|
||||
|
||||
pub(crate) fn mark_connection_used(connection_id: &str) -> AppResult<()> {
|
||||
storage()?.mark_connection_used(connection_id)
|
||||
}
|
||||
|
||||
+56
@@ -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,
|
||||
|
||||
@@ -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<typeof import("react-i18next")>()),
|
||||
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(<GeneralTab />);
|
||||
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(<GeneralTab />);
|
||||
expect(screen.queryByText("settings.rdpClient")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection contentClassName="space-y-4">
|
||||
{isWindows && (
|
||||
<SettingSelect
|
||||
label={t("settings.rdpClient")}
|
||||
desc={t("settings.rdpClientDesc")}
|
||||
value={appSettings.general.rdp_client_mode ?? "builtin"}
|
||||
onValueChange={(value) =>
|
||||
updateAppSettings({
|
||||
general: {
|
||||
...appSettings.general,
|
||||
rdp_client_mode: value === "windows" ? "windows" : "builtin",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectItem value="builtin">
|
||||
{t("settings.rdpClientBuiltin")}
|
||||
</SelectItem>
|
||||
<SelectItem value="windows">
|
||||
{t("settings.rdpClientWindows")}
|
||||
</SelectItem>
|
||||
</SettingSelect>
|
||||
)}
|
||||
|
||||
<SettingRow label={t("settings.startupRestore")} desc={t("settings.startupRestoreDesc")}>
|
||||
<SettingSwitch
|
||||
checked={appSettings.general.startup_restore}
|
||||
|
||||
@@ -77,6 +77,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
minimize_to_tray: false,
|
||||
boss_key: null,
|
||||
confirm_on_close: true,
|
||||
rdp_client_mode: "builtin",
|
||||
},
|
||||
appearance: {
|
||||
theme: "github-dark",
|
||||
|
||||
@@ -46,6 +46,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
minimize_to_tray: false,
|
||||
boss_key: null,
|
||||
confirm_on_close: true,
|
||||
rdp_client_mode: "builtin",
|
||||
},
|
||||
appearance: {
|
||||
theme: "github-dark",
|
||||
|
||||
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
||||
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<typeof import("@/lib/appSessionFactory")>()),
|
||||
closeStaleCreatedSession: mocks.closeStaleCreatedSession,
|
||||
focusTerminalSession: mocks.focusTerminalSession,
|
||||
launchSavedRdpWithSystemClient: mocks.launchSavedRdpWithSystemClient,
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -45,6 +47,7 @@ function renderConnect(overrides: Partial<Parameters<typeof useConnectAfterEdit>
|
||||
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();
|
||||
|
||||
@@ -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<SetStateAction<TerminalWindowNode | null>>;
|
||||
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<SavedConnection[]>("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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "새 세션 자동 녹화",
|
||||
|
||||
@@ -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": "自动录制新会话",
|
||||
|
||||
@@ -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": "自動錄製新工作階段",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<SavedConnection, "type"> | null | undefined,
|
||||
mode: GeneralSettings["rdp_client_mode"] | undefined,
|
||||
windows = isWindows,
|
||||
) {
|
||||
return windows && mode === "windows" && connection?.type === "rdp";
|
||||
}
|
||||
|
||||
export async function launchSavedRdpWithSystemClient(
|
||||
connection: Pick<SavedConnection, "id" | "type">,
|
||||
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()
|
||||
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user