chore(web): merge main and preserve runtime adapters

This commit is contained in:
Kang committed 2026-10-07 11:22:08 +08:00
commit bceaed3ce0
25 files changed
+1531 -353

No files matched your search

@@ -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");
}
}
@@ -149,6 +149,12 @@ pub fn get_connection(connection_id: &str) -> AppResult<Option<crate::config::Sa
storage()?.get_connection_with_secret(connection_id)
}
pub fn get_connection_without_secret(
connection_id: &str,
) -> AppResult<Option<crate::config::SavedConnection>> {
storage()?.get_connection(connection_id)
}
pub fn mark_connection_used(connection_id: &str) -> AppResult<()> {
storage()?.mark_connection_used(connection_id)
}
+81 -1
View File
@@ -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
View File
@@ -4,7 +4,6 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::mem;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{self, SyncSender, TrySendError};
+48 -38
View File
@@ -118,31 +118,34 @@ fn context_records(
#[cfg(test)]
fn strip_terminal_control_sequences(text: &str) -> String {
let replayed = replay_terminal_output(text, "", 0);
let mut pending = Vec::new();
let mut cursor = 0;
let replayed = replay_terminal_output(text, &mut pending, &mut cursor);
let mut out = String::with_capacity(text.len());
for line in replayed.lines {
out.push_str(&line);
out.push('\n');
}
out.push_str(&replayed.tail);
out.extend(pending);
out
}
struct TerminalReplayResult {
lines: Vec<String>,
tail: String,
cursor: usize,
// Echo matching must stop at the first artificial line boundary.
first_forced_split: Option<usize>,
}
fn replay_terminal_output(
text: &str,
initial_line: &str,
initial_cursor: usize,
line: &mut Vec<char>,
cursor: &mut usize,
) -> TerminalReplayResult {
let bytes = text.as_bytes();
let mut line = initial_line.chars().collect::<Vec<_>>();
let mut cursor = initial_cursor.min(line.len());
let mut lines = Vec::new();
let mut replayed = TerminalReplayResult {
lines: Vec::new(),
first_forced_split: None,
};
let mut i = 0;
while i < bytes.len() {
@@ -163,9 +166,9 @@ fn replay_terminal_output(
apply_csi_sequence(
&text[params_start..i - 1],
b,
&mut line,
&mut cursor,
&mut lines,
line,
cursor,
&mut replayed.lines,
);
break;
}
@@ -201,28 +204,26 @@ fn replay_terminal_output(
}
}
b'\r' => {
cursor = 0;
*cursor = 0;
i += 1;
}
b'\n' => {
lines.push(line.iter().collect());
line.clear();
cursor = 0;
replayed.lines.push(take_replay_line(line, cursor));
i += 1;
}
b'\t' => {
write_terminal_char(&mut line, &mut cursor, '\t');
write_terminal_char(line, cursor, '\t', &mut replayed);
i += 1;
}
b'\x08' => {
cursor = cursor.saturating_sub(1);
*cursor = cursor.saturating_sub(1);
i += 1;
}
b if b.is_ascii_control() => {
i += 1;
}
b if b.is_ascii() => {
write_terminal_char(&mut line, &mut cursor, b as char);
write_terminal_char(line, cursor, b as char, &mut replayed);
i += 1;
}
_ => {
@@ -233,24 +234,36 @@ fn replay_terminal_output(
let Some(ch) = text[i..].chars().next() else {
break;
};
write_terminal_char(&mut line, &mut cursor, ch);
write_terminal_char(line, cursor, ch, &mut replayed);
i += ch.len_utf8();
}
}
}
TerminalReplayResult {
lines,
tail: line.iter().collect(),
cursor,
}
replayed
}
const MAX_REPLAY_CURSOR_COLUMNS: usize = 4096;
// Transcript segments are bounded even when a TUI never sends a newline.
const MAX_REPLAY_LINE_CHARS: usize = 16 * 1024;
fn write_terminal_char(line: &mut Vec<char>, cursor: &mut usize, ch: char) {
while *cursor > line.len() {
line.push(' ');
fn take_replay_line(line: &mut Vec<char>, cursor: &mut usize) -> String {
*cursor = 0;
line.drain(..).collect()
}
fn write_terminal_char(
line: &mut Vec<char>,
cursor: &mut usize,
ch: char,
replayed: &mut TerminalReplayResult,
) {
if *cursor >= MAX_REPLAY_LINE_CHARS {
replayed.first_forced_split.get_or_insert(replayed.lines.len());
replayed.lines.push(take_replay_line(line, cursor));
}
if *cursor > line.len() {
line.resize(*cursor, ' ');
}
if *cursor == line.len() {
@@ -270,12 +283,9 @@ fn apply_csi_sequence(
) {
match final_byte {
b'B' | b'E' => {
let count = csi_param(params, 0, 1).max(1);
for _ in 0..count {
lines.push(line.iter().collect());
line.clear();
}
*cursor = 0;
// Empty transcript records are discarded; do not expand cursor
// movement into a parameter-sized collection of blank rows.
lines.push(take_replay_line(line, cursor));
}
b'C' => {
let count = csi_param(params, 0, 1);
@@ -316,10 +326,10 @@ fn apply_csi_sequence(
}
}
b'@' => {
let count = csi_param(params, 0, 1);
for _ in 0..count {
line.insert((*cursor).min(line.len()), ' ');
}
let count = csi_param(params, 0, 1)
.min(MAX_REPLAY_LINE_CHARS.saturating_sub(line.len()));
let start = (*cursor).min(line.len());
line.splice(start..start, std::iter::repeat_n(' ', count));
}
_ => {}
}
+19 -20
View File
@@ -2,7 +2,7 @@ struct TranscriptState {
records: VecDeque<TranscriptRecord>,
record_bytes: usize,
memory_limit_bytes: usize,
output_buffer: String,
output_buffer: Vec<char>,
output_cursor: usize,
submitted_line_echo: Option<String>,
next_line_id: u64,
@@ -14,7 +14,7 @@ impl TranscriptState {
records: VecDeque::new(),
record_bytes: 0,
memory_limit_bytes,
output_buffer: String::new(),
output_buffer: Vec::new(),
output_cursor: 0,
submitted_line_echo: None,
next_line_id: 1,
@@ -50,14 +50,23 @@ impl TranscriptState {
return Vec::new();
}
let replayed = replay_terminal_output(data, &self.output_buffer, self.output_cursor);
self.output_buffer = replayed.tail;
self.output_cursor = replayed.cursor;
let replayed = replay_terminal_output(
data,
&mut self.output_buffer,
&mut self.output_cursor,
);
let first_forced_split = replayed.first_forced_split;
replayed
.lines
.into_iter()
.filter_map(|line| self.append_output_line(line))
.enumerate()
.filter_map(|(index, line)| {
if Some(index) == first_forced_split {
// A segment ending in the command is not proof of an echo.
self.submitted_line_echo = None;
}
self.append_output_line(line)
})
.collect()
}
@@ -93,11 +102,8 @@ impl TranscriptState {
fn flush_output_lines(&mut self, flush_partial: bool) -> Vec<TranscriptRecord> {
let mut flushed = Vec::new();
if flush_partial && !self.output_buffer.is_empty() {
let tail = mem::take(&mut self.output_buffer)
.trim_end_matches('\r')
.to_string();
self.output_cursor = 0;
if flush_partial {
let tail = take_replay_line(&mut self.output_buffer, &mut self.output_cursor);
if let Some(record) = self.append_output_line(tail) {
flushed.push(record);
}
@@ -137,14 +143,7 @@ impl TranscriptState {
}
fn flush_output_before_command(&mut self) -> Vec<TranscriptRecord> {
if self.output_buffer.is_empty() {
return Vec::new();
}
let tail = mem::take(&mut self.output_buffer)
.trim_end_matches('\r')
.to_string();
self.output_cursor = 0;
let tail = take_replay_line(&mut self.output_buffer, &mut self.output_cursor);
if looks_like_prompt_tail(&tail) {
return Vec::new();
+221 -4
View File
@@ -120,6 +120,220 @@ mod tests {
assert!(!cleaned.contains("CoderKangMode"));
}
#[test]
fn segments_long_output_in_one_chunk_without_losing_unicode() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
let output = "你🙂".repeat(super::MAX_REPLAY_LINE_CHARS + 3);
let mut records = transcript.write_output(&output);
assert_eq!(records.len(), 2);
assert!(records.iter().all(|record| {
record.data.chars().count() == super::MAX_REPLAY_LINE_CHARS
}));
records.extend(transcript.finish());
let restored: String = records.iter().map(|record| record.data.as_str()).collect();
assert_eq!(restored, output);
}
#[test]
fn segments_long_output_across_chunks_with_bounded_pending_line() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
let chunk = "x".repeat(257);
let mut records = Vec::new();
for _ in 0..256 {
records.extend(transcript.write_output(&chunk));
assert!(transcript.output_buffer.len() <= super::MAX_REPLAY_LINE_CHARS);
}
records.extend(transcript.finish());
assert!(records.iter().all(|record| {
record.data.chars().count() <= super::MAX_REPLAY_LINE_CHARS
}));
let restored: String = records.iter().map(|record| record.data.as_str()).collect();
assert_eq!(restored, chunk.repeat(256));
}
#[test]
fn keeps_full_pending_line_editable_until_it_needs_another_segment() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
let full_line = "x".repeat(super::MAX_REPLAY_LINE_CHARS);
assert!(transcript.write_output(&full_line).is_empty());
assert!(transcript.write_output("\rZ").is_empty());
let edited = transcript.write_output("\n");
assert_eq!(edited.len(), 1);
assert_eq!(edited[0].data, format!("Z{}", &full_line[1..]));
assert!(transcript.write_output(&full_line).is_empty());
let segments = transcript.write_output("!");
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].data, full_line);
assert_eq!(transcript.finish()[0].data, "!");
}
#[test]
fn preserves_split_output_ending_with_a_pending_command() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.record_command("pwd".to_string());
let output = format!("{}pwdTAIL", "x".repeat(super::MAX_REPLAY_LINE_CHARS - 3));
let records = transcript.write_output(&format!("{output}\n"));
assert_eq!(records.len(), 2);
let restored: String = records.iter().map(|record| record.data.as_str()).collect();
assert_eq!(restored, output);
}
#[test]
fn filters_complete_command_echo_before_later_forced_splits() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.record_command("pwd".to_string());
let output = format!("{}tail", "x".repeat(super::MAX_REPLAY_LINE_CHARS));
let records = transcript.write_output(&format!("pwd\n{output}\n"));
assert_eq!(records.len(), 2);
let restored: String = records.iter().map(|record| record.data.as_str()).collect();
assert_eq!(restored, output);
}
#[test]
fn reuses_pending_line_storage_across_chunks_and_flushes() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.output_buffer.reserve(128);
let allocation = transcript.output_buffer.as_ptr();
let capacity = transcript.output_buffer.capacity();
for _ in 0..64 {
assert!(transcript.write_output("x").is_empty());
assert_eq!(transcript.output_buffer.as_ptr(), allocation);
}
assert_eq!(transcript.write_output("\n")[0].data, "x".repeat(64));
assert_eq!(transcript.output_buffer.capacity(), capacity);
transcript.write_output("next");
assert_eq!(transcript.output_buffer.as_ptr(), allocation);
}
#[test]
fn preserves_cursor_position_between_output_chunks() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.write_output("abc");
transcript.write_output("\x1b[8G");
let records = transcript.write_output("X\n");
assert_eq!(records.len(), 1);
assert_eq!(records[0].data, "abc X");
}
#[test]
fn resets_cursor_when_flushing_an_empty_pending_line() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.write_output("\x1b[8G");
assert!(transcript.finish().is_empty());
assert_eq!(transcript.write_output("first\n")[0].data, "first");
transcript.write_output("\x1b[8G");
transcript.snapshot_records();
assert_eq!(transcript.write_output("second\n")[0].data, "second");
transcript.write_output("\x1b[8G");
transcript.record_command("echo third".to_string());
assert_eq!(transcript.write_output("third\n")[0].data, "third");
}
#[test]
fn bounds_csi_insert_expansion_without_losing_existing_text() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.write_output("tail");
let insert = format!("\r\x1b[{}@", usize::MAX);
assert!(transcript.write_output(&insert).is_empty());
assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_LINE_CHARS);
assert!(transcript.write_output(&insert).is_empty());
assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_LINE_CHARS);
let records = transcript.write_output("ok\nnext\n");
assert_eq!(records.len(), 2);
assert!(records[0].data.starts_with("ok"));
assert!(records[0].data.ends_with("tail"));
assert_eq!(records[0].data.chars().count(), super::MAX_REPLAY_LINE_CHARS);
assert_eq!(records[1].data, "next");
}
#[test]
fn bounds_csi_vertical_movement_without_expanding_empty_rows() {
for final_byte in ['B', 'E'] {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
let output = format!("before\x1b[{}{final_byte}after\n", usize::MAX);
let records = transcript.write_output(&output);
assert_eq!(records.len(), 2);
assert_eq!(records[0].data, "before");
assert_eq!(records[1].data, "after");
assert!(transcript.output_buffer.is_empty());
}
}
#[test]
fn bounds_csi_horizontal_movement_and_deletion() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.write_output(&format!("\x1b[{}C", usize::MAX));
transcript.write_output("X");
assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_CURSOR_COLUMNS + 1);
let records = transcript.write_output(&format!(
"\x1b[{}D\x1b[{}Pdone\n", usize::MAX, usize::MAX
));
assert_eq!(records.len(), 1);
assert_eq!(records[0].data, "done");
}
#[test]
fn bounds_repeated_tui_redraws_without_linefeeds() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
let text = "x".repeat(200);
let chunk = format!("\x1b[1;1H\x1b[32m{text}\x1b[0m");
let mut recorded_chars = 0;
for _ in 0..2048 {
for record in transcript.write_output(&chunk) {
assert!(record.data.len() <= super::MAX_REPLAY_LINE_CHARS);
recorded_chars += record.data.len();
}
assert!(transcript.output_buffer.len() <= super::MAX_REPLAY_LINE_CHARS);
}
for record in transcript.finish() {
recorded_chars += record.data.len();
}
assert_eq!(recorded_chars, text.len() * 2048);
}
#[test]
fn searches_output_after_long_unterminated_text() {
let manager = RecordingManager::new();
manager.write_output("s1", &"x".repeat(super::MAX_REPLAY_LINE_CHARS * 3));
manager.write_output("s1", "tail-sentinel\n");
let result = manager.search_history(super::TerminalHistorySearchRequest {
session_id: "s1".to_string(),
query: "tail-sentinel".to_string(),
case_sensitive: true,
regex: false,
whole_word: false,
limit: None,
context_before: None,
context_after: None,
max_lines: None,
}).unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.results[0].preview, "tail-sentinel");
}
#[test]
fn consumes_matching_echo_prefix() {
let mut prefix = "ps -ef".to_string();
@@ -571,13 +785,16 @@ mod tests {
let path = manager
.start_with_profile(session_id, context, profile, None)
.unwrap();
manager.write_raw_output(session_id, b"\x1b[31mERROR\x1b[0m\n");
manager.write_output(session_id, "\x1b[31mERROR\x1b[0m\n");
let output = format!(
"\x1b[31mERROR\x1b[0m\n{}",
"x".repeat(super::MAX_REPLAY_LINE_CHARS * 2 + 3),
);
manager.write_raw_output(session_id, output.as_bytes());
manager.write_output(session_id, &output);
manager.stop(session_id).unwrap();
let raw = fs::read(&path).unwrap();
assert!(raw.windows(5).any(|window| window == b"ERROR"));
assert!(raw.contains(&0x1b));
assert_eq!(raw, output.as_bytes());
let transcript_path = unique_path("raw-transcript");
manager
+1
View File
@@ -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,
+56
View File
@@ -56,6 +56,7 @@ import {
getRemoteDesktopPaneDisplay,
getTemporaryLinkSessionType,
isSessionCreationCancelled,
launchSavedRdpWithSystemClient,
type StartupCommandRequest,
sendStartupCommandToSession,
} from "./lib/appSessionFactory";
@@ -488,6 +489,7 @@ function App() {
setActiveTabId,
updatePaneSession,
updateTabSession,
rdpClientMode: appSettings.general.rdp_client_mode,
setTerminalWindows,
updateAutoIconForSessionStart,
});
@@ -645,6 +647,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),
@@ -699,6 +728,7 @@ function App() {
},
[
addPendingTab,
appSettings.general.rdp_client_mode,
hasTab,
markTabConnectionFailed,
maybePromptConnectionEdit,
@@ -1090,6 +1120,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);
}
@@ -1151,6 +1206,7 @@ function App() {
},
[
addPendingTab,
appSettings.general.rdp_client_mode,
hasTab,
handleSelectLeafTab,
setTerminalWindows,
+276
View File
@@ -0,0 +1,276 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
QuickCommand,
QuickCommandCategory,
QuickCommandsConfig,
QuickCommandViewMode,
} from "@/types/global";
import QuickCommands from "./QuickCommands";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
listen: vi.fn(async () => vi.fn()),
openQuickCommand: vi.fn(),
writeClipboardText: vi.fn(async () => undefined),
}));
let appState: {
appSettings: {
ui: {
quick_cmd_category_width: number;
quick_cmd_view_mode: QuickCommandViewMode;
quick_cmd_sort_mode: "created";
quick_cmd_selected_category: string;
};
};
updateUi: ReturnType<typeof vi.fn>;
};
let config: QuickCommandsConfig;
vi.mock("react-i18next", () => ({
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, options?: Record<string, unknown>) => {
const labels: Record<string, string> = {
"quickCommands.addCategory": "Add Category",
"quickCommands.addCommand": "Add Command",
"quickCommands.allCategories": "All Categories",
"quickCommands.uncategorized": "Uncategorized",
"quickCommands.edit": "Edit",
"quickCommands.copyCommand": "Copy command",
"quickCommands.sendToAll": "Send to all",
"quickCommands.delete": "Delete",
"quickCommands.noCommandsFound": "No commands found",
"quickCommands.newCategoryRootHint": "Create at root",
"quickCommands.newCategoryParentHint": `Create under ${String(options?.category ?? "")}`,
"quickCommands.categoryName": "Category name",
"quickCommands.categoryPlaceholder": "Category name",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
};
return labels[key] ?? key;
},
}),
}));
vi.mock("@/context/AppContext", () => ({
useApp: () => appState,
}));
vi.mock("@/lib/invoke", () => ({
invoke: mocks.invoke,
}));
vi.mock("@/lib/windowManager", () => ({
openQuickCommand: mocks.openQuickCommand,
}));
vi.mock("@/lib/clipboard", () => ({
writeClipboardText: mocks.writeClipboardText,
}));
vi.mock("@/lib/aiEvents", () => ({
openAIAssistant: vi.fn(),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: mocks.listen,
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(),
save: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: vi.fn(),
}));
const dockerCategory: QuickCommandCategory = {
id: "docker",
name: "Docker",
};
const deployCommand: QuickCommand = {
id: "deploy",
label: "Deploy",
command: "docker compose up -d",
category_id: dockerCategory.id,
execution_mode: "execute",
};
function setUi(viewMode: QuickCommandViewMode, selectedCategory: string) {
appState = {
appSettings: {
ui: {
quick_cmd_category_width: 176,
quick_cmd_view_mode: viewMode,
quick_cmd_sort_mode: "created",
quick_cmd_selected_category: selectedCategory,
},
},
updateUi: vi.fn(),
};
}
function renderQuickCommands(options?: {
viewMode?: QuickCommandViewMode;
selectedCategory?: string;
commands?: QuickCommand[];
categories?: QuickCommandCategory[];
}) {
setUi(options?.viewMode ?? "list", options?.selectedCategory ?? "all");
config = {
commands: options?.commands ?? [deployCommand],
categories: options?.categories ?? [dockerCategory],
};
const onSend = vi.fn();
const onSendToAll = vi.fn();
const view = render(
<QuickCommands onSend={onSend} onSendToAll={onSendToAll} />,
);
return { ...view, onSend, onSendToAll };
}
async function waitForLoadedCommand() {
await screen.findByText(deployCommand.label);
}
async function waitForLoadedCategories() {
await screen.findByText(dockerCategory.name);
}
describe("QuickCommands context actions", () => {
beforeEach(() => {
mocks.invoke.mockReset();
mocks.openQuickCommand.mockReset();
mocks.writeClipboardText.mockReset();
mocks.writeClipboardText.mockResolvedValue(undefined);
mocks.invoke.mockImplementation((command: string) => {
if (command === "get_quick_commands") return Promise.resolve(config);
return Promise.resolve(undefined);
});
});
it.each(["list", "compact", "tile"] as const)(
"distinguishes command and blank context menus in %s view",
async (viewMode) => {
const { onSend, onSendToAll } = renderQuickCommands({ viewMode });
await waitForLoadedCommand();
fireEvent.contextMenu(screen.getByText(deployCommand.label));
const edit = await screen.findByText("Edit");
const itemMenu = edit.closest('[data-slot="context-menu-content"]');
expect(itemMenu).not.toBeNull();
expect(
Array.from(itemMenu?.querySelectorAll('[role="menuitem"]') ?? []).map(
(item) => item.textContent?.trim(),
),
).toEqual(["Edit", "Copy command", "Send to all", "Delete"]);
fireEvent.click(screen.getByText("Copy command"));
await waitFor(() => {
expect(mocks.writeClipboardText).toHaveBeenCalledWith(
deployCommand.command,
);
});
expect(onSend).not.toHaveBeenCalled();
expect(onSendToAll).not.toHaveBeenCalled();
fireEvent.contextMenu(screen.getByTestId("quick-command-pane"));
const addCommand = await screen.findByText("Add Command");
expect(screen.queryByText("Edit")).toBeNull();
fireEvent.click(addCommand);
expect(mocks.openQuickCommand).toHaveBeenCalledWith(undefined, {
categoryId: null,
});
},
);
it("resolves command context from an SVG descendant", async () => {
renderQuickCommands();
await waitForLoadedCommand();
const commandItem = screen
.getByText(deployCommand.label)
.closest("[data-quick-command-id]");
const icon = commandItem?.querySelector("svg");
expect(icon).not.toBeNull();
fireEvent.contextMenu(icon!);
const edit = await screen.findByText("Edit");
const itemMenu = edit.closest('[data-slot="context-menu-content"]');
expect(itemMenu).not.toBeNull();
expect(
Array.from(itemMenu?.querySelectorAll('[role="menuitem"]') ?? []).map(
(item) => item.textContent?.trim(),
),
).toEqual(["Edit", "Copy command", "Send to all", "Delete"]);
});
it("inherits the selected saved category from toolbar and empty-state creation", async () => {
renderQuickCommands({
selectedCategory: dockerCategory.id,
commands: [],
});
await waitForLoadedCategories();
const addButtons = screen.getAllByRole("button", { name: "Add Command" });
fireEvent.click(addButtons[0]);
expect(mocks.openQuickCommand).toHaveBeenLastCalledWith(undefined, {
categoryId: dockerCategory.id,
});
fireEvent.click(addButtons[addButtons.length - 1]);
expect(mocks.openQuickCommand).toHaveBeenLastCalledWith(undefined, {
categoryId: dockerCategory.id,
});
fireEvent.contextMenu(screen.getByTestId("quick-command-pane"));
fireEvent.click(
await screen.findByRole("menuitem", { name: "Add Command" }),
);
expect(mocks.openQuickCommand).toHaveBeenLastCalledWith(undefined, {
categoryId: dockerCategory.id,
});
});
it.each(["all", "uncategorized", "missing-category"])(
"does not force a category for %s selection",
async (selectedCategory) => {
renderQuickCommands({ selectedCategory, commands: [] });
await waitForLoadedCategories();
fireEvent.click(
screen.getAllByRole("button", { name: "Add Command" })[0],
);
expect(mocks.openQuickCommand).toHaveBeenLastCalledWith(undefined, {
categoryId: null,
});
},
);
it("creates a root category from the sidebar blank area", async () => {
renderQuickCommands();
await waitForLoadedCommand();
fireEvent.contextMenu(
screen.getByTestId("quick-command-category-empty-area"),
);
fireEvent.click(await screen.findByText("Add Category"));
expect(await screen.findByText("Create at root")).not.toBeNull();
});
it("preserves Add Command on a saved category", async () => {
renderQuickCommands();
await waitForLoadedCommand();
fireEvent.contextMenu(screen.getByText(dockerCategory.name));
fireEvent.click(await screen.findByText("Add Command"));
expect(mocks.openQuickCommand).toHaveBeenCalledWith(undefined, {
categoryId: dockerCategory.id,
});
});
});
File diff suppressed because it is too large. Load diff
@@ -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();
});
});
+24
View File
@@ -6,6 +6,7 @@ import { useConfigTransfer } from "@/hooks/useConfigTransfer";
import { AVAILABLE_LANGUAGES } from "@/i18n";
import { runtime, supports } from "@/lib/backend/runtime";
import { HEADER_STATUS_MODES, normalizeHeaderStatusMode } from "@/lib/headerStatus";
import { isWindows } from "@/lib/platform";
import {
SettingFieldGrid,
SettingRow,
@@ -70,6 +71,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}
+1
View File
@@ -87,6 +87,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",
+1
View File
@@ -47,6 +47,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",
+29
View File
@@ -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();
+23 -1
View File
@@ -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,
};
+4
View File
@@ -2506,6 +2506,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",
+4
View File
@@ -2504,6 +2504,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": "새 세션 자동 녹화",
+4
View File
@@ -2504,6 +2504,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": "自动录制新会话",
+4
View File
@@ -2504,6 +2504,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": "自動錄製新工作階段",
+54 -1
View File
@@ -1,7 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setOwnerMainWindowLabel } from "./windowManager";
import type { SavedConnection, TerminalSessionPane } from "@/types/global";
import { createTemporarySession, createSessionForConnection, createSessionForPane } from "./appSessionFactory";
import {
createTemporarySession,
createSessionForConnection,
createSessionForPane,
launchSavedRdpWithSystemClient,
shouldLaunchSavedRdpWithSystemClient,
} from "./appSessionFactory";
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
@@ -105,3 +111,50 @@ it("retains the temporary Telnet route and encoding when connecting and reconnec
expect(invokeMock).toHaveBeenCalledWith("create_telnet_session", expect.objectContaining({ network: temporary.network, encoding: "GBK", createRequestId: "temporary-create" }));
expect(invokeMock).toHaveBeenCalledWith("create_telnet_session", expect.objectContaining({ network: temporary.network, encoding: "GBK", createRequestId: "temporary-reconnect", recordingScopeId: "pane-telnet" }));
});
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();
});
});
+22
View File
@@ -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()
+1
View File
@@ -1159,6 +1159,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";