feat(serial): implement flow control selection in SerialForm and integrate with NewSessionPage (#794)
This commit is contained in:
1 parent
5b5a8b4278
commit
86879ec58e
15 files changed
+417
-32
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in new issue
Block a user