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
This commit is contained in:
Kang committed 2026-03-04 17:12:22 +08:00
1 parent 08af389843
commit dd9d4c32fd
4 files changed
+467

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve"> <image id="image0" width="32" height="32" x="0" y="0"
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAIGNIUk0AAHomAACAhAAA+gAAAIDo
AAB1MAAA6mAAADqYAAAXcJy6UTwAAAI9UExURQAAADEAAL0AALAaGvVYWL5bPbvxBLz9AWegAFSR
AAcNACcAALcAAIknC67pAbDtAWCgAD9vAAAAAB0AALEAAHEvAEZ2AKbgAZvWAWSsAG6+ACpKABUA
AFsuAEZ4AJ7UAYG6AGu5AGawABkqAAYJAD5qAEZ5AHHCAFiZAAwTAAMGADllAEl9AEh6AAAAAAAA
ADRaADJWAMzMzDU1NcjIyFtbW9TU1P7+/ra2tk5OTnR0dO7u7t7e3mhoaM/Pz/39/aKiov///8PD
w1VVVT9OZTxHV0dghmCe+16Y8T5MYTY3OU90rGGg/1+b9UFSazg2NqhLS6dLSzY1NTk+RlaGzlWK
2zI5REQ4OMtSUv9cXLRNTTc1NT1JW1yT5kyAzwZb4S46S1k8POVXV8BQUEhZeF+c92Gg/j1yxgBZ
53hCQvZaWv5cXKRlfWKg/l2a9RNn7BRMpTQ2OJ1JSfVaW4h1o1yc/i5boJ1DQ/dbW3WGyWKg+F+c
+EFTbqMQEMxJSbtcaWKe+oOyVqzjD2ihyWKf75PDLmR5JKQNDcgBAcdFRcVaZGSb9WOg7ZbHK8D/
ArLrCmuhumegzqfcFL/9Am2GIYIaGskAAMNBQc5ZYKbPGbbxB32taLTuCMbkbn8aGr87O9tdUbzn
C7z6A5XQMnUeHsgAALw1NeBbVLrdD67qBWutCnLEAMuCgskDA95TU/5dXfN4a7PsIXzGFXbECsuP
j8kGBttLS/5fX/V0asjoH6jfL3XEB37FG7zLqU6HAPiM1KIAAAAydFJOUwA+8cz1xfb9v7wSMem8
8PHKkAMm4cbY69/e+GAcq9/ky/HmNxPL0v3IGg2/2aAHCJdw+qQOmgAAAAFiS0dEQYnebE4AAAAH
dElNRQfqAwMHABeogtQ4AAABfUlEQVQ4y2MwIgAYiFBgjAcQq8DElIACM3MLS/xWWFnb4FVga2fv
gFeBo5MzhpSLK7IJILajI7K8m7uHJx5venn7+Pj4+mEq8A8IDAJSwSE+IBAahq4gPCIyMiraOCYW
LB8Xn4CmIDEpEgiSU1JB0mnpGRloCjKzQPLZOblg/Xn5GQWFyAqCirJB8sUlPlBQWobqi3KQdGRF
JViyCkRU16AoqK0Dq6hvAEo1NjUDyZbWNmQF7R2dYBVd3T29fX39E3wmTuqbPAVJwdRp06aDVcyY
2QcEs2bPAVFzkXwxb9q0+WAVCxb2wcCixcjeXLJ02jKwT5avgMqvXLUaJdGuWTtt3XqQig0Q+Y2b
Vm9GTdVbtk7btj0ycsdOsPyu3av37DViQAGMTNOYWVjZQNLsHKtXc3IxoANunmm8fCB5foHVqwWF
GDCBsIiomHhfn4TkailpBqxARnafnLyC4molZQYcQEV1n5r6ag1NBpxAS1tnta4eAx6gb2DIQDwA
AMB47WH9AeILAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTAzLTAzVDA3OjAwOjIzKzAwOjAw+k0V
kQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wMy0wM1QwNzowMDoyMyswMDowMIsQrS0AAAAodEVY
dGRhdGU6dGltZXN0YW1wADIwMjYtMDMtMDNUMDc6MDA6MjMrMDA6MDDcBYzyAAAAAElFTkSuQmCC" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve"> <image id="image0" width="32" height="32" x="0" y="0"
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAACDUlEQVR4nO2X3UrjQBTHJx9m5pzQ2oJgC4J2RcXqnBPwJvgUgg/gA/oqPkH3ZgJS8AEWm9kk40W7y3ZbN4nYyoq/m4FkPn6cDId/vONe/ziJMWFAJlCUICaHYXgoqkpsHEThrXvei6IeY8yMwKSAkhiTCykvlHNqKwLrCH0/PMP4jBGYAChBTEgBDX1vuBWB19hXap8RmQGYEIkB+FzK87Cqwq0IrEMGgRxjPE4QEgIkRuBrgGsphKwVOEA8uB99u89ymxmbG2OtMTY3Jrcmm82y57J4fovUA/PDVRhe/S2wUqYoCKJUyTRVMhWis/TOCc89VdWTsda8l2D9d/oDTzhv4HuDgZKDOsGJtZO775O7uj39NgJNBVMl09tu57bJmncVeAubE3AfLVCVHyzQkC+BzysQI8ZhENQ2us9bgS8BUVVCuPp2+Ikr8L8ItAokTSg8v5gWxdTMfhjn6i9Ba4HCXxxgrcmszUy+iGWL8XE2e7RlaZvutyJQOVeZsjTz3Pdznvvy3Pwap9ZO2xxQx0ZiuRBCDAGGBEAEQBpA33S7Nx3PWw6R61JxWyAMYYw4JgAiRNIAmgF5T7i9JutbCRwhHmkAzYisAbRWSp9G0WngXLA8s2Eee02gs7PT0XGstVKaMCatpCYA2hVid2Vyg273T4GTfv+EEflSwSXj/P9uFAQjz7mN3Y/fRJF4Adjt29MjOCgSAAAAAElFTkSuQmCC" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve"> <image id="image0" width="32" height="32" x="0" y="0"
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEZklEQVR4nO1WT1AbZRR/ppuGbjD7IQFJtcsSOhBHAtsZBw5UkkxpDz0gHT2qgPZIC9XxZiFArxax3jwIjEccoIMeCk6W0hxgWgh/KtAKfAQVJGn5NsI2JMvoISwu+Qcpvdnfaed7b9/7vfe9Px/AS/zf8cpRDSAtha6YcxoqjMhewuh5dJxCAAAesunBUhAPrD3tF/wBwStt4xdOoN6c09BkyXUqTpOhbXbZ2Tb/e8sLI/DdmdPf1+S+XpvKP3griCvdMw51NjTP4/x64ZvNqToHAOD0adxQeZGLpXWccpZyBlhax/124Z2lRHISkgkJhwmnP8El0vGQTc8F90MHCcsk5QxcLzzVnMjouXszjuyfRzMKBsfzsn8azfhk/FEd3nqGo3V5lM7X55saAZ4jA+sXyzaii04dUZUpo7qYSee9UhAL/oAAANBTaunlUTqv/oeEZFIw+CAvJQIVRoN96KzVFX1++s79PK+0jXtKC3urThqrlXO8FcTvj81eEsM75L6dn4gm/tn04rWUrgBptSj6zEM2PV5pG1eZMqvVzgEiRfdLudUVCO+Qbxb/7Ij+12Zk7CkRMGg1MQTE8A4BAChmaD4u6eMUqs83NX67sPo1CclELSsx6EuoMXvJBNIeizGsRqkwdYaEZTItbnmiZbm7LUXC+43vc8ToeRKWCZaCmIM0blJlhwIASNYyAABWRs+P+EVhWQphEpKJ+i45fRr3rpGx/+D1dV41mxrUthRdRFEMAMBUYHMSaSl03j3jUHQ0U4HNyWTOAQBsRoNdifKunwjR8q+KuHYAgEr3Q0e3d71r2CcKtxb+6Li99rRfracQUUOjtEoyXDWfbEDaSNQdi2sxxcSjdH7MXjLB0Tru8vjj2vPuGcew/2/hYza7BiASOQAAS6dxip09AgOrG30HEVAKCQBgxC8KrXMrMUuF06dxQ2etrlB1+T/rF8s2esosvYqsb3Wjj6V1HI/S+eha0ZCwTIZ94oFZaLKwzdbdSr8x53XeWohtKzVh5btr+a/OEb8o1JzKqgEAWI5ayxoAgNb52Iji4cfSt3qVRfL59FJj69xKS7xRq2DYJwpfzOBrLK3jPmKzawH+uw4Fe5NwsLzIZcti7AeRUKbbtCh5ACLLqcJosFflvPZexu6gwlIQd6/4ukb8ooC0FLpT/rZLGcUfjP566bbq2vcIsLSOizcuE6FtdtnZteLrSvTSUWyq9wDeCuKCwQd5ap19u6A+39R402puPwwBgEif968+6RtYe9KPpSCeFJ95kJZCxQzN2zINtiv5bzSqA7o8/qiu2+vrTEgAAOBLC+tsssRfuUdBvOgB4ryIbsx5nfHa7CggIZlUqqafGgnX8YdsVu3NInP7YWsimfNz7mmHUrSHJgAQKaImC+tUJlqqGPaJwqcTj+uSFeqhHiQKkYrMV20HLS7Fcev8SsuI/+ABl/KTzMrQfB59gitmaD5DewwZKIoJyLK4JG1jrxTCd/2ikGw1v0Q0/gUUTvvpe7FxNQAAAABJRU5ErkJggg==" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+430
View File
@@ -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<Vec<String>>,
}
/// 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<Vec<ImportedSession>> {
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<ImportedSession> {
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<String> = 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<String, HashMap<String, String>> {
let mut sections: HashMap<String, HashMap<String, String>> = 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(&current_section) {
section.insert(key.trim().to_string(), value.trim().to_string());
}
}
}
sections
}
// ── MobaXterm (.mxtsessions) ────────────────────────────────────────────────
fn parse_mobaxterm(path: &str) -> AppResult<Vec<ImportedSession>> {
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 &sections {
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<String> = 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<Vec<String>>,
) -> Option<ImportedSession> {
// Format: #<type>#<subtype>%host%port%username%...
let hash_parts: Vec<&str> = value.splitn(2, '#').skip(1).collect::<Vec<_>>();
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<Vec<ImportedSession>> {
let content = std::fs::read_to_string(path)
.map_err(|e| AppError::Config(format!("Cannot read file: {e}")))?;
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();
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<String> = 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<usize> {
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<Vec<String>, String> = HashMap::new();
// Reconstruct paths for existing groups by walking parent_id chains
fn build_path(groups: &[Group], id: &str) -> Vec<String> {
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<String> = 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<String> = 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)
}