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

+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,