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:
Logan.Li committed 2026-08-12 00:07:48 +08:00
1 parent fea6d8db04
commit b0d0e3a5f3
301 files changed
+28002 -2705

No files matched your search

+229 -23
View File
@@ -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);