From dd9d4c32fd6d95c01db01697bc5cf2f45a13c8e5 Mon Sep 17 00:00:00 2001 From: Kang Date: Wed, 4 Mar 2026 17:12:22 +0800 Subject: [PATCH] feat(import): add session import from Xshell, MobaXterm, and WindTerm Parse .xts, .mxtsessions, and .sessions formats, build group hierarchy, and add connections to config. Include SVG icons for each import source. Made-with: Cursor --- public/MobaXterm.svg | 27 +++ public/WindTerm.svg | 5 + public/Xshell.svg | 5 + src-tauri/src/import.rs | 430 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 467 insertions(+) create mode 100644 public/MobaXterm.svg create mode 100644 public/WindTerm.svg create mode 100644 public/Xshell.svg create mode 100644 src-tauri/src/import.rs diff --git a/public/MobaXterm.svg b/public/MobaXterm.svg new file mode 100644 index 00000000..e58f0b01 --- /dev/null +++ b/public/MobaXterm.svg @@ -0,0 +1,27 @@ + + + + diff --git a/public/WindTerm.svg b/public/WindTerm.svg new file mode 100644 index 00000000..b226356f --- /dev/null +++ b/public/WindTerm.svg @@ -0,0 +1,5 @@ + + + + diff --git a/public/Xshell.svg b/public/Xshell.svg new file mode 100644 index 00000000..458747fa --- /dev/null +++ b/public/Xshell.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src-tauri/src/import.rs b/src-tauri/src/import.rs new file mode 100644 index 00000000..a1694b6e --- /dev/null +++ b/src-tauri/src/import.rs @@ -0,0 +1,430 @@ +//! Import sessions from Xshell (.xts), MobaXterm (.mxtsessions), and WindTerm (.sessions) files. + +use crate::config::{self, Group, SavedConnection}; +use crate::error::{AppError, AppResult}; +use std::collections::HashMap; +use std::io::Read; +use std::path::Path; + +struct ImportedSession { + name: String, + host: String, + port: u16, + username: String, + auth_type: String, + /// Hierarchical group path segments, e.g. ["亚鸿", "湖北"]. + group_path: Option>, +} + +/// Detect BOM (UTF-8/UTF-16) and decode accordingly; fall back to GBK. +fn decode_bytes(raw: &[u8]) -> String { + if let Some((enc, bom_len)) = encoding_rs::Encoding::for_bom(raw) { + let (decoded, _, _) = enc.decode(&raw[bom_len..]); + return decoded.into_owned(); + } + match std::str::from_utf8(raw) { + Ok(s) => s.to_string(), + Err(_) => { + let (decoded, _, _) = encoding_rs::GBK.decode(raw); + decoded.into_owned() + } + } +} + +// ── Xshell (.xts) ────────────────────────────────────────────────────────── + +fn parse_xshell(path: &str) -> AppResult> { + let file = std::fs::File::open(path) + .map_err(|e| AppError::Config(format!("Cannot open file: {e}")))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| AppError::Config(format!("Invalid ZIP/XTS file: {e}")))?; + + let mut sessions = Vec::new(); + + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| AppError::Config(format!("ZIP entry error: {e}")))?; + + // ZIP filenames on Chinese Windows are typically GBK-encoded + let entry_path_raw = entry.name_raw().to_vec(); + let entry_path = decode_bytes(&entry_path_raw); + if !entry_path.ends_with(".xsh") { + continue; + } + + let mut raw = Vec::new(); + entry + .read_to_end(&mut raw) + .map_err(|e| AppError::Config(format!("Failed to read {entry_path}: {e}")))?; + + let content = decode_bytes(&raw); + + if let Some(sess) = parse_xsh_content(&content, &entry_path) { + sessions.push(sess); + } + } + + Ok(sessions) +} + +fn parse_xsh_content(content: &str, entry_path: &str) -> Option { + let sections = parse_ini_sections(content); + + let conn = sections.get("CONNECTION")?; + let protocol = conn.get("Protocol").map(String::as_str).unwrap_or(""); + if !protocol.eq_ignore_ascii_case("SSH") { + return None; + } + + let host = conn.get("Host")?.clone(); + if host.is_empty() { + return None; + } + + let port: u16 = conn + .get("Port") + .and_then(|p| p.parse().ok()) + .unwrap_or(22); + + let auth = sections.get("CONNECTION:AUTHENTICATION"); + let username = auth + .and_then(|a| a.get("UserName")) + .cloned() + .unwrap_or_else(|| "root".to_string()); + + let has_user_key = auth + .and_then(|a| a.get("UserKey")) + .is_some_and(|k| !k.is_empty()); + let auth_type = if has_user_key { "key" } else { "password" }.to_string(); + + let path_obj = Path::new(entry_path); + let name = path_obj + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unnamed") + .to_string(); + + let group_path = path_obj.parent().and_then(|p| { + let p_str = p.to_str().unwrap_or(""); + let stripped = p_str + .strip_prefix("Xshell/Sessions/") + .or_else(|| p_str.strip_prefix("Xshell/")) + .unwrap_or(p_str); + if stripped.is_empty() { + None + } else { + let segments: Vec = stripped + .split('/') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + if segments.is_empty() { None } else { Some(segments) } + } + }); + + Some(ImportedSession { + name, + host, + port, + username, + auth_type, + group_path, + }) +} + +fn parse_ini_sections(content: &str) -> HashMap> { + let mut sections: HashMap> = HashMap::new(); + let mut current_section = String::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with(';') || line.starts_with('#') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + current_section = line[1..line.len() - 1].to_string(); + sections.entry(current_section.clone()).or_default(); + } else if let Some((key, value)) = line.split_once('=') { + if let Some(section) = sections.get_mut(¤t_section) { + section.insert(key.trim().to_string(), value.trim().to_string()); + } + } + } + + sections +} + +// ── MobaXterm (.mxtsessions) ──────────────────────────────────────────────── + +fn parse_mobaxterm(path: &str) -> AppResult> { + let raw = std::fs::read(path) + .map_err(|e| AppError::Config(format!("Cannot read file: {e}")))?; + let content = decode_bytes(&raw); + + let sections = parse_ini_sections(&content); + let mut sessions = Vec::new(); + + for (section_name, entries) in §ions { + if !section_name.starts_with("Bookmarks") { + continue; + } + + let group_path = entries.get("SubRep").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) } + } + }); + + for (entry_name, value) in entries { + if entry_name == "SubRep" || entry_name == "ImgNum" { + continue; + } + + if let Some(sess) = parse_moba_entry(entry_name, value, &group_path) { + sessions.push(sess); + } + } + } + + Ok(sessions) +} + +fn parse_moba_entry( + name: &str, + value: &str, + group_path: &Option>, +) -> Option { + // Format: ##%host%port%username%... + let hash_parts: Vec<&str> = value.splitn(2, '#').skip(1).collect::>(); + if hash_parts.is_empty() { + return None; + } + + let after_hash = hash_parts.join("#"); + let type_and_rest: Vec<&str> = after_hash.splitn(2, '%').collect(); + if type_and_rest.len() < 2 { + return None; + } + + // Type 109 = SSH + let type_marker = type_and_rest[0]; + if !type_marker.starts_with("109") { + return None; + } + + let fields: Vec<&str> = type_and_rest[1].split('%').collect(); + if fields.len() < 3 { + return None; + } + + let host = fields[0].to_string(); + if host.is_empty() { + return None; + } + + let port: u16 = fields[1].parse().unwrap_or(22); + let username = if fields[2].is_empty() { + "root".to_string() + } else { + fields[2].to_string() + }; + + Some(ImportedSession { + name: name.to_string(), + host, + port, + username, + auth_type: "password".to_string(), + group_path: group_path.clone(), + }) +} + +// ── WindTerm (user.sessions) ──────────────────────────────────────────────── + +fn parse_windterm(path: &str) -> AppResult> { + let content = std::fs::read_to_string(path) + .map_err(|e| AppError::Config(format!("Cannot read file: {e}")))?; + + let entries: Vec = serde_json::from_str(&content) + .map_err(|e| AppError::Config(format!("Invalid WindTerm JSON: {e}")))?; + + let mut sessions = Vec::new(); + + for entry in &entries { + let protocol = entry + .get("session.protocol") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !protocol.eq_ignore_ascii_case("SSH") { + continue; + } + + let host = entry + .get("session.target") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if host.is_empty() { + continue; + } + + let name = entry + .get("session.label") + .and_then(|v| v.as_str()) + .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 group_path = entry + .get("session.group") + .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) } + } + }); + + sessions.push(ImportedSession { + name, + host, + port, + username: "root".to_string(), + auth_type: "password".to_string(), + group_path, + }); + } + + Ok(sessions) +} + +// ── Tauri Command ─────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn import_sessions(app: tauri::AppHandle, file_path: String) -> AppResult { + let lower = file_path.to_lowercase(); + let imported = if lower.ends_with(".xts") { + parse_xshell(&file_path)? + } else if lower.ends_with(".mxtsessions") { + parse_mobaxterm(&file_path)? + } else if lower.ends_with(".sessions") { + parse_windterm(&file_path)? + } else { + return Err(AppError::Config( + "Unsupported file format. Please use .xts (Xshell), .mxtsessions (MobaXterm), or .sessions (WindTerm)." + .to_string(), + )); + }; + + if imported.is_empty() { + return Ok(0); + } + + let mut cfg = config::load_config(&app)?; + let count = imported.len(); + + // Build a path -> id map of existing groups for dedup. + // Key: full path segments from root to this group. + let mut path_map: HashMap, String> = HashMap::new(); + + // Reconstruct paths for existing groups by walking parent_id chains + fn build_path(groups: &[Group], id: &str) -> Vec { + let mut segments = Vec::new(); + let mut current = id; + loop { + if let Some(g) = groups.iter().find(|g| g.id == current) { + segments.push(g.name.clone()); + if let Some(ref pid) = g.parent_id { + current = pid; + } else { + break; + } + } else { + break; + } + } + segments.reverse(); + segments + } + + for g in &cfg.groups { + let path = build_path(&cfg.groups, &g.id); + path_map.insert(path, g.id.clone()); + } + + let mut next_sort = cfg + .groups + .iter() + .map(|g| g.sort_order) + .max() + .unwrap_or(0) + + 1; + + for sess in imported { + let group_id = sess.group_path.map(|segments| { + let mut leaf_id = String::new(); + for depth in 1..=segments.len() { + let prefix: Vec = segments[..depth].to_vec(); + if let Some(existing) = path_map.get(&prefix) { + leaf_id = existing.clone(); + } else { + let id = uuid::Uuid::new_v4().to_string(); + let parent_id = if depth > 1 { + let parent_prefix: Vec = segments[..depth - 1].to_vec(); + path_map.get(&parent_prefix).cloned() + } else { + None + }; + cfg.groups.push(Group { + id: id.clone(), + name: segments[depth - 1].clone(), + parent_id, + sort_order: next_sort, + }); + next_sort += 1; + path_map.insert(prefix, id.clone()); + leaf_id = id; + } + } + leaf_id + }); + + cfg.connections.push(SavedConnection { + id: uuid::Uuid::new_v4().to_string(), + name: sess.name, + group_id, + description: None, + host: sess.host, + port: sess.port, + username: sess.username, + auth_type: sess.auth_type, + password: None, + key_id: None, + sort_order: 0, + icon: None, + }); + } + + config::save_config(&app, &cfg)?; + Ok(count) +}