From 86879ec58e5b09d2bb5f4a7596df09cf73d97b86 Mon Sep 17 00:00:00 2001 From: Kang Date: Sat, 3 Oct 2026 01:01:34 +0800 Subject: [PATCH] feat(serial): implement flow control selection in SerialForm and integrate with NewSessionPage (#794) --- src-tauri/src/cmd/session.rs | 4 + src-tauri/src/config/connection.rs | 75 +++++++++++++-- src-tauri/src/config/mod.rs | 23 ++--- src-tauri/src/core/importer/nyaterm_json.rs | 2 + src-tauri/src/core/importer/tests.rs | 28 ++++++ src-tauri/src/core/importer/types.rs | 2 + src-tauri/src/core/portable_snapshot/tests.rs | 29 ++++++ .../core/terminal_session/serial/config.rs | 10 ++ .../core/terminal_session/serial/manager.rs | 6 +- .../core/terminal_session/serial/session.rs | 68 +++++++++++++- src/components/sessions/SerialForm.test.tsx | 58 ++++++++++++ src/components/sessions/SerialForm.tsx | 34 ++++++- src/pages/NewSessionPage.test.tsx | 93 +++++++++++++++++-- src/pages/NewSessionPage.tsx | 14 ++- src/types/global.d.ts | 3 + 15 files changed, 417 insertions(+), 32 deletions(-) create mode 100644 src/components/sessions/SerialForm.test.tsx diff --git a/src-tauri/src/cmd/session.rs b/src-tauri/src/cmd/session.rs index c1fc0c65..30395c13 100644 --- a/src-tauri/src/cmd/session.rs +++ b/src-tauri/src/cmd/session.rs @@ -413,6 +413,7 @@ pub async fn create_serial_session( data_bits: Option, parity: Option, stop_bits: Option, + flow_control: Option, name: Option, create_request_id: Option, recording_scope_id: Option, @@ -434,6 +435,7 @@ pub async fn create_serial_session( stop_bits, backspace_mode, modem_upload_protocol, + flow_control, .. } => core::SerialConfig { port_name, @@ -444,6 +446,7 @@ pub async fn create_serial_session( name: conn.name, backspace_mode, modem_upload_protocol, + flow_control, encoding, }, _ => { @@ -463,6 +466,7 @@ pub async fn create_serial_session( data_bits: data_bits.unwrap_or(8), parity: parity.unwrap_or_else(|| "none".to_string()), stop_bits: stop_bits.unwrap_or_else(|| "1".to_string()), + flow_control: flow_control.unwrap_or_default(), name: name.unwrap_or_else(|| "Serial".to_string()), backspace_mode: "ctrl_h".to_string(), modem_upload_protocol: config::SerialModemUploadProtocol::Zmodem, diff --git a/src-tauri/src/config/connection.rs b/src-tauri/src/config/connection.rs index a78ae738..84eb64b6 100644 --- a/src-tauri/src/config/connection.rs +++ b/src-tauri/src/config/connection.rs @@ -597,6 +597,19 @@ pub enum SerialModemUploadProtocol { Zmodem, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum SerialFlowControl { + #[default] + None, + Software, + Hardware, +} + +fn is_default_serial_flow_control(value: &SerialFlowControl) -> bool { + *value == SerialFlowControl::None +} + fn is_default_serial_modem_upload_protocol(value: &SerialModemUploadProtocol) -> bool { *value == SerialModemUploadProtocol::Zmodem } @@ -690,6 +703,8 @@ pub enum ConnectionType { parity: String, #[serde(default = "default_stop_bits")] stop_bits: String, + #[serde(default, skip_serializing_if = "is_default_serial_flow_control")] + flow_control: SerialFlowControl, #[serde(default, skip_serializing_if = "is_ai_execution_profile_auto")] ai_execution_profile: AiExecutionProfile, #[serde(default = "default_backspace_mode_serial")] @@ -1488,14 +1503,62 @@ mod tests { MAX_SFTP_PIPELINE_DEPTH, MAX_SSH_AGENT_ENVIRONMENT_VARIABLE_LEN, MAX_SSH_AGENT_FORWARDING_ENDPOINTS, MAX_SSH_AGENT_FORWARDING_IDENTITIES, MAX_SSH_AGENT_UNIX_SOCKET_PATH_LEN, MIN_SFTP_PIPELINE_DEPTH, SavedConnection, - SerialModemUploadProtocol, SftpCwdFollowMode, SftpSettings, SshAgentEndpoint, - SshAgentForwardingConfig, SshAgentForwardingPolicy, SshAgentForwardingSources, - SshAlgorithmMode, SshProfile, SshTerminalType, effective_cwd_follow_mode, - effective_cwd_follow_mode_for_profile, migrate_legacy_asset_tags, - migrate_legacy_ssh_agent_settings, normalize_connection_tags, resolve_ssh_terminal_type, - validate_ssh_agent_endpoint, validate_ssh_agent_settings, + SerialFlowControl, SerialModemUploadProtocol, SftpCwdFollowMode, SftpSettings, + SshAgentEndpoint, SshAgentForwardingConfig, SshAgentForwardingPolicy, + SshAgentForwardingSources, SshAlgorithmMode, SshProfile, SshTerminalType, + effective_cwd_follow_mode, effective_cwd_follow_mode_for_profile, + migrate_legacy_asset_tags, migrate_legacy_ssh_agent_settings, normalize_connection_tags, + resolve_ssh_terminal_type, validate_ssh_agent_endpoint, validate_ssh_agent_settings, }; + #[test] + fn serial_flow_control_defaults_to_none_for_legacy_connections() { + let connection: SavedConnection = serde_json::from_value(serde_json::json!({ + "id": "serial-1", "name": "Serial", "type": "serial", "port_name": "COM3" + })) + .expect("legacy serial connection"); + assert!(matches!( + connection.config, + ConnectionType::Serial { + flow_control: SerialFlowControl::None, + .. + } + )); + let encoded = serde_json::to_value(&connection).expect("serialized connection"); + assert!(encoded.get("flow_control").is_none()); + } + + #[test] + fn serial_flow_control_round_trips_all_values() { + for (wire, expected) in [ + ("none", SerialFlowControl::None), + ("software", SerialFlowControl::Software), + ("hardware", SerialFlowControl::Hardware), + ] { + let connection: SavedConnection = serde_json::from_value(serde_json::json!({ + "id": "serial-1", "name": "Serial", "type": "serial", "port_name": "COM3", "flow_control": wire + })).expect("serial connection"); + let encoded = serde_json::to_value(&connection).expect("serialized connection"); + if expected == SerialFlowControl::None { + assert!(encoded.get("flow_control").is_none()); + } else { + assert_eq!(encoded["flow_control"], wire); + } + let decoded: SavedConnection = serde_json::from_value(encoded).expect("round trip"); + assert!( + matches!(decoded.config, ConnectionType::Serial { flow_control, .. } if flow_control == expected) + ); + } + } + + #[test] + fn serial_flow_control_rejects_unknown_values() { + let connection = serde_json::from_value::(serde_json::json!({ + "id": "serial-1", "name": "Serial", "type": "serial", "port_name": "COM3", "flow_control": "rtscts" + })); + assert!(connection.is_err()); + } + #[test] fn serial_modem_protocol_defaults_to_zmodem_for_legacy_connections() { let connection: SavedConnection = serde_json::from_value(serde_json::json!({ diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index aeae6a3a..f9113aaa 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -32,17 +32,18 @@ pub use connection::{ MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS, MAX_SSH_AGENT_FORWARDING_ENDPOINTS, MAX_SSH_AGENT_FORWARDING_IDENTITIES, MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS, RdpClipboardSettings, RdpDisplaySettings, RdpReconnectSettings, RdpSecuritySettings, SavedConnection, - SerialModemUploadProtocol, SessionsConfig, SftpCwdFollowMode, SftpSettings, SshAgentEndpoint, - SshAgentForwardingConfig, SshAgentForwardingPolicy, SshAgentForwardingSources, - SshAlgorithmMode, SshAlgorithmPreferences, SshProfile, SshRuntimeMode, SshTerminalType, - TelnetAutoLoginConfig, VncClipboardSettings, VncDisplaySettings, VncReconnectSettings, - VncSecuritySettings, connection_custom_icon_from_data_url, - connection_custom_icon_id_for_data_url, effective_cwd_follow_mode, - effective_cwd_follow_mode_for_profile, effective_cwd_follow_mode_for_runtime, - is_connection_custom_icon_data_url, load_config, load_connection_by_id, load_sessions, - migrate_legacy_asset_tags, migrate_legacy_ssh_agent_settings, normalize_connection_tags, - resolve_connection_encoding, resolve_ssh_terminal_type, save_config, save_sessions, - ssh_agent_endpoint_key, validate_ssh_agent_endpoint, validate_ssh_agent_endpoint_shape, + SerialFlowControl, SerialModemUploadProtocol, SessionsConfig, SftpCwdFollowMode, SftpSettings, + SshAgentEndpoint, SshAgentForwardingConfig, SshAgentForwardingPolicy, + SshAgentForwardingSources, SshAlgorithmMode, SshAlgorithmPreferences, SshProfile, + SshRuntimeMode, SshTerminalType, TelnetAutoLoginConfig, VncClipboardSettings, + VncDisplaySettings, VncReconnectSettings, VncSecuritySettings, + connection_custom_icon_from_data_url, connection_custom_icon_id_for_data_url, + effective_cwd_follow_mode, effective_cwd_follow_mode_for_profile, + effective_cwd_follow_mode_for_runtime, is_connection_custom_icon_data_url, load_config, + load_connection_by_id, load_sessions, migrate_legacy_asset_tags, + migrate_legacy_ssh_agent_settings, normalize_connection_tags, resolve_connection_encoding, + resolve_ssh_terminal_type, save_config, save_sessions, ssh_agent_endpoint_key, + validate_ssh_agent_endpoint, validate_ssh_agent_endpoint_shape, validate_ssh_agent_forwarding_config, validate_ssh_agent_forwarding_shape, validate_ssh_agent_settings, }; diff --git a/src-tauri/src/core/importer/nyaterm_json.rs b/src-tauri/src/core/importer/nyaterm_json.rs index 472d0994..cce54e70 100644 --- a/src-tauri/src/core/importer/nyaterm_json.rs +++ b/src-tauri/src/core/importer/nyaterm_json.rs @@ -225,6 +225,7 @@ fn prepare_nyaterm_json_session( data_bits, parity, stop_bits, + flow_control, backspace_mode, description, mut tags, @@ -242,6 +243,7 @@ fn prepare_nyaterm_json_session( data_bits, parity, stop_bits, + flow_control, ai_execution_profile: AiExecutionProfile::Auto, backspace_mode, modem_upload_protocol: crate::config::SerialModemUploadProtocol::Zmodem, diff --git a/src-tauri/src/core/importer/tests.rs b/src-tauri/src/core/importer/tests.rs index ca8fc6a6..02f6d692 100644 --- a/src-tauri/src/core/importer/tests.rs +++ b/src-tauri/src/core/importer/tests.rs @@ -653,6 +653,34 @@ mod tests { base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ciphertext) } + #[test] + fn nyaterm_json_serial_flow_control_import_preserves_values_and_legacy_default() { + for wire in [None, Some("none"), Some("software"), Some("hardware")] { + let mut session = serde_json::json!({ + "name": "Serial", "type": "serial", "port_name": "COM3" + }); + if let Some(wire) = wire { + session["flow_control"] = serde_json::json!(wire); + } + let content = serde_json::json!({ "version": 1, "sessions": [session] }).to_string(); + let prepared = parse_nyaterm_json_content(&content).expect("import serial"); + let encoded = + serde_json::to_value(&prepared.connections[0].config).expect("serialize serial"); + assert_eq!( + encoded + .get("flow_control") + .and_then(|value| value.as_str()) + .unwrap_or("none"), + wire.unwrap_or("none") + ); + let decoded: ConnectionType = + serde_json::from_value(encoded).expect("round trip imported serial"); + assert_eq!(decoded, prepared.connections[0].config); + } + let invalid = r#"{"version":1,"sessions":[{"name":"Serial","type":"serial","port_name":"COM3","flow_control":"rtscts"}]}"#; + assert!(parse_nyaterm_json_content(invalid).is_err()); + } + #[test] fn nyaterm_json_sample_import_prepares_supported_shapes() { crate::utils::crypto::set_master_password(None); diff --git a/src-tauri/src/core/importer/types.rs b/src-tauri/src/core/importer/types.rs index 51807c1b..dd4788ae 100644 --- a/src-tauri/src/core/importer/types.rs +++ b/src-tauri/src/core/importer/types.rs @@ -159,6 +159,8 @@ enum NyatermJsonSession { parity: String, #[serde(default = "default_serial_stop_bits")] stop_bits: String, + #[serde(default)] + flow_control: config::SerialFlowControl, #[serde(default = "default_serial_backspace_mode")] backspace_mode: String, #[serde(default)] diff --git a/src-tauri/src/core/portable_snapshot/tests.rs b/src-tauri/src/core/portable_snapshot/tests.rs index 1c305e74..c21d1b30 100644 --- a/src-tauri/src/core/portable_snapshot/tests.rs +++ b/src-tauri/src/core/portable_snapshot/tests.rs @@ -645,6 +645,7 @@ mod tests { data_bits: 8, parity: "none".to_string(), stop_bits: "1".to_string(), + flow_control: config::SerialFlowControl::None, ai_execution_profile: config::AiExecutionProfile::Auto, backspace_mode: "del".to_string(), modem_upload_protocol: config::SerialModemUploadProtocol::Zmodem, @@ -797,6 +798,34 @@ mod tests { assert_ne!(left.payload_hash, right.payload_hash); } + #[test] + fn serial_flow_control_survives_portable_snapshot_roundtrip() { + for flow_control in [ + config::SerialFlowControl::None, + config::SerialFlowControl::Software, + config::SerialFlowControl::Hardware, + ] { + let mut snapshot = sample_snapshot(); + snapshot.sessions = sample_sessions_with_device_local_connections(); + let config::ConnectionType::Serial { + flow_control: saved_flow_control, + .. + } = &mut snapshot.sessions.connections[1].config + else { + panic!("expected serial"); + }; + *saved_flow_control = flow_control; + snapshot.payload_hash = calculate_payload_hash(&snapshot).expect("snapshot hash"); + let encoded = encode_portable_snapshot(&snapshot).expect("encode .nya snapshot"); + let decoded = super::decode_portable_snapshot(&encoded).expect("decode .nya snapshot"); + assert_eq!( + decoded.sessions.connections[1].config, + snapshot.sessions.connections[1].config + ); + assert_eq!(decoded.payload_hash, snapshot.payload_hash); + } + } + #[test] fn portable_snapshot_zip_roundtrip() { let mut snapshot = sample_snapshot(); diff --git a/src-tauri/src/core/terminal_session/serial/config.rs b/src-tauri/src/core/terminal_session/serial/config.rs index a0895ffc..dc077c6b 100644 --- a/src-tauri/src/core/terminal_session/serial/config.rs +++ b/src-tauri/src/core/terminal_session/serial/config.rs @@ -4,8 +4,18 @@ pub struct SerialConfig { pub data_bits: u8, pub parity: String, pub stop_bits: String, + pub flow_control: crate::config::SerialFlowControl, pub name: String, pub backspace_mode: String, pub modem_upload_protocol: crate::config::SerialModemUploadProtocol, pub encoding: String, } + +fn validate_serial_modem_flow_control( + flow_control: crate::config::SerialFlowControl, +) -> Result<(), String> { + if flow_control == crate::config::SerialFlowControl::Software { + return Err("XMODEM/YMODEM/ZMODEM transfers are disabled with software flow control (XON/XOFF). Use None or Hardware (RTS/CTS) flow control and reconnect.".to_string()); + } + Ok(()) +} diff --git a/src-tauri/src/core/terminal_session/serial/manager.rs b/src-tauri/src/core/terminal_session/serial/manager.rs index 47135996..bc174bea 100644 --- a/src-tauri/src/core/terminal_session/serial/manager.rs +++ b/src-tauri/src/core/terminal_session/serial/manager.rs @@ -116,7 +116,11 @@ fn open_serial_port(config: &SerialConfig) -> serialport::Result FlowControl::None, + crate::config::SerialFlowControl::Software => FlowControl::Software, + crate::config::SerialFlowControl::Hardware => FlowControl::Hardware, + }) .timeout(Duration::from_millis(10)) .open() } diff --git a/src-tauri/src/core/terminal_session/serial/session.rs b/src-tauri/src/core/terminal_session/serial/session.rs index 8d92a8ad..2eb4391f 100644 --- a/src-tauri/src/core/terminal_session/serial/session.rs +++ b/src-tauri/src/core/terminal_session/serial/session.rs @@ -1,5 +1,20 @@ use self::xymodem::{XyModemAction, XyModemTransfer}; +fn detect_serial_zmodem( + detector: &mut ZmodemDetector, + raw: &[u8], + flow_control: crate::config::SerialFlowControl, +) -> ZmodemDetectResult { + // XON/XOFF consumes binary payload bytes in the OS serial layer. Keep incoming + // data on the terminal path rather than automatically starting a transfer. + if flow_control == crate::config::SerialFlowControl::Software { + return ZmodemDetectResult::NoMatch { + passthrough: raw.to_vec(), + }; + } + detector.feed(raw) +} + fn process_xymodem_actions( app: &AppHandle, event_name: &str, @@ -90,6 +105,7 @@ fn serial_session_thread( let serial_modem_event_name = format!("serial-modem-event-{session_id}"); let serial_modem_event_reader = serial_modem_event_name.clone(); let modem_upload_protocol = config.modem_upload_protocol; + let flow_control = config.flow_control; // Reader thread let app_reader = app.clone(); @@ -178,7 +194,8 @@ fn serial_session_thread( } // ZMODEM: detect header. - let process_raw = match zmodem_detector.feed(raw) { + let detected = detect_serial_zmodem(&mut zmodem_detector, raw, flow_control); + let process_raw = match detected { ZmodemDetectResult::Detected { direction, passthrough, @@ -423,6 +440,10 @@ fn serial_session_thread( preserve_timestamps, result_tx, } => { + if let Err(error) = validate_serial_modem_flow_control(flow_control) { + let _ = result_tx.send(Err(error)); + continue; + } let result = match modem_upload_protocol { crate::config::SerialModemUploadProtocol::Xmodem | crate::config::SerialModemUploadProtocol::Ymodem => { @@ -646,6 +667,51 @@ mod serial_modem_bootstrap_tests { wire } + #[test] + fn serial_flow_control_rejects_modem_uploads_only_for_software() { + use crate::config::SerialFlowControl; + assert!(validate_serial_modem_flow_control(SerialFlowControl::None).is_ok()); + assert!(validate_serial_modem_flow_control(SerialFlowControl::Hardware).is_ok()); + let reason = validate_serial_modem_flow_control(SerialFlowControl::Software).unwrap_err(); + assert!(reason.contains("XMODEM/YMODEM/ZMODEM")); + assert!(reason.contains("XON/XOFF")); + } + + #[test] + fn serial_flow_control_software_passes_through_split_zmodem_headers() { + for frame in [Frame::ZRINIT, Frame::ZRQINIT] { + let mut wire = Vec::new(); + Header::new(Encoding::ZHEX, frame, &[0; 4]) + .write(&mut wire) + .unwrap() + .unwrap(); + let mut detector = ZmodemDetector::new(); + for chunk in wire.chunks(3) { + let result = detect_serial_zmodem( + &mut detector, + chunk, + crate::config::SerialFlowControl::Software, + ); + assert!( + matches!(result, ZmodemDetectResult::NoMatch { passthrough } if passthrough == chunk) + ); + } + assert!(!detector.has_pending_prefix()); + } + for flow_control in [ + crate::config::SerialFlowControl::None, + crate::config::SerialFlowControl::Hardware, + ] { + assert!(matches!( + detect_serial_zmodem(&mut ZmodemDetector::new(), &zrinit(), flow_control), + ZmodemDetectResult::Detected { + direction: ZmodemDirection::Upload, + .. + } + )); + } + } + #[test] fn serial_modem_missing_prepared_zmodem_file_does_not_stay_active() { let missing = std::env::temp_dir().join(format!( diff --git a/src/components/sessions/SerialForm.test.tsx b/src/components/sessions/SerialForm.test.tsx new file mode 100644 index 00000000..b3831e1c --- /dev/null +++ b/src/components/sessions/SerialForm.test.tsx @@ -0,0 +1,58 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { SerialFlowControl } from "@/types/global"; +import { SerialForm } from "./SerialForm"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function props(flowControl: SerialFlowControl) { + return { + serialPortName: "COM3", + setSerialPortName: vi.fn(), + serialPortOptions: [{ value: "COM3" }], + serialPortsLoading: false, + serialPortsError: "", + onSerialPortDropdownOpen: vi.fn(), + baudRate: "115200", + setBaudRate: vi.fn(), + dataBits: "8", + setDataBits: vi.fn(), + parity: "none", + setParity: vi.fn(), + stopBits: "1", + setStopBits: vi.fn(), + flowControl, + setFlowControl: vi.fn(), + backspaceMode: "ctrl_h", + setBackspaceMode: vi.fn(), + modemUploadProtocol: "zmodem" as const, + setModemUploadProtocol: vi.fn(), + recordingUseGlobal: true, + setRecordingUseGlobal: vi.fn(), + recordingAutoStart: false, + setRecordingAutoStart: vi.fn(), + recordingMode: "transcript" as const, + setRecordingMode: vi.fn(), + encoding: "global", + setEncoding: vi.fn(), + }; +} + +describe("SerialForm flow control", () => { + it.each([ + "none", + "software", + "hardware", + ] as const)("shows the conflict explanation and disables modem selection only for software (%s)", (flowControl) => { + render(); + expect(screen.getByRole("combobox", { name: "dialog.serialFlowControl" })).not.toBeNull(); + expect(screen.queryByText("dialog.serialSoftwareFlowControlModemDisabled") !== null).toBe( + flowControl === "software", + ); + fireEvent.click(screen.getByText("dialog.advancedConfig")); + const protocolSelect = screen.getByText("ZMODEM").closest("button"); + expect(protocolSelect?.disabled).toBe(flowControl === "software"); + }); +}); diff --git a/src/components/sessions/SerialForm.tsx b/src/components/sessions/SerialForm.tsx index a878cb91..0e50e8d0 100644 --- a/src/components/sessions/SerialForm.tsx +++ b/src/components/sessions/SerialForm.tsx @@ -24,7 +24,7 @@ import { SERIAL_BAUD_RATE_OPTIONS, } from "@/lib/serial"; import { cn } from "@/lib/utils"; -import type { RecordingMode } from "@/types/global"; +import type { RecordingMode, SerialFlowControl } from "@/types/global"; interface SerialPortOption { unavailable?: boolean; @@ -46,6 +46,8 @@ interface SerialFormProps { setParity: (v: string) => void; stopBits: string; setStopBits: (v: string) => void; + flowControl: SerialFlowControl; + setFlowControl: (v: SerialFlowControl) => void; backspaceMode: string; setBackspaceMode: (v: string) => void; modemUploadProtocol: "xmodem" | "ymodem" | "zmodem"; @@ -215,6 +217,8 @@ export function SerialForm({ setParity, stopBits, setStopBits, + flowControl, + setFlowControl, backspaceMode, setBackspaceMode, modemUploadProtocol, @@ -341,6 +345,29 @@ export function SerialForm({ +
+ + + {flowControl === "software" && ( +

+ {t("dialog.serialSoftwareFlowControlModemDisabled")} +

+ )} +
{t("dialog.backspaceCtrlH", "Ctrl+H (BS)")} - {t("dialog.backspaceDel", "DEL (0x7F)")} + + {t("dialog.backspaceDel", "DEL (0x7F)")} + @@ -399,6 +428,7 @@ export function SerialForm({