diff --git a/CHANGELOG.md b/CHANGELOG.md index ce0314b..7366575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Every release entry is provided in English and Simplified Chinese. - **A built-in font fallback** — Maple Mono Normal NF CN is now embedded in Nebula as a runtime fallback, so the interface remains readable when the system font has not been installed. The system-installed font is still preferred, the installation reminder remains dismissible, and the portable archive still includes the original font under `fonts` for normal installation. - **Choose the directory shown in Files and Git** — the Files drawer can now select a custom root with the native folder picker, and the Git view follows the same root. The choice lasts until the current window closes; “Follow current directory” immediately returns to the focused terminal directory. -- **Five SSH sign-in modes** — each saved host can now use Automatic, Password, Private Key, Agent, or Keyboard-interactive authentication. Automatic mode follows Tabby's practical order, while an explicitly selected mode stays strict and does not silently fall back to another method. +- **Four SSH sign-in modes** — each saved host can now use Automatic, Password, Private Key, or Keyboard-interactive authentication. Automatic mode tries configured keys before password and interactive prompts, while an explicitly selected mode stays strict and does not silently switch methods. - **Private-key sign-in** — choose one or more OpenSSH, PEM, or PPK private keys from the host editor. Key paths stay on the local machine, encrypted-key passphrases are stored in Windows Credential Manager by key fingerprint, and public-key files are rejected with a clear message. - **SFTP file transfer** — open SFTP from an SSH host's right-click menu and reuse the same authenticated connection. The remote drawer supports editable paths, filtering, refresh, file and folder uploads, file and folder downloads, new folders, rename, recursive delete, symlink targets, progress, cancellation, and visible errors. - **Pull from the Git drawer** — the right-side Git view now has a Pull button beside Stage, Commit, and Push. Pulls use fast-forward-only mode so Nebula never creates an unexpected merge commit. @@ -36,7 +36,7 @@ Every release entry is provided in English and Simplified Chinese. - **内置了字体显示兜底** — Maple Mono Normal NF CN 现在会随主程序一起内置。即使用户忘了安装系统字体,界面也能正常显示;已经安装的系统字体仍然优先,缺少字体时的提醒依旧可以关闭,便携包里的 `fonts` 目录和原始字体文件也继续保留,方便按正常方式安装。 - **文件和 Git 可以切换查看目录了** — 文件抽屉新增原生目录选择器,选中的目录也会同步用于 Git 页面。这个选择只保留到当前窗口关闭;点击“跟随”后会立即重新跟随当前终端所在目录。 -- **五种 SSH 登录方式** — 每个已保存主机都可以选择自动、密码、密钥、Agent 或交互式认证。自动模式会按照 Tabby 的实用顺序逐项尝试;手动选择某种方式后则只使用该方式,不会失败后悄悄换成其他登录方式。 +- **四种 SSH 登录方式** — 每个已保存主机都可以选择自动、密码、密钥或交互式认证。自动模式会先尝试配置的私钥,再尝试密码和交互式提示;手动选择某种方式后则只使用该方式,不会失败后悄悄切换。 - **私钥登录** — 主机编辑器现在可以选择一把或多把 OpenSSH、PEM 或 PPK 私钥。Nebula 只保存本机密钥路径,加密私钥的口令会按密钥指纹存入 Windows 凭据管理器;如果误选 `.pub` 公钥,也会直接提示重新选择私钥。 - **SFTP 文件传输** — 在 SSH 主机右键菜单中即可打开 SFTP,并复用同一条已认证连接。远端抽屉支持手动路径、筛选、刷新、上传文件和文件夹、下载文件和文件夹、新建文件夹、重命名、递归删除、符号链接目标、传输进度、取消和可见错误提示。 - **Git 抽屉可以拉取了** — 右侧 Git 页面在暂存、提交和推送旁新增“拉取”按钮。拉取只接受快进更新,不会在用户不知情时自动生成合并提交。 diff --git a/README.md b/README.md index 71cc4df..7e3cb79 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,9 @@ experience that works without extra setup. transport: no wrapper shell and no external console window. Host aliases, usernames, ports and identity files are resolved from `~/.ssh/config`; authentication supports standard private keys and certificates, encrypted-key - passphrases, Windows OpenSSH Agent, Pageant, Windows Credential Manager - passwords, and keyboard-interactive/MFA. Accepted host keys use the standard - `known_hosts` store, and additional tabs reuse an already authenticated + passphrases, Windows Credential Manager passwords, and keyboard-interactive/MFA. + Accepted host keys use the standard `known_hosts` store, and additional tabs + reuse an already authenticated connection to the same `user@host:port` for a faster second shell. - **Built-in SFTP transfers** — open a remote file drawer from any saved SSH host and reuse its authenticated connection. Browse or type remote paths, @@ -275,9 +275,9 @@ Nebula 是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU - **原生 SSH 会话** — 保存的主机现在直接通过 Nebula 的 Rust SSH 传输连接, 不再经过包装 Shell,也不会弹出外部控制台窗口。主机别名、用户名、端口和 IdentityFile 会从 `~/.ssh/config` 解析;认证覆盖标准私钥与证书、加密密钥 - 口令、Windows OpenSSH Agent、Pageant、Windows 凭据管理器密码以及 - keyboard-interactive/MFA。主机密钥写入标准 `known_hosts`;同一 - `user@host:port` 再开标签页会复用已认证连接,让第二个 Shell 更快出现。 + 口令、Windows 凭据管理器密码以及 keyboard-interactive/MFA。主机密钥写入 + 标准 `known_hosts`;同一 `user@host:port` 再开标签页会复用已认证连接,让第二个 + Shell 更快出现。 - **内置 SFTP 传输** — 从已保存 SSH 主机即可打开远端文件抽屉,并复用已认证 连接。支持浏览或手动输入远端路径、筛选、上传/下载文件与文件夹、新建与重命名 文件夹、递归删除、跟随符号链接目标,以及带进度、取消和错误提示的后台传输。 diff --git a/nebula_app/src/display/ssh_editor_render.rs b/nebula_app/src/display/ssh_editor_render.rs index 0cd29aa..b835c30 100644 --- a/nebula_app/src/display/ssh_editor_render.rs +++ b/nebula_app/src/display/ssh_editor_render.rs @@ -54,12 +54,11 @@ impl Display { let auth_y = destination.1 + destination.3 + s(40.0); let auth_track = (destination.0, auth_y, field_w, s(40.0)); let auth_pad = s(3.0); - let auth_w = (field_w - auth_pad * 2.0) / 5.0; + let auth_w = (field_w - auth_pad * 2.0) / 4.0; let auth_modes = [ SshAuthMode::Auto, SshAuthMode::Password, SshAuthMode::PublicKey, - SshAuthMode::Agent, SshAuthMode::KeyboardInteractive, ]; let auth = std::array::from_fn(|index| { @@ -321,7 +320,6 @@ impl Display { cancel, primary, self.nebula_ssh_editor_hover, - accent, &skin, scale, editor.focus.current(), @@ -423,9 +421,9 @@ impl Display { glyph_cache, ); let auth_labels = if language == super::UiLanguage::ZhCn { - ["自动", "密码", "密钥", "Agent", "交互式"] + ["自动", "密码", "密钥", "交互式"] } else { - ["Auto", "Password", "Key", "Agent", "Interactive"] + ["Auto", "Password", "Key", "Interactive"] }; for ((mode, rect), label) in auth.iter().zip(auth_labels) { self.renderer.draw_chrome_text( @@ -509,18 +507,6 @@ impl Display { glyph_cache, ); } - } else if editor.auth == SshAuthMode::Agent { - self.renderer.draw_chrome_text( - &size, - destination.0, - content_y, - skin.ink_dim, - language.pick( - "仅使用 Windows OpenSSH Agent 与 Pageant,不回退密码。", - "Use Windows OpenSSH Agent and Pageant only; do not fall back to passwords.", - ), - glyph_cache, - ); } else if editor.auth == SshAuthMode::KeyboardInteractive { self.renderer.draw_chrome_text( &size, @@ -596,22 +582,20 @@ fn button_quads( cancel: Rect, primary: Rect, hover: SshEditorHit, - accent: Rgba, skin: &theme::Skin, scale: f32, focus: usize, shows_password: bool, ) { let s = |value: f32| value * scale; - let primary_edge = Rgba::new(accent.r, accent.g, accent.b, if skin.is_light { 72 } else { 92 }); - for (rect, edge) in [(cancel, skin.hairline), (primary, primary_edge)] { + for rect in [cancel, primary] { quads.push(UiQuad::solid( rect.0 - s(1.0), rect.1 - s(1.0), rect.2 + s(2.0), rect.3 + s(2.0), s(9.0), - edge, + skin.hairline, )); } let cancel_focus = if shows_password { 2 } else { 1 }; @@ -627,16 +611,23 @@ fn button_quads( rect.2 + s(4.0), rect.3 + s(4.0), s(10.0), - Rgba::new(skin.accent.r, skin.accent.g, skin.accent.b, 255), + skin.hover_strong, )); } quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), skin.surface)); - quads.push(UiQuad::solid(primary.0, primary.1, primary.2, primary.3, s(8.0), skin.accent_soft)); + quads.push(UiQuad::solid(primary.0, primary.1, primary.2, primary.3, s(8.0), skin.input)); if hover == SshEditorHit::Cancel { quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), skin.hover)); } if hover == SshEditorHit::Primary { - quads.push(UiQuad::solid(primary.0, primary.1, primary.2, primary.3, s(8.0), skin.hover)); + quads.push(UiQuad::solid( + primary.0, + primary.1, + primary.2, + primary.3, + s(8.0), + skin.hover_strong, + )); } } diff --git a/nebula_app/src/display/ssh_ui.rs b/nebula_app/src/display/ssh_ui.rs index 46ae774..8c1e85b 100644 --- a/nebula_app/src/display/ssh_ui.rs +++ b/nebula_app/src/display/ssh_ui.rs @@ -16,7 +16,7 @@ pub fn auth_sections(mode: SshAuthMode) -> (bool, bool) { SshAuthMode::Auto => (true, true), SshAuthMode::Password => (true, false), SshAuthMode::PublicKey => (false, true), - SshAuthMode::Agent | SshAuthMode::KeyboardInteractive => (false, false), + SshAuthMode::KeyboardInteractive => (false, false), } } @@ -98,7 +98,7 @@ pub struct SshEditorRects { pub destination: (f32, f32, f32, f32), pub password: (f32, f32, f32, f32), pub password_toggle: (f32, f32, f32, f32), - pub auth: [(SshAuthMode, (f32, f32, f32, f32)); 5], + pub auth: [(SshAuthMode, (f32, f32, f32, f32)); 4], pub add_private_key: (f32, f32, f32, f32), pub private_key_rows: Vec<(usize, (f32, f32, f32, f32))>, pub save_checkbox: (f32, f32, f32, f32), @@ -114,11 +114,10 @@ mod tests { use std::path::PathBuf; #[test] - fn auth_modes_show_the_same_sections_as_tabby() { + fn auth_modes_show_the_expected_editor_sections() { assert_eq!(auth_sections(SshAuthMode::Auto), (true, true)); assert_eq!(auth_sections(SshAuthMode::Password), (true, false)); assert_eq!(auth_sections(SshAuthMode::PublicKey), (false, true)); - assert_eq!(auth_sections(SshAuthMode::Agent), (false, false)); assert_eq!(auth_sections(SshAuthMode::KeyboardInteractive), (false, false)); } diff --git a/nebula_app/src/ssh_profiles.rs b/nebula_app/src/ssh_profiles.rs index a59ab78..a70d0fe 100644 --- a/nebula_app/src/ssh_profiles.rs +++ b/nebula_app/src/ssh_profiles.rs @@ -15,7 +15,6 @@ pub enum SshAuthMode { Auto, Password, PublicKey, - Agent, KeyboardInteractive, } @@ -28,7 +27,8 @@ impl<'de> Deserialize<'de> for SshAuthMode { Ok(match value.as_str() { "password" => Self::Password, "public_key" => Self::PublicKey, - "agent" => Self::Agent, + // v0.5 移除了 Agent;旧配置回退到 Auto,避免升级后配置失效。 + "agent" => Self::Auto, "keyboard_interactive" => Self::KeyboardInteractive, _ => Self::Auto, }) @@ -220,14 +220,28 @@ mod tests { let mut profiles = SshProfiles::default(); profiles.upsert(SshProfileAuth { destination: "old@example.com".to_owned(), - auth: SshAuthMode::Agent, + auth: SshAuthMode::PublicKey, private_keys: vec![PathBuf::from(r"C:\Keys\id_ed25519")], }); profiles.rename("old@example.com", "new@example.com"); assert_eq!(profiles.for_destination("old@example.com").auth, SshAuthMode::Auto); - assert_eq!(profiles.for_destination("new@example.com").auth, SshAuthMode::Agent); + assert_eq!(profiles.for_destination("new@example.com").auth, SshAuthMode::PublicKey); + } + + #[test] + fn legacy_agent_mode_migrates_to_auto() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ssh_profiles.json"); + std::fs::write( + &path, + r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"agent","private_keys":[]}]}"#, + ) + .unwrap(); + + let loaded = SshProfiles::load(&path).unwrap(); + assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto); } #[test] diff --git a/nebula_app/src/ssh_session.rs b/nebula_app/src/ssh_session.rs index cb322fa..69dd83a 100644 --- a/nebula_app/src/ssh_session.rs +++ b/nebula_app/src/ssh_session.rs @@ -17,12 +17,9 @@ use nebula_terminal::event_loop::{EventLoopSender, Msg, StreamProcessor}; use nebula_terminal::sync::FairMutex; use nebula_terminal::term::Term; use russh::ChannelMsg; -use russh::client::{self, AuthResult, KeyboardInteractiveAuthResponse}; -use russh::keys::agent::AgentIdentity; -use russh::keys::agent::client::AgentClient; +use russh::client::{self, KeyboardInteractiveAuthResponse}; use russh::keys::ssh_key; use russh::keys::{HashAlg, PrivateKeyWithHashAlg}; -use tokio::io::{AsyncRead, AsyncWrite}; use crate::event::EventProxy; @@ -33,7 +30,6 @@ type SharedSession = Arc>; #[derive(Debug, Clone, PartialEq, Eq)] enum AuthMethod { PrivateKey(PathBuf), - Agent, StoredPassword, KeyboardInteractive, PromptPassword, @@ -69,7 +65,6 @@ fn authentication_plan( SshAuthMode::Auto => { let mut methods = key_methods(); methods.extend([ - AuthMethod::Agent, AuthMethod::StoredPassword, AuthMethod::KeyboardInteractive, AuthMethod::PromptPassword, @@ -80,7 +75,6 @@ fn authentication_plan( vec![AuthMethod::StoredPassword, AuthMethod::PromptPassword] }, SshAuthMode::PublicKey => key_methods(), - SshAuthMode::Agent => vec![AuthMethod::Agent], SshAuthMode::KeyboardInteractive => vec![AuthMethod::KeyboardInteractive], } } @@ -509,12 +503,6 @@ async fn authenticate( return Ok(()); } }, - AuthMethod::Agent => { - if try_windows_agents(session, &destination.user, &profile.private_keys).await { - clear_secret(&mut reusable_password); - return Ok(()); - } - }, AuthMethod::StoredPassword => { if !loaded_stored_password { reusable_password = @@ -612,9 +600,6 @@ fn auth_failure(mode: crate::ssh_profiles::SshAuthMode, key_count: usize) -> Str "密钥认证没有可用的私钥,请选择私钥文件或配置 IdentityFile".to_owned() }, SshAuthMode::PublicKey => "服务器拒绝了指定的私钥,未回退到密码认证".to_owned(), - SshAuthMode::Agent => { - "OpenSSH Agent 与 Pageant 均未提供可用身份,未回退到密码认证".to_owned() - }, SshAuthMode::KeyboardInteractive => { "服务器拒绝了 keyboard-interactive 认证,未回退到密码认证".to_owned() }, @@ -675,69 +660,6 @@ async fn try_private_key( Ok(session.authenticate_publickey(&destination.user, key).await?.success()) } -async fn try_windows_agents( - session: &mut ClientSession, - user: &str, - preferred_private_keys: &[PathBuf], -) -> bool { - let preferred_public_keys = preferred_private_keys - .iter() - .filter_map(|path| { - let public_path = PathBuf::from(format!("{}.pub", path.display())); - russh::keys::load_public_key(public_path).ok() - }) - .collect::>(); - const OPENSSH_AGENT: &str = r"\\.\pipe\openssh-ssh-agent"; - if let Ok(mut agent) = AgentClient::connect_named_pipe(OPENSSH_AGENT).await { - if authenticate_agent(session, user, &preferred_public_keys, &mut agent) - .await - .unwrap_or(false) - { - return true; - } - } - if let Ok(mut agent) = AgentClient::connect_pageant().await { - if authenticate_agent(session, user, &preferred_public_keys, &mut agent) - .await - .unwrap_or(false) - { - return true; - } - } - false -} - -async fn authenticate_agent( - session: &mut ClientSession, - user: &str, - preferred_public_keys: &[ssh_key::PublicKey], - agent: &mut AgentClient, -) -> Result -where - S: AsyncRead + AsyncWrite + Unpin + Send, -{ - let mut identities = agent.request_identities().await?; - identities.sort_by_key(|identity| { - (!preferred_public_keys.iter().any(|key| key == identity.public_key().as_ref())) as u8 - }); - for identity in identities.into_iter().take(5) { - let is_rsa = identity.public_key().algorithm().is_rsa(); - let hash = rsa_hash_for(session, is_rsa).await; - let result: AuthResult = match identity { - AgentIdentity::PublicKey { key, .. } => { - session.authenticate_publickey_with(user, key, hash, agent).await? - }, - AgentIdentity::Certificate { certificate, .. } => { - session.authenticate_certificate_with(user, certificate, hash, agent).await? - }, - }; - if result.success() { - return Ok(true); - } - } - Ok(false) -} - async fn rsa_hash_for(session: &ClientSession, rsa: bool) -> Option { if !rsa { return None; @@ -929,7 +851,6 @@ mod tests { vec![ AuthMethod::PrivateKey(PathBuf::from(r"C:\Keys\chosen")), AuthMethod::PrivateKey(PathBuf::from(r"C:\Keys\config")), - AuthMethod::Agent, AuthMethod::StoredPassword, AuthMethod::KeyboardInteractive, AuthMethod::PromptPassword, @@ -965,8 +886,7 @@ mod tests { } #[test] - fn agent_and_interactive_modes_are_strict() { - assert_eq!(authentication_plan(SshAuthMode::Agent, &[], &[]), vec![AuthMethod::Agent]); + fn interactive_mode_is_strict() { assert_eq!( authentication_plan(SshAuthMode::KeyboardInteractive, &[], &[]), vec![AuthMethod::KeyboardInteractive] diff --git a/nebula_app/src/window_context/split.rs b/nebula_app/src/window_context/split.rs index 4b5484c..0c8a9ba 100644 --- a/nebula_app/src/window_context/split.rs +++ b/nebula_app/src/window_context/split.rs @@ -300,6 +300,12 @@ fn terminal_content_rect(size: &SizeInfo) -> (f32, f32, f32, f32) { (x, y, width, height) } +fn terminal_content_contains(size: &SizeInfo, x: f32, y: f32) -> bool { + // 分屏 SizeInfo 共享整窗尺寸,只能用网格行列还原当前 Pane 的真实内容边界。 + let (left, top, width, height) = terminal_content_rect(size); + x >= left && x < left + width && y >= top && y < top + height +} + impl WindowContext { /// The pane that currently owns keyboard focus (active tab's active pane). pub(super) fn focused_pane_id(&self) -> PaneId { @@ -369,13 +375,9 @@ impl WindowContext { /// The pane whose rectangle contains the screen point `(x, y)`, if any. pub(super) fn pane_at_position(&self, x: f32, y: f32) -> Option { let (panes, _) = self.layout_geometry(false); - panes.into_iter().find_map(|(id, view)| { - let x0 = view.padding_x(); - let y0 = view.padding_y(); - let x1 = view.width() - view.padding_x(); - let y1 = view.height() - view.padding_bottom(); - (x >= x0 && x < x1 && y >= y0 && y < y1).then_some(id) - }) + panes + .into_iter() + .find_map(|(id, view)| terminal_content_contains(&view, x, y).then_some(id)) } /// Move focus to the nearest pane in direction `nav` from the focused one. @@ -389,8 +391,7 @@ impl WindowContext { // Centre of the currently focused pane. let Some((_, fview)) = rects.iter().find(|(id, _)| *id == focused) else { return }; let center = |v: &SizeInfo| { - let cx = v.padding_x() - + (v.width() - v.padding_x() - v.padding_right()) * 0.5; + let cx = v.padding_x() + (v.width() - v.padding_x() - v.padding_right()) * 0.5; let cy = v.padding_y() + (v.height() - v.padding_y() - v.padding_bottom()) * 0.5; (cx, cy) }; @@ -852,7 +853,7 @@ impl WindowContext { mod resize_layout_tests { use nebula_terminal::grid::Dimensions; - use super::{Layout, collect_layout, terminal_content_rect}; + use super::{Layout, collect_layout, terminal_content_contains, terminal_content_rect}; use crate::display::{SizeInfo, SplitDirection}; #[test] @@ -937,4 +938,44 @@ mod resize_layout_tests { assert_eq!(panes[1].1.width(), 1000.0); assert_eq!(panes[1].1.height(), 1000.0); } + + #[test] + fn split_hit_testing_respects_each_panes_asymmetric_edges() { + let size = + SizeInfo::new_fully_asymmetric(1000.0, 700.0, 10.0, 20.0, 100.0, 20.0, 80.0, 20.0); + let (x, y, width, height) = terminal_content_rect(&size); + let layout = Layout::Split { + direction: SplitDirection::LeftRight, + ratio: 0.5, + preview_ratio: None, + dragging: false, + first: Box::new(Layout::Leaf(1)), + second: Box::new(Layout::Leaf(2)), + }; + let mut panes = Vec::new(); + collect_layout( + &layout, + size.width(), + size.height(), + size.cell_width(), + size.cell_height(), + 8.0, + false, + x, + y, + width, + height, + &mut Vec::new(), + &mut panes, + &mut Vec::new(), + ); + + let left = panes.iter().find(|(id, _)| *id == 1).unwrap().1; + let right = panes.iter().find(|(id, _)| *id == 2).unwrap().1; + let right_center_x = right.padding_x() + right.columns() as f32 * right.cell_width() * 0.5; + let center_y = right.padding_y() + right.screen_lines() as f32 * right.cell_height() * 0.5; + + assert!(!terminal_content_contains(&left, right_center_x, center_y)); + assert!(terminal_content_contains(&right, right_center_x, center_y)); + } }