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

+176
View File
@@ -0,0 +1,176 @@
//! One-shot remote host snapshot for the status bar (active SSH tab only).
use crate::domain::ssh::{HostDiskMetric, HostMetrics};
/// POSIX-ish script: timezone (IANA + offset), CPU%, mem%, disk top3.
/// Uses `/proc` when present; `sleep 1` for a cheap CPU delta sample.
pub const HOST_METRICS_SCRIPT: &str = r#"
ZONE=$(timedatectl show -p Timezone --value 2>/dev/null \
|| cat /etc/timezone 2>/dev/null \
|| readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||' \
|| echo -)
OFF=$(date +%z 2>/dev/null || echo -)
CPU=-
if [ -r /proc/stat ]; then
# Aggregate "cpu " line = sum of all cores (same as top/htop summary).
# busy% = 100 * (total - idle - iowait) / total → always 0–100, not N×100.
set -- $(awk '/^cpu /{
idle=$5; iow=$6; total=0;
for (i=2;i<=NF;i++) total+=$i;
print total, idle+iow; exit
}' /proc/stat)
T1=$1; I1=$2
sleep 1
set -- $(awk '/^cpu /{
idle=$5; iow=$6; total=0;
for (i=2;i<=NF;i++) total+=$i;
print total, idle+iow; exit
}' /proc/stat)
T2=$1; I2=$2
DT=$((T2-T1)); DI=$((I2-I1))
if [ "$DT" -gt 0 ] 2>/dev/null; then
BUSY=$((DT-DI))
if [ "$BUSY" -lt 0 ] 2>/dev/null; then BUSY=0; fi
CPU=$(awk -v busy="$BUSY" -v dt="$DT" 'BEGIN {
v = 100.0 * busy / dt
if (v < 0) v = 0
if (v > 100) v = 100
printf "%.1f", v
}')
fi
fi
MEM_PCT=-; MEM_USED=-; MEM_TOTAL=-
if [ -r /proc/meminfo ]; then
MT=$(awk '/^MemTotal:/{print $2; exit}' /proc/meminfo)
MA=$(awk '/^MemAvailable:/{print $2; exit}' /proc/meminfo)
if [ -n "$MT" ] && [ "$MT" -gt 0 ] 2>/dev/null && [ -n "$MA" ]; then
MU=$((MT-MA))
MEM_USED=$MU
MEM_TOTAL=$MT
MEM_PCT=$(( (100*MU)/MT ))
fi
fi
DISK=$(df -Pk 2>/dev/null | awk 'NR>1 && $1!~/^(tmpfs|devtmpfs|overlay|squashfs|udev|proc|sysfs)/ && $6!~/^\/(dev|run|sys|proc)/ {
p=$5; gsub(/%/,"",p); printf "%d %s %s %s\n", p+0, $6, $3, $2
}' | sort -nr | head -3 | awk '{printf "%s%s:%s:%s:%s", (NR>1?",":""), $2,$1,$3,$4}')
OS=$(awk -F= '/^PRETTY_NAME=/{
v=$0; sub(/^[^=]*=/, "", v); gsub(/^"/, "", v); gsub(/"$/, "", v); print v; exit
}' /etc/os-release 2>/dev/null || echo -)
printf "os=%s\ntz=%s\noff=%s\ncpu=%s\nmem_used=%s\nmem_total=%s\nmem_pct=%s\ndisk=%s\n" \
"$OS" "$ZONE" "$OFF" "$CPU" "$MEM_USED" "$MEM_TOTAL" "$MEM_PCT" "$DISK"
"#;
pub fn parse_host_metrics(raw: &str) -> HostMetrics {
let mut os_pretty_name = None;
let mut tz = None;
let mut tz_offset = None;
let mut cpu_percent = None;
let mut mem_used_kb = None;
let mut mem_total_kb = None;
let mut mem_percent = None;
let mut disks = Vec::new();
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim();
if value.is_empty() || value == "-" {
continue;
}
match key {
"os" => os_pretty_name = Some(value.to_string()),
"tz" => tz = Some(value.to_string()),
"off" => tz_offset = Some(value.to_string()),
"cpu" => {
cpu_percent = value.parse::<f32>().ok().map(|v| {
if v.is_finite() {
(v.clamp(0.0, 100.0) * 10.0).round() / 10.0
} else {
0.0
}
})
}
"mem_used" => mem_used_kb = value.parse().ok(),
"mem_total" => mem_total_kb = value.parse().ok(),
"mem_pct" => mem_percent = value.parse::<u8>().ok().map(|v| v.min(100)),
"disk" => {
disks = value
.split(',')
.filter_map(|entry| {
let mut parts = entry.split(':');
let mount = parts.next()?.to_string();
if mount.is_empty() {
return None;
}
let percent = parts.next()?.parse::<u8>().ok()?.min(100);
let used_kb = parts.next()?.parse().ok()?;
let total_kb = parts.next()?.parse().ok()?;
Some(HostDiskMetric {
mount,
percent,
used_kb,
total_kb,
})
})
.take(3)
.collect();
}
_ => {}
}
}
HostMetrics {
os_pretty_name,
timezone: tz,
timezone_offset: tz_offset,
uptime_sec: None,
cpu_percent,
mem_used_kb,
mem_total_kb,
mem_percent,
disks,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_snapshot() {
let raw = "\
os=Ubuntu 24.04.4 LTS
tz=Asia/Shanghai
off=+0800
cpu=23.4
mem_used=4096000
mem_total=8192000
mem_pct=50
disk=/:78:1000:2000,/data:45:500:1000,/var:30:100:400
";
let m = parse_host_metrics(raw);
assert_eq!(m.os_pretty_name.as_deref(), Some("Ubuntu 24.04.4 LTS"));
assert_eq!(m.timezone.as_deref(), Some("Asia/Shanghai"));
assert_eq!(m.timezone_offset.as_deref(), Some("+0800"));
assert_eq!(m.uptime_sec, None);
assert_eq!(m.cpu_percent, Some(23.4));
assert_eq!(m.mem_percent, Some(50));
assert_eq!(m.disks.len(), 3);
assert_eq!(m.disks[0].mount, "/");
assert_eq!(m.disks[0].percent, 78);
assert_eq!(m.disks[1].mount, "/data");
}
#[test]
fn ignores_missing_fields() {
let m = parse_host_metrics("tz=-\ncpu=-\n");
assert!(m.timezone.is_none());
assert!(m.cpu_percent.is_none());
assert!(m.disks.is_empty());
}
}
+319 -12
View File
@@ -1,11 +1,109 @@
use std::fs;
//! Host key verification against `~/.ssh/known_hosts` with UI confirmation.
//!
//! Unknown / changed keys emit `ssh:host-key-prompt` and block until the UI
//! responds (or the wait times out). Silent TOFU is intentionally not used.
use std::{
collections::{HashMap, HashSet},
fs,
sync::{Mutex, OnceLock},
time::Duration,
};
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine};
use serde::Serialize;
use sha2::{Digest, Sha256};
use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, KnownHostKeyFormat, Session};
use tauri::{AppHandle, Emitter};
use uuid::Uuid;
const PROMPT_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostKeyDecision {
AcceptOnce,
AcceptAlways,
Reject,
}
impl HostKeyDecision {
pub fn parse(value: &str) -> Option<Self> {
match value {
"accept-once" => Some(Self::AcceptOnce),
"accept-always" => Some(Self::AcceptAlways),
"reject" => Some(Self::Reject),
_ => None,
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct HostKeyPromptEvent {
request_id: String,
host: String,
port: u16,
key_type: String,
fingerprint: String,
is_changed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
previous_fingerprint: Option<String>,
}
struct PendingPrompt {
tx: std::sync::mpsc::Sender<HostKeyDecision>,
}
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
static PENDING: OnceLock<Mutex<HashMap<String, PendingPrompt>>> = OnceLock::new();
/// Process-lifetime accept-once cache so tunnels/reconnects do not re-prompt.
static ONCE_TRUSTED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
fn pending_map() -> &'static Mutex<HashMap<String, PendingPrompt>> {
PENDING.get_or_init(|| Mutex::new(HashMap::new()))
}
fn once_trusted() -> &'static Mutex<HashSet<String>> {
ONCE_TRUSTED.get_or_init(|| Mutex::new(HashSet::new()))
}
fn once_key(host: &str, port: u16, fingerprint: &str) -> String {
format!("{host}:{port}:{fingerprint}")
}
/// Bind the app handle once at startup so SSH workers can emit prompts.
pub fn bind_app_handle(app: AppHandle) {
let _ = APP_HANDLE.set(app);
}
pub fn respond(request_id: &str, decision: HostKeyDecision) -> Result<(), String> {
let mut pending = pending_map()
.lock()
.map_err(|_| "主机密钥确认队列异常".to_string())?;
let Some(entry) = pending.remove(request_id) else {
return Err(format!("主机密钥确认请求不存在或已过期: {request_id}"));
};
entry
.tx
.send(decision)
.map_err(|_| "主机密钥确认通道已关闭".to_string())
}
pub(super) fn verify_host_key(session: &Session, host: &str, port: u16) -> Result<(), String> {
let (key, key_type) = session
.host_key()
.ok_or_else(|| "服务器未提供主机密钥".to_string())?;
let fingerprint = sha256_fingerprint(key);
let key_type_name = host_key_type_name(key_type);
if once_trusted()
.lock()
.map_err(|_| "主机密钥缓存异常".to_string())?
.contains(&once_key(host, port, &fingerprint))
{
return Ok(());
}
let Some(home) = dirs::home_dir() else {
return Err("无法确定用户主目录,不能校验 known_hosts".to_string());
};
@@ -20,24 +118,217 @@ pub(super) fn verify_host_key(session: &Session, host: &str, port: u16) -> Resul
.read_file(&path, KnownHostFileKind::OpenSSH)
.map_err(|error| format!("读取 known_hosts 失败: {error}"))?;
}
match known.check_port(host, port, key) {
CheckResult::Match => Ok(()),
CheckResult::Mismatch => Err(format!(
"主机密钥已变化,已拒绝连接。请确认安全后从 known_hosts 删除 {}:{} 的旧记录",
host, port
)),
CheckResult::Mismatch => {
let previous = lookup_stored_fingerprint(host, port);
let decision = prompt_user(
host,
port,
&key_type_name,
&fingerprint,
true,
previous.as_deref(),
)?;
match decision {
HostKeyDecision::Reject => {
Err(format!("主机密钥已变化,已拒绝连接({host}:{port})"))
}
HostKeyDecision::AcceptOnce => {
remember_once(host, port, &fingerprint);
Ok(())
}
HostKeyDecision::AcceptAlways => {
remove_known_host_entries(host, port)?;
// Reload after deletion so add does not keep the stale key.
let mut known = session
.known_hosts()
.map_err(|error| format!("初始化 known_hosts 失败: {error}"))?;
if path.is_file() {
known
.read_file(&path, KnownHostFileKind::OpenSSH)
.map_err(|error| format!("读取 known_hosts 失败: {error}"))?;
}
write_known_host(&mut known, &path, host, port, key, key_type)?;
remember_once(host, port, &fingerprint);
Ok(())
}
}
}
CheckResult::NotFound | CheckResult::Failure => {
let host_label = known_host_label(host, port);
known
.add(&host_label, key, &host_label, known_host_format(key_type))
.map_err(|error| format!("记录主机密钥失败: {error}"))?;
known
.write_file(&path, KnownHostFileKind::OpenSSH)
.map_err(|error| format!("写入 known_hosts 失败: {error}"))
let decision = prompt_user(host, port, &key_type_name, &fingerprint, false, None)?;
match decision {
HostKeyDecision::Reject => Err(format!("用户拒绝了主机密钥({host}:{port})")),
HostKeyDecision::AcceptOnce => {
remember_once(host, port, &fingerprint);
Ok(())
}
HostKeyDecision::AcceptAlways => {
write_known_host(&mut known, &path, host, port, key, key_type)?;
remember_once(host, port, &fingerprint);
Ok(())
}
}
}
}
}
fn prompt_user(
host: &str,
port: u16,
key_type: &str,
fingerprint: &str,
is_changed: bool,
previous_fingerprint: Option<&str>,
) -> Result<HostKeyDecision, String> {
let Some(app) = APP_HANDLE.get() else {
return Err(
"无法弹出主机密钥确认(应用未就绪)。请稍后重试,或手动将密钥写入 ~/.ssh/known_hosts"
.to_string(),
);
};
let request_id = Uuid::new_v4().to_string();
let (tx, rx) = std::sync::mpsc::channel();
pending_map()
.lock()
.map_err(|_| "主机密钥确认队列异常".to_string())?
.insert(request_id.clone(), PendingPrompt { tx });
let payload = HostKeyPromptEvent {
request_id: request_id.clone(),
host: host.to_string(),
port,
key_type: key_type.to_string(),
fingerprint: fingerprint.to_string(),
is_changed,
previous_fingerprint: previous_fingerprint.map(str::to_string),
};
if app.emit("ssh:host-key-prompt", payload).is_err() {
pending_map()
.lock()
.ok()
.map(|mut map| map.remove(&request_id));
return Err("发送主机密钥确认事件失败".to_string());
}
match rx.recv_timeout(PROMPT_TIMEOUT) {
Ok(decision) => Ok(decision),
Err(_) => {
pending_map()
.lock()
.ok()
.map(|mut map| map.remove(&request_id));
Err(format!(
"主机密钥确认超时({} 秒)",
PROMPT_TIMEOUT.as_secs()
))
}
}
}
fn remember_once(host: &str, port: u16, fingerprint: &str) {
if let Ok(mut set) = once_trusted().lock() {
set.insert(once_key(host, port, fingerprint));
}
}
fn write_known_host(
known: &mut ssh2::KnownHosts,
path: &std::path::Path,
host: &str,
port: u16,
key: &[u8],
key_type: HostKeyType,
) -> Result<(), String> {
let host_label = known_host_label(host, port);
known
.add(&host_label, key, &host_label, known_host_format(key_type))
.map_err(|error| format!("记录主机密钥失败: {error}"))?;
known
.write_file(path, KnownHostFileKind::OpenSSH)
.map_err(|error| format!("写入 known_hosts 失败: {error}"))
}
fn remove_known_host_entries(host: &str, port: u16) -> Result<(), String> {
let Some(path) = dirs::home_dir().map(|home| home.join(".ssh").join("known_hosts")) else {
return Ok(());
};
if !path.is_file() {
return Ok(());
}
let input = fs::read_to_string(&path).map_err(|error| error.to_string())?;
let label = known_host_label(host, port);
let retained = input
.lines()
.filter(|line| {
line.split_whitespace().next().map_or(true, |hosts| {
!hosts
.split(',')
.any(|candidate| candidate == label || (port == 22 && candidate == host))
})
})
.collect::<Vec<_>>()
.join("\n");
fs::write(
path,
if retained.is_empty() {
retained
} else {
format!("{retained}\n")
},
)
.map_err(|error| error.to_string())
}
fn lookup_stored_fingerprint(host: &str, port: u16) -> Option<String> {
let path = dirs::home_dir()?.join(".ssh").join("known_hosts");
if !path.is_file() {
return None;
}
let input = fs::read_to_string(path).ok()?;
let label = known_host_label(host, port);
for line in input
.lines()
.filter(|line| !line.trim_start().starts_with('#'))
{
let fields = line.split_whitespace().collect::<Vec<_>>();
if fields.len() < 3 || fields[0].starts_with('|') {
continue;
}
let matches = fields[0]
.split(',')
.any(|candidate| candidate == label || (port == 22 && candidate == host));
if !matches {
continue;
}
let Ok(key) = STANDARD_NO_PAD.decode(fields[2]) else {
continue;
};
return Some(sha256_fingerprint(&key));
}
None
}
fn sha256_fingerprint(key: &[u8]) -> String {
STANDARD_NO_PAD.encode(Sha256::digest(key))
}
fn host_key_type_name(key_type: HostKeyType) -> String {
match key_type {
HostKeyType::Rsa => "ssh-rsa",
HostKeyType::Dss => "ssh-dss",
HostKeyType::Ecdsa256 => "ecdsa-sha2-nistp256",
HostKeyType::Ecdsa384 => "ecdsa-sha2-nistp384",
HostKeyType::Ecdsa521 => "ecdsa-sha2-nistp521",
HostKeyType::Ed25519 => "ssh-ed25519",
HostKeyType::Unknown => "unknown",
}
.to_string()
}
fn known_host_label(host: &str, port: u16) -> String {
if port == 22 {
host.to_string()
@@ -67,4 +358,20 @@ mod tests {
assert_eq!(known_host_label("example.com", 22), "example.com");
assert_eq!(known_host_label("example.com", 2222), "[example.com]:2222");
}
#[test]
fn parses_host_key_decisions() {
assert_eq!(
HostKeyDecision::parse("accept-always"),
Some(HostKeyDecision::AcceptAlways)
);
assert_eq!(HostKeyDecision::parse("nope"), None);
}
#[test]
fn fingerprint_is_unpadded_sha256_base64() {
let fp = sha256_fingerprint(b"test-key");
assert!(!fp.contains('='));
assert!(!fp.is_empty());
}
}
+164
View File
@@ -75,6 +75,36 @@ impl SshService {
})
}
pub fn sftp_upload_dir(
&self,
app: &AppHandle,
sftp_id: &str,
local_dir: &str,
remote_dir: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
upload_dir(
&sftp,
&expand_home(local_dir),
remote_dir,
&mut |transferred, total| emit_progress(app, sftp_id, transferred, total),
)
}
pub fn sftp_download_dir(
&self,
app: &AppHandle,
sftp_id: &str,
remote_dir: &str,
local_dir: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
let local_dir = expand_home(local_dir);
download_dir(&sftp, remote_dir, &local_dir, &mut |transferred, total| {
emit_progress(app, sftp_id, transferred, total)
})
}
pub fn sftp_rename(
&self,
sftp_id: &str,
@@ -311,6 +341,140 @@ pub(super) fn download_file(
fs::rename(&partial_path, local_path).map_err(|error| format!("提交下载文件失败: {error}"))
}
fn join_remote(parent: &str, name: &str) -> String {
if parent.is_empty() || parent == "/" {
format!("/{name}")
} else {
format!("{}/{name}", parent.trim_end_matches('/'))
}
}
fn is_dir_mode(mode: u32) -> bool {
mode & 0o170000 == 0o040000
}
fn ensure_remote_dir(sftp: &ssh2::Sftp, remote_dir: &str) -> Result<(), String> {
let normalized = if remote_dir.is_empty() {
"/".to_string()
} else {
remote_dir.trim_end_matches('/').to_string()
};
if normalized.is_empty() || normalized == "/" {
return Ok(());
}
let mut parts = Vec::new();
for part in normalized.trim_start_matches('/').split('/') {
if part.is_empty() || part == "." {
continue;
}
if part == ".." {
return Err("远程目录路径不能包含 ..".into());
}
parts.push(part);
}
let mut current = String::new();
for part in parts {
current = if current.is_empty() {
format!("/{part}")
} else {
format!("{current}/{part}")
};
match sftp.lstat(Path::new(&current)) {
Ok(stat) if is_dir_mode(stat.perm.unwrap_or(0)) => continue,
Ok(_) => return Err(format!("远程路径已存在且不是目录: {current}")),
Err(_) => {
sftp.mkdir(Path::new(&current), 0o755)
.map_err(|error| format!("创建远程目录失败 ({current}): {error}"))?;
}
}
}
Ok(())
}
fn upload_dir(
sftp: &ssh2::Sftp,
local_dir: &Path,
remote_dir: &str,
progress: &mut dyn FnMut(u64, u64),
) -> Result<(), String> {
let meta = fs::metadata(local_dir)
.map_err(|error| format!("读取本地目录失败 ({}): {error}", local_dir.display()))?;
if !meta.is_dir() {
return Err(format!("本地路径不是目录: {}", local_dir.display()));
}
ensure_remote_dir(sftp, remote_dir)?;
let mut entries = fs::read_dir(local_dir)
.map_err(|error| format!("遍历本地目录失败 ({}): {error}", local_dir.display()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取本地目录项失败: {error}"))?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let file_type = entry
.file_type()
.map_err(|error| format!("读取本地文件类型失败: {error}"))?;
let name = entry.file_name().to_string_lossy().to_string();
if name == "." || name == ".." {
continue;
}
let local_child = entry.path();
let remote_child = join_remote(remote_dir, &name);
if file_type.is_dir() {
upload_dir(sftp, &local_child, &remote_child, progress)?;
} else if file_type.is_file() {
upload_file(sftp, &local_child, &remote_child, |transferred, total| {
progress(transferred, total)
})?;
}
// skip symlinks / special files for safety
}
Ok(())
}
fn download_dir(
sftp: &ssh2::Sftp,
remote_dir: &str,
local_dir: &Path,
progress: &mut dyn FnMut(u64, u64),
) -> Result<(), String> {
let stat = sftp
.lstat(Path::new(remote_dir))
.map_err(|error| format!("读取远程目录失败 ({remote_dir}): {error}"))?;
if !is_dir_mode(stat.perm.unwrap_or(0)) {
return Err(format!("远程路径不是目录: {remote_dir}"));
}
fs::create_dir_all(local_dir)
.map_err(|error| format!("创建本地目录失败 ({}): {error}", local_dir.display()))?;
let mut entries = sftp
.readdir(Path::new(remote_dir))
.map_err(|error| format!("遍历远程目录失败 ({remote_dir}): {error}"))?;
entries.sort_by_key(|(path, _)| path.file_name().map(|name| name.to_os_string()));
for (path, entry_stat) in entries {
let name = match path.file_name() {
Some(value) => value.to_string_lossy().to_string(),
None => continue,
};
if name == "." || name == ".." {
continue;
}
let remote_child = join_remote(remote_dir, &name);
let local_child = local_dir.join(&name);
if is_dir_mode(entry_stat.perm.unwrap_or(0)) {
download_dir(sftp, &remote_child, &local_child, progress)?;
} else {
download_file(sftp, &remote_child, &local_child, |transferred, total| {
progress(transferred, total)
})?;
}
}
Ok(())
}
pub(super) fn copy_with_progress(
reader: &mut impl Read,
writer: &mut impl Write,