feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection
- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru - Auto-detect system language via navigator.languages; fallback to English - Add BCP47 locale mapping for date formatting and sorting - Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each) - Update language selector with native language names - Extend locale key parity test to cover all 7 languages (70 assertions) - Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features - Add credential encryption, host metrics, session backup crypto, and zmodem bridge - Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
This commit is contained in:
1 parent
fea6d8db04
commit
b0d0e3a5f3
301 files changed
+28002
-2705
No files matched your search
+229
-23
@@ -2,7 +2,10 @@ use std::{
|
||||
collections::HashMap,
|
||||
io::{self, Read, Write},
|
||||
net::{Shutdown, TcpStream},
|
||||
sync::Mutex,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
@@ -12,19 +15,23 @@ use tauri::{AppHandle, Emitter};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::ssh::{
|
||||
ConnectionClosedEvent, ExecResponse, ForwardStatus, ShellClosedEvent, ShellDataEvent,
|
||||
SshConnectRequest,
|
||||
ConnectionClosedEvent, ExecResponse, ForwardStatus, HostMetricsResponse, ShellClosedEvent,
|
||||
ShellDataEvent, SshConnectRequest,
|
||||
};
|
||||
|
||||
use super::tunnel::TunnelManager;
|
||||
|
||||
mod auth;
|
||||
mod host_metrics;
|
||||
mod known_hosts;
|
||||
mod path;
|
||||
mod proxy;
|
||||
mod sftp;
|
||||
|
||||
pub use known_hosts::{bind_app_handle, respond as respond_host_key, HostKeyDecision};
|
||||
|
||||
use auth::authenticate;
|
||||
use host_metrics::{parse_host_metrics, HOST_METRICS_SCRIPT};
|
||||
use known_hosts::verify_host_key;
|
||||
use proxy::connect_transport;
|
||||
|
||||
@@ -38,6 +45,9 @@ struct Shell {
|
||||
connection_id: String,
|
||||
session: Session,
|
||||
channel: ssh2::Channel,
|
||||
/// When false, reader buffers bytes until the UI opens the stream (avoids lost MOTD).
|
||||
output_live: Arc<AtomicBool>,
|
||||
pending_output: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -45,6 +55,8 @@ pub struct SshService {
|
||||
connections: Mutex<HashMap<String, Connection>>,
|
||||
shells: Mutex<HashMap<String, Shell>>,
|
||||
sftps: Mutex<HashMap<String, String>>,
|
||||
/// Per-connection cancel flag for in-flight `ssh_exec` (Agent 停止).
|
||||
active_execs: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
tunnels: TunnelManager,
|
||||
}
|
||||
|
||||
@@ -62,6 +74,14 @@ impl SshService {
|
||||
|
||||
pub fn disconnect(&self, app: &AppHandle, connection_id: &str) -> Result<(), String> {
|
||||
self.tunnels.stop_connection(connection_id)?;
|
||||
if let Some(flag) = self
|
||||
.active_execs
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.remove(connection_id)
|
||||
{
|
||||
flag.store(true, Ordering::Release);
|
||||
}
|
||||
let shell_ids = {
|
||||
let shells = self.shells.lock().map_err(lock_error)?;
|
||||
shells
|
||||
@@ -103,27 +123,130 @@ impl SshService {
|
||||
&self,
|
||||
connection_id: &str,
|
||||
command: &str,
|
||||
) -> Result<(String, i32), String> {
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let mut active = self.active_execs.lock().map_err(lock_error)?;
|
||||
active.insert(connection_id.to_string(), cancel.clone());
|
||||
}
|
||||
let result = self.exec_cancellable(connection_id, command, &cancel);
|
||||
let _ = self
|
||||
.active_execs
|
||||
.lock()
|
||||
.map(|mut active| active.remove(connection_id));
|
||||
result
|
||||
}
|
||||
|
||||
/// 请求终止该连接上正在执行的 `ssh_exec`(关闭远端命令通道)。
|
||||
pub fn cancel_exec(&self, connection_id: &str) -> Result<(), String> {
|
||||
if let Some(flag) = self
|
||||
.active_execs
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.get(connection_id)
|
||||
{
|
||||
flag.store(true, Ordering::Release);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exec_cancellable(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
command: &str,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<(String, i32), String> {
|
||||
let connection = self.connection(connection_id)?;
|
||||
// Short poll interval so Agent「停止」能尽快关闭通道;执行结束后恢复。
|
||||
let poll_ms = 250_u32;
|
||||
let restore_ms = connection
|
||||
.config
|
||||
.timeout_seconds
|
||||
.unwrap_or(15)
|
||||
.clamp(3, 120)
|
||||
.saturating_mul(1_000) as u32;
|
||||
connection.session.set_timeout(poll_ms);
|
||||
|
||||
let mut channel = connection
|
||||
.session
|
||||
.channel_session()
|
||||
.map_err(|error| format!("创建命令通道失败: {error}"))?;
|
||||
channel
|
||||
.exec(command)
|
||||
.map_err(|error| format!("执行命令失败: {error}"))?;
|
||||
let mut output = String::new();
|
||||
channel
|
||||
.read_to_string(&mut output)
|
||||
.map_err(|error| format!("读取命令输出失败: {error}"))?;
|
||||
let mut stderr = String::new();
|
||||
let _ = channel.stderr().read_to_string(&mut stderr);
|
||||
if let Err(error) = channel.exec(command) {
|
||||
connection.session.set_timeout(restore_ms);
|
||||
return Err(format!("执行命令失败: {error}"));
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let cancelled = loop {
|
||||
if cancel.load(Ordering::Acquire) {
|
||||
break true;
|
||||
}
|
||||
match channel.read(&mut buffer) {
|
||||
Ok(0) if channel.eof() => break false,
|
||||
Ok(0) => thread::sleep(Duration::from_millis(20)),
|
||||
Ok(length) => output.extend_from_slice(&buffer[..length]),
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::TimedOut => continue,
|
||||
Err(error) => {
|
||||
connection.session.set_timeout(restore_ms);
|
||||
return Err(format!("读取命令输出失败: {error}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if cancelled {
|
||||
let _ = channel.close();
|
||||
let _ = channel.wait_close();
|
||||
connection.session.set_timeout(restore_ms);
|
||||
return Err("命令已终止".to_string());
|
||||
}
|
||||
|
||||
let mut stderr = Vec::new();
|
||||
loop {
|
||||
if cancel.load(Ordering::Acquire) {
|
||||
let _ = channel.close();
|
||||
let _ = channel.wait_close();
|
||||
connection.session.set_timeout(restore_ms);
|
||||
return Err("命令已终止".to_string());
|
||||
}
|
||||
match channel.stderr().read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(length) => stderr.extend_from_slice(&buffer[..length]),
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
|
||||
Err(error) if error.kind() == io::ErrorKind::TimedOut => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = channel.wait_close();
|
||||
let exit_code = channel.exit_status().unwrap_or(-1);
|
||||
connection.session.set_timeout(restore_ms);
|
||||
|
||||
let mut text = String::from_utf8_lossy(&output).into_owned();
|
||||
if !stderr.is_empty() {
|
||||
output.push_str(&stderr);
|
||||
text.push_str(&String::from_utf8_lossy(&stderr));
|
||||
}
|
||||
Ok((text, exit_code))
|
||||
}
|
||||
|
||||
/// Active-tab status bar snapshot. Skips when Agent/`ssh_exec` holds the connection.
|
||||
pub fn host_metrics(&self, connection_id: &str) -> HostMetricsResponse {
|
||||
let busy = match self.active_execs.lock() {
|
||||
Ok(map) => map.contains_key(connection_id),
|
||||
Err(_) => true,
|
||||
};
|
||||
if busy {
|
||||
return HostMetricsResponse::skipped();
|
||||
}
|
||||
|
||||
let command = format!("sh -c {}", shell_single_quote(HOST_METRICS_SCRIPT));
|
||||
match self.exec_with_status(connection_id, &command) {
|
||||
Ok((raw, _)) => HostMetricsResponse::ok(parse_host_metrics(&raw)),
|
||||
Err(error) => HostMetricsResponse::error(error),
|
||||
}
|
||||
Ok((output, exit_code))
|
||||
}
|
||||
|
||||
pub fn keepalive(&self, connection_id: &str) -> Result<(), String> {
|
||||
@@ -166,6 +289,10 @@ impl SshService {
|
||||
let mut channel = session
|
||||
.channel_session()
|
||||
.map_err(|error| format!("创建 Shell 通道失败: {error}"))?;
|
||||
// Best-effort: many servers only AcceptEnv LANG/LC_*. Ignore failures.
|
||||
let _ = channel.setenv("COLORTERM", "truecolor");
|
||||
let _ = channel.setenv("TERM", "xterm-256color");
|
||||
let _ = channel.setenv("LANG", &utf8_lang_for_ssh());
|
||||
channel
|
||||
.request_pty("xterm-256color", None, Some((cols, rows, 0, 0)))
|
||||
.map_err(|error| format!("申请远程 PTY 失败: {error}"))?;
|
||||
@@ -175,6 +302,8 @@ impl SshService {
|
||||
session.set_blocking(false);
|
||||
|
||||
let shell_id = preferred_id.unwrap_or_else(|| format!("shell-{}", Uuid::new_v4()));
|
||||
let output_live = Arc::new(AtomicBool::new(false));
|
||||
let pending_output = Arc::new(Mutex::new(Vec::new()));
|
||||
let reader = channel.clone();
|
||||
self.shells.lock().map_err(lock_error)?.insert(
|
||||
shell_id.clone(),
|
||||
@@ -182,6 +311,8 @@ impl SshService {
|
||||
connection_id: connection_id.to_string(),
|
||||
session,
|
||||
channel,
|
||||
output_live: Arc::clone(&output_live),
|
||||
pending_output: Arc::clone(&pending_output),
|
||||
},
|
||||
);
|
||||
spawn_shell_reader(
|
||||
@@ -190,10 +321,43 @@ impl SshService {
|
||||
connection_id.to_string(),
|
||||
shell_id.clone(),
|
||||
reader,
|
||||
output_live,
|
||||
pending_output,
|
||||
);
|
||||
Ok(shell_id)
|
||||
}
|
||||
|
||||
/// Flush MOTD/banner buffered before the UI subscribed to `ssh:shell-data`, then go live.
|
||||
pub fn shell_open_output(&self, shell_id: &str) -> Result<Vec<u8>, String> {
|
||||
let (output_live, pending_output) = {
|
||||
let shells = self.shells.lock().map_err(lock_error)?;
|
||||
let shell = shells
|
||||
.get(shell_id)
|
||||
.ok_or_else(|| format!("远程 Shell 不存在: {shell_id}"))?;
|
||||
(
|
||||
Arc::clone(&shell.output_live),
|
||||
Arc::clone(&shell.pending_output),
|
||||
)
|
||||
};
|
||||
|
||||
// Give the server a short window to push MOTD / lastlog before we go live.
|
||||
let deadline = std::time::Instant::now() + Duration::from_millis(300);
|
||||
while std::time::Instant::now() < deadline {
|
||||
let has_data = pending_output
|
||||
.lock()
|
||||
.map(|guard| !guard.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_data {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
|
||||
let mut pending = pending_output.lock().map_err(lock_error)?;
|
||||
output_live.store(true, Ordering::Release);
|
||||
Ok(std::mem::take(&mut *pending))
|
||||
}
|
||||
|
||||
pub fn shell_write(&self, shell_id: &str, data: &[u8]) -> Result<(), String> {
|
||||
let mut shells = self.shells.lock().map_err(lock_error)?;
|
||||
let shell = shells
|
||||
@@ -329,6 +493,8 @@ fn spawn_shell_reader(
|
||||
connection_id: String,
|
||||
shell_id: String,
|
||||
mut channel: ssh2::Channel,
|
||||
output_live: Arc<AtomicBool>,
|
||||
pending_output: Arc<Mutex<Vec<u8>>>,
|
||||
) {
|
||||
thread::spawn(move || {
|
||||
let mut buffer = [0_u8; 8192];
|
||||
@@ -339,15 +505,35 @@ fn spawn_shell_reader(
|
||||
Ok(0) if channel.eof() => break,
|
||||
Ok(0) => thread::sleep(Duration::from_millis(8)),
|
||||
Ok(length) => {
|
||||
append_output_tail(&mut output_tail, &buffer[..length]);
|
||||
let _ = app.emit(
|
||||
"ssh:shell-data",
|
||||
ShellDataEvent {
|
||||
connection_id: connection_id.clone(),
|
||||
shell_id: shell_id.clone(),
|
||||
data: buffer[..length].to_vec(),
|
||||
},
|
||||
);
|
||||
let chunk = &buffer[..length];
|
||||
append_output_tail(&mut output_tail, chunk);
|
||||
let should_emit = {
|
||||
let mut pending = match pending_output.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => break,
|
||||
};
|
||||
if output_live.load(Ordering::Acquire) {
|
||||
true
|
||||
} else {
|
||||
// Cap bootstrap buffer so a stuck UI cannot grow forever.
|
||||
const MAX_PENDING: usize = 256 * 1024;
|
||||
if pending.len() < MAX_PENDING {
|
||||
let room = MAX_PENDING - pending.len();
|
||||
pending.extend_from_slice(&chunk[..length.min(room)]);
|
||||
}
|
||||
false
|
||||
}
|
||||
};
|
||||
if should_emit {
|
||||
let _ = app.emit(
|
||||
"ssh:shell-data",
|
||||
ShellDataEvent {
|
||||
connection_id: connection_id.clone(),
|
||||
shell_id: shell_id.clone(),
|
||||
data: chunk.to_vec(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(8));
|
||||
@@ -454,6 +640,26 @@ fn lock_error<T>(_: std::sync::PoisonError<T>) -> String {
|
||||
"SSH 服务状态锁异常".to_string()
|
||||
}
|
||||
|
||||
fn shell_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
/// Best-effort UTF-8 LANG for remote AcceptEnv (mirrors local_terminal).
|
||||
fn utf8_lang_for_ssh() -> String {
|
||||
if let Ok(lang) = std::env::var("LANG") {
|
||||
let upper = lang.to_ascii_uppercase();
|
||||
if upper.contains("UTF-8") || upper.contains("UTF8") {
|
||||
return lang;
|
||||
}
|
||||
if let Some(base) = lang.split('.').next() {
|
||||
if !base.is_empty() {
|
||||
return format!("{base}.UTF-8");
|
||||
}
|
||||
}
|
||||
}
|
||||
"en_US.UTF-8".to_string()
|
||||
}
|
||||
|
||||
fn relay_channel(session: Session, mut channel: ssh2::Channel, mut socket: TcpStream) {
|
||||
session.set_blocking(false);
|
||||
let _ = socket.set_nonblocking(true);
|
||||
|
||||
Reference in new issue
Block a user