feat(import): enhance WindTerm session import functionality

- Added support for importing WindTerm sessions with optional master password decryption.
- Implemented UI components for entering the WindTerm master password when required.
- Refactored session import logic to handle different sources and improved error handling.
- Updated tests to cover new WindTerm import scenarios, including encrypted auto-login and master password requirements.
This commit is contained in:
Kang
2026-08-02 11:01:50 +08:00
parent 9d540574c5
commit 0cc6bd2e6c
7 changed files with 781 additions and 66 deletions
+27 -4
View File
@@ -3551,6 +3551,15 @@ dependencies = [
"serde_json",
]
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "keccak"
version = "0.2.0"
@@ -3963,7 +3972,7 @@ dependencies = [
"module-lattice",
"pkcs8 0.11.0",
"rand_core 0.10.0",
"sha3",
"sha3 0.11.0",
]
[[package]]
@@ -4314,11 +4323,13 @@ dependencies = [
name = "nyaterm"
version = "1.1.18"
dependencies = [
"aes 0.8.4",
"aes-gcm 0.10.3",
"arboard",
"async-http-proxy",
"async-trait",
"base64 0.22.1",
"cbc 0.1.2",
"crypto_secretbox",
"dirs",
"encoding_rs",
@@ -4337,6 +4348,7 @@ dependencies = [
"nyaterm-otp",
"open",
"opendal",
"pbkdf2 0.12.2",
"portable-pty",
"quick-xml 0.40.1",
"rand 0.8.5",
@@ -4353,6 +4365,7 @@ dependencies = [
"serialport",
"sha1 0.10.6",
"sha2 0.10.9",
"sha3 0.10.9",
"ssh-key 0.6.7",
"strip-ansi-escapes",
"tauri",
@@ -6303,7 +6316,7 @@ dependencies = [
"hmac 0.13.0",
"inout 0.2.2",
"internal-russh-num-bigint",
"keccak",
"keccak 0.2.0",
"log",
"md5",
"ml-kem",
@@ -6329,7 +6342,7 @@ dependencies = [
"sec1 0.8.1",
"sha1 0.11.0",
"sha2 0.11.0",
"sha3",
"sha3 0.11.0",
"signature 3.0.0",
"spki 0.8.0",
"ssh-encoding 0.3.0",
@@ -7044,6 +7057,16 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest 0.10.7",
"keccak 0.1.6",
]
[[package]]
name = "sha3"
version = "0.11.0"
@@ -7051,7 +7074,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
dependencies = [
"digest 0.11.3",
"keccak",
"keccak 0.2.0",
]
[[package]]
+4
View File
@@ -110,6 +110,8 @@ tracing-appender = "0.2"
nucleo-matcher = "0.3"
dirs = "6.0.0"
aes-gcm = "0.10"
aes = "0.8"
cbc = "0.1"
base64 = "0.22"
crypto_secretbox = "0.1"
keyring = { version = "3.6", features = [
@@ -122,6 +124,8 @@ rand = "0.8"
rusty-leveldb = "4.0"
sha2 = "0.10"
sha1 = "0.10"
sha3 = "0.10"
pbkdf2 = "0.12"
zeroize = "1"
tauri-plugin-dialog = "2"
tauri-plugin-deep-link = "2"
+7 -2
View File
@@ -1,8 +1,13 @@
use crate::error::AppResult;
#[tauri::command]
pub fn import_sessions(app: tauri::AppHandle, file_path: String) -> AppResult<usize> {
let count = crate::core::importer::import_sessions(app.clone(), file_path)?;
pub fn import_sessions(
app: tauri::AppHandle,
file_path: String,
windterm_master_password: Option<String>,
) -> AppResult<usize> {
let count =
crate::core::importer::import_sessions(app.clone(), file_path, windterm_master_password)?;
tauri::async_runtime::spawn(async move {
crate::core::cloud_sync::notify_config_changed(&app).await;
});
+9 -2
View File
@@ -217,7 +217,11 @@ fn is_nyaterm_json_import(value: &serde_json::Value) -> bool {
// ── Tauri Command ───────────────────────────────────────────────────────────
pub fn import_sessions(app: tauri::AppHandle, file_path: String) -> AppResult<usize> {
pub fn import_sessions(
app: tauri::AppHandle,
file_path: String,
windterm_master_password: Option<String>,
) -> AppResult<usize> {
let path = Path::new(&file_path);
if path.is_dir() {
let count = import_legacy_sessions(&app, parse_finalshell(&file_path)?)?;
@@ -233,7 +237,10 @@ pub fn import_sessions(app: tauri::AppHandle, file_path: String) -> AppResult<us
} else if lower.ends_with(".mxtsessions") {
import_legacy_sessions(&app, parse_mobaxterm(&file_path)?)?
} else if lower.ends_with(".sessions") {
import_legacy_sessions(&app, parse_windterm(&file_path)?)?
import_prepared_nyaterm_json(
&app,
parse_windterm(&file_path, windterm_master_password.as_deref())?,
)?
} else if lower.ends_with(".xml") {
import_legacy_sessions(&app, parse_securecrt(&file_path)?)?
} else if lower.ends_with(".json") {
+195 -10
View File
@@ -166,7 +166,7 @@ mod tests {
#[test]
fn windterm_import_splits_user_at_host_targets() {
let sessions = parse_windterm_content(
let prepared = parse_windterm_content(
r#"
[
{
@@ -180,16 +180,22 @@ mod tests {
)
.expect("parse windterm sessions");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].name, "Prod web");
assert_eq!(sessions[0].host, "192.168.1.10");
assert_eq!(sessions[0].username, "deploy");
assert_eq!(sessions[0].port, 2222);
assert_eq!(prepared.connections.len(), 1);
assert_eq!(prepared.connections[0].name, "Prod web");
assert!(matches!(
&prepared.connections[0].config,
ConnectionType::Ssh {
host,
username,
port,
..
} if host == "192.168.1.10" && username == "deploy" && *port == 2222
));
}
#[test]
fn windterm_import_defaults_username_when_target_has_no_user() {
let sessions = parse_windterm_content(
let prepared = parse_windterm_content(
r#"
[
{
@@ -201,9 +207,176 @@ mod tests {
)
.expect("parse windterm sessions");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].host, "192.168.1.10");
assert_eq!(sessions[0].username, "root");
assert_eq!(prepared.connections.len(), 1);
assert!(matches!(
&prepared.connections[0].config,
ConnectionType::Ssh {
host, username, ..
} if host == "192.168.1.10" && username == "root"
));
}
#[test]
fn windterm_decrypts_encrypted_auto_login() {
let crypto = derive_windterm_crypto("fingerprint-1", "master-secret");
let encrypted = encrypt_windterm_auto_login_for_test(
r#"{"Password":"secret","PasswordEnabled":true,"session.user":"deploy"}"#,
&crypto,
);
let content = format!(
r#"
[
{{
"session.protocol": "SSH",
"session.target": "192.168.1.10",
"session.autoLogin": "{encrypted}"
}}
]
"#
);
let prepared =
parse_windterm_content_with_crypto(&content, Some(&crypto), None).expect("parse");
assert_eq!(prepared.connections.len(), 1);
assert!(matches!(
&prepared.connections[0].config,
ConnectionType::Ssh { username, .. } if username == "deploy"
));
let auth = prepared.connections[0].auth.as_ref().expect("auth");
assert_eq!(auth.mode, "password");
assert_eq!(auth.password.as_deref(), Some("test-encrypted:secret"));
}
#[test]
fn windterm_requires_master_password_when_enabled() {
let root = importer_test_dir("windterm-master-password-required");
let terminal = root.join("terminal");
std::fs::create_dir_all(&terminal).expect("create windterm profile");
let sessions_path = terminal.join("user.sessions");
std::fs::write(&sessions_path, "[]").expect("write sessions");
std::fs::write(
root.join("user.config"),
r#"{"application.fingerprint":"fingerprint-1","application.masterPassword":true}"#,
)
.expect("write config");
let error = parse_windterm(sessions_path.to_str().expect("utf8 path"), None).unwrap_err();
assert!(
error
.to_string()
.contains("WindTerm master password is required")
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn windterm_password_auth_uses_inline_encrypted_password() {
let prepared = parse_windterm_content(
r#"
[
{
"session.protocol": "SSH",
"session.target": "fallback@example.com",
"session.autoLogin": "{\"Password\":\"secret\",\"PasswordEnabled\":true,\"session.user\":\"deploy\"}"
}
]
"#,
)
.expect("parse windterm sessions");
assert!(prepared.passwords.is_empty());
assert!(prepared.ssh_keys.is_empty());
assert!(matches!(
&prepared.connections[0].config,
ConnectionType::Ssh { username, .. } if username == "deploy"
));
let auth = prepared.connections[0].auth.as_ref().expect("auth");
assert_eq!(auth.mode, "password");
assert!(auth.password_id.is_none());
assert_eq!(auth.password.as_deref(), Some("test-encrypted:secret"));
}
#[test]
fn windterm_key_auth_reads_key_file_and_reuses_matching_keys() {
let root = importer_test_dir("windterm-key-auth");
let terminal = root.join("terminal");
std::fs::create_dir_all(&terminal).expect("create windterm profile");
let key_path = root.join("id_ed25519");
std::fs::write(&key_path, "private key material").expect("write key");
let escaped_key_path =
serde_json::to_string(&key_path.to_string_lossy()).expect("escape key path");
let auto_login = serde_json::json!({
"Public Key": {
"windows.pass": "passphrase",
"windows.path": key_path.to_string_lossy(),
},
"session.user": "root",
})
.to_string();
let escaped_auto_login = serde_json::to_string(&auto_login).expect("escape auto login");
let content = format!(
r#"
[
{{
"session.protocol": "SSH",
"session.target": "one.example.com",
"session.label": "One",
"session.autoLogin": {escaped_auto_login}
}},
{{
"session.protocol": "SSH",
"session.target": "two.example.com",
"session.label": "Two",
"ssh.identityFilePath.windows": {escaped_key_path}
}}
]
"#
);
let sessions_path = terminal.join("user.sessions");
let prepared = parse_windterm_content_with_crypto(&content, None, Some(&sessions_path))
.expect("parse windterm sessions");
assert_eq!(prepared.connections.len(), 2);
assert_eq!(prepared.ssh_keys.len(), 1);
assert_eq!(
prepared.ssh_keys[0].key.as_deref(),
Some("test-encrypted:private key material")
);
assert_eq!(
prepared.ssh_keys[0].passphrase.as_deref(),
Some("test-encrypted:passphrase")
);
let first_auth = prepared.connections[0].auth.as_ref().expect("first auth");
let second_auth = prepared.connections[1].auth.as_ref().expect("second auth");
assert_eq!(first_auth.mode, "key");
assert_eq!(second_auth.mode, "key");
assert_eq!(first_auth.key_id, second_auth.key_id);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn windterm_unreadable_key_path_falls_back_to_none_auth() {
let prepared = parse_windterm_content(
r#"
[
{
"session.protocol": "SSH",
"session.target": "example.com",
"ssh.identityFilePath.windows": "Z:/definitely/missing/key"
}
]
"#,
)
.expect("parse windterm sessions");
assert!(prepared.ssh_keys.is_empty());
let auth = prepared.connections[0].auth.as_ref().expect("auth");
assert_eq!(auth.mode, "none");
assert!(auth.key_id.is_none());
}
#[test]
@@ -362,6 +535,18 @@ mod tests {
std::env::temp_dir().join(format!("nyaterm-importer-{name}-{nanos}"))
}
fn encrypt_windterm_auto_login_for_test(plaintext: &str, crypto: &WindtermCrypto) -> String {
use cbc::cipher::{BlockEncryptMut, KeyIvInit, block_padding::Pkcs7};
let mut buffer = vec![0_u8; plaintext.len() + WINDTERM_AES_IV_LENGTH];
buffer[..plaintext.len()].copy_from_slice(plaintext.as_bytes());
let ciphertext =
cbc::Encryptor::<aes::Aes256>::new(&crypto.key.into(), &crypto.iv.into())
.encrypt_padded_mut::<Pkcs7>(&mut buffer, plaintext.len())
.expect("encrypt windterm payload");
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ciphertext)
}
#[test]
fn nyaterm_json_sample_import_prepares_supported_shapes() {
crate::utils::crypto::set_master_password(None);
+411 -37
View File
@@ -1,14 +1,47 @@
fn parse_windterm(path: &str) -> AppResult<Vec<ImportedSession>> {
let content = std::fs::read_to_string(path)
.map_err(|e| AppError::Config(format!("Cannot read file: {e}")))?;
parse_windterm_content(&content)
use aes::Aes256;
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7};
use pbkdf2::pbkdf2_hmac;
use sha3::Sha3_512;
type Aes256CbcDecryptor = cbc::Decryptor<Aes256>;
const WINDTERM_PBKDF2_ITERATIONS: u32 = 100_000;
const WINDTERM_DERIVED_LENGTH: usize = 48;
const WINDTERM_AES_KEY_LENGTH: usize = 32;
const WINDTERM_AES_IV_LENGTH: usize = 16;
struct WindtermCrypto {
key: [u8; WINDTERM_AES_KEY_LENGTH],
iv: [u8; WINDTERM_AES_IV_LENGTH],
}
fn parse_windterm_content(content: &str) -> AppResult<Vec<ImportedSession>> {
fn parse_windterm(
path: &str,
windterm_master_password: Option<&str>,
) -> AppResult<PreparedJsonImport> {
let content = std::fs::read_to_string(path)
.map_err(|e| AppError::Config(format!("Cannot read file: {e}")))?;
let crypto = load_windterm_crypto(Path::new(path), windterm_master_password)?;
parse_windterm_content_with_crypto(&content, crypto.as_ref(), Some(Path::new(path)))
}
#[cfg(test)]
fn parse_windterm_content(content: &str) -> AppResult<PreparedJsonImport> {
parse_windterm_content_with_crypto(content, None, None)
}
fn parse_windterm_content_with_crypto(
content: &str,
crypto: Option<&WindtermCrypto>,
source_path: Option<&Path>,
) -> AppResult<PreparedJsonImport> {
let entries: Vec<serde_json::Value> = serde_json::from_str(&content)
.map_err(|e| AppError::Config(format!("Invalid WindTerm JSON: {e}")))?;
let mut sessions = Vec::new();
let mut groups = Vec::new();
let mut ssh_keys = Vec::new();
let mut key_ids: HashMap<(String, Option<String>), String> = HashMap::new();
let mut connections = Vec::new();
for entry in &entries {
let protocol = entry
@@ -19,12 +52,13 @@ fn parse_windterm_content(content: &str) -> AppResult<Vec<ImportedSession>> {
continue;
}
let auto_login = parse_windterm_auto_login(entry, crypto)?;
let target = entry
.get("session.target")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let (host, username) = parse_windterm_target(target);
let (host, target_username) = parse_windterm_target(target);
if host.is_empty() {
continue;
}
@@ -32,47 +66,78 @@ fn parse_windterm_content(content: &str) -> AppResult<Vec<ImportedSession>> {
let name = entry
.get("session.label")
.and_then(|v| v.as_str())
.and_then(normalize_windterm_string_ref)
.unwrap_or(&host)
.to_string();
let port: u16 = entry
.get("session.port")
.and_then(|v| v.as_u64())
.map_or(22, |p| p as u16);
let port = match entry.get("session.port").and_then(|v| v.as_u64()) {
Some(port) if (1..=u64::from(u16::MAX)).contains(&port) => port as u16,
Some(_) => continue,
None => 22,
};
let group_path = entry
.get("session.group")
let group_path = parse_windterm_group_path(entry);
if let Some(path) = &group_path {
if !groups.contains(path) {
groups.push(path.clone());
}
}
let username = auto_login
.as_ref()
.and_then(|payload| payload.get("session.user"))
.and_then(|value| value.as_str())
.and_then(normalize_windterm_string_ref)
.map(str::to_string)
.unwrap_or(target_username);
let description = entry
.get("session.description")
.and_then(|v| v.as_str())
.and_then(|s| {
let s = s.trim();
if s.is_empty() {
None
} else {
let segments: Vec<String> = s
.split('>')
.filter(|seg| !seg.is_empty())
.map(|seg| seg.trim().to_string())
.collect();
if segments.is_empty() {
None
} else {
Some(segments)
}
}
});
.and_then(normalize_windterm_string_ref)
.map(str::to_string);
let icon = entry
.get("session.icon")
.and_then(|v| v.as_str())
.and_then(normalize_windterm_string_ref)
.map(str::to_string);
let x11_forwarding = entry
.get("ssh.x11")
.and_then(|value| value.as_bool())
.unwrap_or(false);
let auth = prepare_windterm_auth(
entry,
auto_login.as_ref(),
source_path,
&name,
&mut ssh_keys,
&mut key_ids,
)?;
sessions.push(ImportedSession {
connections.push(PreparedJsonConnection {
name,
host,
port,
username,
auth_type: "password".to_string(),
config: ConnectionType::Ssh {
host,
port,
username,
backspace_mode: "del".to_string(),
x11_forwarding,
encoding: String::new(),
},
group_path,
description: None,
description,
sort_order: 0,
icon,
auth: Some(auth),
});
}
Ok(sessions)
Ok(PreparedJsonImport {
groups,
passwords: Vec::new(),
ssh_keys,
connections,
})
}
fn parse_windterm_target(target: &str) -> (String, String) {
@@ -85,4 +150,313 @@ fn parse_windterm_target(target: &str) -> (String, String) {
(target.to_string(), "root".to_string())
}
fn load_windterm_crypto(
sessions_path: &Path,
windterm_master_password: Option<&str>,
) -> AppResult<Option<WindtermCrypto>> {
let Some(profile_dir) = sessions_path.parent().and_then(Path::parent) else {
return Ok(None);
};
let config_path = profile_dir.join("user.config");
if !config_path.is_file() {
return Ok(None);
}
let content = std::fs::read_to_string(&config_path)
.map_err(|e| AppError::Config(format!("Cannot read WindTerm user.config: {e}")))?;
let config: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| AppError::Config(format!("Invalid WindTerm user.config: {e}")))?;
let fingerprint = config
.get("application.fingerprint")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::Config("WindTerm user.config is missing application.fingerprint".to_string())
})?;
let master_password_enabled = config
.get("application.masterPassword")
.and_then(|value| value.as_bool())
.unwrap_or(false);
if master_password_enabled && windterm_master_password.unwrap_or_default().is_empty() {
return Err(AppError::Config(
"WindTerm master password is required".to_string(),
));
}
Ok(Some(derive_windterm_crypto(
fingerprint,
windterm_master_password.unwrap_or_default(),
)))
}
fn derive_windterm_crypto(fingerprint: &str, master_password: &str) -> WindtermCrypto {
let mut material = [0_u8; WINDTERM_DERIVED_LENGTH];
pbkdf2_hmac::<Sha3_512>(
master_password.as_bytes(),
fingerprint.as_bytes(),
WINDTERM_PBKDF2_ITERATIONS,
&mut material,
);
let mut key = [0_u8; WINDTERM_AES_KEY_LENGTH];
key.copy_from_slice(&material[..WINDTERM_AES_KEY_LENGTH]);
let mut iv = [0_u8; WINDTERM_AES_IV_LENGTH];
iv.copy_from_slice(&material[WINDTERM_AES_KEY_LENGTH..]);
WindtermCrypto { key, iv }
}
fn parse_windterm_auto_login(
entry: &serde_json::Value,
crypto: Option<&WindtermCrypto>,
) -> AppResult<Option<serde_json::Map<String, serde_json::Value>>> {
let Some(raw) = entry
.get("session.autoLogin")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
if let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) {
return value.as_object().cloned().map(Some).ok_or_else(|| {
AppError::Config("WindTerm session.autoLogin must decode to a JSON object".to_string())
});
}
let crypto = crypto.ok_or_else(|| {
AppError::Config(
"WindTerm session.autoLogin is encrypted but user.config was not found".to_string(),
)
})?;
let ciphertext = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, raw)
.map_err(|_| AppError::Config("Failed to decode WindTerm autoLogin data".to_string()))?;
let plaintext = decrypt_windterm_auto_login(&ciphertext, crypto)?;
let plaintext = String::from_utf8(plaintext).map_err(|_| {
AppError::Config("Failed to decode decrypted WindTerm autoLogin text".to_string())
})?;
let value: serde_json::Value = serde_json::from_str(&plaintext)
.map_err(|_| AppError::Config("Failed to parse decrypted WindTerm autoLogin JSON".to_string()))?;
value.as_object().cloned().map(Some).ok_or_else(|| {
AppError::Config("WindTerm session.autoLogin must decode to a JSON object".to_string())
})
}
fn decrypt_windterm_auto_login(
ciphertext: &[u8],
crypto: &WindtermCrypto,
) -> AppResult<Vec<u8>> {
if ciphertext.is_empty() || ciphertext.len() % WINDTERM_AES_IV_LENGTH != 0 {
return Err(AppError::Config(
"Invalid WindTerm autoLogin ciphertext length".to_string(),
));
}
let mut buffer = ciphertext.to_vec();
Aes256CbcDecryptor::new(&crypto.key.into(), &crypto.iv.into())
.decrypt_padded_mut::<Pkcs7>(&mut buffer)
.map(|plaintext| plaintext.to_vec())
.map_err(|_| AppError::Config("Failed to decrypt WindTerm autoLogin data".to_string()))
}
fn parse_windterm_group_path(entry: &serde_json::Value) -> Option<Vec<String>> {
entry
.get("session.group")
.and_then(|v| v.as_str())
.and_then(|s| {
let segments: Vec<String> = s
.split('>')
.filter_map(|seg| normalize_windterm_string_ref(seg).map(str::to_string))
.collect();
if segments.is_empty() {
None
} else {
Some(segments)
}
})
}
fn prepare_windterm_auth(
entry: &serde_json::Value,
auto_login: Option<&serde_json::Map<String, serde_json::Value>>,
source_path: Option<&Path>,
session_name: &str,
ssh_keys: &mut Vec<config::SshKey>,
key_ids: &mut HashMap<(String, Option<String>), String>,
) -> AppResult<ConnectionAuth> {
if let Some(password) = auto_login
.and_then(|payload| {
let enabled = payload
.get("PasswordEnabled")
.and_then(|value| value.as_bool())
.unwrap_or(false);
if enabled {
payload.get("Password").and_then(|value| value.as_str())
} else {
None
}
})
.and_then(normalize_windterm_string_ref)
{
return Ok(ConnectionAuth {
mode: "password".to_string(),
password_id: None,
password: Some(encrypt_import_secret(password)?),
key_id: None,
otp_id: None,
auto_fill_otp: false,
has_password: false,
});
}
if let Some(key_id) = import_windterm_key(entry, auto_login, source_path, session_name, ssh_keys, key_ids)? {
return Ok(ConnectionAuth {
mode: "key".to_string(),
password_id: None,
password: None,
key_id: Some(key_id),
otp_id: None,
auto_fill_otp: false,
has_password: false,
});
}
Ok(ConnectionAuth {
mode: "none".to_string(),
password_id: None,
password: None,
key_id: None,
otp_id: None,
auto_fill_otp: false,
has_password: false,
})
}
fn import_windterm_key(
entry: &serde_json::Value,
auto_login: Option<&serde_json::Map<String, serde_json::Value>>,
source_path: Option<&Path>,
session_name: &str,
ssh_keys: &mut Vec<config::SshKey>,
key_ids: &mut HashMap<(String, Option<String>), String>,
) -> AppResult<Option<String>> {
let mut key_paths = Vec::new();
if let Some(path) = auto_login
.and_then(|payload| payload.get("Public Key"))
.and_then(|value| value.as_object())
.and_then(|object| object.get("windows.path"))
.and_then(|value| value.as_str())
.and_then(normalize_windterm_string_ref)
{
key_paths.push(path);
}
if let Some(path) = entry
.get("ssh.identityFilePath.windows")
.and_then(|value| value.as_str())
.and_then(normalize_windterm_string_ref)
{
if !key_paths.contains(&path) {
key_paths.push(path);
}
}
if key_paths.is_empty() {
return Ok(None);
}
let Some((key_path, resolved_path, key_content)) =
key_paths.into_iter().find_map(|path| {
let resolved = resolve_windterm_key_path(path, source_path)?;
let content = std::fs::read_to_string(&resolved).ok()?;
if content.trim().is_empty() {
None
} else {
Some((path, resolved, content))
}
})
else {
return Ok(None);
};
let passphrase = auto_login
.and_then(|payload| payload.get("Public Key"))
.and_then(|value| value.as_object())
.and_then(|object| object.get("windows.pass"))
.and_then(|value| value.as_str())
.and_then(normalize_windterm_string_ref)
.map(str::to_string);
let normalized_path = resolved_path.to_string_lossy().replace('/', "\\");
let dedupe_key = (normalized_path.clone(), passphrase.clone());
if let Some(id) = key_ids.get(&dedupe_key) {
return Ok(Some(id.clone()));
}
let id = uuid::Uuid::new_v4().to_string();
let display_name = Path::new(key_path)
.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.trim().is_empty())
.map_or_else(
|| format!("{session_name} SSH key"),
|file_name| format!("{session_name} ({file_name})"),
);
ssh_keys.push(config::SshKey {
id: id.clone(),
name: display_name,
key: Some(encrypt_import_secret(&key_content)?),
cert: None,
passphrase: passphrase
.as_deref()
.map(encrypt_import_secret)
.transpose()?,
key_data: None,
cert_data: None,
key_file_path: None,
cert_file_path: None,
has_key_data: false,
has_cert_data: false,
});
key_ids.insert(dedupe_key, id.clone());
Ok(Some(id))
}
fn resolve_windterm_key_path(path: &str, source_path: Option<&Path>) -> Option<std::path::PathBuf> {
let trimmed = path.trim();
if trimmed.is_empty() {
return None;
}
let home = dirs::home_dir();
let mut expanded = trimmed.to_string();
if let Some(home) = &home {
let home_str = home.to_string_lossy();
expanded = expanded
.replace("$(HomeDir)", &home_str)
.replace("${HomeDir}", &home_str);
if expanded == "~" {
expanded = home_str.to_string();
} else if let Some(rest) = expanded.strip_prefix("~/").or_else(|| expanded.strip_prefix("~\\")) {
expanded = home.join(rest).to_string_lossy().to_string();
}
}
let candidate = Path::new(&expanded);
if candidate.is_absolute() {
return Some(candidate.to_path_buf());
}
source_path
.and_then(Path::parent)
.map(|parent| parent.join(candidate))
.or_else(|| Some(candidate.to_path_buf()))
}
fn normalize_windterm_string_ref(value: &str) -> Option<&str> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
// ── NyaTerm JSON (.json) ───────────────────────────────────────────────────
@@ -1,16 +1,20 @@
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { ComponentType } from "react";
import { type ComponentType, useState } from "react";
import { useTranslation } from "react-i18next";
import { MdDataObject, MdOpenInNew, MdTerminal } from "react-icons/md";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useApp } from "@/context/AppContext";
import { useConfigTransfer } from "@/hooks/useConfigTransfer";
import { invoke } from "@/lib/invoke";
@@ -115,6 +119,9 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
const { i18n, t } = useTranslation();
const { refreshConnections } = useApp();
const { handleImport, passwordAlert } = useConfigTransfer();
const [windtermImportPath, setWindtermImportPath] = useState<string | null>(null);
const [windtermMasterPassword, setWindtermMasterPassword] = useState("");
const [windtermImporting, setWindtermImporting] = useState(false);
const docsUrl = i18n.language.toLowerCase().startsWith("zh")
? SESSION_IMPORT_DOC_URLS.zh
: SESSION_IMPORT_DOC_URLS.en;
@@ -128,6 +135,33 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
return <Icon className="h-10 w-10 text-[var(--df-primary)]" />;
};
const finishSessionImport = (count: number) => {
if (count > 0) {
toast.success(t("savedConnections.importSuccess", { count }));
refreshConnections();
} else {
toast.info(t("savedConnections.importSuccess", { count: 0 }));
}
};
const isWindtermMasterPasswordRequired = (error: unknown) =>
String(error).includes("WindTerm master password is required");
const importSelectedSessions = async (
source: ImportSource,
selected: string,
windtermPassword?: string,
) => {
const count =
source.id === "termius"
? await invoke<number>("import_termius_sessions", { indexedDbPath: selected })
: await invoke<number>("import_sessions", {
filePath: selected,
windtermMasterPassword: windtermPassword,
});
finishSessionImport(count);
};
const handleSelect = async (source: ImportSource) => {
onClose();
@@ -169,18 +203,16 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
filters: [{ name: source.name, extensions: source.extensions ?? [] }],
});
if (!selected) return;
const selectedPath = Array.isArray(selected) ? selected[0] : selected;
if (!selectedPath) return;
try {
const count =
source.id === "termius"
? await invoke<number>("import_termius_sessions", { indexedDbPath: selected })
: await invoke<number>("import_sessions", { filePath: selected });
if (count > 0) {
toast.success(t("savedConnections.importSuccess", { count }));
refreshConnections();
} else {
toast.info(t("savedConnections.importSuccess", { count: 0 }));
}
await importSelectedSessions(source, selectedPath);
} catch (e) {
if (source.id === "windterm" && isWindtermMasterPasswordRequired(e)) {
setWindtermImportPath(selectedPath);
setWindtermMasterPassword("");
return;
}
logger.error({
domain: "settings.persistence",
event: "sessions.import_failed",
@@ -191,6 +223,30 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
}
};
const handleWindtermPasswordSubmit = async () => {
if (!windtermImportPath || windtermImporting) return;
setWindtermImporting(true);
try {
await importSelectedSessions(
IMPORT_SOURCES.find((source) => source.id === "windterm")!,
windtermImportPath,
windtermMasterPassword,
);
setWindtermImportPath(null);
setWindtermMasterPassword("");
} catch (error) {
logger.error({
domain: "settings.persistence",
event: "sessions.import_windterm_failed",
message: "Import WindTerm sessions failed",
error,
});
toast.error(t("savedConnections.importFailed", { error }));
} finally {
setWindtermImporting(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
@@ -245,6 +301,67 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
</div>
</DialogContent>
</Dialog>
<Dialog
open={windtermImportPath !== null}
onOpenChange={(v) => {
if (!v && !windtermImporting) {
setWindtermImportPath(null);
setWindtermMasterPassword("");
}
}}
>
<DialogContent className="w-[min(420px,calc(100vw-2rem))] sm:max-w-[420px]">
<DialogHeader>
<DialogTitle className="text-sm">
{t("savedConnections.windtermMasterPasswordTitle")}
</DialogTitle>
<DialogDescription className="text-xs">
{t("savedConnections.windtermMasterPasswordDesc")}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="windterm-master-password" className="text-xs">
{t("savedConnections.windtermMasterPasswordLabel")}
</Label>
<Input
id="windterm-master-password"
type="password"
value={windtermMasterPassword}
disabled={windtermImporting}
autoFocus
onChange={(event) => setWindtermMasterPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleWindtermPasswordSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={windtermImporting}
onClick={() => {
setWindtermImportPath(null);
setWindtermMasterPassword("");
}}
>
{t("common.cancel")}
</Button>
<Button
type="button"
disabled={windtermImporting}
onClick={() => void handleWindtermPasswordSubmit()}
>
{windtermImporting
? t("savedConnections.windtermMasterPasswordImporting")
: t("savedConnections.windtermMasterPasswordConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{passwordAlert}
</>
);