mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-10-07 00:01:05 +00:00
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:
Generated
+27
-4
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user