From 0cc6bd2e6cdacdd8e4db3de28762aeb8d0d4fa01 Mon Sep 17 00:00:00 2001 From: Kang Date: Sun, 2 Aug 2026 11:01:50 +0800 Subject: [PATCH] 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. --- src-tauri/Cargo.lock | 31 +- src-tauri/Cargo.toml | 4 + src-tauri/src/cmd/importer.rs | 9 +- src-tauri/src/core/importer/merge.rs | 11 +- src-tauri/src/core/importer/tests.rs | 205 +++++++- src-tauri/src/core/importer/windterm.rs | 448 ++++++++++++++++-- .../dialog/connections/ImportDialog.tsx | 139 +++++- 7 files changed, 781 insertions(+), 66 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index eb96ee3fa..07f68c211 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 156ba532d..8f301b323 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/cmd/importer.rs b/src-tauri/src/cmd/importer.rs index 16d60e50c..c5fc3f44c 100644 --- a/src-tauri/src/cmd/importer.rs +++ b/src-tauri/src/cmd/importer.rs @@ -1,8 +1,13 @@ use crate::error::AppResult; #[tauri::command] -pub fn import_sessions(app: tauri::AppHandle, file_path: String) -> AppResult { - 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, +) -> AppResult { + 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; }); diff --git a/src-tauri/src/core/importer/merge.rs b/src-tauri/src/core/importer/merge.rs index 453a534f1..ddce14eba 100644 --- a/src-tauri/src/core/importer/merge.rs +++ b/src-tauri/src/core/importer/merge.rs @@ -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 { +pub fn import_sessions( + app: tauri::AppHandle, + file_path: String, + windterm_master_password: Option, +) -> AppResult { 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 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::::new(&crypto.key.into(), &crypto.iv.into()) + .encrypt_padded_mut::(&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); diff --git a/src-tauri/src/core/importer/windterm.rs b/src-tauri/src/core/importer/windterm.rs index 50e2c0113..55ac8d934 100644 --- a/src-tauri/src/core/importer/windterm.rs +++ b/src-tauri/src/core/importer/windterm.rs @@ -1,14 +1,47 @@ -fn parse_windterm(path: &str) -> AppResult> { - 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; + +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> { +fn parse_windterm( + path: &str, + windterm_master_password: Option<&str>, +) -> AppResult { + 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 { + parse_windterm_content_with_crypto(content, None, None) +} + +fn parse_windterm_content_with_crypto( + content: &str, + crypto: Option<&WindtermCrypto>, + source_path: Option<&Path>, +) -> AppResult { let entries: Vec = 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> = 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> { 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> { 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 = 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> { + 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::( + 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>> { + 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::(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> { + 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::(&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> { + entry + .get("session.group") + .and_then(|v| v.as_str()) + .and_then(|s| { + let segments: Vec = 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>, + source_path: Option<&Path>, + session_name: &str, + ssh_keys: &mut Vec, + key_ids: &mut HashMap<(String, Option), String>, +) -> AppResult { + 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>, + source_path: Option<&Path>, + session_name: &str, + ssh_keys: &mut Vec, + key_ids: &mut HashMap<(String, Option), String>, +) -> AppResult> { + 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 { + 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) ─────────────────────────────────────────────────── diff --git a/src/components/dialog/connections/ImportDialog.tsx b/src/components/dialog/connections/ImportDialog.tsx index a4d866288..35d9437fe 100644 --- a/src/components/dialog/connections/ImportDialog.tsx +++ b/src/components/dialog/connections/ImportDialog.tsx @@ -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(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 ; }; + 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("import_termius_sessions", { indexedDbPath: selected }) + : await invoke("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("import_termius_sessions", { indexedDbPath: selected }) - : await invoke("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 ( <> !v && onClose()}> @@ -245,6 +301,67 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) { + { + if (!v && !windtermImporting) { + setWindtermImportPath(null); + setWindtermMasterPassword(""); + } + }} + > + + + + {t("savedConnections.windtermMasterPasswordTitle")} + + + {t("savedConnections.windtermMasterPasswordDesc")} + + +
+ + setWindtermMasterPassword(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleWindtermPasswordSubmit(); + } + }} + /> +
+ + + + +
+
{passwordAlert} );