feat: add support for serial port connections with virtual PTY fallback and automatic newline translation

This commit is contained in:
TomZz
2026-07-20 17:06:21 +08:00
parent 84eccb5521
commit e7ca7bdc23
12 changed files with 928 additions and 291 deletions
Generated
+83 -3
View File
@@ -334,6 +334,7 @@ dependencies = [
"hex",
"hmac",
"image",
"libc",
"menu",
"notify 6.1.1",
"open",
@@ -347,6 +348,7 @@ dependencies = [
"rust-i18n 3.1.5",
"serde",
"serde_json",
"serialport",
"sha2",
"ssh-key",
"sys-locale",
@@ -2760,7 +2762,7 @@ dependencies = [
"itertools 0.14.0",
"log",
"lyon",
"mach2",
"mach2 0.5.0",
"media",
"metal",
"num_cpus",
@@ -2950,7 +2952,7 @@ dependencies = [
"itertools 0.14.0",
"libc",
"log",
"mach2",
"mach2 0.5.0",
"media",
"metal",
"objc",
@@ -3683,6 +3685,16 @@ dependencies = [
"rustversion",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2 0.4.3",
]
[[package]]
name = "io-surface"
version = "0.16.1"
@@ -3952,6 +3964,26 @@ dependencies = [
"libc",
]
[[package]]
name = "libudev"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
@@ -4099,6 +4131,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "mach2"
version = "0.5.0"
@@ -4397,6 +4438,17 @@ dependencies = [
"pin-utils",
]
[[package]]
name = "nix"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -6897,6 +6949,25 @@ dependencies = [
"serial-core",
]
[[package]]
name = "serialport"
version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
dependencies = [
"bitflags 2.12.1",
"cfg-if",
"core-foundation 0.10.0",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2 0.4.3",
"nix 0.26.4",
"scopeguard",
"unescaper",
"windows-sys 0.52.0",
]
[[package]]
name = "sha1"
version = "0.10.6"
@@ -8133,6 +8204,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unescaper"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "unicase"
version = "2.9.0"
@@ -8310,7 +8390,7 @@ dependencies = [
"itertools 0.14.0",
"libc",
"log",
"mach2",
"mach2 0.5.0",
"nix 0.29.0",
"percent-encoding",
"regex",
+4
View File
@@ -50,10 +50,14 @@ base64 = "0.22"
chacha20poly1305 = "0.10"
hmac = "0.12"
hex = "0.4"
serialport = "4"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
sha2 = "0.10"
[dev-dependencies]
libc = "0.2"
[package.metadata.deb]
maintainer = "ashell contributors"
license-file = ["LICENSE"]
+5
View File
@@ -13,6 +13,11 @@ size: "SIZE"
modified: "MODIFIED"
new_ssh_connection: "New SSH Connection"
create_or_edit_ssh_session: "Create or edit an SSH session"
new_serial_connection: "New Serial Connection"
create_or_edit_serial_session: "Create or edit a Serial session"
serial_port: "Serial Port Path"
baud_rate: "Baud Rate"
session_name: "Session Name"
new_folder: "New Folder"
create_folder_success: "Created folder: %{name}"
create_folder_failed: "Failed to create folder: %{err}"
+5
View File
@@ -13,6 +13,11 @@ size: "大小"
modified: "修改时间"
new_ssh_connection: "新建 SSH 连接"
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
new_serial_connection: "新建串口连接"
create_or_edit_serial_session: "创建或编辑串口会话"
serial_port: "串口设备路径"
baud_rate: "波特率"
session_name: "会话名称"
new_folder: "新建文件夹"
create_folder_success: "成功创建文件夹: %{name}"
create_folder_failed: "创建文件夹失败: %{err}"
+308 -236
View File
@@ -26,6 +26,15 @@ impl Ashell {
}
self.active_dialog = Some(crate::app::DialogKind::NewSsh);
if let Some(id) = &self.editing_session_id {
if let Some(session) = self.config.get(id) {
self.session_protocol = session.protocol.clone();
}
} else {
self.session_protocol = "ssh".to_string();
}
let initial_is_serial = self.session_protocol == "serial";
let view = cx.entity();
let session_name_input = self.session_name_input.clone();
let host_input = self.host_input.clone();
@@ -40,10 +49,11 @@ impl Ashell {
let proxy_port_input = self.proxy_port_input.clone();
let proxy_user_input = self.proxy_user_input.clone();
let proxy_password_input = self.proxy_password_input.clone();
let baud_rate_input = self.baud_rate_input.clone();
window.open_dialog(cx, move |dialog: Dialog, _window, _cx| {
dialog
.title(t!("new_ssh_connection"))
.title(if initial_is_serial { t!("new_serial_connection") } else { t!("new_ssh_connection") })
.w(px(520.))
.overlay_closable(true)
.on_close({
@@ -69,6 +79,7 @@ impl Ashell {
let proxy_port_input = proxy_port_input.clone();
let proxy_user_input = proxy_user_input.clone();
let proxy_password_input = proxy_password_input.clone();
let baud_rate_input = baud_rate_input.clone();
move |content, window, cx| {
let auth_method = view.read(cx).ssh_auth_method;
let is_password = auth_method == AuthMethod::Password;
@@ -77,6 +88,9 @@ impl Ashell {
let is_editing = view.read(cx).editing_session_id.is_some();
let proxy_type = view.read(cx).ssh_proxy_type.clone();
let show_proxy_fields = proxy_type != "none";
let protocol = view.read(cx).session_protocol.clone();
let is_ssh = protocol == "ssh";
let is_serial = protocol == "serial";
content.child(
v_flex()
.gap_3()
@@ -84,258 +98,307 @@ impl Ashell {
h_flex()
.gap_2()
.child(
Button::new("ssh-auth-password")
.label(t!("password").to_string())
.when(is_password, |button| button.primary())
Button::new("proto-ssh")
.label("SSH")
.when(is_ssh, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_auth_method(
AuthMethod::Password,
cx,
)
|this, _, window, cx| {
this.set_session_protocol("ssh".to_string(), cx);
Self::set_input_value(&this.port_input, "22", window, cx);
},
)),
)
.child(
Button::new("ssh-auth-key")
.label(t!("key").to_string())
.when(is_key, |button| button.primary())
Button::new("proto-serial")
.label("Serial")
.when(is_serial, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_auth_method(
AuthMethod::Key,
cx,
)
},
)),
)
.child(
Button::new("ssh-auth-config")
.label(t!("ssh_config").to_string())
.when(is_config, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_auth_method(
AuthMethod::Config,
cx,
)
|this, _, _window, cx| {
this.set_session_protocol("serial".to_string(), cx);
},
)),
),
)
.when(!is_config, |this| {
this.child(Input::new(&session_name_input).tab_index(0))
.child(Input::new(&host_input).tab_index(1))
.when(is_serial, |this| {
this.child(
v_flex()
.gap_1()
.child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("session_name").to_string()))
.child(Input::new(&session_name_input).tab_index(0))
)
.child(
v_flex()
.gap_1()
.child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("serial_port").to_string()))
.child(Input::new(&host_input).tab_index(1))
)
.child(
v_flex()
.gap_1()
.child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("baud_rate").to_string()))
.child(Input::new(&baud_rate_input).tab_index(2))
)
})
.when(is_ssh, |this| {
this.child(
h_flex()
.gap_2()
.child(
Button::new("ssh-auth-password")
.label(t!("password").to_string())
.when(is_password, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_auth_method(
AuthMethod::Password,
cx,
)
},
)),
)
.child(
Button::new("ssh-auth-key")
.label(t!("key").to_string())
.when(is_key, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_auth_method(
AuthMethod::Key,
cx,
)
},
)),
)
.child(
Button::new("ssh-auth-config")
.label(t!("ssh_config").to_string())
.when(is_config, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_auth_method(
AuthMethod::Config,
cx,
)
},
)),
),
)
.when(!is_config, |this| {
this.child(Input::new(&session_name_input).tab_index(0))
.child(Input::new(&host_input).tab_index(1))
.child(
h_flex()
.gap_2()
.child(
Input::new(&port_input).w(px(96.)).tab_index(2),
)
.child(
Input::new(&user_input).flex_1().tab_index(3),
),
)
})
.when(is_password, |this| {
this.child(
Input::new(&password_input).mask_toggle().tab_index(4),
)
})
.when(is_key, |this| {
this.child(
h_flex()
.gap_2()
.child(
div()
.flex_1()
.cursor_pointer()
.on_mouse_down(
MouseButton::Left,
window.listener_for(
&view,
|this, _, window, cx| {
this.pick_ssh_key_path(window, cx);
},
),
)
.child(
Input::new(&key_path_input).tab_index(4),
),
)
.child(
Button::new("clear-key-path")
.ghost()
.icon(IconName::Close)
.on_click(window.listener_for(
&view,
|this, _, window, cx| {
Self::set_input_value(
&this.key_path_input,
"",
window,
cx,
);
},
)),
),
)
.child(Input::new(&key_inline_input).h(px(128.)).tab_index(5))
.child(Input::new(&passphrase_input).mask_toggle().tab_index(6))
})
.when(is_config, |this| {
let entries = view.read(cx).ssh_config_entries.clone();
let selected = view.read(cx).ssh_config_selected;
let theme = cx.theme();
if entries.is_empty() {
this.child(
div()
.text_sm()
.text_color(theme.muted_foreground)
.child(t!("ssh_config_empty").to_string()),
)
} else {
this.child(
div()
.h(px(192.))
.id("ssh-config-list")
.track_scroll(
&view.read(cx).connection_scroll_handle,
)
.overflow_y_scroll()
.border_1()
.border_color(theme.border)
.rounded_md()
.children(entries.iter().enumerate().map(
|(i, entry)| {
let is_selected = selected == Some(i);
let label = if entry.user.is_empty() {
format!(
"{}:{}",
entry.hostname, entry.port
)
} else {
format!(
"{}@{}:{}",
entry.user,
entry.hostname,
entry.port
)
};
let alias_label =
if entry.host_alias == entry.hostname {
String::new()
} else {
format!(" ({})", entry.host_alias)
};
let view_clone = view.clone();
div()
.id(("ssh-config-entry", i))
.px_2()
.py_1()
.when(is_selected, |el| {
el.bg(theme.selection)
})
.cursor_pointer()
.hover(|el| el.bg(theme.selection))
.text_sm()
.child(format!("{label}{alias_label}"))
.on_click(window.listener_for(
&view_clone,
move |this, _, window, cx| {
this.select_ssh_config_entry(
i, window, cx,
);
},
))
},
)),
)
}
})
.when(!is_config, |this| {
this.child(
div()
.text_sm()
.font_weight(FontWeight::BOLD)
.child(t!("proxy").to_string()),
)
.child(
h_flex()
.gap_2()
.child(
Input::new(&port_input).w(px(96.)).tab_index(2),
Button::new("proxy-none")
.label(t!("proxy_none").to_string())
.when(proxy_type == "none", |button| {
button.primary()
})
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_proxy_type(
"none".to_string(),
cx,
)
},
)),
)
.child(
Input::new(&user_input).flex_1().tab_index(3),
Button::new("proxy-socks5")
.label("SOCKS5")
.when(proxy_type == "socks5", |button| {
button.primary()
})
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_proxy_type(
"socks5".to_string(),
cx,
)
},
)),
)
.child(
Button::new("proxy-http")
.label("HTTP")
.when(proxy_type == "http", |button| {
button.primary()
})
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_proxy_type(
"http".to_string(),
cx,
)
},
)),
),
)
})
.when(is_password, |this| {
this.child(
Input::new(&password_input).mask_toggle().tab_index(4),
)
})
.when(is_key, |this| {
this.child(
h_flex()
.gap_2()
.child(
div()
.flex_1()
.cursor_pointer()
.on_mouse_down(
MouseButton::Left,
window.listener_for(
&view,
|this, _, window, cx| {
this.pick_ssh_key_path(window, cx);
},
.when(
show_proxy_fields,
|this| {
this.child(
h_flex()
.gap_2()
.child(Input::new(&proxy_host_input).flex_1())
.child(
Input::new(&proxy_port_input).w(px(96.)),
),
)
.child(
Input::new(&key_path_input).tab_index(4),
),
)
.child(
Button::new("clear-key-path")
.ghost()
.icon(IconName::Close)
.on_click(window.listener_for(
&view,
|this, _, window, cx| {
Self::set_input_value(
&this.key_path_input,
"",
window,
cx,
);
},
)),
),
)
.child(Input::new(&key_inline_input).h(px(128.)).tab_index(5))
.child(Input::new(&passphrase_input).mask_toggle().tab_index(6))
})
.when(is_config, |this| {
let entries = view.read(cx).ssh_config_entries.clone();
let selected = view.read(cx).ssh_config_selected;
let theme = cx.theme();
if entries.is_empty() {
this.child(
div()
.text_sm()
.text_color(theme.muted_foreground)
.child(t!("ssh_config_empty").to_string()),
)
} else {
this.child(
div()
.h(px(192.))
.id("ssh-config-list")
.track_scroll(
&view.read(cx).connection_scroll_handle,
)
.overflow_y_scroll()
.border_1()
.border_color(theme.border)
.rounded_md()
.children(entries.iter().enumerate().map(
|(i, entry)| {
let is_selected = selected == Some(i);
let label = if entry.user.is_empty() {
format!(
"{}:{}",
entry.hostname, entry.port
)
} else {
format!(
"{}@{}:{}",
entry.user,
entry.hostname,
entry.port
)
};
let alias_label =
if entry.host_alias == entry.hostname {
String::new()
} else {
format!(" ({})", entry.host_alias)
};
let view_clone = view.clone();
div()
.id(("ssh-config-entry", i))
.px_2()
.py_1()
.when(is_selected, |el| {
el.bg(theme.selection)
})
.cursor_pointer()
.hover(|el| el.bg(theme.selection))
.text_sm()
.child(format!("{label}{alias_label}"))
.on_click(window.listener_for(
&view_clone,
move |this, _, window, cx| {
this.select_ssh_config_entry(
i, window, cx,
);
},
))
},
)),
.child(
h_flex()
.gap_2()
.child(Input::new(&proxy_user_input).flex_1())
.child(
Input::new(&proxy_password_input).flex_1(),
),
)
},
)
}
})
.when(!is_config, |this| {
this.child(
div()
.text_sm()
.font_weight(FontWeight::BOLD)
.child(t!("proxy").to_string()),
)
.child(
h_flex()
.gap_2()
.child(
Button::new("proxy-none")
.label(t!("proxy_none").to_string())
.when(proxy_type == "none", |button| {
button.primary()
})
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_proxy_type(
"none".to_string(),
cx,
)
},
)),
)
.child(
Button::new("proxy-socks5")
.label("SOCKS5")
.when(proxy_type == "socks5", |button| {
button.primary()
})
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_proxy_type(
"socks5".to_string(),
cx,
)
},
)),
)
.child(
Button::new("proxy-http")
.label("HTTP")
.when(proxy_type == "http", |button| {
button.primary()
})
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_proxy_type(
"http".to_string(),
cx,
)
},
)),
),
)
.when(
show_proxy_fields,
|this| {
this.child(
h_flex()
.gap_2()
.child(Input::new(&proxy_host_input).flex_1())
.child(
Input::new(&proxy_port_input).w(px(96.)),
),
)
.child(
h_flex()
.gap_2()
.child(Input::new(&proxy_user_input).flex_1())
.child(
Input::new(&proxy_password_input).flex_1(),
),
)
},
)
})
})
.child(
h_flex()
@@ -538,12 +601,21 @@ impl Ashell {
let is_selected =
selected_index == ix + 2;
let name = session.name.clone();
let detail = format!(
"{}@{}:{}",
session.user,
session.host,
session.port
);
let detail = if session.protocol
== "serial"
{
format!(
"Serial: {}@{}",
session.host, session.baud_rate
)
} else {
format!(
"{}@{}:{}",
session.user,
session.host,
session.port
)
};
div()
.id(("selector-open", ix))
.w_full()
+46 -23
View File
@@ -212,6 +212,8 @@ pub(crate) struct Ashell {
pub(crate) key_path_input: Entity<InputState>,
pub(crate) key_inline_input: Entity<InputState>,
pub(crate) passphrase_input: Entity<InputState>,
pub(crate) baud_rate_input: Entity<InputState>,
pub(crate) session_protocol: String,
pub(crate) ssh_proxy_type: String,
pub(crate) proxy_host_input: Entity<InputState>,
pub(crate) proxy_port_input: Entity<InputState>,
@@ -401,6 +403,7 @@ impl Ashell {
.placeholder("SSH private key passphrase (optional)")
.masked(true)
});
let baud_rate_input = cx.new(|cx| InputState::new(window, cx).default_value("115200"));
let proxy_host_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_host").to_string()));
let proxy_port_input =
@@ -510,6 +513,7 @@ impl Ashell {
cx.subscribe_in(&key_path_input, window, Self::on_input_event),
cx.subscribe_in(&key_inline_input, window, Self::on_input_event),
cx.subscribe_in(&passphrase_input, window, Self::on_input_event),
cx.subscribe_in(&baud_rate_input, window, Self::on_input_event),
cx.subscribe_in(&proxy_host_input, window, Self::on_input_event),
cx.subscribe_in(&proxy_port_input, window, Self::on_input_event),
cx.subscribe_in(&proxy_user_input, window, Self::on_input_event),
@@ -588,6 +592,8 @@ impl Ashell {
key_path_input,
key_inline_input,
passphrase_input,
baud_rate_input,
session_protocol: "ssh".to_string(),
ssh_proxy_type: "none".to_string(),
proxy_host_input,
proxy_port_input,
@@ -1145,7 +1151,7 @@ impl Ashell {
let mut retry_tabs = Vec::new();
for (ix, tab) in self.tabs.iter().enumerate() {
if !tab.connected && tab.session.is_some() && tab.id == progress.tab_id {
retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap()));
retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap(), tab.kind));
}
}
@@ -1154,19 +1160,34 @@ impl Ashell {
return;
}
for (ix, tab_id, session) in retry_tabs {
for (ix, tab_id, session, tab_kind) in retry_tabs {
// Close old backend
self.tabs[ix].send_backend(crate::terminal::BackendCommand::Close);
// Spawn new backend
let backend = crate::backend::ssh::spawn_ssh_terminal(
self.runtime.handle(),
tab_id.clone(),
session.clone(),
self.tabs[ix].cols,
self.tabs[ix].rows,
self.events_tx.clone(),
);
let backend = match tab_kind {
crate::terminal::TabKind::Serial => {
let b = crate::backend::serial::spawn_serial_client(
self.runtime.handle(),
tab_id.clone(),
session.clone(),
self.events_tx.clone(),
);
crate::terminal::BackendTx::Serial(b)
}
crate::terminal::TabKind::Ssh => {
let b = crate::backend::ssh::spawn_ssh_terminal(
self.runtime.handle(),
tab_id.clone(),
session.clone(),
self.tabs[ix].cols,
self.tabs[ix].rows,
self.events_tx.clone(),
);
b
}
_ => continue,
};
// Replace tab state
self.tabs[ix].set_backend(backend);
@@ -1189,20 +1210,22 @@ impl Ashell {
.and_then(|t| t.session.clone());
if let Some(session) = group_session {
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
old_handle.close();
}
let sftp_handle = crate::sftp::spawn_sftp(
self.runtime.handle(),
group_id.clone(),
session,
self.events_tx.clone(),
);
self.sftp_handles.insert(group_id.clone(), sftp_handle);
if session.protocol != "serial" {
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
old_handle.close();
}
let sftp_handle = crate::sftp::spawn_sftp(
self.runtime.handle(),
group_id.clone(),
session,
self.events_tx.clone(),
);
self.sftp_handles.insert(group_id.clone(), sftp_handle);
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
}
}
}
}
+7
View File
@@ -1622,6 +1622,13 @@ impl Ashell {
"ssh".to_string()
}
}
TabKind::Serial => {
if let Some((_, session)) = self.active_ssh_session() {
format!("serial / {}", session.name)
} else {
"serial".to_string()
}
}
}
} else {
self.active_title()
+1
View File
@@ -1,2 +1,3 @@
pub mod local;
pub mod serial;
pub mod ssh;
+223
View File
@@ -0,0 +1,223 @@
use crate::session::config::Session;
use crate::terminal::{BackendCommand, BackendEvent};
use std::io::{Read, Write};
/// Spawn the serial port backend threads.
/// Returns a sender to send commands (like keyboard inputs) to the serial port.
pub fn spawn_serial_client(
_handle: &tokio::runtime::Handle,
tab_id: String,
session: Session,
events_tx: std::sync::mpsc::Sender<BackendEvent>,
) -> tokio::sync::mpsc::UnboundedSender<BackendCommand> {
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<BackendCommand>();
let tab_id_clone = tab_id.clone();
let events_tx_clone = events_tx.clone();
std::thread::spawn(move || {
let _ = events_tx_clone.send(BackendEvent::Status {
tab_id: tab_id_clone.clone(),
text: rust_i18n::t!("starting_connection").to_string(),
});
let port_name = session.host;
let baud_rate = session.baud_rate;
tracing::info!(
"[serial] opening port {} at baud rate {}",
port_name,
baud_rate
);
let mut port_result = serialport::new(&port_name, baud_rate)
.timeout(std::time::Duration::from_millis(100))
.open();
if port_result.is_err() && baud_rate != 0 {
tracing::info!(
"[serial] failed to open port with baud rate {}, retrying with 0 (virtual port mode)",
baud_rate
);
port_result = serialport::new(&port_name, 0)
.timeout(std::time::Duration::from_millis(100))
.open();
}
let mut port = match port_result {
Ok(p) => p,
Err(e) => {
tracing::error!("[serial] failed to open port {}: {}", port_name, e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to open serial port {port_name}: {e}"),
});
return;
}
};
let mut port_write = match port.try_clone() {
Ok(pw) => pw,
Err(e) => {
tracing::error!("[serial] failed to clone port: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to clone serial port: {e}"),
});
return;
}
};
// Notify connected
let _ = events_tx_clone.send(BackendEvent::Connected {
tab_id: tab_id_clone.clone(),
});
// Spawn write thread
let tab_id_write = tab_id_clone.clone();
let events_tx_write = events_tx_clone.clone();
std::thread::spawn(move || {
while let Some(cmd) = cmd_rx.blocking_recv() {
match cmd {
BackendCommand::Input(bytes) => {
if let Err(e) = port_write.write_all(&bytes) {
tracing::error!("[serial] write error: {}", e);
let _ = events_tx_write.send(BackendEvent::Closed {
tab_id: tab_id_write.clone(),
reason: format!("Serial write error: {e}"),
});
break;
}
let _ = port_write.flush();
}
BackendCommand::Close => break,
_ => {}
}
}
});
// Read loop in current thread
let mut buf = [0u8; 1024];
let mut last_was_cr = false;
loop {
match port.read(&mut buf) {
Ok(n) if n > 0 => {
let mut processed = Vec::with_capacity(n * 2);
let read_bytes = &buf[..n];
for i in 0..n {
let b = read_bytes[i];
if b == b'\n' {
let prev_was_cr = if i > 0 {
read_bytes[i - 1] == b'\r'
} else {
last_was_cr
};
if !prev_was_cr {
processed.push(b'\r');
}
}
processed.push(b);
}
last_was_cr = read_bytes[n - 1] == b'\r';
let _ = events_tx_clone.send(BackendEvent::Output {
tab_id: tab_id_clone.clone(),
bytes: processed,
});
}
Ok(_) => {}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {}
Err(e) => {
tracing::info!("[serial] port read error/closed: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Serial read error: {e}"),
});
break;
}
}
}
});
cmd_tx
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use portable_pty::{NativePtySystem, PtySize, PtySystem};
#[tokio::test]
async fn test_serial_read_write_simulation() {
// 1. Create a PTY pair using portable-pty to simulate a serial device
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
// On macOS/Linux, the slave name path behaves like a TTY device.
let fd = pair.master.as_raw_fd().unwrap();
let slave_name = unsafe {
let ptr = libc::ptsname(fd);
assert!(!ptr.is_null());
std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
};
println!("Simulating serial device on PTY slave path: {}", slave_name);
// 2. Spawn the serial backend targeting the PTY slave path
let (events_tx, events_rx) = std::sync::mpsc::channel();
let handle = tokio::runtime::Handle::current();
let session = Session::serial(slave_name, 0);
let cmd_tx = spawn_serial_client(&handle, "test-tab".to_string(), session, events_tx);
// Wait for the Status event
let status_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
assert!(status_event.is_ok(), "Failed to receive Status event");
if let Ok(BackendEvent::Status { tab_id, .. }) = status_event {
assert_eq!(tab_id, "test-tab");
} else {
panic!("Expected Status event, got: {:?}", status_event);
}
// Wait for the Connected event
let connected_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
assert!(connected_event.is_ok(), "Failed to receive Connected event");
if let Ok(BackendEvent::Connected { tab_id }) = connected_event {
assert_eq!(tab_id, "test-tab");
} else {
panic!("Expected Connected event, got: {:?}", connected_event);
}
// 3. Test Reading: Write to PTY master, verify serial backend outputs it to UI
let mut master_writer = pair.master.take_writer().unwrap();
master_writer.write_all(b"hello serial simulator").unwrap();
master_writer.flush().unwrap();
let output_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
assert!(output_event.is_ok(), "Failed to receive Output event");
if let Ok(BackendEvent::Output { tab_id, bytes }) = output_event {
assert_eq!(tab_id, "test-tab");
assert_eq!(bytes, b"hello serial simulator");
} else {
panic!("Expected Output event");
}
// 4. Test Writing: Send BackendCommand::Input to backend, verify PTY master reads it
cmd_tx
.send(BackendCommand::Input(b"world serial simulator".to_vec()))
.unwrap();
let mut master_reader = pair.master.try_clone_reader().unwrap();
let mut read_buf = [0u8; 128];
let bytes_read = master_reader.read(&mut read_buf).unwrap();
assert_eq!(&read_buf[..bytes_read], b"world serial simulator");
// 5. Clean up
cmd_tx.send(BackendCommand::Close).unwrap();
}
}
+40
View File
@@ -20,6 +20,14 @@ pub enum AuthMethod {
Config,
}
fn default_protocol() -> String {
"ssh".to_string()
}
fn default_baud_rate() -> u32 {
115200
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
@@ -48,6 +56,10 @@ pub struct Session {
pub proxy_user: String,
#[serde(default)]
pub proxy_password: String,
#[serde(default = "default_protocol")]
pub protocol: String,
#[serde(default = "default_baud_rate")]
pub baud_rate: u32,
}
impl Session {
@@ -70,6 +82,8 @@ impl Session {
proxy_port: None,
proxy_user: String::new(),
proxy_password: String::new(),
protocol: "ssh".to_string(),
baud_rate: 115200,
}
}
@@ -99,6 +113,32 @@ impl Session {
proxy_port: None,
proxy_user: String::new(),
proxy_password: String::new(),
protocol: "ssh".to_string(),
baud_rate: 115200,
}
}
pub fn serial(port_name: String, baud_rate: u32) -> Self {
let name = format!("serial://{port_name}@{baud_rate}");
Self {
id: Uuid::new_v4().to_string(),
name,
host: port_name,
port: 0,
user: String::new(),
auth: AuthMethod::Password,
password: String::new(),
private_key_path: String::new(),
private_key_inline: String::new(),
passphrase: String::new(),
last_used: None,
proxy_type: "none".to_string(),
proxy_host: String::new(),
proxy_port: None,
proxy_user: String::new(),
proxy_password: String::new(),
protocol: "serial".to_string(),
baud_rate,
}
}
}
+182 -29
View File
@@ -56,6 +56,55 @@ impl Ashell {
}
pub(crate) fn connect_ssh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.session_protocol == "serial" {
let session_name = self.session_name_input.read(cx).value().trim().to_string();
let port_name = self.host_input.read(cx).value().trim().to_string();
let baud_rate = self
.baud_rate_input
.read(cx)
.value()
.trim()
.parse::<u32>()
.unwrap_or(115200);
if port_name.is_empty() {
self.status = "Serial port path is required".into();
cx.notify();
return;
}
let name = if session_name.is_empty() {
port_name.clone()
} else {
session_name
};
let existing_id = self.editing_session_id.clone();
let existing_last_used = existing_id
.as_deref()
.and_then(|id| self.config.get(id))
.and_then(|session| session.last_used.clone());
let mut session = Session::serial(port_name, baud_rate);
session.name = name;
if let Some(id) = existing_id {
session.id = id;
}
session.last_used = existing_last_used;
self.config.upsert(session.clone());
if let Err(err) = self.config.save() {
tracing::warn!("failed to save config: {err:#}");
}
self.open_serial_session(session, cx);
self.editing_session_id = None;
self.active_dialog = None;
window.close_dialog(cx);
cx.notify();
return;
}
tracing::info!("[ui] user initiating new ssh connection from form");
let session_name = self.session_name_input.read(cx).value().trim().to_string();
let host = self.host_input.read(cx).value().trim().to_string();
@@ -153,6 +202,7 @@ impl Ashell {
self.editing_session_id = None;
self.ssh_auth_method = AuthMethod::Password;
self.ssh_config_selected = None;
self.session_protocol = "ssh".to_string();
Self::set_input_value(&self.session_name_input, "", window, cx);
Self::set_input_value(&self.host_input, "", window, cx);
Self::set_input_value(&self.port_input, "22", window, cx);
@@ -161,6 +211,7 @@ impl Ashell {
Self::set_input_value(&self.key_path_input, "", window, cx);
Self::set_input_value(&self.key_inline_input, "", window, cx);
Self::set_input_value(&self.passphrase_input, "", window, cx);
Self::set_input_value(&self.baud_rate_input, "115200", window, cx);
self.ssh_proxy_type = "none".to_string();
Self::set_input_value(&self.proxy_host_input, "", window, cx);
Self::set_input_value(&self.proxy_port_input, "", window, cx);
@@ -176,6 +227,7 @@ impl Ashell {
) {
self.editing_session_id = Some(session.id.clone());
self.ssh_auth_method = session.auth;
self.session_protocol = session.protocol.clone();
Self::set_input_value(&self.session_name_input, session.name.clone(), window, cx);
Self::set_input_value(&self.host_input, session.host.clone(), window, cx);
Self::set_input_value(&self.port_input, session.port.to_string(), window, cx);
@@ -199,6 +251,12 @@ impl Ashell {
window,
cx,
);
Self::set_input_value(
&self.baud_rate_input,
session.baud_rate.to_string(),
window,
cx,
);
self.ssh_proxy_type = if session.proxy_type.is_empty() {
"none".to_string()
} else {
@@ -389,6 +447,11 @@ impl Ashell {
cx.notify();
}
pub(crate) fn set_session_protocol(&mut self, protocol: String, cx: &mut Context<Self>) {
self.session_protocol = protocol;
cx.notify();
}
pub(crate) fn refresh_ssh_config(&mut self) {
self.ssh_config_entries =
crate::session::ssh_config::parse_ssh_config().unwrap_or_default();
@@ -448,7 +511,11 @@ impl Ashell {
cx.notify();
return;
};
self.open_ssh_session(session, cx);
if session.protocol == "serial" {
self.open_serial_session(session, cx);
} else {
self.open_ssh_session(session, cx);
}
}
pub(crate) fn selector_entries(&self) -> Vec<SelectorEntry> {
@@ -613,6 +680,57 @@ impl Ashell {
cx.notify();
}
pub(crate) fn open_serial_session(&mut self, session: Session, cx: &mut Context<Self>) {
tracing::info!(
"[session] opening serial tab for session '{}' ({})",
session.name,
session.host
);
let id = Uuid::new_v4().to_string();
let backend = crate::backend::serial::spawn_serial_client(
self.runtime.handle(),
id.clone(),
session.clone(),
self.events_tx.clone(),
);
self.tabs.push(TerminalTab::new_serial(
id.clone(),
&session,
crate::terminal::BackendTx::Serial(backend),
self.events_tx.clone(),
));
self.active_tab = Some(id.clone());
self.connection_progress = Some(crate::app::ConnectionProgress {
tab_id: id.clone(),
title: rust_i18n::t!("connecting").into(),
lines: vec![rust_i18n::t!("starting_connection").into()],
failed: false,
});
self.pane_root = PaneLayout::Single(id.clone());
self.focused_pane_path = vec![];
let group_id = Uuid::new_v4().to_string();
self.tab_groups.push(TabGroup {
id: group_id.clone(),
title: session.name.clone(),
pane_root: PaneLayout::Single(id.clone()),
sftp: None,
});
self.active_group = Some(group_id.clone());
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
if let Some(session_id) = self.active_session_id() {
if let Some(index) = self
.config
.sessions()
.iter()
.position(|s| s.id == session_id)
{
self.saved_scroll_handle.scroll_to_item(index);
}
}
self.status = "serial tab opened".into();
cx.notify();
}
pub(crate) fn remove_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
self.config.remove(&session_id);
if let Err(err) = self.config.save() {
@@ -646,20 +764,30 @@ impl Ashell {
self.tabs[ix].send_backend(BackendCommand::Close);
if let Some(session) = session {
// SSH tab: spawn new SSH connection
let backend = ssh::spawn_ssh_terminal(
self.runtime.handle(),
tab_id.to_string(),
session.clone(),
cols,
rows,
self.events_tx.clone(),
);
// Swap the backend — the Term's internal listener shares the
// same Arc<Mutex<BackendTx>>, so user input is automatically
// routed to the new backend. Terminal history is preserved.
self.tabs[ix].set_backend(backend);
let tab_kind = self.tabs[ix].kind;
match tab_kind {
crate::terminal::TabKind::Serial => {
let backend = crate::backend::serial::spawn_serial_client(
self.runtime.handle(),
tab_id.to_string(),
session.clone(),
self.events_tx.clone(),
);
self.tabs[ix].set_backend(crate::terminal::BackendTx::Serial(backend));
}
crate::terminal::TabKind::Ssh => {
let backend = ssh::spawn_ssh_terminal(
self.runtime.handle(),
tab_id.to_string(),
session.clone(),
cols,
rows,
self.events_tx.clone(),
);
self.tabs[ix].set_backend(backend);
}
_ => {}
}
self.tabs[ix].connected = false;
self.tabs[ix].status = "connecting".into();
self.tabs[ix].disconnected_reason = None;
@@ -680,20 +808,22 @@ impl Ashell {
.and_then(|t| t.session.clone());
if let Some(session) = group_session {
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
old_handle.close();
}
let sftp_handle = crate::sftp::spawn_sftp(
self.runtime.handle(),
group_id.clone(),
session,
self.events_tx.clone(),
);
self.sftp_handles.insert(group_id.clone(), sftp_handle);
if session.protocol != "serial" {
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
old_handle.close();
}
let sftp_handle = crate::sftp::spawn_sftp(
self.runtime.handle(),
group_id.clone(),
session,
self.events_tx.clone(),
);
self.sftp_handles.insert(group_id.clone(), sftp_handle);
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
}
}
}
}
@@ -1046,7 +1176,11 @@ impl Ashell {
}
pub(crate) fn session_detail(&self, session: &Session) -> String {
format!("{}@{}:{}", session.user, session.host, session.port)
if session.protocol == "serial" {
format!("Serial: {}@{}", session.host, session.baud_rate)
} else {
format!("{}@{}:{}", session.user, session.host, session.port)
}
}
pub(crate) fn split_current_pane(&mut self, direction: &str, cx: &mut Context<Self>) {
@@ -1112,6 +1246,25 @@ impl Ashell {
self.sftp_handles.insert(new_id.clone(), sftp_handle);
TerminalTab::new_ssh(new_id.clone(), &session, backend, self.events_tx.clone())
}
TabKind::Serial => {
let Some(session) = current_tab.session.clone() else {
self.status = "cannot split: no session info".into();
cx.notify();
return;
};
let backend = crate::backend::serial::spawn_serial_client(
self.runtime.handle(),
new_id.clone(),
session.clone(),
self.events_tx.clone(),
);
TerminalTab::new_serial(
new_id.clone(),
&session,
crate::terminal::BackendTx::Serial(backend),
self.events_tx.clone(),
)
}
};
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
// Do NOT add to tab_groups — pane stays within the existing group
+24
View File
@@ -23,6 +23,7 @@ use crate::system::SystemSnapshot;
pub enum TabKind {
Local,
Ssh,
Serial,
}
#[derive(Debug)]
@@ -98,6 +99,7 @@ pub enum BackendEvent {
pub enum BackendTx {
Local(Sender<BackendCommand>),
Ssh(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
Serial(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
}
impl BackendTx {
@@ -109,6 +111,9 @@ impl BackendTx {
Self::Ssh(tx) => {
let _ = tx.send(command);
}
Self::Serial(tx) => {
let _ = tx.send(command);
}
}
}
}
@@ -228,6 +233,25 @@ impl TerminalTab {
tab
}
pub fn new_serial(
id: String,
session: &Session,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
let mut tab = Self::new(
id,
session.name.clone(),
TabKind::Serial,
format!("connecting serial://{}@{}", session.host, session.baud_rate),
backend,
events,
);
tab.session = Some(session.clone());
tab.connected = false;
tab
}
fn new(
id: String,
title: String,