From e7ca7bdc2316edaa175f8ced4bb432f8508fe048 Mon Sep 17 00:00:00 2001 From: TomZz Date: Mon, 20 Jul 2026 17:06:21 +0800 Subject: [PATCH] feat: add support for serial port connections with virtual PTY fallback and automatic newline translation --- Cargo.lock | 86 ++++++- Cargo.toml | 4 + locales/en.yml | 5 + locales/zh-CN.yml | 5 + src/app/dialogs.rs | 544 ++++++++++++++++++++++++------------------ src/app/mod.rs | 69 ++++-- src/app/ui.rs | 7 + src/backend/mod.rs | 1 + src/backend/serial.rs | 223 +++++++++++++++++ src/session/config.rs | 40 ++++ src/session/mod.rs | 211 +++++++++++++--- src/terminal/mod.rs | 24 ++ 12 files changed, 928 insertions(+), 291 deletions(-) create mode 100644 src/backend/serial.rs diff --git a/Cargo.lock b/Cargo.lock index 4be5d02..636ebac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 0a83671..8d288ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/locales/en.yml b/locales/en.yml index 676807e..e96466e 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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}" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 456746e..db9ee73 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -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}" diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index ef802aa..1889b95 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -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() diff --git a/src/app/mod.rs b/src/app/mod.rs index 0d6707e..bc16a90 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -212,6 +212,8 @@ pub(crate) struct Ashell { pub(crate) key_path_input: Entity, pub(crate) key_inline_input: Entity, pub(crate) passphrase_input: Entity, + pub(crate) baud_rate_input: Entity, + pub(crate) session_protocol: String, pub(crate) ssh_proxy_type: String, pub(crate) proxy_host_input: Entity, pub(crate) proxy_port_input: Entity, @@ -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(); + } } } } diff --git a/src/app/ui.rs b/src/app/ui.rs index c71e31e..86e52af 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -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() diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 6d243a0..99531f9 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -1,2 +1,3 @@ pub mod local; +pub mod serial; pub mod ssh; diff --git a/src/backend/serial.rs b/src/backend/serial.rs new file mode 100644 index 0000000..1e0c8fa --- /dev/null +++ b/src/backend/serial.rs @@ -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, +) -> tokio::sync::mpsc::UnboundedSender { + let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); + + 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(); + } +} diff --git a/src/session/config.rs b/src/session/config.rs index 8ccfcd1..c1e936c 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -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, } } } diff --git a/src/session/mod.rs b/src/session/mod.rs index b196699..6a47015 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -56,6 +56,55 @@ impl Ashell { } pub(crate) fn connect_ssh(&mut self, window: &mut Window, cx: &mut Context) { + 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::() + .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.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 { @@ -613,6 +680,57 @@ impl Ashell { cx.notify(); } + pub(crate) fn open_serial_session(&mut self, session: Session, cx: &mut Context) { + 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.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>, 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) { @@ -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 diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 79db433..c1be157 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -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), Ssh(tokio::sync::mpsc::UnboundedSender), + Serial(tokio::sync::mpsc::UnboundedSender), } 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, + ) -> 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,