chore(web): merge main and preserve runtime adapters
This commit is contained in:
commit
bceaed3ce0
25 files changed
+1367
-189
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)
|
||||
}
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "새 세션 자동 녹화",
|
||||
|
||||
@@ -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": "自动录制新会话",
|
||||
|
||||
@@ -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": "自動錄製新工作階段",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user