feat(serial): implement flow control selection in SerialForm and integrate with NewSessionPage (#794)

This commit is contained in:
Kang committed 2026-10-03 01:01:34 +08:00
1 parent 5b5a8b4278
commit 86879ec58e
15 files changed
+417 -32

No files matched your search

+4
View File
@@ -413,6 +413,7 @@ pub async fn create_serial_session(
data_bits: Option<u8>,
parity: Option<String>,
stop_bits: Option<String>,
flow_control: Option<config::SerialFlowControl>,
name: Option<String>,
create_request_id: Option<String>,
recording_scope_id: Option<String>,
@@ -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,
+69 -6
View File
@@ -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::<SavedConnection>(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!({
+12 -11
View File
@@ -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,
};
@@ -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,
+28
View File
@@ -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);
+2
View File
@@ -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)]
@@ -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();
@@ -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(())
}
@@ -116,7 +116,11 @@ fn open_serial_port(config: &SerialConfig) -> serialport::Result<Box<dyn SerialP
.data_bits(parse_data_bits(config.data_bits))
.parity(parse_parity(&config.parity))
.stop_bits(parse_stop_bits(&config.stop_bits))
.flow_control(FlowControl::None)
.flow_control(match config.flow_control {
crate::config::SerialFlowControl::None => FlowControl::None,
crate::config::SerialFlowControl::Software => FlowControl::Software,
crate::config::SerialFlowControl::Hardware => FlowControl::Hardware,
})
.timeout(Duration::from_millis(10))
.open()
}
@@ -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!(