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
@@ -25,6 +25,11 @@ use model::{descriptor, ConnectionGuard, TunnelCounters, TunnelRuntime};
|
||||
use relay::relay_channel;
|
||||
use socks5::read_target;
|
||||
|
||||
enum IdClaim {
|
||||
Fresh(String),
|
||||
Existing(ForwardStatus),
|
||||
}
|
||||
|
||||
/// Owns forwarding listeners independently from persisted forwarding rules.
|
||||
/// A rule may exist without a runtime; a runtime always has a cancellable id.
|
||||
#[derive(Default)]
|
||||
@@ -43,7 +48,10 @@ impl TunnelManager {
|
||||
remote_port: u16,
|
||||
) -> Result<ForwardStatus, String> {
|
||||
validate_target(&remote_host, remote_port)?;
|
||||
let forward_id = self.available_id(forward_id)?;
|
||||
let forward_id = match self.claim_id(forward_id, connection_id, "local")? {
|
||||
IdClaim::Existing(status) => return Ok(status),
|
||||
IdClaim::Fresh(id) => id,
|
||||
};
|
||||
let listener = TcpListener::bind(("127.0.0.1", local_port))
|
||||
.map_err(|error| format!("监听本地端口失败: {error}"))?;
|
||||
listener
|
||||
@@ -119,7 +127,10 @@ impl TunnelManager {
|
||||
config: SshConnectRequest,
|
||||
local_port: u16,
|
||||
) -> Result<ForwardStatus, String> {
|
||||
let forward_id = self.available_id(forward_id)?;
|
||||
let forward_id = match self.claim_id(forward_id, connection_id, "dynamic")? {
|
||||
IdClaim::Existing(status) => return Ok(status),
|
||||
IdClaim::Fresh(id) => id,
|
||||
};
|
||||
let listener = TcpListener::bind(("127.0.0.1", local_port))
|
||||
.map_err(|error| format!("监听 SOCKS5 端口失败: {error}"))?;
|
||||
listener
|
||||
@@ -216,7 +227,10 @@ impl TunnelManager {
|
||||
local_port: u16,
|
||||
) -> Result<ForwardStatus, String> {
|
||||
validate_target(&local_host, local_port)?;
|
||||
let forward_id = self.available_id(forward_id)?;
|
||||
let forward_id = match self.claim_id(forward_id, connection_id, "remote")? {
|
||||
IdClaim::Existing(status) => return Ok(status),
|
||||
IdClaim::Fresh(id) => id,
|
||||
};
|
||||
let session = establish_session(&config)?;
|
||||
let (mut listener, bound_port) = session
|
||||
.channel_forward_listen(remote_port, None, None)
|
||||
@@ -322,14 +336,36 @@ impl TunnelManager {
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
fn available_id(&self, requested: Option<String>) -> Result<String, String> {
|
||||
/// Claim a forward id for start.
|
||||
/// - Same connection + kind already running → return existing status (idempotent).
|
||||
/// - Same id owned by another connection (or different kind) → stop stale runtime and reuse.
|
||||
/// Session-scoped rules keep a stable id across reconnects; orphans must not block start.
|
||||
fn claim_id(
|
||||
&self,
|
||||
requested: Option<String>,
|
||||
connection_id: &str,
|
||||
kind: &str,
|
||||
) -> Result<IdClaim, String> {
|
||||
let id = requested
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("forward-{}", Uuid::new_v4()));
|
||||
if self.runtimes.lock().map_err(lock_error)?.contains_key(&id) {
|
||||
return Err(format!("端口转发已在运行: {id}"));
|
||||
let stale = {
|
||||
let mut runtimes = self.runtimes.lock().map_err(lock_error)?;
|
||||
match runtimes.get(&id) {
|
||||
Some(runtime)
|
||||
if runtime.descriptor.connection_id == connection_id
|
||||
&& runtime.descriptor.kind == kind =>
|
||||
{
|
||||
return Ok(IdClaim::Existing(runtime.snapshot()));
|
||||
}
|
||||
Some(_) => runtimes.remove(&id),
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
if let Some(runtime) = stale {
|
||||
runtime.stop();
|
||||
}
|
||||
Ok(id)
|
||||
Ok(IdClaim::Fresh(id))
|
||||
}
|
||||
|
||||
fn register(&self, id: String, runtime: TunnelRuntime) -> Result<ForwardStatus, String> {
|
||||
@@ -408,9 +444,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_runtime_id_is_rejected() {
|
||||
fn duplicate_runtime_id_is_idempotent_for_same_connection() {
|
||||
let manager = TunnelManager::default();
|
||||
manager
|
||||
let first = manager
|
||||
.start_dynamic(
|
||||
Some("same-id".to_string()),
|
||||
"connection-test",
|
||||
@@ -418,15 +454,46 @@ mod tests {
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let error = manager
|
||||
let second = manager
|
||||
.start_dynamic(
|
||||
Some("same-id".to_string()),
|
||||
"connection-test",
|
||||
unreachable_config(),
|
||||
0,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("已在运行"));
|
||||
.unwrap();
|
||||
assert_eq!(first.forward_id, second.forward_id);
|
||||
assert_eq!(first.bound_port, second.bound_port);
|
||||
assert_eq!(manager.list(Some("connection-test")).unwrap().len(), 1);
|
||||
manager.stop_connection("connection-test").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_runtime_id_from_other_connection_is_reclaimed() {
|
||||
let manager = TunnelManager::default();
|
||||
let first = manager
|
||||
.start_dynamic(
|
||||
Some("shared-rule".to_string()),
|
||||
"connection-old",
|
||||
unreachable_config(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let second = manager
|
||||
.start_dynamic(
|
||||
Some("shared-rule".to_string()),
|
||||
"connection-new",
|
||||
unreachable_config(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(second.forward_id, "shared-rule");
|
||||
assert_eq!(second.connection_id, "connection-new");
|
||||
assert!(manager.list(Some("connection-old")).unwrap().is_empty());
|
||||
assert_eq!(manager.list(Some("connection-new")).unwrap().len(), 1);
|
||||
// Old listener was stopped; new bind may reuse the port or pick another (port 0).
|
||||
assert_ne!(second.bound_port, 0);
|
||||
let _ = first;
|
||||
manager.stop_connection("connection-new").unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user