终端模块初版

This commit is contained in:
zhongluofeng
2026-09-18 18:28:13 +08:00
parent f6c1cc250e
commit b018abd922
63 changed files with 26695 additions and 185 deletions
+1237 -144
View File
File diff suppressed because it is too large Load Diff
+72 -7
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "terminal-window",
"description": "Capability for detached terminal windows (one session per window, label = terminal-window-<sessionId>)",
"windows": ["terminal-window-*"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-toggle-maximize",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-set-background-color",
"core:event:allow-listen",
"core:event:allow-emit",
"opener:default",
"opener:allow-open-path",
"opener:allow-reveal-item-in-dir",
"snap-layout:default"
]
}
+21
View File
@@ -16,6 +16,9 @@ pub mod windows {
pub const SCREENSHOT_SCROLL: &str = "screenshot-scroll";
/// 取词翻译悬浮窗(由 translate 模块预创建,非激活显示)
pub const TRANSLATE_POPUP: &str = "translate-popup";
/// 终端独立窗口前缀,实际 label = `${TERMINAL_WINDOW}-<sessionId>`
/// 每个终端窗口承载一个会话,脱离主窗口独立存在。
pub const TERMINAL_WINDOW: &str = "terminal-window";
}
/// Tauri 事件名(与前端 constants::EVENTS 对应)
@@ -73,6 +76,24 @@ pub mod events {
pub const TRANSLATE_STREAM_ERROR: &str = "translate-stream-error";
// 音乐模块:Python 便携运行时安装进度
pub const MUSIC_RUNTIME_INSTALL_PROGRESS: &str = "music-runtime-install-progress";
// 终端模块:会话输出批次(负载见 terminal::events::OutputPayload
// 按 8~16ms 窗口聚合,前端用 seq 校验连续性
pub const TERMINAL_OUTPUT: &str = "terminal-output";
// 终端模块:会话结束(负载见 terminal::events::ExitPayload
pub const TERMINAL_EXIT: &str = "terminal-exit";
// 终端模块:会话状态变更(负载为 SessionInfo,前端据此刷新侧栏与标签)
pub const TERMINAL_STATE: &str = "terminal-state";
// 终端模块:工作目录变化(OSC 7 hook 上报;SFTP 跟随目录依赖此事件)
pub const TERMINAL_CWD: &str = "terminal-cwd";
// 终端模块:SSH 主机密钥需用户确认(阻塞式交互,握手暂停等待回传)
pub const TERMINAL_HOST_KEY_PROMPT: &str = "terminal-host-key-prompt";
// 终端模块:请求前端对「关闭仍在运行的会话」二次确认
// (P2 起用:P0 的关闭确认在前端 store 内完成,事件通道先占位)
#[allow(dead_code)]
pub const TERMINAL_CONFIRM_CLOSE: &str = "terminal-confirm-close";
// 终端模块:SFTP 传输进度(负载见 terminal::ssh::sftp::TransferProgress
// 节流后发送(约 200ms 一次),前端据此画进度条
pub const TERMINAL_TRANSFER_PROGRESS: &str = "terminal-transfer-progress";
// 音乐模块:下载任务事件(桥接事件行 → 前端,负载见 bridge.py _emit_event
pub const MUSIC_DOWNLOAD_EVENT: &str = "music-download-event";
// 后端自动切换节点完成(前端据以刷新节点列表并提示)
+169 -1
View File
@@ -16,6 +16,7 @@ mod secrets;
mod setup;
mod shortcut;
mod snap_fix;
mod terminal;
mod translate;
mod tray_menu;
mod updater;
@@ -103,6 +104,46 @@ use quickpanel::{
quickpanel_show_window, quickpanel_unregister_shortcut, quickpanel_focus_main_window,
};
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
use terminal::commands::{
terminal_attach_session, terminal_clear_known_hosts, terminal_clear_session,
terminal_close_session, terminal_confirm_host_key, terminal_default_cwd, terminal_delete_host,
terminal_delete_key, terminal_delete_shell, terminal_detach_session,
terminal_export_known_hosts, terminal_forget_host, terminal_generate_key,
terminal_get_settings, terminal_import_key, terminal_import_known_hosts,
terminal_import_ssh_config, terminal_key_public, terminal_list_hosts,
terminal_list_known_hosts, terminal_list_keys, terminal_list_sessions,
terminal_list_shells, terminal_new_host_id, terminal_open_local,
terminal_open_ssh, terminal_open_local_path, terminal_reveal_local_path,
terminal_refresh_shells, terminal_rename_key, terminal_rename_session,
terminal_resize, terminal_save_appearance, terminal_save_host, terminal_save_layout,
terminal_save_security, terminal_save_selection, terminal_save_settings,
terminal_save_shell, terminal_save_shortcuts, terminal_send_key, terminal_session_alive,
terminal_session_cwd, terminal_session_cwd_value, terminal_set_host_password,
terminal_set_encoding,
terminal_set_key_passphrase,
terminal_set_last_shell, terminal_test_shell, terminal_write,
// SFTP 文件管理(P1
terminal_sftp_close, terminal_sftp_delete, terminal_sftp_download, terminal_sftp_is_open,
terminal_sftp_list, terminal_sftp_mkdir, terminal_sftp_open, terminal_sftp_parent,
terminal_sftp_read_link, terminal_sftp_rename, terminal_sftp_upload,
// 命令片段(P1
terminal_delete_snippet, terminal_list_snippets, terminal_render_snippet,
terminal_restore_default_snippets, terminal_run_snippet, terminal_save_snippet,
// 命令历史(P1
terminal_history_clear, terminal_history_delete, terminal_history_query,
terminal_history_run, terminal_history_sources, terminal_history_toggle_favorite,
// 端口转发(P2
terminal_add_forward, terminal_list_forwards, terminal_remove_forward,
// 会话日志(P2
terminal_log_path, terminal_toggle_logging,
// 主机配置同步(P2
terminal_export_hosts, terminal_import_hosts,
// 会话模板(P2
terminal_delete_template, terminal_save_template,
// AI 命令助手(P2
terminal_ai_engines, terminal_ai_suggest,
};
use terminal::TerminalManager;
use translate::{
translate_abort, translate_apply_shortcuts, translate_copy_text, translate_engine_delete,
translate_engine_models, translate_engine_save, translate_engine_test_config,
@@ -208,6 +249,34 @@ fn export_bindings() {
translate_history_list, translate_history_delete, translate_history_clear,
translate_history_set_favorited,
translate_stream_start, translate_abort, translate_paste_back,
// terminal4139 业务 + key_public / new_host_id 两个辅助)
terminal_get_settings, terminal_save_settings, terminal_save_appearance,
terminal_save_layout, terminal_save_selection, terminal_save_security,
terminal_save_shortcuts,
terminal_list_shells, terminal_refresh_shells, terminal_save_shell,
terminal_delete_shell, terminal_set_last_shell, terminal_test_shell,
terminal_default_cwd,
terminal_open_local, terminal_open_ssh, terminal_list_sessions,
terminal_close_session, terminal_write, terminal_resize, terminal_rename_session,
terminal_detach_session, terminal_attach_session, terminal_session_cwd,
terminal_send_key, terminal_clear_session, terminal_session_alive,
terminal_set_encoding,
terminal_list_hosts, terminal_save_host, terminal_delete_host,
terminal_set_host_password, terminal_import_ssh_config,
terminal_list_keys, terminal_generate_key, terminal_import_key,
terminal_delete_key, terminal_rename_key, terminal_set_key_passphrase,
terminal_key_public, terminal_new_host_id,
terminal_list_known_hosts, terminal_forget_host, terminal_clear_known_hosts,
terminal_export_known_hosts, terminal_import_known_hosts,
terminal_confirm_host_key,
// 命令历史(6 个)
//
// 注意:这 6 个命令**必须与 `run()` 里那份 `collect_commands!` 同时登记**。
// 只登记其中一处不会报错:漏了这里 = 运行时能调但 `bindings.ts` 里没有类型;
// 漏了那边 = 有类型但调用失败。两种都是「编译/启动全绿但功能静默不可用」。
terminal_history_query, terminal_history_sources,
terminal_history_toggle_favorite, terminal_history_delete,
terminal_history_clear, terminal_history_run,
])
.export(Typescript::default(), "../src/lib/bindings.ts")
.expect("failed to export bindings");
@@ -473,7 +542,100 @@ pub fn run() {
translate_history_set_favorited,
translate_stream_start,
translate_abort,
translate_paste_back
translate_paste_back,
terminal_get_settings,
terminal_save_settings,
terminal_save_appearance,
terminal_save_layout,
terminal_save_selection,
terminal_save_security,
terminal_save_shortcuts,
terminal_list_shells,
terminal_refresh_shells,
terminal_save_shell,
terminal_delete_shell,
terminal_set_last_shell,
terminal_test_shell,
terminal_default_cwd,
terminal_open_local,
terminal_open_ssh,
terminal_list_sessions,
terminal_close_session,
terminal_write,
terminal_resize,
terminal_rename_session,
terminal_detach_session,
terminal_attach_session,
terminal_session_cwd,
terminal_set_encoding,
terminal_send_key,
terminal_clear_session,
terminal_session_alive,
terminal_list_hosts,
terminal_save_host,
terminal_delete_host,
terminal_set_host_password,
terminal_import_ssh_config,
terminal_list_keys,
terminal_generate_key,
terminal_import_key,
terminal_delete_key,
terminal_rename_key,
terminal_set_key_passphrase,
terminal_key_public,
terminal_new_host_id,
terminal_list_known_hosts,
terminal_forget_host,
terminal_clear_known_hosts,
terminal_export_known_hosts,
terminal_import_known_hosts,
terminal_confirm_host_key,
// SFTP 文件管理(P1
terminal_sftp_is_open,
terminal_sftp_open,
terminal_sftp_close,
terminal_sftp_list,
terminal_sftp_parent,
terminal_sftp_read_link,
terminal_sftp_mkdir,
terminal_sftp_delete,
terminal_sftp_rename,
terminal_sftp_upload,
terminal_sftp_download,
terminal_session_cwd_value,
// 本地文件操作(SFTP 面板的「打开 / 在资源管理器中显示」)
terminal_open_local_path,
terminal_reveal_local_path,
// 命令片段(P1
terminal_list_snippets,
terminal_save_snippet,
terminal_delete_snippet,
terminal_render_snippet,
terminal_run_snippet,
terminal_restore_default_snippets,
// 命令历史(P1
terminal_history_query,
terminal_history_sources,
terminal_history_toggle_favorite,
terminal_history_delete,
terminal_history_clear,
terminal_history_run,
// 端口转发(P2
terminal_add_forward,
terminal_list_forwards,
terminal_remove_forward,
// 会话日志(P2
terminal_log_path,
terminal_toggle_logging,
// 主机配置同步(P2
terminal_export_hosts,
terminal_import_hosts,
// 会话模板(P2
terminal_delete_template,
terminal_save_template,
// AI 命令助手(P2
terminal_ai_engines,
terminal_ai_suggest
])
.setup(setup::init)
.on_window_event(|window, event| {
@@ -515,6 +677,12 @@ pub fn run() {
// 停止音乐桥接进程(kill 快速返回,wait 在后台线程完成)
music.cleanup_on_exit();
}
if let Some(term) = app.try_state::<TerminalManager>() {
// 关闭全部终端会话。**不等待**ConPTY 的 ClosePseudoConsole 会
// 阻塞到所有句柄关闭,退出路径上等待会卡死(会话侧已把关闭动作
// 放进后台线程,进程终止时 OS 回收剩余资源)。
term.cleanup_on_exit();
}
if let Some(clip) = app.try_state::<ClipboardManager>() {
clip.stop();
}
+15
View File
@@ -21,6 +21,7 @@ use crate::monitor_kernel::{MonitorKernel, check_and_relaunch_if_needed};
use crate::music::MusicManager;
use crate::network_monitor::NetworkMonitor;
use crate::process_manager::{ProcessManager, start_monitoring_thread};
use crate::terminal::TerminalManager;
use crate::translate::TranslateManager;
/// 应用启动初始化入口(setup 闭包调用)。
@@ -72,6 +73,20 @@ pub fn init(app: &mut App<Wry>) -> Result<(), Box<dyn std::error::Error>> {
// 失败只记日志(快捷键被占用不该阻断启动)。
crate::translate::init_on_launch(app.handle());
// ===== 终端模块:TerminalManager =====
// 仅注册状态 + 做一次 Shell 探测,**不建立任何 SSH 连接**
// (沿用 translate 的姿态:启动阶段不发网络请求,会话只在用户主动打开时创建)。
let terminal = TerminalManager::new(app_data_dir.clone());
// 初始化 known_hosts 存储(主机密钥信任库,非机密,明文 JSON)
crate::terminal::ssh::hostkey::init(terminal.root().join("known_hosts.json"));
app.manage(terminal);
// Shell 探测 + 清理上次运行遗留的 hook 脚本
if let Ok(t) = crate::terminal::manager(app.handle()) {
t.init_on_launch(app.handle());
} else {
crate::logger::log_warn("terminal", "终端模块初始化异常:State 未注册");
}
// 网速采样不依赖提权,应用启动即开始
let network_monitor = Arc::new(NetworkMonitor::new());
app.manage(network_monitor.clone());
+207
View File
@@ -0,0 +1,207 @@
//! AI 命令助手(P2)。
//!
//! 根据用户意图(+ 可选的终端上下文)生成可执行的命令建议。
//! **复用翻译模块的 AI 引擎配置**:引擎列表、Base URL、模型、密钥
//! (凭据管理器)全部来自 translate 的设置——用户只需配置一份 API。
//!
//! # 输出契约(与模型约定的 JSON)
//!
//! 模型被要求只输出 `[{"command":"...","description":"..."}]` 数组。
//! 但模型不完全可靠,解析器做了三层防御:
//! 1. 剥掉可能包裹的 Markdown 代码块标记(```json ... ```);
//! 2. 数组解析失败时尝试提取首个 `[...]` 子串再解析;
//! 3. 条目字段校验(command 非空),并截断到 5 条防止异常输出刷屏。
use serde::Serialize;
use specta::Type;
use crate::translate::chat_once;
use crate::translate::TranslateEngineConfig;
use crate::translate::TranslateSettings;
/// 可用于命令生成的引擎(kind = "ai" 且配置完整)。
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AiEngineOption {
pub id: String,
pub name: String,
pub model: String,
}
/// 一条命令建议。
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommandSuggestion {
pub command: String,
pub description: String,
}
/// 系统提示词。
///
/// 输出契约写进 prompt 只是第一道防线;解析器(`parse_suggestions`
/// 才是真正的保证——两者都不假设模型 100% 遵守。
const SYSTEM_PROMPT: &str = r#"你是终端命令助手。根据用户的意图和(可选的)终端最近输出,给出可在 shell 中执行的命令建议。
严格规则:
1. 只输出一个 JSON 数组,格式为 [{"command":"命令","description":"说明"}],不要输出任何其他文字、不要使用 Markdown 代码块标记。
2. 给 1~3 条建议:第一条是最直接的做法,其余是备选方案或更安全的变体。
3. description 用简体中文,说明这条命令做什么;有风险的命令必须醒目标注风险。
4. 命令默认面向 POSIX shellbash)。仅当用户明确提到 Windows/PowerShell 时才用 PowerShell 语法。
5. 命令中的路径、用户名等参数保持通用;不要编造用户环境里不存在的变量值。"#;
/// 列出可用于命令生成的 AI 引擎(按翻译设置的优先级排序)。
pub fn ai_engine_options(settings: &TranslateSettings) -> Vec<AiEngineOption> {
let mut opts: Vec<AiEngineOption> = settings
.engines
.iter()
.filter(|e| e.kind == "ai" && e.enabled && !e.base_url.trim().is_empty())
.map(|e| AiEngineOption {
id: e.id.clone(),
name: e.name.clone(),
model: e.model.clone(),
})
.collect();
opts.sort_by_key(|o| {
settings
.engines
.iter()
.find(|e| e.id == o.id)
.map(|e| e.priority)
.unwrap_or(100)
});
opts
}
/// 生成命令建议。
///
/// `context` 是可选的终端最近输出/选中文本(前端从 xterm 缓冲取),
/// 帮助模型理解「接着上一步做什么」;为空则只看意图。
pub async fn suggest(
engine: &TranslateEngineConfig,
intent: &str,
context: &str,
) -> Result<Vec<CommandSuggestion>, String> {
if intent.trim().is_empty() {
return Err("请先描述你想做什么".to_string());
}
let mut user = format!("我的意图:{}", intent.trim());
if !context.trim().is_empty() {
// 上下文截断到 2 KB:太长的输出(cat 大文件)只会稀释意图,
// 且模型上下文窗口是按 token 计费的
let ctx: String = context.chars().take(2048).collect();
user.push_str(&format!("\n\n终端最近的输出(供参考):\n{ctx}"));
}
let raw = chat_once(engine, vec![("system", SYSTEM_PROMPT.to_string()), ("user", user)]).await?;
parse_suggestions(&raw)
}
/// 解析模型输出为建议列表(纯函数,单测覆盖)。
fn parse_suggestions(raw: &str) -> Result<Vec<CommandSuggestion>, String> {
let text = strip_code_fence(raw);
let parsed: Result<Vec<SuggestionRaw>, _> = serde_json::from_str(text);
let items = match parsed {
Ok(items) => items,
Err(_) => {
// 防御二:提取首个 [...] 子串(模型可能在 JSON 前后加了说明文字)
let start = text.find('[').ok_or_else(|| {
format!("模型未按约定输出 JSON。原始内容:{}", text.chars().take(300).collect::<String>())
})?;
let end = text.rfind(']').ok_or_else(|| "模型输出缺少 JSON 数组结尾".to_string())?;
if end <= start {
return Err("模型输出的 JSON 数组为空或格式错误".to_string());
}
serde_json::from_str(&text[start..=end])
.map_err(|e| format!("模型输出的 JSON 解析失败: {e}"))?
}
};
let out: Vec<CommandSuggestion> = items
.into_iter()
.map(|s| CommandSuggestion {
command: s.command.trim().to_string(),
description: s.description.trim().to_string(),
})
.filter(|s| !s.command.is_empty())
.take(5)
.collect();
if out.is_empty() {
return Err("模型没有给出有效的命令建议".to_string());
}
Ok(out)
}
/// 剥掉 Markdown 代码块围栏(```json / ```),以及首尾空白。
fn strip_code_fence(raw: &str) -> &str {
let t = raw.trim();
let t = t.strip_prefix("```json").or_else(|| t.strip_prefix("```")).unwrap_or(t);
let t = t.strip_suffix("```").unwrap_or(t);
t.trim()
}
/// 模型输出的宽松条目结构(description 缺失时容忍为空串)。
#[derive(serde::Deserialize)]
struct SuggestionRaw {
command: String,
#[serde(default)]
description: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_plain_json_array() {
let raw = r#"[{"command":"ls -la","description":"列出文件"},{"command":"du -sh *","description":"查看各目录大小"}]"#;
let out = parse_suggestions(raw).unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].command, "ls -la");
assert_eq!(out[1].description, "查看各目录大小");
}
#[test]
fn parses_markdown_fenced_json() {
let raw = "```json\n[{\"command\":\"git status\",\"description\":\"查看状态\"}]\n```";
let out = parse_suggestions(raw).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].command, "git status");
}
#[test]
fn parses_json_with_surrounding_prose() {
// 防御二:模型在 JSON 前后加了说明文字
let raw = "好的,以下是建议:\n[{\"command\":\"df -h\",\"description\":\"查看磁盘\"}]\n希望有帮助";
let out = parse_suggestions(raw).unwrap();
assert_eq!(out[0].command, "df -h");
}
#[test]
fn tolerates_missing_description_and_blank_commands() {
let raw = r#"[{"command":" ","description":"空命令应被过滤"},{"command":"pwd"}]"#;
let out = parse_suggestions(raw).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].command, "pwd");
assert_eq!(out[0].description, "");
}
#[test]
fn caps_at_five_and_reports_garbage() {
let items: Vec<String> = (0..8)
.map(|i| format!(r#"{{"command":"cmd{i}","description":""}}"#))
.collect();
let out = parse_suggestions(&format!("[{}]", items.join(","))).unwrap();
assert_eq!(out.len(), 5, "超出 5 条的异常输出应被截断");
assert!(parse_suggestions("这不是 JSON").is_err());
}
#[test]
fn fence_without_json_marker_also_stripped() {
let raw = "```\n[{\"command\":\"top\",\"description\":\"进程\"}]\n```";
let out = parse_suggestions(raw).unwrap();
assert_eq!(out[0].command, "top");
}
}
+229
View File
@@ -0,0 +1,229 @@
//! 会话日志与审计(P2)。
//!
//! 把会话的**原始输出字节流**(含 ANSI 序列)追加写入磁盘文件。
//! 「审计」的另一半——命令文本与退出码——已由命令历史(OSC 133 落库)承担,
//! 本模块补的是命令历史覆盖不了的部分:完整屏幕内容、非命令输出、时序原貌。
//!
//! # 设计取舍
//!
//! - **记原始字节,不做任何转义清洗**:与 PuTTY 的会话日志同策略。
//! 清洗(剥 ANSI / 转可读文本)是**阅读时**的事,日志要保真——
//! 排查「界面显示为什么这样」恰恰需要原始序列。
//! - **只记输出不记输入**:PTY 会回显输入(本地与 SSH 皆然),
//! 输出流已包含用户敲了什么。更重要的是,关闭回显的密码输入
//! **不会**出现在输出流里——这保证了日志不会意外存下密码。
//! - **每批同步追加 + 即时 flush**:日志的价值在崩溃/断线后仍然完整,
//! 攒缓冲反而丢最关键的最后几行。单批最大 4 MiB(聚合缓冲上限),
//! 同步写无性能问题。
//! - **不持久化开关状态**:会话结束日志自然终止,下次会话默认关闭。
//! 自动记录所有会话涉及磁盘占用与隐私权衡(日志含屏幕上的一切),
//! 交给用户显式开启更稳妥。
//!
//! # 挂点
//!
//! 本地 ConPTY 与 SSH 两个后端的 `flush_output` 是所有输出的必经之路
//! (聚合 → 发前端事件),在此处写日志能保证**零遗漏**且与前端所见一致。
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
/// 注册表:会话 id → 日志条目。
static LOGS: OnceLock<Mutex<HashMap<String, LogEntry>>> = OnceLock::new();
struct LogEntry {
path: PathBuf,
file: std::fs::File,
/// 已写入字节数(预留:后续可做单文件上限保护)
#[allow(dead_code)]
written: u64,
}
fn registry() -> &'static Mutex<HashMap<String, LogEntry>> {
LOGS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 开启或关闭某会话的日志。
///
/// 开启:在 `dir` 下创建 `{session_id}_{时间戳}.log`,写入头部说明,
/// 返回 `Some(路径)`。已在记录中则幂等(返回现有路径,不重复建文件)。
/// 关闭:flush 并移除条目,返回 `None`。未在记录中时关闭是空操作。
pub fn toggle(
session_id: &str,
enabled: bool,
dir: &PathBuf,
header: &str,
) -> Result<Option<String>, String> {
let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner());
if !enabled {
// flush 在 Dropentry 被 remove)时完成;显式 flush 一次更稳
if let Some(e) = reg.remove(session_id) {
let mut e = e;
let _ = e.file.flush();
}
return Ok(None);
}
if let Some(e) = reg.get(session_id) {
return Ok(Some(e.path.to_string_lossy().to_string()));
}
std::fs::create_dir_all(dir).map_err(|e| format!("创建日志目录失败: {e}"))?;
let stamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let path = dir.join(format!("{session_id}_{stamp}.log"));
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("创建日志文件失败({}: {e}", path.display()))?;
// 头部人可读:定位「这是谁的日志」不需要任何工具
writeln!(
file,
"# 终端会话日志 | {header} | 开始于 {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
)
.map_err(|e| format!("写入日志头部失败: {e}"))?;
let path_str = path.to_string_lossy().to_string();
reg.insert(
session_id.to_string(),
LogEntry {
path,
file,
written: 0,
},
);
Ok(Some(path_str))
}
/// 会话是否正在记录。
pub fn is_logging(session_id: &str) -> bool {
registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains_key(session_id)
}
/// 当前日志文件路径(未记录时为 `None`)。
pub fn path_of(session_id: &str) -> Option<String> {
registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(session_id)
.map(|e| e.path.to_string_lossy().to_string())
}
/// 追加一批输出(热路径:未开启时只付一次哈希查表的钱)。
pub fn write(session_id: &str, bytes: &[u8]) {
if bytes.is_empty() {
return;
}
let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner());
let Some(entry) = reg.get_mut(session_id) else {
return;
};
if entry.file.write_all(bytes).is_ok() {
entry.written += bytes.len() as u64;
}
// 单条写入失败不中断会话:日志是尽力而为的旁路,不能反过来影响终端 I/O
let _ = entry.file.flush();
}
/// 会话关闭时的清理:flush 并移除条目。幂等。
pub fn cleanup(session_id: &str) {
if let Some(mut e) = registry()
.lock()
.unwrap_or_else(|x| x.into_inner())
.remove(session_id)
{
let _ = e.file.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"thing-audit-test-{tag}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&d);
d
}
/// 每条测试用独立会话 id,避免注册表(全局静态)在测试间串扰
fn sid(tag: &str) -> String {
format!("{tag}-{}", std::process::id())
}
#[test]
fn toggle_write_and_stop_roundtrip() {
let id = sid("roundtrip");
let dir = temp_dir("roundtrip");
// 开启:返回路径,头部已写入
let path = toggle(&id, true, &dir, "测试会话 ops@example").unwrap().unwrap();
assert!(is_logging(&id));
assert_eq!(path_of(&id).unwrap(), path);
assert!(path.contains(&id));
// 写入:文件里能找到原始字节与头部
write(&id, b"hello \x1b[31mred\x1b[0m\n");
let content = std::fs::read(&path).unwrap();
let text = String::from_utf8_lossy(&content);
assert!(text.contains("# 终端会话日志"), "头部缺失");
assert!(text.contains("ops@example"), "头部信息缺失");
assert!(content.windows(5).any(|w| w == b"\x1b[31m"), "原始 ANSI 序列应原样保留");
assert!(text.contains("hello "));
// 关闭:条目移除,文件保留在磁盘上
assert!(toggle(&id, false, &dir, "").unwrap().is_none());
assert!(!is_logging(&id));
assert!(path_of(&id).is_none());
assert!(std::path::Path::new(&path).exists(), "关闭后日志文件应保留");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_without_log_is_noop() {
let id = sid("noop");
// 未开启时写入不应 panic、不应创建任何文件
write(&id, b"ignored");
assert!(!is_logging(&id));
}
#[test]
fn toggle_on_twice_is_idempotent() {
let id = sid("idempotent");
let dir = temp_dir("idempotent");
let p1 = toggle(&id, true, &dir, "a").unwrap().unwrap();
let p2 = toggle(&id, true, &dir, "b").unwrap().unwrap();
assert_eq!(p1, p2, "重复开启应返回同一路径而不是新建文件");
cleanup(&id);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cleanup_and_off_when_not_logging_are_safe() {
let id = sid("safe");
cleanup(&id); // 未开启时清理是空操作
assert!(toggle(&id, false, &temp_dir("safe"), "").unwrap().is_none());
}
#[test]
fn disabled_log_file_keeps_bytes_after_cleanup() {
let id = sid("persist");
let dir = temp_dir("persist");
let path = toggle(&id, true, &dir, "h").unwrap().unwrap();
write(&id, b"line1\n");
write(&id, b"line2\n");
cleanup(&id); // 等价于会话关闭路径
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("line1") && text.contains("line2"), "cleanup 后内容应完整落盘");
let _ = std::fs::remove_dir_all(&dir);
}
}
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
//! 终端字符编码转换。
//!
//! # 为什么需要这个模块
//!
//! 终端输出在 SSH 场景下**不保证是 UTF-8**。国内的存量服务器(CentOS 6/7 时代装机、
//! 未改 `LANG`)默认 `zh_CN.GBK``ls` 一个中文文件名就会吐出 GBK 字节。
//! 若按 UTF-8 解码,得到的是 `测试` 这类不可逆的乱码——而且因为前端拿到的
//! 是字符串,原始字节已经丢了,用户除了改服务器配置别无办法。
//!
//! 因此设计上做了两件事:
//! 1. **Rust 侧始终以字节流发往前端**(base64),不在这里转 String
//! 2. 本模块只在**需要把字节解释成文本**的地方使用(当前是「标题」与
//! 「cwd」这两处从 OSC 序列解析出的字段,它们是给 UI 直接显示/使用的)。
//!
//! 终端画面本身的编码转换放在前端做(xterm 支持自定义 `write` 解码),
//! 因为那里才能拿到「用户当前是否切了编码」这一运行时状态。
//!
//! # 为什么不用 iconv
//!
//! `iconv` 绑定需要 C 工具链与系统库;`encoding_rs` 是纯 RustFirefox 的
//! 实现抽出),且它按 WHATWG Encoding 标准处理 GBK 的**单双字节混合**与
//! 非法序列替换,与浏览器行为一致——这在终端场景下很重要,因为服务端常会
//! 混发半截多字节字符。
/// 把指定编码的字节解码为 UTF-8 字符串。
///
/// 未知编码名一律按 UTF-8 处理(并做有损解码):宁可显示替换字符,
/// 也不要因为一个拼错的编码名让整个会话不可用。
pub fn decode(bytes: &[u8], encoding: &str) -> String {
let enc = lookup(encoding);
match enc {
// UTF-8 走 `from_utf8_lossy`:它比 encoding_rs 的 UTF-8 解码器更快,
// 且对非法序列同样产出 U+FFFD(行为一致)。
Encoding::Utf8 | Encoding::Fallback => String::from_utf8_lossy(bytes).to_string(),
Encoding::Other(e) => {
let (cow, _, _) = e.decode(bytes);
cow.into_owned()
}
}
}
/// 把 UTF-8 字符串编码为目标编码的字节。
///
/// 用于「用户键入的内容发往远端」:若服务器是 GBK,输入的中文也必须以 GBK 发出,
/// 否则远端会显示乱码(甚至把半个字符吃掉导致后续命令错位)。
pub fn encode(text: &str, encoding: &str) -> Vec<u8> {
match lookup(encoding) {
Encoding::Utf8 | Encoding::Fallback => text.as_bytes().to_vec(),
Encoding::Other(e) => {
let (cow, _, _) = e.encode(text);
cow.into_owned()
}
}
}
/// 编码名是否被识别(供命令层做输入校验与 UI 提示)。
pub fn is_supported(encoding: &str) -> bool {
!matches!(lookup(encoding), Encoding::Fallback)
}
/// 归一化编码名到标准写法(供 UI 显示与去重比较)。
///
/// 输入容忍 `gbk` / `GBK` / `gb18030` / `cp936` / `utf8` 等常见写法。
pub fn normalize(encoding: &str) -> String {
let e = encoding.trim().to_ascii_lowercase();
match e.as_str() {
"" => "utf-8".to_string(),
"utf8" | "utf-8" | "utf_8" => "utf-8".to_string(),
"gbk" | "cp936" | "ms936" | "gb2312" | "gb_2312" => "gbk".to_string(),
"gb18030" => "gb18030".to_string(),
"big5" | "big-5" | "cp950" => "big5".to_string(),
"shift_jis" | "shift-jis" | "sjis" | "cp932" => "shift_jis".to_string(),
"euc-kr" | "euckr" | "cp949" => "euc-kr".to_string(),
"latin1" | "iso-8859-1" => "latin1".to_string(),
other => other.to_string(),
}
}
/// 前端设置页可选的编码列表(值与 `normalize` 的输出一致)。
///
/// 只列终端场景真实会遇到的:中文(GBK/GB18030)、港台(Big5)、
/// 日韩(Shift_JIS/EUC-KR)。ISO-8859-1 保留给嵌入式设备(它们的 busybox
/// 经常只有 C locale)。
pub const SUPPORTED: &[&str] = &[
"utf-8",
"gbk",
"gb18030",
"big5",
"shift_jis",
"euc-kr",
"latin1",
];
enum Encoding {
Utf8,
Other(&'static encoding_rs::Encoding),
/// 未识别的编码名(回退到 UTF-8 语义,但 `is_supported` 会报 false
Fallback,
}
fn lookup(encoding: &str) -> Encoding {
let e = normalize(encoding);
if e == "utf-8" {
return Encoding::Utf8;
}
match encoding_rs::Encoding::for_label(e.as_bytes()) {
// UTF-8 通过 label 也能查到,但我们要走更快的 from_utf8_lossy 分支
Some(enc) if enc == encoding_rs::UTF_8 => Encoding::Utf8,
Some(enc) => Encoding::Other(enc),
None => Encoding::Fallback,
}
}
// ===== 测试 =====
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utf8_roundtrip() {
let s = "测试目录 /tmp";
assert_eq!(decode(&encode(s, "utf-8"), "utf-8"), s);
}
#[test]
fn gbk_roundtrip() {
let s = "测试";
let bytes = encode(s, "gbk");
// GBK 下「测试」是 4 字节(每字 2 字节),而 UTF-8 是 6 字节。
// 断言长度而非魔法数字,是为了让这条测试同时验证「确实用了 GBK」。
assert_eq!(bytes.len(), 4, "GBK 编码「测试」应为 4 字节");
assert_eq!(decode(&bytes, "gbk"), s);
}
#[test]
fn gbk_bytes_are_mojibake_under_utf8() {
// 反向验证:GBK 字节按 UTF-8 解读会失真。这正是前端必须知道
// 会话编码的原因(也是本模块存在的理由)。
let bytes = encode("测试", "gbk");
let as_utf8 = String::from_utf8_lossy(&bytes).to_string();
assert_ne!(as_utf8, "测试");
}
#[test]
fn alias_normalization() {
assert_eq!(normalize("GBK"), "gbk");
assert_eq!(normalize("cp936"), "gbk");
assert_eq!(normalize("utf8"), "utf-8");
assert_eq!(normalize(""), "utf-8");
assert_eq!(normalize(" UTF-8 "), "utf-8");
}
#[test]
fn all_supported_labels_resolve() {
for name in SUPPORTED {
assert!(is_supported(name), "{name} 应被识别");
}
assert!(!is_supported("not-a-real-encoding"));
}
#[test]
fn invalid_bytes_do_not_panic() {
// 终端输出经常在半截多字节字符处被切断,解码必须容错而不是 panic
let broken = [0xB2u8, 0xE2]; // GBK「测」的前 2 字节(完整),再截断一个
let _ = decode(&broken, "gbk");
let _ = decode(&[0xFF, 0xFE, 0xFD], "utf-8");
let _ = decode(&[0xC0], "gb18030");
}
}
+92
View File
@@ -0,0 +1,92 @@
//! 终端模块 Tauri 事件定义与负载类型。
//!
//! 事件名常量集中在 `crate::constants::events`,这里只放负载结构与便捷发射函数。
//! 命名口径与既有模块一致:kebab-case、`terminal-` 前缀。
use serde::Serialize;
use specta::Type;
use super::session::SessionId;
/// 事件名再导出,让两个后端可以写 `events::TERMINAL_OUTPUT` 而不必回退两层路径。
///
/// `allow(unused_imports)` 的原因:这是**模块对外的常量出口**,六个事件名成组
/// 定义、成组暴露,便于调用方按同一路径取用;个别常量在当前代码路径上暂未被
/// 本模块自身引用(如 `TERMINAL_CONFIRM_CLOSE` 由窗口层发射、
/// `TERMINAL_CWD` 由 shell hook 发射),但都属于稳定契约,不应按使用情况逐个删改。
#[allow(unused_imports)]
pub use crate::constants::events::{
TERMINAL_CONFIRM_CLOSE, TERMINAL_CWD, TERMINAL_EXIT, TERMINAL_HOST_KEY_PROMPT,
TERMINAL_OUTPUT, TERMINAL_STATE, TERMINAL_TRANSFER_PROGRESS,
};
/// 发射 SFTP 传输进度事件。
///
/// 单独一个函数而不是在命令层直接 `emit`:事件名常量与负载类型分属两个模块,
/// 集中在这里能让「事件名 ↔ 负载类型」的对应关系一眼可见(也是排查
/// 「前端收到的字段对不上」这类问题的第一落点)。
pub fn emit_transfer(app: &tauri::AppHandle, payload: &super::ssh::sftp::TransferProgress) {
use tauri::Emitter;
let _ = app.emit(TERMINAL_TRANSFER_PROGRESS, payload);
}
/// 会话输出批次(**发往前端**的形态)。
///
/// 字段与内部聚合缓冲一一对应但单独定义,是为了让「前端契约」与
/// 「内部分批策略」可以独立演进(例如未来内部改成分块再组装,前端不必感知)。
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct OutputPayload {
pub session_id: SessionId,
/// base64 编码的原始输出字节。
///
/// **为什么用 base64 而不是直接给字符串**:终端输出可能是 GBK 等非 UTF-8
/// 编码(老服务器常见),若在 Rust 侧转 String 就永久丢失了原始字节,
/// 前端再做后处理也无从下手。用 base64 保持字节完整性,由前端按会话编码解码。
pub data: String,
/// 全局单调批次序号,前端据此检测丢包。
pub seq: u64,
}
/// 会话结束。
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExitPayload {
pub session_id: SessionId,
/// 退出码。本地会话通常有;SSH 通道关闭时多为 None。
pub exit_code: Option<i32>,
/// 结束原因:"eof" | "process-exit" | "killed" | "disconnected" | "error"
pub reason: Option<String>,
}
/// 会话工作目录变化(由 OSC 7 hook 上报)。
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CwdPayload {
pub session_id: SessionId,
pub cwd: String,
}
/// 状态变更事件直接复用 [`SessionInfo`] 作为负载——
/// 前端拿到它就能刷新侧栏与标签,不必再发一次查询。
/// SSH 主机密钥需要用户确认(首次连接或指纹变更)。
///
/// 这是一个**阻塞性的交互请求**:Rust 侧握手暂停,等前端调
/// `terminal_confirm_host_key` 回传决定。
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HostKeyPromptPayload {
pub session_id: SessionId,
pub host: String,
pub port: u16,
/// 密钥算法(如 "ssh-ed25519" / "ssh-rsa"
pub key_type: String,
/// 服务端出示的指纹(SHA256OpenSSH 展示格式)
pub fingerprint: String,
/// 本次是「首次见到」还是「与记录不符」。
/// 后者是**高危信号**,前端必须以红色阻断式 UI 呈现。
pub reason: String,
/// 与记录不符时,给出先前记录的指纹以便用户对比
pub previous_fingerprint: Option<String>,
}
+857
View File
@@ -0,0 +1,857 @@
//! 终端命令历史(SQLite)。
//!
//! 存储约定:`{app_data_dir}/terminal/history.db`,范式与 `translate/history.rs` 一致
//! WAL、`PRAGMA user_version` 作结构版本、FTS5 trigram 索引)。
//!
//! # 与翻译历史的关键差异:为什么不做整库重建式迁移
//!
//! 翻译历史用了「版本不等就 DROP 重建」的简化策略,理由是「历史属于可丢弃数据」。
//! 终端历史**不能照抄**:翻译历史里一条记录是「一段译文」,重建丢的是可以重翻的东西;
//! 终端历史里一条记录是**用户敲过的命令**,包含服务器地址、路径、以及偶尔泄露在
//! 命令行里的凭据。用户可能恰恰是为了翻查这些才留着它。
//!
//! 因此这里从一开始就写**增量迁移**(`migrate`),不做 DROP。
//!
//! # 为什么存两列(command + cwd
//!
//! 同一条命令在 `/var/log` 下和在 `~` 下含义完全不同(`ls`、`make`、`git status`
//! 都是典型例子)。只存命令会让「我上次在哪个目录跑的那条命令」无从追查,
//! 而 `cwd` 又是一次 OSC 7 就能免费拿到的信息。
//!
//! # 去重键的选择
//!
//! 用 `(command, cwd, host_id)` 而不是 `(command, host_id)`:在 A 目录跑过的命令,
//! 换到 B 目录再跑应当各留一条 —— 它们对用户是两个不同的事实。
//! 重复执行同一条命令(同一目录)只更新 `ts` 与 `count`,避免历史被刷屏。
use std::path::Path;
use std::sync::Mutex;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use specta::Type;
/// 库结构版本(与 `PRAGMA user_version` 对应)。
///
/// **递增时必须同步在 `migrate` 里加分支**,否则旧库会因缺少新列而在查询时报
/// 「no such column」,而报错点在读取路径上、远离真正的成因。
const SCHEMA_VERSION: i64 = 1;
/// 默认保留条数。超出后在写入路径上淘汰最旧的**非收藏**记录。
const DEFAULT_MAX_ENTRIES: i64 = 5000;
/// 走 FTS 索引所需的最小字符数。
///
/// trigram 分词器把文本切成连续 3 字符的 n-gram,长度小于 3 的查询词
/// **不会报错、只会静默返回空**。而终端里两字符的命令恰恰是最常见的
/// `ls`、`cd`、`rm`),所以短词回退到 `LIKE` 前缀匹配。
const FTS_MIN_CHARS: usize = 3;
/// 一条命令历史。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommandHistoryItem {
pub id: i64,
/// 毫秒时间戳(最后一次执行)
pub ts: i64,
pub command: String,
/// 执行时的工作目录(可能为空 —— OSC 7 未被远端 shell 上报时)
pub cwd: String,
/// 会话来源:本地 shell 的 id,或 SSH 主机的 id。
/// 为空串表示「来源未知」(如手工录入的历史)。
pub host_id: String,
/// 显示用的来源名(「PowerShell」/「生产服务器」),随记录一起存。
///
/// # 为什么冗余存名字而不只存 id
///
/// 主机被删除后,若只有 id,历史列表里那一列会变成一串无意义的 hash。
/// 存名字的代价是「主机改名后历史里的旧名字不会更新」—— 这里选择
/// **保留历史当时的名字**,因为「我在那台现在叫 X 的机器上跑过什么」
/// 本来就是一个有时间性的问题。
pub host_name: String,
/// 是否为 SSH 会话
pub ssh: bool,
/// 累计执行次数(同一命令在同一目录重复执行时累加)
pub count: i64,
/// 用户收藏(收藏项不参与容量淘汰)
pub favorited: bool,
/// 退出码。`None` 表示未捕获(如会话结束时命令仍在运行)
pub exit_code: Option<i32>,
}
/// 查询参数。
///
/// 用结构体而不是一长串位置参数:命令层要把前端 payload 原样转发,
/// 而 5 个 `Option<String>` 的位置参数在调用点极易顺序写错,且编译器
/// 无法发现(全是同类型)。
#[derive(Debug, Clone, Default, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct HistoryQuery {
/// 关键词(对 command 做匹配;空则不过滤)
pub keyword: String,
/// 只看某个来源(host_id;空则全部)
pub host_id: String,
/// 只看收藏
pub favorited_only: bool,
/// 分页偏移
pub offset: i64,
/// 分页大小
pub limit: i64,
}
impl HistoryQuery {
/// 归一化:`limit` 兜底并设上限,`offset` 不为负。
///
/// 上限 500:前端分页步长是 50~100,500 已远超一屏能展示的量,
/// 再大只会让 IPC 序列化成为瓶颈。
fn normalized(mut self) -> Self {
if self.limit <= 0 {
self.limit = 100;
}
if self.limit > 500 {
self.limit = 500;
}
if self.offset < 0 {
self.offset = 0;
}
self
}
}
/// 一次查询的结果(含总数,供前端显示「共 N 条」)。
///
/// # 为什么叫 `TerminalHistoryPage` 而不是 `HistoryPage`
///
/// `tauri-specta` 给 `Type` 派生的类型注册表是**全局按类型名索引**的,重名会让
/// `export_bindings()` 直接 panic`Detected multiple types with the same name`)。
/// `clipboard::commands::HistoryPage``items: Vec<ClipboardItem>`)已占用这个名字。
///
/// specta 2.0.0-rc.25 **没有**给 struct 提供重命名手段 —— `#[specta(rename = ...)]`
/// 只对**函数**宏生效(`specta-macros/src/specta.rs` 的 `parse_name_attrs`);
/// derive 路径下导出名直接取自 Rust 标识符
/// `specta-macros/src/type/mod.rs``let name = unraw_raw_ident(&format_ident!("{}", raw_ident.to_string()))`),
/// 而 `ContainerAttr` 只认 `crate` / `type` / `inline` / `remote` / `collect` /
/// `skip_attr` / `transparent`,且 `reject_unknown_specta_attrs` 会让未知属性直接编译失败。
/// 因此**改名是唯一可行解**。
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TerminalHistoryPage {
pub items: Vec<CommandHistoryItem>,
/// 满足筛选条件的总条数(**不受 limit/offset 影响**
pub total: i64,
}
/// 历史来源(供前端做筛选下拉)。
///
/// 单独定义而不是用元组:元组序列化成 JSON 会变成数组,前端得靠下标取值
/// `s[0]`/`s[1]`/`s[2]`),改一次顺序就静默错位。具名字段让前后端
/// 各自独立演进而不怕顺序变动。
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HistorySource {
pub host_id: String,
pub host_name: String,
/// 该来源的命令条数
pub count: i64,
pub ssh: bool,
}
pub struct History {
conn: Mutex<Connection>,
}
/// 建表语句。
///
/// `v1` 即当前结构。后续版本只追加 `ALTER TABLE`,不重建(见模块头说明)。
fn schema_v1() -> &'static str {
"
CREATE TABLE IF NOT EXISTS cmd_history (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
command TEXT NOT NULL,
cwd TEXT NOT NULL DEFAULT '',
host_id TEXT NOT NULL DEFAULT '',
host_name TEXT NOT NULL DEFAULT '',
ssh INTEGER NOT NULL DEFAULT 0,
count INTEGER NOT NULL DEFAULT 1,
favorited INTEGER NOT NULL DEFAULT 0,
exit_code INTEGER,
UNIQUE(command, cwd, host_id)
);
CREATE INDEX IF NOT EXISTS idx_cmd_hist_ts ON cmd_history(ts DESC);
CREATE INDEX IF NOT EXISTS idx_cmd_hist_host ON cmd_history(host_id, ts DESC);
CREATE INDEX IF NOT EXISTS idx_cmd_hist_fav ON cmd_history(favorited, ts DESC);
-- 外部内容表:索引只存倒排表,正文仍只在 cmd_history 里一份。
-- 代价是不会自动感知主表变化,必须靠下面三个触发器手动同步。
CREATE VIRTUAL TABLE IF NOT EXISTS cmd_history_fts USING fts5(
command,
content='cmd_history', content_rowid='id',
tokenize='trigram'
);
CREATE TRIGGER IF NOT EXISTS cmd_hist_fts_ai AFTER INSERT ON cmd_history BEGIN
INSERT INTO cmd_history_fts(rowid, command) VALUES (new.id, new.command);
END;
CREATE TRIGGER IF NOT EXISTS cmd_hist_fts_ad AFTER DELETE ON cmd_history BEGIN
INSERT INTO cmd_history_fts(cmd_history_fts, rowid, command)
VALUES ('delete', old.id, old.command);
END;
CREATE TRIGGER IF NOT EXISTS cmd_hist_fts_au AFTER UPDATE ON cmd_history BEGIN
INSERT INTO cmd_history_fts(cmd_history_fts, rowid, command)
VALUES ('delete', old.id, old.command);
INSERT INTO cmd_history_fts(rowid, command) VALUES (new.id, new.command);
END;
"
}
/// 转义 FTS5 查询串。
///
/// FTS5 的 `MATCH` 语法把 `"` `*` `(` `)` `:` `^` `-` `+` 等当操作符。
/// 终端命令里这些字符**比比皆是**`grep -v foo`、`ls *.rs`、`git log --oneline`),
/// 不转义时会抛语法错误或产生完全意外的匹配。
///
/// 做法:整体包成双引号短语,并把内部的 `"` 转义为 `""`FTS5 的转义约定,
/// 与 SQL 的 `''` 同理)。包成短语后所有操作符都失去特殊含义,代价是不支持
/// 用户手写布尔表达式 —— 对「搜我敲过的命令」这个场景,字面量匹配正是所需。
fn fts_phrase(keyword: &str) -> String {
format!("\"{}\"", keyword.replace('"', "\"\""))
}
impl History {
pub fn new(dir: &Path) -> Result<Self, String> {
std::fs::create_dir_all(dir).map_err(|e| format!("创建终端目录失败: {e}"))?;
let conn =
Connection::open(dir.join("history.db")).map_err(|e| format!("打开历史库失败: {e}"))?;
conn.execute_batch("PRAGMA journal_mode = WAL;")
.map_err(|e| format!("初始化历史库失败: {e}"))?;
migrate(&conn)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
self.conn.lock().unwrap_or_else(|e| e.into_inner())
}
/// 记录一条命令(同 command+cwd+host 只累加计数并更新时间)。
///
/// # 过滤规则
///
/// - 空命令 / 纯空白:不记(回车空行不该进历史)
/// - 以空格开头:不记。这是 shell 的**惯例**`HISTCONTROL=ignorespace`),
/// 用户用它来避免把含密码的命令写进 `.bash_history`。我们若不遵守,
/// 等于把用户对系统历史的信任**从背后捅穿** —— 这条规则不是可选项。
pub fn record(
&self,
command: &str,
cwd: &str,
host_id: &str,
host_name: &str,
ssh: bool,
exit_code: Option<i32>,
) -> Result<(), String> {
let cmd = command.trim();
if cmd.is_empty() || command.starts_with(' ') {
return Ok(());
}
let now = chrono::Utc::now().timestamp_millis();
self.conn()
.execute(
"INSERT INTO cmd_history (ts, command, cwd, host_id, host_name, ssh, count,
favorited, exit_code)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 0, ?7)
ON CONFLICT(command, cwd, host_id)
DO UPDATE SET ts = excluded.ts,
count = cmd_history.count + 1,
ssh = excluded.ssh,
host_name = excluded.host_name,
exit_code = excluded.exit_code",
params![now, cmd, cwd, host_id, host_name, ssh as i64, exit_code],
)
.map_err(|e| format!("写入命令历史失败: {e}"))?;
Ok(())
}
/// 分页查询。
pub fn query(&self, q: HistoryQuery) -> Result<TerminalHistoryPage, String> {
let q = q.normalized();
let conn = self.conn();
// 动态拼 WHERE。所有用户输入一律走 `params!` 占位符绑定,
// **不做字符串插值** —— 命令历史里出现 `'` 是家常便饭
// `awk '{print $1}'`),插值会直接语法错误甚至注入。
let mut wheres: Vec<String> = Vec::new();
let mut args: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
let keyword = q.keyword.trim();
if !keyword.is_empty() {
if keyword.chars().count() >= FTS_MIN_CHARS {
wheres.push(
"id IN (SELECT rowid FROM cmd_history_fts WHERE cmd_history_fts MATCH ?)"
.to_string(),
);
args.push(Box::new(fts_phrase(keyword)));
} else {
// 短词回退:trigram 索引里没有 2 字符以下的片段,走 FTS 只会
// 静默返回空。用 LIKE 做前缀匹配(`ls` 命中 `ls -la` 也命中 `lsof`)。
wheres.push("command LIKE ? ESCAPE '\\'".to_string());
args.push(Box::new(format!(
"{}%",
escape_like(keyword)
)));
}
}
if !q.host_id.trim().is_empty() {
wheres.push("host_id = ?".to_string());
args.push(Box::new(q.host_id.trim().to_string()));
}
if q.favorited_only {
wheres.push("favorited = 1".to_string());
}
let where_sql = if wheres.is_empty() {
String::new()
} else {
format!(" WHERE {}", wheres.join(" AND "))
};
// 总数单独查一次:`COUNT(*) OVER ()` 需要窗口函数支持(SQLite 3.25+
// bundled 版本满足),但把 total 与分页放在同一查询里会让「无结果时
// total 也拿不到」,而前端在无结果时同样需要显示「共 0 条」。分开查更直白。
let total: i64 = {
let sql = format!("SELECT COUNT(*) FROM cmd_history{where_sql}");
let refs: Vec<&dyn rusqlite::ToSql> = args.iter().map(|b| b.as_ref()).collect();
conn.query_row(&sql, refs.as_slice(), |r| r.get(0))
.map_err(|e| format!("统计命令历史失败: {e}"))?
};
// 排序:收藏优先 → 时间倒序。收藏优先放在 SQL 而非前端排序,
// 否则「翻到第 3 页」的语义会变成「在未排序的集合里翻页」,结果不稳定。
let sql = format!(
"SELECT id, ts, command, cwd, host_id, host_name, ssh, count, favorited, exit_code
FROM cmd_history{where_sql}
ORDER BY favorited DESC, ts DESC
LIMIT ? OFFSET ?"
);
let mut refs: Vec<&dyn rusqlite::ToSql> = args.iter().map(|b| b.as_ref()).collect();
let limit = q.limit;
let offset = q.offset;
refs.push(&limit);
refs.push(&offset);
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("准备查询失败: {e}"))?;
let items = stmt
.query_map(refs.as_slice(), |row| {
Ok(CommandHistoryItem {
id: row.get(0)?,
ts: row.get(1)?,
command: row.get(2)?,
cwd: row.get(3)?,
host_id: row.get(4)?,
host_name: row.get(5)?,
ssh: row.get::<_, i64>(6)? != 0,
count: row.get(7)?,
favorited: row.get::<_, i64>(8)? != 0,
exit_code: row.get(9)?,
})
})
.map_err(|e| format!("查询命令历史失败: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("读取命令历史失败: {e}"))?;
Ok(TerminalHistoryPage { items, total })
}
/// 切换收藏。返回切换后的值。
pub fn toggle_favorite(&self, id: i64) -> Result<bool, String> {
let conn = self.conn();
let cur: i64 = conn
.query_row("SELECT favorited FROM cmd_history WHERE id = ?1", params![id], |r| {
r.get(0)
})
.map_err(|e| format!("找不到历史记录 {id}: {e}"))?;
let next = if cur == 0 { 1 } else { 0 };
conn.execute(
"UPDATE cmd_history SET favorited = ?1 WHERE id = ?2",
params![next, id],
)
.map_err(|e| format!("更新收藏状态失败: {e}"))?;
Ok(next != 0)
}
/// 删除单条。返回是否真的删掉了(`false` = 该 id 不存在)。
pub fn delete(&self, id: i64) -> Result<bool, String> {
let n = self
.conn()
.execute("DELETE FROM cmd_history WHERE id = ?1", params![id])
.map_err(|e| format!("删除历史记录失败: {e}"))?;
Ok(n > 0)
}
/// 清空。`keep_favorites` 为真时保留收藏项。
///
/// 返回删除条数,供前端提示「已清空 N 条」——只说「已清空」而不给数字,
/// 用户无法判断是否误删了收藏项之外的全部内容。
pub fn clear(&self, keep_favorites: bool) -> Result<i64, String> {
let conn = self.conn();
let n = if keep_favorites {
conn.execute("DELETE FROM cmd_history WHERE favorited = 0", [])
} else {
conn.execute("DELETE FROM cmd_history", [])
}
.map_err(|e| format!("清空历史失败: {e}"))?;
Ok(n as i64)
}
/// 淘汰超出容量上限的最旧非收藏记录。
///
/// 在写入路径末尾调用(而非定时任务):容量只会在写入时增长,
/// 挂一个定时器反而要处理「定时器与写入并发」的竞态。
///
/// 用 `id NOT IN (SELECT id ... LIMIT n)` 的子查询形式而不是 `OFFSET`:
/// 后者在超大偏移下要扫描全部前置行,而这里每写一条就跑一次,
/// 不能让单次写入的代价随库增大而线性上升。
pub fn prune(&self, max: i64) -> Result<i64, String> {
let max = if max <= 0 { DEFAULT_MAX_ENTRIES } else { max };
let n = self
.conn()
.execute(
"DELETE FROM cmd_history
WHERE favorited = 0 AND id NOT IN (
SELECT id FROM cmd_history WHERE favorited = 0
ORDER BY ts DESC LIMIT ?1
)",
params![max],
)
.map_err(|e| format!("淘汰历史失败: {e}"))?;
Ok(n as i64)
}
/// 全部来源(供前端做筛选下拉,避免前端自己聚合而漏掉已删除主机的历史)。
pub fn sources(&self) -> Result<Vec<HistorySource>, String> {
let conn = self.conn();
let mut stmt = conn
.prepare(
"SELECT host_id, host_name, COUNT(*) AS n,
MAX(CASE WHEN ssh THEN 1 ELSE 0 END) AS ssh
FROM cmd_history
WHERE host_id <> ''
GROUP BY host_id, host_name
ORDER BY n DESC",
)
.map_err(|e| format!("准备来源查询失败: {e}"))?;
let rows = stmt
.query_map([], |r| {
Ok(HistorySource {
host_id: r.get(0)?,
host_name: r.get(1)?,
count: r.get(2)?,
ssh: r.get::<_, i64>(3)? != 0,
})
})
.map_err(|e| format!("查询来源失败: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("读取来源失败: {e}"))?;
Ok(rows)
}
}
/// 转义 `LIKE` 模式里的通配符。
///
/// 调用方在 SQL 里写了 `ESCAPE '\'`,这里必须把 `\` `%` `_` 三个字符
/// 各自加反斜杠前缀。**`\` 必须最先替换** —— 否则后两步插入的反斜杠
/// 会被第三步再次转义,产生 `\\%` 这种把通配符当成字面量的错误结果。
fn escape_like(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
/// 增量迁移。
///
/// 从 `user_version` 逐级升到 `SCHEMA_VERSION`。首次打开(version = 0
/// 直接建 v1 结构并把版本置为 1。
///
/// # 为什么不复用翻译历史的「版本不等就 DROP」
///
/// 见模块头说明:终端历史里是用户敲过的命令,可能包含服务器地址与路径,
/// 是可追溯的资产而非可丢弃的缓存。
fn migrate(conn: &Connection) -> Result<(), String> {
let mut version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.map_err(|e| format!("读取历史库版本失败: {e}"))?;
if version == 0 {
// 全新库(或来自更早的、没有版本号的实验版本)。
// 用 `CREATE TABLE IF NOT EXISTS` 保证对已有表幂等 ——
// 若库文件存在但 user_version 丢了,这里不会因表已存在而失败。
conn.execute_batch(schema_v1())
.map_err(|e| format!("初始化命令历史表失败: {e}"))?;
version = 1;
conn.execute_batch(&format!("PRAGMA user_version = {version};"))
.map_err(|e| format!("写入历史库版本失败: {e}"))?;
}
// 后续版本在此追加:
// if version == 1 {
// conn.execute_batch("ALTER TABLE cmd_history ADD COLUMN xxx TEXT NOT NULL DEFAULT '';")?;
// version = 2;
// conn.execute_batch(&format!("PRAGMA user_version = {version};"))?;
// }
if version < SCHEMA_VERSION {
// 走到这里说明有迁移分支被漏写了。**显式报错而不是静默继续** ——
// 静默继续会让「新加的列在运行时找不到」变成一个远离成因的报错。
return Err(format!(
"命令历史库版本 {version} 低于期望的 {SCHEMA_VERSION},但缺少对应的迁移步骤"
));
}
Ok(())
}
// ===== 后台 writer =====
/// 一条待落库的命令记录(读线程 → writer 线程的通道载荷)。
pub struct HistoryEntry {
pub command: String,
pub cwd: String,
pub host_id: String,
pub host_name: String,
pub ssh: bool,
pub exit_code: Option<i32>,
}
/// 攒批参数:到达任一阈值(条数 / 时间窗)就把积压写进 SQLite。
const WRITER_BATCH_MAX: usize = 64;
const WRITER_BATCH_WINDOW: std::time::Duration = std::time::Duration::from_millis(400);
/// 把积压的记录写进历史库。
///
/// 放成模块级函数(而非闭包):writer 循环的三个分支(满批 / 超时 / 断开)
/// 都要走到它,闭包会被借用检查卡住(循环里持有 `&mut batch`)。
fn flush_batch(app: &tauri::AppHandle, batch: &mut Vec<HistoryEntry>) {
use tauri::Manager as _;
if batch.is_empty() {
return;
}
let Some(mgr) = app.try_state::<crate::terminal::TerminalManager>() else {
// 应用正在退出(manager 已释放):丢掉这批,历史是辅助功能
batch.clear();
return;
};
let guard = match mgr.history() {
Ok(g) => g,
Err(e) => {
crate::logger::log_error("terminal", &format!("打开历史库失败(丢弃一批记录): {e}"));
batch.clear();
return;
}
};
let Some(h) = guard.as_ref() else {
batch.clear();
return;
};
for e in batch.drain(..) {
if let Err(err) = h.record(
&e.command,
&e.cwd,
&e.host_id,
&e.host_name,
e.ssh,
e.exit_code,
) {
crate::logger::log_error("terminal", &format!("写入命令历史失败(不影响会话): {err}"));
}
}
// 每批淘汰一次(而非每条):把 prune 的 DELETE 子查询从「每命令一次」
// 降为「每批一次」,这是此前输出热路径上最贵的一步。
if let Err(err) = h.prune(0) {
crate::logger::log_error("terminal", &format!("淘汰历史容量失败: {err}"));
}
}
/// 启动后台历史 writer,返回入队端。
///
/// # 为什么需要它(此前的形态是同步写)
///
/// `record_command` 位于**输出热路径**上(OSC 133 的 D 标记到达时,读线程/SSH
/// 读任务正在转发终端输出)。同步形态意味着:整份 settings 深拷贝 + SQLite
/// 写入 + prune 的 DELETE 子查询都发生在转发线程上,`cat` 大文件时每条命令
/// 都会让同一批输出多等一次磁盘 IO。
///
/// 改为通道 + 后台 writer 后,读线程只做一次 `mpsc::send`(非阻塞、无锁竞争)。
/// 落库延迟最多一个攒批窗口(400ms),对「翻历史」场景无感。
///
/// 退出语义:所有入队端 drop 后(`cleanup_on_exit` 会 drop manager 里的那份),
/// `recv` 返回 Err 且缓冲清空,writer 把剩余记录写完后自然退出。
pub fn spawn_writer(app: tauri::AppHandle) -> std::sync::mpsc::Sender<HistoryEntry> {
use std::sync::mpsc::{channel, RecvTimeoutError};
let (tx, rx) = channel::<HistoryEntry>();
std::thread::spawn(move || {
let mut batch: Vec<HistoryEntry> = Vec::new();
loop {
match rx.recv_timeout(WRITER_BATCH_WINDOW) {
Ok(e) => {
batch.push(e);
if batch.len() >= WRITER_BATCH_MAX {
flush_batch(&app, &mut batch);
}
}
Err(RecvTimeoutError::Timeout) => {
flush_batch(&app, &mut batch);
}
Err(RecvTimeoutError::Disconnected) => {
// 入队端全部关闭:写完剩余的,退出
flush_batch(&app, &mut batch);
break;
}
}
}
});
tx
}
#[cfg(test)]
mod tests {
use super::*;
/// 每个用例一个独立目录。
///
/// 沿用 `translate/history.rs` 的做法(`std::env::temp_dir()` + 进程号 + 序号),
/// 而不是引入 `tempdir` dev-dependency`History` 持有的连接在测试结束前不释放,
/// Windows 会拒绝删除仍被打开的文件,所以**本来就没法真正清理**。
/// 为一件做不到的事加一个依赖不划算。
fn open(tag: &str) -> History {
use std::sync::atomic::{AtomicUsize, Ordering};
static SEQ: AtomicUsize = AtomicUsize::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"thing-cmd-hist-test-{}-{tag}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
History::new(&dir).expect("建库失败")
}
#[test]
fn record_and_query_roundtrip() {
let h = open("roundtrip");
h.record("ls -la", "/home/me", "h1", "服务器A", true, Some(0))
.unwrap();
h.record("git status", "/home/me", "h1", "服务器A", true, Some(0))
.unwrap();
let page = h.query(HistoryQuery::default()).unwrap();
assert_eq!(page.total, 2);
// 收藏优先 + 时间倒序:两条都不是收藏,按 ts 倒序 → 后插入的在前
assert_eq!(page.items[0].command, "git status");
}
#[test]
fn dedup_accumulates_count() {
let h = open("dedup");
h.record("make", "/proj", "h1", "A", true, Some(0)).unwrap();
h.record("make", "/proj", "h1", "A", true, Some(2)).unwrap();
let page = h.query(HistoryQuery::default()).unwrap();
assert_eq!(page.total, 1, "同 command+cwd+host 应复用同一行");
assert_eq!(page.items[0].count, 2);
assert_eq!(page.items[0].exit_code, Some(2), "退出码应为最后一次");
}
#[test]
fn same_command_different_cwd_is_separate() {
let h = open("cwd");
h.record("ls", "/a", "h1", "A", true, None).unwrap();
h.record("ls", "/b", "h1", "A", true, None).unwrap();
let page = h.query(HistoryQuery::default()).unwrap();
assert_eq!(page.total, 2, "不同目录下的同名命令是两条独立记录");
}
#[test]
fn leading_space_is_not_recorded() {
// shell 惯例:以空格开头的命令不进历史(HISTCONTROL=ignorespace)。
// 我们若不遵守,等于把用户对系统历史的信任从背后捅穿。
let h = open("leadspace");
h.record(" curl -u user:pass http://x", "/", "h1", "A", true, None)
.unwrap();
h.record("", "/", "h1", "A", true, None).unwrap();
h.record(" ", "/", "h1", "A", true, None).unwrap();
let page = h.query(HistoryQuery::default()).unwrap();
assert_eq!(page.total, 0, "带前导空格与空命令都不应入库");
}
#[test]
fn short_keyword_uses_like_fallback() {
// trigram 索引对 <3 字符的查询静默返回空,必须走 LIKE 回退,
// 否则「搜 ls」会得到空结果 —— 而这正是最常用的搜索词之一。
let h = open("shortkw");
h.record("ls -la", "/", "h1", "A", true, None).unwrap();
h.record("git log", "/", "h1", "A", true, None).unwrap();
let page = h
.query(HistoryQuery {
keyword: "ls".to_string(),
..Default::default()
})
.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items[0].command, "ls -la");
}
#[test]
fn keyword_with_quotes_and_operators_is_safe() {
// 命令里出现 `'` `"` `*` 是家常便饭(`awk '{print $1}'`、`ls *.rs`)。
// 既不能造成 SQL 注入,也不能让 FTS 语法报错。
let h = open("meta");
h.record("awk '{print $1}' file.txt", "/", "h1", "A", true, None)
.unwrap();
h.record("ls *.rs", "/", "h1", "A", true, None).unwrap();
let p1 = h
.query(HistoryQuery {
keyword: "awk '{print $1}'".to_string(),
..Default::default()
})
.unwrap();
assert_eq!(p1.total, 1, "含单引号的查询必须能命中且不报错");
let p2 = h
.query(HistoryQuery {
keyword: "ls *.rs".to_string(),
..Default::default()
})
.unwrap();
assert_eq!(p2.total, 1, "含通配符的查询应按字面量匹配");
}
#[test]
fn favorite_survives_prune_and_clear() {
let h = open("fav");
for i in 0..5 {
h.record(&format!("cmd{i}"), "/", "h1", "A", true, None)
.unwrap();
}
let page = h.query(HistoryQuery::default()).unwrap();
let fav_id = page.items[0].id;
assert!(h.toggle_favorite(fav_id).unwrap());
// 容量淘汰到 2 条:收藏项必须留下(即使它不在最新的 2 条里)
h.prune(2).unwrap();
let after = h.query(HistoryQuery::default()).unwrap();
assert_eq!(after.total, 3, "2 条最新 + 1 条收藏");
assert!(
after.items.iter().any(|i| i.id == fav_id),
"收藏项不应被容量淘汰"
);
// 保留式清空:收藏仍在
let removed = h.clear(true).unwrap();
assert_eq!(removed, 2);
let final_page = h.query(HistoryQuery::default()).unwrap();
assert_eq!(final_page.total, 1);
assert_eq!(final_page.items[0].id, fav_id);
}
#[test]
fn like_escape_handles_backslash_first() {
// `\` 必须最先替换,否则后续插入的反斜杠会被再次转义。
assert_eq!(escape_like("a%b"), "a\\%b");
assert_eq!(escape_like("a_b"), "a\\_b");
assert_eq!(escape_like("a\\b"), "a\\\\b");
// 混合场景:反斜杠 + 通配符
assert_eq!(escape_like("\\%"), "\\\\\\%");
}
#[test]
fn fts_phrase_escapes_double_quotes() {
// FTS5 的短语转义约定:内部 `"` 写成 `""`。
assert_eq!(fts_phrase("a\"b"), "\"a\"\"b\"");
}
#[test]
fn query_limit_is_clamped() {
let q = HistoryQuery {
limit: 99999,
offset: -5,
..Default::default()
}
.normalized();
assert_eq!(q.limit, 500, "limit 上限 500");
assert_eq!(q.offset, 0, "offset 不为负");
let q2 = HistoryQuery::default().normalized();
assert_eq!(q2.limit, 100, "未指定时默认 100");
}
#[test]
fn host_filter_and_favorite_filter() {
let h = open("filters");
h.record("a", "/", "h1", "A", true, None).unwrap();
h.record("b", "/", "h2", "B", true, None).unwrap();
h.record("c", "/", "h1", "A", true, None).unwrap();
let by_host = h
.query(HistoryQuery {
host_id: "h1".to_string(),
..Default::default()
})
.unwrap();
assert_eq!(by_host.total, 2);
let page = by_host.clone();
h.toggle_favorite(page.items[0].id).unwrap();
let favs = h
.query(HistoryQuery {
favorited_only: true,
..Default::default()
})
.unwrap();
assert_eq!(favs.total, 1);
}
#[test]
fn sources_aggregates_by_host_id() {
let h = open("sources");
h.record("a", "/", "h1", "A", true, None).unwrap();
h.record("b", "/", "h1", "A", true, None).unwrap();
h.record("c", "/", "h2", "B", true, None).unwrap();
h.record("d", "/", "", "", false, None).unwrap(); // 无来源,不参与聚合
let s = h.sources().unwrap();
assert_eq!(s.len(), 2);
// 按条数倒序:h1 有 2 条
assert_eq!(s[0].host_id, "h1");
assert_eq!(s[0].count, 2);
}
#[test]
fn migration_is_idempotent_on_reopen() {
// 同一目录重复打开不应报错,也不应因重复建表而丢失数据。
// `migrate` 在 version != 0 时跳过建表,这条用例正是守着那个分支。
use std::sync::atomic::{AtomicUsize, Ordering};
static SEQ: AtomicUsize = AtomicUsize::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"thing-cmd-hist-test-reopen-{}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
{
let h = History::new(&dir).expect("首次打开");
h.record("keepme", "/", "h1", "A", true, None).unwrap();
}
let h2 = History::new(&dir).expect("二次打开(v0 分支不应再执行)");
let page = h2.query(HistoryQuery::default()).unwrap();
assert_eq!(page.total, 1, "重开库不应清空数据");
assert_eq!(page.items[0].command, "keepme");
}
}
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
//! 终端模块:SSH 与本地 Shell 多会话终端。
//!
//! # 模块结构
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`session`] | `Session` trait 抽象 + `SessionRegistry`(本地/SSH 共用) |
//! | [`pty::conpty`] | 本地 ConPTY 后端(含三个已知坑的处理) |
//! | [`shell`] | Shell 探测、命令行组装、OSC 7 cwd 跟踪 |
//! | [`ssh`] | SSH 后端(russh)、认证、主机密钥校验、SFTP |
//! | [`keys`] | SSH 密钥生成/导入/管理 |
//! | [`settings`] | 设置模型与默认值(容器级 `#[serde(default)]` |
//! | [`events`] | 事件负载类型 |
//! | [`commands`] | Tauri 命令层(薄:整形/校验/归类) |
//!
//! # 为什么自带一套会话管理而不复用 `ProcessManager`
//!
//! 见 [`session`] 模块注释。一句话:`ProcessManager` 是「单例守护进程」模型,
//! 终端要的是「N 个独立会话 + 双向流式 I/O + 退出不重启」,语义不同。
//!
//! # 数据目录
//!
//! ```text
//! {app_data_dir}/terminal/
//! ├── settings.json 设置、主机列表、密钥元数据
//! ├── known_hosts.json 主机密钥指纹库(非机密,人可读可导出)
//! ├── keys/ SSH 私钥文件本体
//! └── hooks/ Shell cwd 跟踪临时脚本(Git Bash 用)
//! ```
//!
//! # 凭据存储
//!
//! SSH 密码、私钥 passphrase 一律走 [`crate::secrets`](系统凭据管理器,DPAPI)。
//! 键名约定:
//! - `terminal-ssh-password-{hostId}`
//! - `terminal-key-passphrase-{keyId}`
//!
//! **本模块不存在任何读取凭据明文的命令**:列表接口只回传是否已配置与掩码串。
pub mod assistant;
pub mod audit;
pub mod commands;
pub mod encoding;
pub mod events;
pub mod history;
pub mod keys;
pub mod pty;
pub mod session;
pub mod settings;
pub mod shell;
pub mod ssh;
pub mod window;
use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard};
use tauri::{AppHandle, Emitter, Manager};
use session::SessionRegistry;
use settings::TerminalSettings;
/// 供 `pty::conpty` 与 `ssh` 共用的事件常量入口。
///
/// 两个后端都要 emit 事件,逐处写 `super::super::events::...` 可读性差且容易写错层级,
/// 因此在这里做一次再导出,后端内部统一用 `crate::terminal::events::TERMINAL_*`。
///
/// 注:当前两个后端已改为直接引用 `crate::terminal::events::TERMINAL_*`
/// 此别名保留为对外稳定出口(避免下游按路径引用时因重构断链)。
#[allow(unused_imports)]
pub use crate::constants::events as event_names;
/// 输出事件负载(两个后端共用)。同样作为对外出口保留。
#[allow(unused_imports)]
pub use events::OutputPayload;
/// 终端模块管理器(Tauri State)。
///
/// 与 `TranslateManager` / `MusicManager` 平级,在 `setup::init` 中构造并 `manage`。
pub struct TerminalManager {
/// 模块自身目录:`{app_data_dir}/terminal`
root: PathBuf,
/// 会话注册表(本地 + SSH 共用一张表,id 全局唯一)
pub sessions: SessionRegistry,
/// 活跃的 SFTP 通道(按会话 id 索引)。
///
/// 放在 `TerminalManager` 而不是 `SessionRegistry` 上:SFTP 是**可选能力**
/// 只有 SSH 会话且用户打开了文件面板才存在。挂在会话注册表上会让「会话」
/// 这个概念背负一个大多数情况下为空的字段。
pub sftp: ssh::sftp::SftpRegistry,
/// SSH 连接池(P2 连接复用):同身份的多个会话共享一条 SSH 连接。
///
/// 挂在 `TerminalManager` 上与 SFTP 同理——它是**跨会话**的资源,
/// 生命周期由池内引用计数管理(最后一个使用它的会话关闭时才断开)。
pub ssh_pool: ssh::pool::ConnectionPool,
/// 命令历史库(SQLite)。懒加载:`None` 表示尚未打开连接。
///
/// 与 `settings` 一样懒加载:大多数会话(尤其是刚启动就开标签的场景)
/// 在第一次写入命令之前根本用不到历史库,没必要在 `TerminalManager::new`
/// 里同步打开一个 SQLite 连接(含 WAL 初始化与建表检查)。
pub history: Mutex<Option<history::History>>,
/// 命令历史后台 writer 的入队端。
///
/// `record_command` 位于输出热路径上,此前每次都同步「settings 深拷贝 +
/// SQLite 写 + prune」;现在改为 `mpsc::send` 入队,由 [`history::spawn_writer`]
/// 的 writer 线程攒批落库。`None` 表示尚未启动(init_on_launch 时启动)。
history_tx: Mutex<Option<std::sync::mpsc::Sender<history::HistoryEntry>>>,
/// 设置缓存。`None` 表示尚未加载(懒加载,避免启动时多做一次磁盘 IO)
settings: Mutex<Option<TerminalSettings>>,
}
impl TerminalManager {
pub fn new(app_data_dir: PathBuf) -> Self {
let root = app_data_dir.join("terminal");
// 目录先行创建:后续 keys/ 与 hooks/ 都依赖它
std::fs::create_dir_all(root.join("keys")).ok();
std::fs::create_dir_all(root.join("hooks")).ok();
// logs/ 由 audit::toggle 按需创建(首次开启日志才落盘),此处不预建
Self {
root,
sessions: SessionRegistry::new(),
sftp: ssh::sftp::SftpRegistry::new(),
ssh_pool: ssh::pool::ConnectionPool::default(),
history: Mutex::new(None),
history_tx: Mutex::new(None),
settings: Mutex::new(None),
}
}
/// 取(必要时打开)命令历史库。
///
/// `&self` + 内部 `Mutex<Option<..>>` 而非 `&mut self`:命令层拿到的是
/// `State<'_, TerminalManager>`,多个并发命令(比如历史面板在搜、同时
/// 另一个会话在写新命令)都会调到它,`&mut` 会把两者串行化。
///
/// 打开失败**不缓存失败结果**`left` 保持 `None`):磁盘临时不可用、
/// 目录权限刚被修好这类情况应当允许后续调用重试。若把 `Err` 也当成
/// 「已初始化」,用户就得重启应用才能恢复历史功能。
pub fn history(&self) -> Result<std::sync::MutexGuard<'_, Option<history::History>>, String> {
let mut guard = self.history.lock().unwrap_or_else(|e| e.into_inner());
if guard.is_none() {
*guard = Some(history::History::new(&self.root)?);
}
Ok(guard)
}
pub fn root(&self) -> &PathBuf {
&self.root
}
/// 把一条命令历史入队(由后台 writer 攒批落库)。
///
/// 通道不可用(writer 尚未启动或已退出)时退回同步写:
/// 「writer 死了历史就静默丢失」比「热路径偶尔慢一次」更糟。
pub fn queue_history(&self, mut entry: history::HistoryEntry) {
{
let guard = self.history_tx.lock().unwrap_or_else(|e| e.into_inner());
if let Some(tx) = guard.as_ref() {
match tx.send(entry) {
Ok(()) => return,
// writer 已退出:SendError 里带着原值,取回走同步兜底
Err(e) => entry = e.0,
}
}
}
// 通道已断:同步兜底
if let Ok(guard) = self.history() {
if let Some(h) = guard.as_ref() {
if let Err(e) = h.record(
&entry.command,
&entry.cwd,
&entry.host_id,
&entry.host_name,
entry.ssh,
entry.exit_code,
) {
crate::logger::log_error(
"terminal",
&format!("写入命令历史失败(不影响会话): {e}"),
);
}
if let Err(e) = h.prune(0) {
crate::logger::log_error("terminal", &format!("淘汰历史容量失败: {e}"));
}
}
}
}
/// 查 shell / 主机的显示名(供历史落库)。
///
/// 此前 `record_command` 用 `settings()`(整份深拷贝,含全部主机、Shell、
/// 快捷键表)只为拿一个名字,而它在输出热路径上。这里改为持锁只读出
/// 需要的字段。
pub fn display_name(&self, target_id: &str, ssh: bool) -> String {
let guard = self.lock_settings();
let Some(s) = guard.as_ref() else {
return target_id.to_string();
};
if ssh {
s.hosts
.iter()
.find(|h| h.id == target_id)
.map(|h| h.name.clone())
.unwrap_or_else(|| target_id.to_string())
} else {
s.shells
.iter()
.find(|x| x.id == target_id)
.map(|x| x.name.clone())
.unwrap_or_else(|| target_id.to_string())
}
}
pub fn settings_path(&self) -> PathBuf {
self.root.join("settings.json")
}
/// 密钥文件存放目录。
pub fn keys_dir(&self) -> PathBuf {
self.root.join("keys")
}
/// cwd hook 脚本目录(Git Bash 的 `--init-file` 需要一个真实文件)。
pub fn hooks_dir(&self) -> PathBuf {
self.root.join("hooks")
}
/// 会话日志目录(`{app_data_dir}/terminal/logs/`)。
///
/// 目录由 `audit::toggle` 按需创建:日志是可选能力,
/// 不为它预付一次磁盘 IO。
pub fn logs_dir(&self) -> PathBuf {
self.root.join("logs")
}
/// 读取设置(带缓存)。
///
/// 首次调用会从磁盘读取并执行 `heal()`;若 `heal` 报告变更则立即落盘,
/// 避免「老配置每次启动都要自愈一遍」。
pub fn settings(&self) -> TerminalSettings {
let mut guard = self.lock_settings();
if let Some(s) = guard.as_ref() {
return s.clone();
}
let mut s = self.load_settings_from_disk();
if s.heal() {
if let Err(e) = self.write_settings_file(&s) {
crate::logger::log_error("terminal", &format!("自愈后保存设置失败: {e}"));
}
}
*guard = Some(s.clone());
s
}
/// 写入设置(覆盖缓存 + 落盘)。
pub fn save_settings(&self, mut next: TerminalSettings) -> Result<(), String> {
// 保存前自愈一次:前端可能提交了越界值(滚动缓冲、字体大小等)
next.heal();
self.write_settings_file(&next)?;
*self.lock_settings() = Some(next);
Ok(())
}
/// 局部修改设置:读 → 改 → 写。避免前端为了改一个字段而回传整份设置
/// (回传整份会带来「前端旧快照覆盖后端新值」的竞态)。
pub fn update_settings<F>(&self, f: F) -> Result<TerminalSettings, String>
where
F: FnOnce(&mut TerminalSettings),
{
let mut s = self.settings();
f(&mut s);
self.save_settings(s.clone())?;
Ok(s)
}
/// 从磁盘读取。文件不存在或解析失败时返回默认值——
/// 配置文件损坏不该让整个模块不可用(用户至少还能新建会话)。
fn load_settings_from_disk(&self) -> TerminalSettings {
let path = self.settings_path();
match std::fs::read_to_string(&path) {
Ok(raw) => match serde_json::from_str::<TerminalSettings>(&raw) {
Ok(s) => s,
Err(e) => {
// 备份损坏文件再退回默认:直接覆盖会让用户丢失可手工修复的内容
let bak = path.with_extension("json.broken");
let _ = std::fs::rename(&path, &bak);
crate::logger::log_error(
"terminal",
&format!(
"设置解析失败(已备份至 {}: {e}",
bak.display()
),
);
TerminalSettings::default()
}
},
Err(_) => TerminalSettings::default(),
}
}
fn write_settings_file(&self, s: &TerminalSettings) -> Result<(), String> {
let path = self.settings_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {e}"))?;
}
let json = serde_json::to_string_pretty(s).map_err(|e| format!("序列化设置失败: {e}"))?;
// 先写临时文件再 rename:避免写入中途崩溃留下半截 JSON
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json).map_err(|e| format!("写入设置失败: {e}"))?;
std::fs::rename(&tmp, &path).map_err(|e| format!("保存设置失败: {e}"))
}
/// 统一的锁获取:中毒时取回内部值。
///
/// 设置结构本身没有跨字段不变量(每个字段独立),因此「中毒」不代表数据
/// 已损坏,直接 panic 反而会把一次无关的线程崩溃放大成整个模块不可用。
fn lock_settings(&self) -> MutexGuard<'_, Option<TerminalSettings>> {
self.settings.lock().unwrap_or_else(|e| e.into_inner())
}
/// 刷新 Shell 探测结果并写回设置。
///
/// 返回完整的 Shell 列表(含用户自定义项)。用户在设置页点「重新探测」时调用。
pub fn refresh_shells(&self) -> Result<Vec<settings::ShellProfile>, String> {
let current = self.settings();
let merged = shell::detect_and_merge(&current.shells);
let result = merged.clone();
self.update_settings(|s| {
s.shells = merged;
})?;
Ok(result)
}
/// 应用退出清理:关闭所有会话。
///
/// **不等待**。ConPTY 的 `ClosePseudoConsole` 会阻塞到所有句柄关闭,
/// 退出路径上等待会卡死(参照 `MonitorKernel` 的教训——那里用了独立线程
/// + `recv_timeout(3s)` 防挂起)。会话侧已把关闭动作放进后台线程,
/// 进程终止时 OS 回收剩余资源。
pub fn cleanup_on_exit(&self) {
let n = self.sessions.len();
if n > 0 {
crate::logger::log_info("terminal", &format!("退出:关闭 {n} 个会话"));
}
// 先关 SFTP 通道:它们与 shell 共用同一条 TCP 连接,若先关连接,
// SFTP 侧的 close 报文会写到已关闭的 socket 上(在日志里留下一串噪音)。
let ids: Vec<String> = self.sessions.list().into_iter().map(|s| s.id).collect();
for id in ids {
self.sftp.close(&id);
}
// 丢弃历史 writer 的入队端:writer 会把通道里剩余的记录写完后自然退出
// (不 join —— 退出路径上不做任何等待,见本函数头部注释)
*self.history_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
self.sessions.close_all();
}
/// 在应用启动时初始化:预创建 hooks 目录、刷新 Shell 探测、按需注册全局快捷键。
///
/// **不发任何网络请求**(沿用 translate 模块的姿态):SSH 连接只在用户
/// 主动打开会话时建立。
pub fn init_on_launch(&self, app: &AppHandle) {
// 首次运行或探测结果为空时做一次 Shell 探测
let s = self.settings();
if s.shells.is_empty() {
if let Err(e) = self.refresh_shells() {
crate::logger::log_error("terminal", &format!("Shell 探测失败: {e}"));
}
} else {
// 已有配置也要重新探测:用户可能升级/卸载了 PowerShell 7。
// 失败不阻断启动。
if let Err(e) = self.refresh_shells() {
crate::logger::log_warn("terminal", &format!("Shell 重探测失败(沿用旧配置): {e}"));
}
}
// 清理上次运行遗留的 hook 脚本(内容每次都重新生成,不会丢信息)
let hooks = self.hooks_dir();
if let Ok(entries) = std::fs::read_dir(&hooks) {
for e in entries.flatten() {
if e.path().extension().map_or(false, |x| x == "sh") {
let _ = std::fs::remove_file(e.path());
}
}
}
// 启动命令历史后台 writer(见 `history_tx` 字段的说明)
*self.history_tx.lock().unwrap_or_else(|e| e.into_inner()) =
Some(history::spawn_writer(app.clone()));
let _ = app; // 全局快捷键由前端 store 在模块启用时注册(与截图模块同一范式)
}
}
/// 从 Tauri State 取 TerminalManager。
pub fn manager(app: &AppHandle) -> Result<tauri::State<'_, TerminalManager>, String> {
app.try_state::<TerminalManager>()
.ok_or_else(|| "终端模块未初始化".to_string())
}
/// 发射 SFTP 传输进度事件。
///
/// 转发到 [`events::emit_transfer`],在 `terminal` 根上再导出一层是为了让命令层
/// 写 `crate::terminal::emit_transfer(...)` 与 `emit_state` 保持同一路径风格。
pub fn emit_transfer(app: &AppHandle, payload: &ssh::sftp::TransferProgress) {
events::emit_transfer(app, payload);
}
/// 发射会话状态事件。
///
/// # 为什么需要这个统一入口
///
/// 两个后端(ConPTY / SSH)在**六处**要更新状态:连接中、已认证、已建立、
/// 降级、关闭、失败。若每处各自 `emit(TERMINAL_STATE, session.info())`
/// 就会出现「某处忘了 emit,前端状态点停在旧值」这类难查的不一致。
/// 集中到一处后,「改状态」与「广播状态」永远成对发生。
///
/// `final_state` 参数是**显式传入**而非从 `info()` 读取的:调用方常常是在
/// 刚写入状态、但 `info()` 还持有旧值的时间窗内调用(各自持有不同 Mutex),
/// 传参能避免这个竞态。
pub fn emit_state(
app: &AppHandle,
state: &session::LocalSessionState,
final_state: session::SessionState,
error: Option<String>,
) {
if let Some(err) = error.as_ref() {
*state.error.lock().unwrap_or_else(|e| e.into_inner()) = Some(err.clone());
}
let mut info = session::local_info(state, session::SessionKind::Local);
info.state = final_state;
let _ = app.emit(crate::constants::events::TERMINAL_STATE, info);
}
+746
View File
@@ -0,0 +1,746 @@
//! ConPTY 绑定与本地会话实现。
//!
//! 直接绑定 `windows-sys` 的 `CreatePseudoConsole` 系列 API(而非引入
//! `portable-pty` 之类的封装)。理由:本项目已有大量原生 Win32 调用
//! `win32_util.rs` / `screenshot/wgc_capture.rs` / `translate/capture/uia_capture.rs`),
//! 这条路径熟悉;而 ConPTY 的三个坑(见下)无论加不加封装都要踩,多一层只增加定位难度。
//!
//! # ConPTY 的三个坑(全部已在实现中处理)
//!
//! 1. **`ClosePseudoConsole` 会阻塞**,直到所有引用该 PTY 的句柄被关闭。若在读线程
//! 仍挂起于 `ReadFile` 时调用,就会永久卡住。处理:先 `CancelIoEx` 取消挂起的读,
//! 再在**独立线程**里调用 `ClosePseudoConsole`,调用方不等待(见 [`ConPtySession::kill`])。
//!
//! 2. **`ResizePseudoConsole` 有早期竞态**:进程刚创建、还没开始读 stdout 时调用,
//! 尺寸可能被吞掉(表现为 TUI 程序启动后按 80×24 而不是实际尺寸绘制,vim/htop 花屏)。
//! 处理:首帧输出到达前,resize 请求只入队不执行;首帧到达后再应用队列中的最新值
//! (见 [`PtyInner::pending_size`])。
//!
//! 3. **进程退出 ≠ PTY 关闭**:子进程退出后,管道里可能还有未读完的输出(如最后一行
//! 提示符、错误信息)。必须等 `ReadFile` 返回 0 或 `ERROR_BROKEN_PIPE` 才算真正结束,
//! 否则会丢掉尾部输出——这正是很多自制终端「退出时少一行」的原因。
//!
//! # 线程模型
//!
//! 每个会话起 **两个** 后台线程:
//! - 输出读线程:循环 `ReadFile`,把数据推入聚合缓冲,按 8~16ms 窗口发批次事件。
//! - 退出等待线程:`WaitForSingleObject` 等子进程句柄,拿退出码,等读线程自然结束
//! (即坑 3)后把状态置为 `Closed` 并发事件。
//!
//! 写操作不单独起线程:`WriteFile` 在 ConPTY 上通常不阻塞(有内部缓冲),
//! 由命令层直接同步调用。若未来证实大块粘贴会阻塞,再改成写队列。
use std::io::{ErrorKind, Read, Write};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Console::{
ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON,
};
use windows_sys::Win32::System::Threading::{
CreateProcessW, GetExitCodeProcess, TerminateProcess, WaitForSingleObject,
CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, PROCESS_INFORMATION,
STARTUPINFOEXW,
};
use super::super::session::{
local_info, LocalSessionState, Session, SessionId, SessionInfo, SessionKind, SessionState,
};
/// 输出聚合窗口。
///
/// 8ms 是权衡值:`cat` 大文件时每秒可产生数万次 `ReadFile` 返回,逐条 emit 会压垮
/// WebView;但窗口太长(如 50ms)会让交互式输入出现可感知的延迟。8ms 约等于
/// 一帧(120Hz),人眼无法分辨,同时能把数千次读合并成一次事件。
const AGGREGATE_WINDOW: Duration = Duration::from_millis(8);
/// 单次读缓冲区大小。64KB 与 ConPTY 内部缓冲匹配,避免多次往返。
const READ_BUF_SIZE: usize = 64 * 1024;
/// 聚合缓冲上限:防止「疯狂输出且前端卡住」时内存无限增长。
/// 超出后丢弃**最旧**的数据(终端语义:用户更关心最新的输出)。
const MAX_PENDING_BYTES: usize = 4 * 1024 * 1024;
/// 会话终止时附加到输出的提示(由 Rust 侧统一给出,避免前端各写一套)。
const EXIT_HINT: &str = "\r\n";
// ===== 共享状态 =====
struct PtyInner {
/// PTY 句柄。`Mutex` 保护是因为 resize 需要并发访问,而 ClosePseudoConsole
/// 会把它置为 None(表示已关闭,后续调用应静默忽略)。
hpc: Mutex<Option<HPCON>>,
/// 写入端(我们 → 伪控制台输入)。`Option` 以便 kill 后释放。
writer: Mutex<Option<std::fs::File>>,
/// 子进程句柄,用于退出等待与强制终止。
process: Mutex<Option<OwnedHandle>>,
/// 是否已收到首帧输出(ConPTY resize 竞态的处理依据,见模块注释坑 2)。
first_output_seen: AtomicBool,
/// 首帧之前缓存的尺寸请求。
pending_size: Mutex<Option<(u16, u16)>>,
/// 是否已关闭(幂等保护)。
closed: AtomicBool,
}
/// 本地 ConPTY 会话。
pub struct ConPtySession {
pub state: Arc<LocalSessionState>,
inner: Arc<PtyInner>,
/// 事件发射器。持有 `AppHandle` 而非 `Window`:会话可以「提升」为独立窗口
/// (见 `terminal_detach_session`),事件应发给所有窗口而不是绑定的那一个。
app: AppHandle,
/// 输出批次序号(每会话独立计数,前端按会话校验连续性)。
seq: AtomicU64,
}
impl ConPtySession {
/// 启动一个本地 Shell 会话。
///
/// `command_line` 必须是**完整的命令行**(含可执行文件路径)。Windows 的
/// `CreateProcessW` 在传入 `lpApplicationName = NULL` 时会自行解析命令行首段
/// 作为可执行文件,因此需要调用方保证路径带引号(见 [`super::super::shell::build_command_line`])。
pub fn spawn(
app: AppHandle,
id: SessionId,
shell_id: String,
shell_name: String,
command_line: String,
cwd: Option<String>,
env: Vec<(String, String)>,
cols: u16,
rows: u16,
) -> Result<Self, String> {
let state = Arc::new(LocalSessionState::new(id.clone(), shell_id, shell_name));
*state.size.lock().unwrap_or_else(|e| e.into_inner()) = (cols, rows);
// ===== 1. 创建一对匿名管道 =====
// ConPTY 需要「输入管道」(我们写、PTY 读)与「输出管道」(PTY 写、我们读)。
let (input_read, input_write) = create_pipe()?;
let (output_read, output_write) = create_pipe()?;
// ===== 2. 创建伪控制台 =====
let size = COORD {
X: cols as i16,
Y: rows as i16,
};
let mut hpc: HPCON = 0;
// SAFETY: 传入的两个句柄是本函数刚创建的、有效的管道端;
// size 已按 COORD 的 i16 范围做了钳制(见 clamp_dim)。
let hr = unsafe {
CreatePseudoConsole(size, input_read.as_raw_handle() as HANDLE, output_write.as_raw_handle() as HANDLE, 0, &mut hpc)
};
if hr < 0 {
return Err(format!("CreatePseudoConsole 失败(HRESULT: 0x{hr:08X}"));
}
// 创建后立即关掉我们持有的这两端:
// - input_read:PTY 已持有自己的副本,我们只保留写端
// - output_write:同理,我们只保留读端
// 若不关闭,读端永远等不到 EOF(因为写端仍被本进程持有),
// 表现为「会话关闭后读线程不退出」,进而导致 ClosePseudoConsole 卡死(坑 1)。
drop(input_read);
drop(output_write);
// ===== 3. 组装 STARTUPINFOEX 并把 PTY 传给子进程 =====
let mut si: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
si.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
// 必须设置这两个标志:
// - EXTENDED_STARTUPINFO_PRESENT:让系统读 attribute list 里的 HPCON
// - CREATE_UNICODE_ENVIRONMENT:环境块是 UTF-16
let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
// 把 HPCON 放进进程属性列表。这一步用 ATTRIBUTE 常量的原始值即可,
// 不必引入 PROCTHREAD_ATTRIBUTE 类型(windows-sys 未导出便捷构造器)。
const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x0002_0016;
let mut attr_size: usize = 0;
unsafe {
// 第一次调用取所需大小
InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut attr_size);
}
let mut attr_buf = vec![0u8; attr_size];
let attr_list = attr_buf.as_mut_ptr() as *mut _;
// SAFETY: attr_buf 按 API 报告的大小分配;attr_size 已由上一次调用写入。
let ok = unsafe { InitializeProcThreadAttributeList(attr_list, 1, 0, &mut attr_size) };
if ok == 0 {
unsafe { ClosePseudoConsole(hpc) };
return Err(format!(
"InitializeProcThreadAttributeList 失败: {}",
std::io::Error::last_os_error()
));
}
// SAFETY: attr_list 已初始化且声明可容纳 1 个属性;hpc 是有效的 HPCON。
//
// 注意 windows-sys 0.52 里 `HPCON = isize`0.59+ 才是 `*mut c_void`)。
// `isize as *const c_void` 是不允许的直接转型(E0641),
// 必须先转成 `usize` 再转指针——两步都是明确的大小的整数/指针转换。
let hpc_ptr = hpc as usize as *const std::ffi::c_void;
let ok = unsafe {
UpdateProcThreadAttribute(
attr_list,
0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
hpc_ptr,
std::mem::size_of::<HPCON>(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if ok == 0 {
let e = std::io::Error::last_os_error();
unsafe {
DeleteProcThreadAttributeList(attr_list);
ClosePseudoConsole(hpc);
}
return Err(format!("UpdateProcThreadAttribute 失败: {e}"));
}
si.lpAttributeList = attr_list;
// ===== 4. 组装环境块与命令行 =====
let env_block = build_env_block(&env)?;
let mut cmdline: Vec<u16> = command_line.encode_utf16().chain(std::iter::once(0)).collect();
let cwd_wide: Option<Vec<u16>> = cwd
.as_ref()
.filter(|s| !s.trim().is_empty())
.map(|s| s.encode_utf16().chain(std::iter::once(0)).collect());
// ===== 5. 创建进程 =====
// SAFETY: 所有指针都指向本函数栈/堆上的有效数据,且在调用期间存活;
// cmdline 是可变的(CreateProcessW 可能原地修改它,这是 API 约定)。
let created = unsafe {
CreateProcessW(
std::ptr::null(), // 让系统从命令行解析可执行文件
cmdline.as_mut_ptr(), // 可写缓冲
std::ptr::null(), // 默认进程安全属性
std::ptr::null(), // 默认线程安全属性
0, // 不继承句柄(PTY 走属性列表传递)
EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT,
env_block.as_ptr() as *const std::ffi::c_void, // 环境块
cwd_wide.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()),
&si.StartupInfo,
&mut pi,
)
};
// 无论成功与否,属性列表都可以释放了(系统已复制所需信息)
unsafe {
DeleteProcThreadAttributeList(attr_list);
}
if created == 0 {
let e = std::io::Error::last_os_error();
unsafe { ClosePseudoConsole(hpc) };
return Err(format!("创建进程失败: {e}"));
}
// 主线程句柄用不到,立即关闭(进程句柄保留,用于等待退出)
unsafe { CloseHandle(pi.hThread) };
// ===== 6. 组装会话对象 =====
// `create_pipe` 已直接返回 `File`(在内部完成裸句柄 → `File` 的转换),
// 此处不再做二次转换——早前那版把 `File` 当句柄再转一次,
// 会触发 `expected isize, found File` 的类型错误。
let writer = input_write;
let reader = output_read;
let process = unsafe { OwnedHandle::from_raw_handle(pi.hProcess as *mut _) };
let inner = Arc::new(PtyInner {
hpc: Mutex::new(Some(hpc)),
writer: Mutex::new(Some(writer)),
process: Mutex::new(Some(process)),
first_output_seen: AtomicBool::new(false),
pending_size: Mutex::new(None),
closed: AtomicBool::new(false),
});
let session = Self {
state: state.clone(),
inner: inner.clone(),
app: app.clone(),
seq: AtomicU64::new(0),
};
session.set_state(SessionState::Established, None);
// ===== 7. 启动读线程与退出等待线程 =====
spawn_reader(app.clone(), state.clone(), inner.clone(), reader);
spawn_waiter(app, state.clone(), inner, session.seq_counter());
Ok(session)
}
/// 输出序号计数器(每会话独立;前端按会话分别校验连续性)。
fn seq_counter(&self) -> Arc<AtomicU64> {
// 这里刻意返回一个独立计数器:`ConPtySession` 自身可能被 move 进
// 注册表,而读线程需要在 move 之前就拿到它。两者通过 Arc 共享。
Arc::new(AtomicU64::new(self.seq.load(Ordering::Relaxed)))
}
}
/// 管道创建:返回 (读端, 写端) 两个 `File`。
fn create_pipe() -> Result<(std::fs::File, std::fs::File), String> {
use std::os::windows::io::FromRawHandle;
use windows_sys::Win32::System::Pipes::CreatePipe;
let mut read: HANDLE = INVALID_HANDLE_VALUE;
let mut write: HANDLE = INVALID_HANDLE_VALUE;
// SAFETY: 两个 out 参数都指向本函数栈上的有效 HANDLE 变量;
// 安全属性传 null 表示句柄不可继承(我们不需要子进程继承管道本身)。
let ok = unsafe { CreatePipe(&mut read, &mut write, std::ptr::null(), 0) };
if ok == 0 {
return Err(format!(
"CreatePipe 失败: {}",
std::io::Error::last_os_error()
));
}
// SAFETY: CreatePipe 成功返回后,read/write 都是有效的、由我们独占的句柄。
unsafe {
Ok((
std::fs::File::from_raw_handle(read as *mut _),
std::fs::File::from_raw_handle(write as *mut _),
))
}
}
/// 构造 UTF-16 环境块(`KEY=VALUE\0...\0\0`)。
///
/// 从 `std::env::vars()` 出发做增量修改,而不是从空环境开始:Windows 上进程
/// 需要 `SystemRoot` / `PATH` / `USERPROFILE` 等继承变量才能正常工作。
/// 值为空串表示**删除**该变量(前端用「清空值」表达删除意图,比另设开关直观)。
fn build_env_block(overrides: &[(String, String)]) -> Result<Vec<u16>, String> {
let mut map: std::collections::BTreeMap<String, String> = std::env::vars().collect();
// 未设置会影响 shell 提示符与编码;显式补齐(用户 override 可覆盖)
map.entry("TERM".to_string()).or_insert_with(|| "xterm-256color".to_string());
for (k, v) in overrides {
if v.is_empty() {
map.remove(k);
} else {
map.insert(k.clone(), v.clone());
}
}
let mut block = Vec::with_capacity(4096);
for (k, v) in map {
// 环境块不允许 key 含 '='Windows 用它分隔键值)
if k.contains('=') || k.is_empty() {
continue;
}
block.extend(format!("{k}={v}").encode_utf16());
block.push(0);
}
block.push(0); // 双 null 结尾
Ok(block)
}
/// 输出读线程:读 → 聚合 → 发批次事件。
///
/// 三种结束条件(都发 `terminal-exit`,但来源不同):
/// 1. `ReadFile` 返回 0EOF)—— 正常结束
/// 2. `ERROR_BROKEN_PIPE` / `ERROR_OPERATION_ABORTED` —— PTY 被关闭(kill 路径)
/// 3. 其他 IO 错误 —— 异常,上报 error
fn spawn_reader(
app: AppHandle,
state: Arc<LocalSessionState>,
inner: Arc<PtyInner>,
mut reader: std::fs::File,
) {
std::thread::spawn(move || {
let mut buf = vec![0u8; READ_BUF_SIZE];
let mut pending: Vec<u8> = Vec::with_capacity(READ_BUF_SIZE);
let mut last_flush = Instant::now();
// OSC 序列扫描的拼接缓冲:序列可能被切在两批数据之间(见 shell::parse_control_sequences
let mut osc_tail: Vec<u8> = Vec::new();
// 命令历史累积器(见 session::CommandAccumulator)。
// 由本读线程独占持有 —— 只在读线程里被访问,不需要共享。
let sim = crate::terminal::session::CommandAccumulator::new();
// 上一批发送的序号,用于退出时把 batch 序号一并回传(前端据此判断有无丢包)
let mut last_seq: u64 = 0;
loop {
match reader.read(&mut buf) {
Ok(0) => break, // EOF:坑 3 的正解,子进程退出后管道仍可能有残余数据
Ok(n) => {
pending.extend_from_slice(&buf[..n]);
// 首帧到达:应用之前排队的尺寸(坑 2)
if !inner.first_output_seen.swap(true, Ordering::SeqCst) {
if let Some((cols, rows)) = inner
.pending_size
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
apply_resize(&inner, cols, rows);
}
}
// cwd / 标题 / 命令边界跟踪:在**原始字节**上解析,且解析结果不从前端输出里剔除。
//
// 为什么保留 OSC 7 原文发给前端:xterm 会自行忽略它,而保留原文让
// 「会话输出日志」可以原样重放(P2 的审计功能)。剔除反而会引入
// 一份「两份流不一致」的隐患。
crate::terminal::session::scan_control_sequences(
&app,
&state,
&mut osc_tail,
&buf[..n],
&sim,
);
// 聚合窗口到了就发一批
if last_flush.elapsed() >= AGGREGATE_WINDOW {
last_seq = flush_output(&app, &state, &mut pending).unwrap_or(last_seq);
last_flush = Instant::now();
} else if pending.len() > MAX_PENDING_BYTES {
// 前端卡住导致积压:丢弃最旧的一半,保留最新输出
let drop_len = pending.len() - MAX_PENDING_BYTES / 2;
pending.drain(..drop_len);
crate::logger::log_warn(
"terminal",
&format!(
"会话 {} 输出积压超限,已丢弃 {} 字节最旧数据",
state.id, drop_len
),
);
}
}
Err(e) => {
// BROKEN_PIPE / OPERATION_ABORTED 是 kill 路径的正常表现,不算错误
if e.kind() != ErrorKind::BrokenPipe && e.raw_os_error() != Some(995) {
crate::logger::log_error(
"terminal",
&format!("会话 {} 读取失败: {e}", state.id),
);
}
break;
}
}
// 有未发出的剩余数据时,退化为「尽快发出」:
// 交互式场景下提示符必须立刻可见,不能等满一个窗口
if !pending.is_empty() && last_flush.elapsed() >= AGGREGATE_WINDOW {
last_seq = flush_output(&app, &state, &mut pending).unwrap_or(last_seq);
last_flush = Instant::now();
}
}
// 读线程结束前把残留数据全部发出(否则最后一行提示符会丢)
if let Some(s) = flush_output(&app, &state, &mut pending) {
last_seq = s;
}
// 读线程结束即代表 PTY 侧已无更多数据,此时可以安全关闭 PTY(坑 1 的正解)
close_pty_background(&inner);
// 更新状态并发状态事件:若已被 waiter 置为 Closed 则保持 Closed 不变
let final_state = {
let mut st = state.state.lock().unwrap_or_else(|e| e.into_inner());
if *st != SessionState::Closed && *st != SessionState::Failed {
*st = SessionState::Closed;
}
*st
};
let _ = last_seq;
crate::terminal::emit_state(&app, &state, final_state, None);
let _ = app.emit(
crate::terminal::events::TERMINAL_EXIT,
crate::terminal::events::ExitPayload {
session_id: state.id.clone(),
exit_code: *state.exit_code.lock().unwrap_or_else(|e| e.into_inner()),
reason: Some("eof".to_string()),
},
);
});
}
// OSC 7cwd/ OSC 0,2(标题)/ OSC 133(命令边界)的扫描与落库
// 全部委托给 `session::scan_control_sequences`。
//
// 这里此前有一份与本文件同源的实现,SSH 后端另有一份几乎相同的拷贝。
// P1 加命令历史时把两份合一了 —— 否则「OSC 133 解析 + 落库」要在两处各写一遍,
// 任何一处漏掉都表现为「只有本地会话有历史」这类按后端分支的诡异 bug。
// 具体理由与实现见 `session.rs` 中该函数的长注释。
/// 退出等待线程:等子进程结束 → 拿退出码 → 等读线程收尾。
fn spawn_waiter(
app: AppHandle,
state: Arc<LocalSessionState>,
inner: Arc<PtyInner>,
_seq: Arc<AtomicU64>,
) {
std::thread::spawn(move || {
// 取出进程句柄(不 take,kill 也要用)
let handle = {
let g = inner.process.lock().unwrap_or_else(|e| e.into_inner());
g.as_ref().map(|h| h.as_raw_handle() as HANDLE)
};
let Some(h) = handle else { return };
// SAFETY: h 是本会话持有的有效进程句柄;无限等待直到进程退出。
let _ = unsafe { WaitForSingleObject(h, u32::MAX) };
let mut code: u32 = 0;
// SAFETY: h 有效且进程已退出,GetExitCodeProcess 会写入 code。
let ok = unsafe { GetExitCodeProcess(h, &mut code) };
if ok != 0 {
*state.exit_code.lock().unwrap_or_else(|e| e.into_inner()) = Some(code as i32);
}
// 注意:这里**不**立刻置 Closed。进程退出后管道里可能还有尾部输出,
// 要等读线程把残余数据发完(坑 3)。读线程结束时会把状态置为 Closed。
// 但若进程是被 kill 且读线程已退出,这里的 emit 就成了唯一通知。
let already_closed = {
let st = state.state.lock().unwrap_or_else(|e| e.into_inner());
*st == SessionState::Closed
};
if !already_closed {
// 给读线程一点时间收尾(正常会在 8ms 内完成)
std::thread::sleep(Duration::from_millis(50));
let st_closed = {
let st = state.state.lock().unwrap_or_else(|e| e.into_inner());
*st == SessionState::Closed
};
if !st_closed {
*state.state.lock().unwrap_or_else(|e| e.into_inner()) = SessionState::Closed;
close_pty_background(&inner);
let _ = app.emit(
crate::terminal::events::TERMINAL_EXIT,
crate::terminal::events::ExitPayload {
session_id: state.id.clone(),
exit_code: *state.exit_code.lock().unwrap_or_else(|e| e.into_inner()),
reason: Some("process-exit".to_string()),
},
);
}
}
});
}
/// 把聚合缓冲发成一批事件。返回本次批次序号(缓冲为空时返回 `None`)。
fn flush_output(app: &AppHandle, state: &LocalSessionState, pending: &mut Vec<u8>) -> Option<u64> {
if pending.is_empty() {
return None;
}
// 会话日志(audit)在 clear 之前写:保证日志与前端所见完全一致
crate::terminal::audit::write(&state.id, pending);
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD.encode(&pending);
pending.clear();
let seq = SEQ.fetch_add(1, Ordering::Relaxed) + 1;
let _ = app.emit(
crate::terminal::events::TERMINAL_OUTPUT,
crate::terminal::events::OutputPayload {
session_id: state.id.clone(),
data,
seq,
},
);
Some(seq)
}
/// 全局输出批次序号。
///
/// 用全局计数器而非每会话计数器:前端校验连续性只需一个单调序列,
/// 且跨会话的绝对顺序在排查问题时更有价值(能看出「哪个会话先输出」)。
static SEQ: AtomicU64 = AtomicU64::new(0);
/// 取下一个全局输出批次序号。
///
/// 对 SSH 后端开放(见 `ssh::flush_output`):两种后端共用同一序列,
/// 前端只需一套连续性校验逻辑。
pub fn next_global_seq() -> u64 {
SEQ.fetch_add(1, Ordering::Relaxed) + 1
}
/// 应用尺寸变更(真正调用 ResizePseudoConsole)。
fn apply_resize(inner: &PtyInner, cols: u16, rows: u16) {
let g = inner.hpc.lock().unwrap_or_else(|e| e.into_inner());
let Some(hpc) = *g else { return };
let size = COORD {
X: clamp_dim(cols) as i16,
Y: clamp_dim(rows) as i16,
};
// SAFETY: hpc 是有效的伪控制台句柄(未关闭),size 已钳制到 i16 范围。
let hr = unsafe { ResizePseudoConsole(hpc, size) };
if hr < 0 {
crate::logger::log_warn("terminal", &format!("ResizePseudoConsole 失败(HRESULT 0x{hr:08X}"));
}
}
/// 把维度钳制到 COORD 的 i16 正数范围。
///
/// 为什么需要:`cols`/`rows` 来自前端 xterm 的测量结果,极端布局(超宽显示器 +
/// 极窄侧栏)下可能算出 0 或超出 32767,直接转 i16 会得到负数,ConPTY 会拒绝或
/// 产生诡异绘制。这里统一兜底到合理区间。
fn clamp_dim(v: u16) -> u16 {
v.clamp(1, 1000)
}
/// 在后台线程关闭 PTY(坑 1`ClosePseudoConsole` 可能阻塞)。
///
/// 调用方不等待。先置标志位保证幂等——读线程与退出等待线程都可能走到这里。
fn close_pty_background(inner: &Arc<PtyInner>) {
if inner.closed.swap(true, Ordering::SeqCst) {
return;
}
let inner = inner.clone();
std::thread::spawn(move || {
// 先释放写端:否则 PTY 侧仍认为有输入来源,其内部缓冲不会排空
{
let mut w = inner.writer.lock().unwrap_or_else(|e| e.into_inner());
*w = None;
}
let hpc = {
let mut g = inner.hpc.lock().unwrap_or_else(|e| e.into_inner());
g.take()
};
if let Some(hpc) = hpc {
// SAFETY: hpc 由本会话创建且尚未关闭(take 保证了唯一性)。
// 这个调用可能阻塞到所有句柄关闭,因此放在独立线程。
unsafe { ClosePseudoConsole(hpc) };
}
});
}
// ===== Session trait 实现 =====
impl Session for ConPtySession {
fn id(&self) -> &str {
&self.state.id
}
fn kind(&self) -> SessionKind {
SessionKind::Local
}
fn write(&self, data: &[u8]) -> Result<(), String> {
let mut g = self.inner.writer.lock().unwrap_or_else(|e| e.into_inner());
let Some(w) = g.as_mut() else {
return Err("会话已关闭,无法写入".to_string());
};
w.write_all(data).map_err(|e| format!("写入失败: {e}"))?;
w.flush().map_err(|e| format!("刷新失败: {e}"))
}
fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
*self.state.size.lock().unwrap_or_else(|e| e.into_inner()) = (cols, rows);
// 坑 2:首帧之前只入队。ConPTY 在进程尚未开始读 stdout 时对 resize
// 的处理不可靠(尺寸可能被吞掉),表现为 vim/htop 按 80×24 绘制而花屏。
if !self.inner.first_output_seen.load(Ordering::SeqCst) {
*self
.inner
.pending_size
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some((cols, rows));
return Ok(());
}
apply_resize(&self.inner, cols, rows);
Ok(())
}
fn kill(&self) -> Result<(), String> {
// 幂等:重复 kill 不报错
if self.inner.closed.load(Ordering::SeqCst) {
return Ok(());
}
// 1. 先终止进程(若有)
if let Some(h) = self
.inner
.process
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.map(|h| h.as_raw_handle() as HANDLE)
{
// SAFETY: h 是本会话持有的有效进程句柄。
// 退出码 1 表示「被终止」,与正常退出 0 区分开,便于前端展示。
unsafe { TerminateProcess(h, 1) };
}
// 2. 关 PTY(后台线程,不阻塞调用方)
close_pty_background(&self.inner);
// 3. 状态置为 Closed 并发事件
*self.state.state.lock().unwrap_or_else(|e| e.into_inner()) = SessionState::Closed;
let _ = self.app.emit(
crate::terminal::events::TERMINAL_EXIT,
crate::terminal::events::ExitPayload {
session_id: self.state.id.clone(),
exit_code: Some(1),
reason: Some("killed".to_string()),
},
);
let _ = EXIT_HINT; // 提示文本由前端拼接,此处保留常量以备审计日志使用
Ok(())
}
fn info(&self) -> SessionInfo {
local_info(&self.state, SessionKind::Local)
}
fn set_state(&self, state: SessionState, error: Option<String>) {
*self.state.state.lock().unwrap_or_else(|e| e.into_inner()) = state;
if let Some(e) = error {
*self.state.error.lock().unwrap_or_else(|e| e.into_inner()) = Some(e);
}
let _ = self
.app
.emit(crate::terminal::events::TERMINAL_STATE, self.info());
}
fn set_title(&self, title: &str) {
*self.state.title.lock().unwrap_or_else(|e| e.into_inner()) = title.to_string();
}
fn set_detached(&self, detached: bool) {
self.state.detached.store(detached, Ordering::Relaxed);
}
/// 本地会话的编码切换语义与 SSH 不同:**只影响输出的字节→文本解释**,
/// 不影响输入(Windows 控制台走 UTF-16 转换,`WriteFile` 收到的一直是
/// UTF-8,前端不必按目标代码页重编码)。因此这里不做输入侧处理。
fn set_encoding(&self, encoding: &str) -> bool {
self.state.set_encoding(encoding)
}
fn emit_state(&self) {
let _ = self
.app
.emit(crate::terminal::events::TERMINAL_STATE, self.info());
}
}
// ===== windows-sys 中未随 feature 导出的 API 声明 =====
//
// `InitializeProcThreadAttributeList` / `UpdateProcThreadAttribute` /
// `DeleteProcThreadAttributeList` 属于 `Win32_System_Threading`,但 windows-sys
// 0.52 未把它们纳入已启用的 feature 面。用 extern "system" 直接声明,
// 避免为了三个函数额外开启一个大 feature(会显著增加编译时间)。
unsafe extern "system" {
fn InitializeProcThreadAttributeList(
lp_attribute_list: *mut std::ffi::c_void,
dw_attribute_count: u32,
dw_flags: u32,
lp_size: *mut usize,
) -> i32;
fn UpdateProcThreadAttribute(
lp_attribute_list: *mut std::ffi::c_void,
dw_flags: u32,
attribute: usize,
lp_value: *const std::ffi::c_void,
cb_size: usize,
lp_previous_value: *mut std::ffi::c_void,
lp_return_size: *mut usize,
) -> i32;
fn DeleteProcThreadAttributeList(lp_attribute_list: *mut std::ffi::c_void);
}
+6
View File
@@ -0,0 +1,6 @@
//! 终端进程后端。
//!
//! `conpty` 是本地 Shell 的实现(Windows ConPTY)。未来若需要支持非 Windows
//! 平台,在此目录下新增 `unix_pty` 即可,上层只依赖 [`super::session::Session`]。
pub mod conpty;
+629
View File
@@ -0,0 +1,629 @@
//! 会话抽象与注册表。
//!
//! ## 为什么不用 `crate::process_manager::ProcessManager`
//!
//! `ProcessManager` 是为「单例常驻守护进程」设计的(mihomo 内核):一个模块 ID
//! 对应一个进程,进程崩溃即按策略重启,配置在模块 `index.ts` 里静态声明。
//! 终端要的是完全不同的语义——
//!
//! - **N 个会话并存**,每个会话生命周期独立(开一个标签 = 多一个会话);
//! - 需要**双向流式 I/O**(写 stdin、读 stdout),而不只是「启动/停止/看状态」;
//! - 崩溃**不应重启**(重启一个 shell 只是给用户一个空提示符,毫无意义),
//! 而应把退出码报给前端做展示;
//! - 会话可能因为「网络断开」而进入 `degraded` 而不是 `stopped`。
//!
//! 把这四条塞进 `ProcessManager` 会撑坏它的抽象,因此终端模块自带一套。
//!
//! ## `Session` trait 的价值
//!
//! 本地(ConPTY)与远程(SSH)两种后端在「I/O 形态」上高度一致:都是一个字节流,
//! 都要支持 write / resize / kill / 输出订阅。抽成 trait 后,上层的命令层
//! `terminal_write` / `terminal_resize` / …)与多会话管理逻辑**只需写一遍**。
//!
//! 两处刻意的不对称(值得记下,避免后来者以为是疏漏):
//! - `resize`ConPTY 需要显式调用 `ResizePseudoConsole`SSH 是发
//! `window-change` 请求。两者都要,故都在 trait 上。
//! - `exit_code`:本地拿得到真实退出码;SSH 会话通道关闭时通常拿不到,
//! 统一返回 `None`,由前端展示为「连接已关闭」。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use specta::Type;
use super::pty::conpty::ConPtySession;
/// 会话标识。
pub type SessionId = String;
/// 会话状态机。
///
/// `degraded` 专为 SSH 保留:TCP 断了但会话对象还在(可以尝试重连),
/// 与 `closed`(已终结,需重开)是两回事。本地会话不会进入此态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum SessionState {
/// 已创建,尚未开始握手/启动
Idle,
/// 正在连接(SSH 握手 / 本地启动进程)
Connecting,
/// 正在认证(仅 SSH
Authenticating,
/// 已建立,可交互
Established,
/// 连接降级(SSH 断线,可尝试重连)
Degraded,
/// 已关闭(进程退出或用户主动关闭)
Closed,
/// 异常(启动失败、握手失败、致命错误)
Failed,
}
/// 会话类型(决定前端展示哪些能力)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum SessionKind {
Local,
Ssh,
}
/// 会话元信息(回传前端;**不含任何 I/O 句柄**)。
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub id: SessionId,
pub kind: SessionKind,
/// 标题(本地为 Shell 名,SSH 为 用户@主机)
pub title: String,
pub state: SessionState,
/// 终端当前列数
pub cols: u16,
/// 终端当前行数
pub rows: u16,
/// 当前工作目录(由 OSC 7 hook 上报;未知为空串)
pub cwd: String,
/// 会话创建时间(Unix 毫秒)
pub created_at: u64,
/// 进程退出码(本地可拿到;SSH 通常为 None)
pub exit_code: Option<i32>,
/// 失败原因(state 为 Failed 时非空)
pub error: Option<String>,
/// 远端标识(本地为 shell idSSH 为 host id
pub target_id: String,
/// 是否由独立窗口承载(决定了关闭窗口时销毁还是保留会话)
pub detached: bool,
/// 会话的字符编码(规范名,如 `gbk` / `utf-8`)。
///
/// 回传前端的原因:**终端画面的字节→文本转换在前端做**。xterm 的
/// `write` 接受 `Uint8Array`,解码策略由前端按这个字段决定。若放在 Rust 侧
/// 转换,前端就失去了「用户临时切编码重看历史内容」的能力——
/// 而重看历史恰恰是老服务器场景下最常用的操作。
pub encoding: String,
/// 是否正在记录会话日志(`audit` 模块)。前端据此显示工具栏开关状态。
pub logging: bool,
}
/// 会话抽象:本地 ConPTY 与 SSH 两种后端都实现它。
///
/// 所有方法都要求 `&self`(配合内部可变性)而不是 `&mut self`:会话句柄需要被
/// 多个来源同时访问(输出读线程、命令层、退出监听),用 `&mut` 会把它们串行化。
pub trait Session: Send + Sync {
fn id(&self) -> &str;
fn kind(&self) -> SessionKind;
/// 写入数据(前端键入的字符、粘贴内容)。
fn write(&self, data: &[u8]) -> Result<(), String>;
/// 通知终端尺寸变化。
///
/// 实现方**必须容忍早期调用**:ConPTY 在进程刚开始输出时 resize 有竞态,
/// 需要排队到首帧之后再应用(详见 `pty::conpty`)。
fn resize(&self, cols: u16, rows: u16) -> Result<(), String>;
/// 终止会话。幂等:对已关闭的会话调用不应报错。
fn kill(&self) -> Result<(), String>;
/// 快照当前元信息。
fn info(&self) -> SessionInfo;
/// 更新状态(供内部线程在握手/退出时调用)。
fn set_state(&self, state: SessionState, error: Option<String>);
/// 更新标题。
fn set_title(&self, title: &str);
/// 标记是否由独立窗口承载。
fn set_detached(&self, detached: bool);
/// 切换字符编码。返回 `false` 表示编码名不被支持。
///
/// # 为什么放进 trait 而不是走 `as_ssh()` 下转换
///
/// 本地 ConPTY 会话也需要它 —— 用户的 Windows 控制台若是 936 代码页,
/// 本地会话同样会乱码,只是默认值不同。放进 trait 后命令层不必先判断
/// 会话类型再分派,两条后端路径只有一处实现点。
fn set_encoding(&self, encoding: &str) -> bool;
/// 广播当前会话快照到前端(`TERMINAL_STATE` 事件)。
///
/// 用于「元信息变了但状态没变」的场景(改编码、改标题),此时
/// 既有的状态机路径不会触发广播,需要显式一次。
fn emit_state(&self);
/// 向下转换成 SSH 会话(仅 SFTP 面板需要)。
///
/// # 为什么用「返回 `Option<&SshSession>`」而不是 `Any` 向下转换
///
/// `Any::downcast_ref` 要求 trait 对象是 `'static` 且需要引入 `std::any`
/// 更关键的是**编译期一无所知**:调用方写错目标类型要到运行期才炸。
/// 这里给出一个具名方法,`SshSession` 的返回 `Some(self)`、其余返回 `None`
/// 类型由签名保证,调用点的 `ok_or_else` 也就有了明确的中文错误提示。
///
/// 默认实现返回 `None`(本地会话不需要覆写)。
fn as_ssh(&self) -> Option<&super::ssh::SshSession> {
None
}
}
/// 会话注册表。
///
/// 用 `DashMap` 而不是 `Mutex<HashMap>`:会话的读操作极其频繁(每次输出批次都要
/// 查表找会话),而写操作少(创建/销毁)。分片锁能让多个会话的 I/O 线程互不等待。
///
/// 注意 `Arc<dyn Session>`:注册表持有一份,各 I/O 线程各持一份,生命周期由
/// 引用计数管理。**不使用 `Weak`**——会话的存活由用户显式关闭决定,不该因为
/// 某个线程退出而被回收。
pub struct SessionRegistry {
sessions: DashMap<SessionId, Arc<dyn Session>>,
/// 会话 id 发生器
next_id: AtomicU64,
}
impl SessionRegistry {
pub fn new() -> Self {
Self {
sessions: DashMap::new(),
next_id: AtomicU64::new(1),
}
}
/// 生成下一个会话 id。
///
/// 形如 `s1` / `s2`:短、可读、便于日志检索。不用 UUID 的理由是这个 id 会
/// 出现在窗口 label`terminal-window-s1`)与日志里,UUID 会让两者都难读。
/// 会话 id 只在本次进程生命周期内有效,重启后不保证不重复,因此无需全局唯一性。
pub fn next_session_id(&self) -> SessionId {
let n = self.next_id.fetch_add(1, Ordering::Relaxed);
format!("s{n}")
}
pub fn insert(&self, session: Arc<dyn Session>) {
self.sessions.insert(session.id().to_string(), session);
}
pub fn get(&self, id: &str) -> Option<Arc<dyn Session>> {
self.sessions.get(id).map(|e| e.value().clone())
}
/// 移除会话(**不调用 kill**,由调用方决定是否先终止)。
pub fn remove(&self, id: &str) -> Option<Arc<dyn Session>> {
self.sessions.remove(id).map(|(_, v)| v)
}
pub fn list(&self) -> Vec<SessionInfo> {
let mut list: Vec<SessionInfo> = self.sessions.iter().map(|e| e.value().info()).collect();
// 按创建时间排序,保证前端标签顺序稳定(DashMap 的迭代顺序不确定)
list.sort_by_key(|s| s.created_at);
list
}
pub fn len(&self) -> usize {
self.sessions.len()
}
/// 关闭并移除所有会话(应用退出时调用)。
///
/// 逐个 `kill` 后清表。**不做等待**:退出路径上不能阻塞(ConPTY 的
/// `ClosePseudoConsole` 会阻塞到所有句柄关闭,见 `pty::conpty`)。
/// 进程终止时 OS 会回收残留资源,这里是「尽力而为」。
pub fn close_all(&self) {
let ids: Vec<String> = self.sessions.iter().map(|e| e.key().clone()).collect();
for id in ids {
if let Some(s) = self.get(&id) {
if let Err(e) = s.kill() {
crate::logger::log_warn(
"terminal",
&format!("关闭会话 {id} 失败(退出路径,忽略): {e}"),
);
}
}
// 会话日志收尾(flush + 移除条目;与 close 命令路径保持一致)
crate::terminal::audit::cleanup(&id);
self.remove(&id);
}
}
}
impl Default for SessionRegistry {
fn default() -> Self {
Self::new()
}
}
/// 为一个新会话分配终端默认尺寸。
///
/// 80×24 是 VT 规范的经典默认值。前端挂载 xterm 后会立刻上报真实尺寸,
/// 这里的值只在「创建 → 首帧」之间短暂生效。
pub const DEFAULT_COLS: u16 = 80;
pub const DEFAULT_ROWS: u16 = 24;
/// 本地会话的共享状态(供 `ConPtySession` 与命令层共用)。
///
/// 独立成一个结构而不是塞进 `ConPtySession`,是因为状态字段的读写来自
/// 多个线程(命令层、读线程、退出监听线程),集中放置便于审计加锁范围。
pub struct LocalSessionState {
pub id: SessionId,
pub target_id: String,
/// 会话类型(本地 ConPTY / SSH)。
///
/// # 为什么存在这里而不是只由会话对象自己知道
///
/// `scan_control_sequences` 是**自由函数**ConPTY 与 SSH 两个后端共用),
/// 它只拿到 `LocalSessionState` 而拿不到会话对象。命令历史落库需要区分
/// 「target_id 是 shell id 还是主机 id」才能查对显示名 ——
/// 没有这个字段就只能靠 `target_id` 的形式去猜,那是不可靠的。
pub kind: SessionKind,
pub title: std::sync::Mutex<String>,
pub state: std::sync::Mutex<SessionState>,
pub error: std::sync::Mutex<Option<String>>,
pub cwd: std::sync::Mutex<String>,
pub size: std::sync::Mutex<(u16, u16)>,
pub created_at: u64,
pub exit_code: std::sync::Mutex<Option<i32>>,
pub detached: std::sync::atomic::AtomicBool,
/// 会话的字符编码(`encoding::normalize` 之后的规范名,如 `gbk` / `utf-8`)。
///
/// 放在这里而不是让读线程从 `SshConnectParams` 持有:编码在会话存续期间
/// **可能被用户改**(连上后发现是 GBK,在状态栏切一下),此时需要立即生效。
/// 用 `Mutex<String>` 而非 `Arc<str>` 就是为了支持这个运行时变更。
pub encoding: std::sync::Mutex<String>,
}
impl LocalSessionState {
pub fn new(id: SessionId, target_id: String, title: String) -> Self {
Self::with_encoding(id, target_id, title, SessionKind::Local, "utf-8")
}
/// 带编码与类型构造。
///
/// `kind` 由**创建方**传入而不是从 `target_id` 推断:本地会话的 target_id 是
/// shell id、SSH 会话的是主机 id,两者都是任意字符串,形式上看不出区别。
/// 让调用方(`ConPtySession::spawn` / `SshSession::spawn`)显式声明是唯一可靠的来源。
pub fn with_encoding(
id: SessionId,
target_id: String,
title: String,
kind: SessionKind,
encoding: &str,
) -> Self {
Self {
id,
target_id,
kind,
title: std::sync::Mutex::new(title),
state: std::sync::Mutex::new(SessionState::Idle),
error: std::sync::Mutex::new(None),
cwd: std::sync::Mutex::new(String::new()),
size: std::sync::Mutex::new((DEFAULT_COLS, DEFAULT_ROWS)),
created_at: now_millis(),
exit_code: std::sync::Mutex::new(None),
detached: std::sync::atomic::AtomicBool::new(false),
encoding: std::sync::Mutex::new(crate::terminal::encoding::normalize(encoding)),
}
}
/// 是否为 SSH 会话(供命令历史等需要区分来源的场景)。
pub fn is_ssh(&self) -> bool {
matches!(self.kind, SessionKind::Ssh)
}
/// 当前编码(供读线程与命令层读取)。
pub fn encoding(&self) -> String {
self.encoding
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// 切换编码。返回 `false` 表示编码名不被支持(调用方应回滚 UI)。
pub fn set_encoding(&self, encoding: &str) -> bool {
let norm = crate::terminal::encoding::normalize(encoding);
if !crate::terminal::encoding::is_supported(&norm) {
return false;
}
*self.encoding.lock().unwrap_or_else(|e| e.into_inner()) = norm;
true
}
}
/// 当前 Unix 毫秒时间戳。
pub fn now_millis() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
// ===== 控制序列扫描(ConPTY / SSH 共用) =====
/// `carry` 缓冲上限。正常 OSC 序列只有几十字节,超过即视为畸形数据。
///
/// 没有上限时,一段缺终止符的畸形输出(比如二进制文件被 `cat` 出来)
/// 会让 `carry` 无限增长,最终吃满内存。
const MAX_OSC_CARRY: usize = 8 * 1024;
/// 扫描输出批次里的控制序列,把结果落到会话状态、广播事件、并在命令边界处落库历史。
///
/// # 为什么两个后端共用这一份(此前 ConPTY 与 SSH 各有一份几乎相同的拷贝)
///
/// 两份拷贝的差异只有一处:SSH 需要按会话编码二次解码 OSC 载荷
/// (GBK 服务器上的标题与 cwd 会乱码)。其余(carry 拼接、上限防御、
/// cwd 变更判定、只上报变化量)**完全一致**。
///
/// P1 新增命令历史时这个问题变成硬约束:若继续维持两份拷贝,
/// 「OSC 133 解析 + 落库」就要在两处各写一遍 —— 任何一处漏掉都表现为
/// 「只有本地会话有历史」或「只有 SSH 有历史」这类**按后端分支的诡异 bug**,
/// 而且因为两条路径平时都各自能用,极难在测试中发现。
///
/// 编码与类型都从 `state` 上取(`state.encoding()` / `state.kind` /
/// `state.is_ssh()`),所以这个函数不需要任何按后端分派的参数。
///
/// # 关于 `emit_state` 的差异
///
/// 标题变化时要广播 `TERMINAL_STATE`,而 ConPTY 走 `terminal::emit_state`
/// (带显式 `final_state`)、SSH 走 `local_info(.., Ssh)`。统一为
/// `local_info(state, state.kind)` + 保持当前 state 不变即可 ——
/// 两条路径的原意都是「状态没变,只是标题变了」,用 `state.kind` 恰好等价。
pub fn scan_control_sequences(
app: &tauri::AppHandle,
state: &LocalSessionState,
carry: &mut Vec<u8>,
chunk: &[u8],
sim: &CommandAccumulator,
) {
use tauri::Emitter as _;
carry.extend_from_slice(chunk);
let parsed = crate::terminal::shell::parse_control_sequences(carry);
if parsed.consumed > 0 {
carry.drain(..parsed.consumed);
}
// 防御:畸形数据(没有终止符的超长序列)会让 carry 无限增长
if carry.len() > MAX_OSC_CARRY {
carry.clear();
}
// 编码转换:OSC 载荷与终端画面**共用同一套字节**,因此也必须用会话编码解码。
// 不转的话,GBK 服务器上 `echo -e "\e]0;测试\a"` 这种标题会变成乱码。
// 注意只在会话编码不是 UTF-8 时才有实际效果 —— `parse_control_sequences`
// 内部已按 UTF-8 有损解码过一轮,这一步是在其基础上的「纠正」。
let enc = state.encoding();
let recode = |v: Vec<String>| -> Vec<String> {
if enc == "utf-8" {
return v;
}
v.into_iter()
.map(|s| crate::terminal::encoding::decode(s.as_bytes(), &enc))
.collect()
};
let cwds = recode(parsed.cwds);
let titles = recode(parsed.titles);
if let Some(cwd) = cwds.last() {
let changed = {
let mut cur = state.cwd.lock().unwrap_or_else(|e| e.into_inner());
if *cur == *cwd {
false
} else {
*cur = cwd.clone();
true
}
};
if changed {
let _ = app.emit(
crate::terminal::events::TERMINAL_CWD,
crate::terminal::events::CwdPayload {
session_id: state.id.clone(),
cwd: cwd.clone(),
},
);
}
}
if let Some(title) = titles.last() {
let changed = {
let mut cur = state.title.lock().unwrap_or_else(|e| e.into_inner());
if *cur == *title {
false
} else {
*cur = title.clone();
true
}
};
if changed {
let _ = app.emit(
crate::terminal::events::TERMINAL_STATE,
local_info(state, state.kind),
);
}
}
// 命令边界:累积到 D 才落库(见 `CommandAccumulator` 的说明)
sim.absorb(app, state, &parsed.marks);
}
/// 跨批次累积「当前正在执行的命令」,在 OSC 133 的 `D` 标记处落库。
///
/// # 为什么需要累积而不是收到 D 就存
///
/// 协议里命令文本(`1337;Cmd=`)与结束标记(`133;D`)是**两个独立序列**
/// 顺序由 shell hook 决定,且可能被切在不同批次里。若收到 D 就立刻用
/// 「当前已知的命令」落库,遇到「D 先到、Cmd 后到」的顺序会存下**上一条**命令 ——
/// 错位一条,且只在特定时序下复现,是最难查的一类 bug。
///
/// 因此:Cmd 到达时先暂存,D 到达时用暂存的文本落库并清空。
/// 若 D 到达时没有暂存文本(例如 hook 未被注入的老会话),则跳过 ——
/// 记一条空命令进历史毫无意义。
///
/// # 为什么锁粒度是「整段」
///
/// 一个批次里可能有多组 Cmd/D(`ls; pwd` 在极快执行时被一次性读取)。
/// 逐条加锁会让「暂存 → 落库 → 清空」三步之间可能被另一批次的同三步插入,
/// 产生交叉覆盖。锁住整段即可,临界区里只有内存操作与一次 SQLite 写入。
///
/// # 挂载位置
///
/// 由调用方(两个后端的读线程)持有,与会话同生命周期。不放进
/// `LocalSessionState`:那个结构是**状态**(可被任意线程读),
/// 而这个是**读线程的私有工作变量**,混在一起会让「谁在改它」变得不清晰。
pub struct CommandAccumulator {
inner: std::sync::Mutex<Option<String>>,
}
impl CommandAccumulator {
pub fn new() -> Self {
Self {
inner: std::sync::Mutex::new(None),
}
}
fn absorb(
&self,
app: &tauri::AppHandle,
state: &LocalSessionState,
marks: &[crate::terminal::shell::CommandMark],
) {
use crate::terminal::shell::CommandMark;
if marks.is_empty() {
return;
}
let mut pending = self.inner.lock().unwrap_or_else(|e| e.into_inner());
for mark in marks {
match mark {
CommandMark::Command(cmd) => {
// 覆盖而非追加:`history 1` 总是给最新一条,
// 同一批里出现两次 Cmd 时后者才是当前命令
*pending = Some(cmd.clone());
}
CommandMark::End(code) => {
let Some(cmd) = pending.take() else {
continue;
};
if cmd.trim().is_empty() {
continue;
}
// 记录失败**不影响终端**:历史是辅助功能,
// 磁盘满 / 库损坏都不该让用户的命令执行流程中断。
if let Err(e) = record_command(app, state, &cmd, *code) {
crate::logger::log_error(
"terminal",
&format!("写入命令历史失败(不影响会话): {e}"),
);
}
}
CommandMark::Start => {}
}
}
}
}
impl Default for CommandAccumulator {
fn default() -> Self {
Self::new()
}
}
/// 把一条命令送进历史库。
///
/// 从 `AppHandle` 反查 `TerminalManager`:本函数由读线程调用,那里只有
/// `AppHandle` 与会话状态,没有 manager 引用。走 Tauri 的 state 查询
/// 是这里唯一可行的方式(也是项目里 `manager(&app)` 的既有范式)。
///
/// # 落库在后台 writer
///
/// 本函数位于**输出热路径**(OSC 133 的 D 标记到达时读线程正在转发输出),
/// 因此只做两次轻量读(显示名持锁读小字段、cwd 克隆)+ 一次 `mpsc::send`。
/// SQLite 写入与 prune 由 [`crate::terminal::history::spawn_writer`] 的
/// writer 线程攒批完成(通道断开时 manager 内部有同步兜底)。
fn record_command(
app: &tauri::AppHandle,
state: &LocalSessionState,
command: &str,
exit_code: Option<i32>,
) -> Result<(), String> {
use tauri::Manager as _;
let Some(mgr) = app.try_state::<crate::terminal::TerminalManager>() else {
return Ok(()); // 应用正在退出,manager 已释放 —— 静默跳过
};
// 解析显示名。本地会话的 target_id 是 shell idSSH 会话是主机 id
// 两者对用户是不同含义,所以分开查。
//
// 显示名**随记录一起存**(而不是查询时再联表):主机被删除后,
// 若只有 id,历史列表里那一列会变成一串无意义的 hash。
let name = mgr.display_name(&state.target_id, state.is_ssh());
let cwd = state.cwd.lock().unwrap_or_else(|e| e.into_inner()).clone();
mgr.queue_history(crate::terminal::history::HistoryEntry {
command: command.to_string(),
cwd,
host_id: state.target_id.clone(),
host_name: name,
ssh: state.is_ssh(),
exit_code,
});
Ok(())
}
/// 由本地会话状态组装 `SessionInfo`。
pub fn local_info(state: &LocalSessionState, kind: SessionKind) -> SessionInfo {
let (cols, rows) = *state.size.lock().unwrap_or_else(|e| e.into_inner());
SessionInfo {
id: state.id.clone(),
kind,
title: state
.title
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone(),
state: *state.state.lock().unwrap_or_else(|e| e.into_inner()),
cols,
rows,
cwd: state.cwd.lock().unwrap_or_else(|e| e.into_inner()).clone(),
created_at: state.created_at,
exit_code: *state.exit_code.lock().unwrap_or_else(|e| e.into_inner()),
error: state
.error
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone(),
target_id: state.target_id.clone(),
detached: state.detached.load(Ordering::Relaxed),
encoding: state.encoding(),
logging: crate::terminal::audit::is_logging(&state.id),
}
}
/// 供 `ConPtySession` 引用的类型别名,避免上层直接依赖 `pty` 模块。
pub type BoxedSession = Arc<dyn Session>;
/// 类型占位:确保 `ConPtySession` 在编译期满足 `Session` 契约。
/// 若 `ConPtySession` 漏实现某个方法,这里会直接编译失败(比等到使用处才报错更早)。
#[allow(dead_code)]
fn _assert_conpty_is_session(s: ConPtySession) -> BoxedSession {
Arc::new(s)
}
+928
View File
@@ -0,0 +1,928 @@
//! 终端模块设置的数据模型与默认值。
//!
//! 持久化位置:`{app_data_dir}/terminal/settings.json`(与 translate / music 同一范式)。
//! 容器级 `#[serde(default)]`:新增字段对旧配置文件是**向后兼容**的——缺字段取默认值
//! 而不是让整份设置反序列化失败,避免用户因为一次升级丢掉全部配置。
//!
//! 安全姿态(与 `crate::secrets` 的约定一致):**本结构里不允许出现任何明文凭据**。
//! SSH 密码、私钥 passphrase 一律进系统凭据管理器,本结构只保存它们的引用 id 与
//! 派生展示字段(如 `hasPassphrase`,由命令层回填,不落盘)。
use serde::{Deserialize, Serialize};
use specta::Type;
// ===== 本地 Shell =====
/// 本地 Shell 配置。
///
/// 探测到的 Shell 与用户自定义的 Shell 用同一结构表达:`detected` 为 true 表示
/// 由 [`super::shell::detect_shells`] 自动发现,前端只允许改启动参数而不可改路径
/// (路径已被验证存在,改错会让会话起不来)。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct ShellProfile {
/// 唯一标识(同时是新建会话时的 shellKey)
pub id: String,
/// 展示名称,如 "PowerShell 7"
pub name: String,
/// 可执行文件绝对路径
pub path: String,
/// 启动参数
pub args: Vec<String>,
/// 启动时的工作目录(空串表示用用户主目录)
pub cwd: String,
/// 环境变量覆盖(键值对;值为空串表示删除该变量)
pub env: Vec<EnvVar>,
/// Shell 类型:"powershell" | "cmd" | "bash" | "wsl"
///
/// 决定三件事:cwd 跟踪 hook 的注入方式、清屏命令、以及 OSC 7 的解析口径。
pub kind: String,
/// 是否由自动探测得到(true 时前端不可编辑 path)
pub detected: bool,
/// 是否在新建会话菜单中显示
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct EnvVar {
pub key: String,
pub value: String,
}
/// 手写 `Default` 而不是 derive`detected` 与 `enabled` 的默认值必须是
/// `true` / `true`derive 会给 `false`),而 `#[serde(default)]` 在容器级
/// 要求每个字段类型都实现 `Default`。两者不一致会导致「反序列化出来的
/// Shell 默认禁用」这种隐性 bug。
impl Default for ShellProfile {
fn default() -> Self {
Self {
id: String::new(),
name: String::new(),
path: String::new(),
args: Vec::new(),
cwd: String::new(),
env: Vec::new(),
kind: "bash".to_string(),
detected: true,
enabled: true,
}
}
}
/// 容器级 `#[serde(default)]` 要求字段类型实现 `Default`。
/// `EnvVar` 的「空值」语义就是空键空值,用 derive 的默认即可。
impl Default for EnvVar {
fn default() -> Self {
Self {
key: String::new(),
value: String::new(),
}
}
}
impl ShellProfile {
pub fn new(id: &str, name: &str, path: &str, kind: &str) -> Self {
Self {
id: id.to_string(),
name: name.to_string(),
path: path.to_string(),
args: Vec::new(),
cwd: String::new(),
env: Vec::new(),
kind: kind.to_string(),
detected: true,
enabled: true,
}
}
}
// ===== SSH 主机 =====
/// SSH 主机条目。
///
/// `id` 是凭据键名的一部分(`terminal-ssh-password-{id}`),**创建后不应修改**
/// 改了会让已存进凭据管理器的密码读不到。前端在编辑态需禁用该字段。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct SshHost {
pub id: String,
/// 展示别名(列表主标题)
pub name: String,
pub host: String,
pub port: u16,
pub username: String,
/// 认证方式:"key"(公钥,默认)| "password" | "agent"P1| "keyboard"P1
pub auth_method: String,
/// 公钥认证使用的密钥 id(指向 [`super::keys`] 的密钥库)
pub key_id: String,
/// 分组名(侧栏按此分组)
pub group: String,
/// 备注
pub note: String,
/// 标签色(前端用于状态点/分组标识)
pub color: String,
/// 是否收藏(置顶显示)
pub favorited: bool,
/// 连接超时(毫秒)
pub connect_timeout_ms: u64,
/// keep-alive 间隔(秒,0 表示关闭)
pub keepalive_secs: u64,
/// 启动目录(空串表示登录后进入默认目录)
pub remote_cwd: String,
/// 登录后自动执行的命令
pub startup_command: String,
/// 是否走代理模块(mihomo)。默认关闭:内网主机不该被绕进代理。
pub use_proxy: bool,
/// 跳板机链(ProxyJump):按连接顺序排列的主机 id。
///
/// 每一项引用**本主机列表里的另一台主机**(复用它的地址、账号与凭据),
/// 连接方向为 `本机 → jump_ids[0] → jump_ids[1] → … → 本主机`。
/// 空 = 直连。约束(命令层校验):不能引用自己、不能有环、
/// 链长上限 5、每一跳的认证方式必须是 key/password。
///
/// 用 id 引用而不是内联一份地址+凭据的理由:跳板机自己的密码/密钥
/// 存在凭据管理器里,按 id 复用可以避免同一台跳板机在多处配置里
/// 留下多份凭据副本(改密码时漏改一处就是连接事故)。
pub jump_ids: Vec<String>,
/// 终端的字符编码("utf-8" 默认 | "gbk" 等)。老服务器常见 GBK,中文环境刚需。
pub encoding: String,
}
impl Default for SshHost {
fn default() -> Self {
Self {
id: String::new(),
name: String::new(),
host: String::new(),
port: 22,
username: String::new(),
auth_method: "key".to_string(),
key_id: String::new(),
group: String::new(),
note: String::new(),
color: String::new(),
favorited: false,
connect_timeout_ms: 15_000,
keepalive_secs: 30,
remote_cwd: String::new(),
startup_command: String::new(),
use_proxy: false,
jump_ids: Vec::new(),
encoding: "utf-8".to_string(),
}
}
}
// ===== 外观 =====
/// 终端外观设置。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct AppearanceSettings {
/// 配色主题 id(内置若干,见前端 terminalThemes.ts
pub theme: String,
/// 是否跟随应用亮暗主题(开启时 `theme` 只作为亮/暗的取色基准)
pub follow_app_theme: bool,
/// 字体族(逗号分隔的 CSS font-family
pub font_family: String,
pub font_size: u32,
/// 行高倍数
pub line_height: f64,
/// 字母间距
pub letter_spacing: f64,
/// 光标样式:"block" | "bar" | "underline"
pub cursor_style: String,
/// 光标是否闪烁
pub cursor_blink: bool,
/// 滚动缓冲区行数。上限 200000:再高会显著吃内存且滚动查找变慢。
pub scrollback: u32,
/// 背景不透明度百分比(100 = 不透明)
pub opacity: u32,
/// 是否启用 GPU 渲染(addon-webgl)。极少数显卡驱动下有花屏问题,故给开关。
pub gpu_rendering: bool,
}
impl Default for AppearanceSettings {
fn default() -> Self {
Self {
theme: "thing-dark".to_string(),
follow_app_theme: true,
font_family: "Cascadia Mono, Consolas, Microsoft YaHei Mono, monospace".to_string(),
font_size: 14,
line_height: 1.2,
letter_spacing: 0.0,
cursor_style: "block".to_string(),
cursor_blink: true,
scrollback: 10_000,
opacity: 100,
gpu_rendering: true,
}
}
}
// ===== 快捷键 =====
/// 一条终端内快捷键绑定。
///
/// 只覆盖**终端内**(第二层)快捷键:全局快捷键(第一层)由 `crate::shortcut` 统一
/// 注册并做应用内冲突检测,不走这里;shell 原生快捷键(第三层)不做拦截。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct ShortcutBinding {
/// 动作标识,见前端 `terminalActions.ts`(如 "copy" / "newTab"
pub action: String,
/// 键位字符串,格式与 `crate::shortcut::parse_shortcut` 一致(如 "Ctrl+Shift+C"
pub keys: String,
/// 是否启用(关掉后该动作无快捷键,但仍可从菜单触发)
pub enabled: bool,
}
impl Default for ShortcutBinding {
fn default() -> Self {
Self {
action: String::new(),
keys: String::new(),
enabled: true,
}
}
}
// ===== 终端内选中行为 =====
/// 终端内选中行为。
///
/// # 为什么叫 `TerminalSelectionSettings` 而不是 `SelectionSettings`
///
/// 与 `translate::settings::SelectionSettings` 撞名。`tauri-specta` 的类型注册表
/// **全局按类型名索引**,重名会让 `export_bindings()` panic
/// `Detected multiple types with the same name`)。
/// specta 2.0.0-rc.25 的 derive 路径无法重命名导出类型
/// (详见 `history::TerminalHistoryPage` 的注释),只能改 Rust 标识符本身。
///
/// 注意:这是**第二个**独立引入的 `SelectionSettings`。新增跨模块共享名之前,
/// 先确认没有同名 `Type` 已存在 —— 否则会在**运行时启动阶段**才炸,
/// 而不是编译期(见 `TERMINAL_MODULE_PLAN.md` 坑 27)。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct TerminalSelectionSettings {
/// 选中即复制(受 Linux/macOS 习惯影响的用户会开;默认关,避免误触)
pub copy_on_select: bool,
/// 中键粘贴(X11 习惯;Windows 下默认关)
pub middle_click_paste: bool,
/// 右键行为:"menu"(默认,弹菜单)| "paste"(直接粘贴)| "select-word"
pub right_click: String,
/// 复制时是否去掉尾部空行
pub trim_trailing_newline: bool,
}
impl Default for TerminalSelectionSettings {
fn default() -> Self {
Self {
copy_on_select: false,
middle_click_paste: false,
right_click: "menu".to_string(),
trim_trailing_newline: true,
}
}
}
// ===== 布局 =====
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct LayoutSettings {
/// 关闭标签页时若会话仍有活跃进程,是否二次确认
pub confirm_close_running: bool,
/// 新建标签页时是否继承当前会话的工作目录
pub inherit_cwd: bool,
/// 侧栏默认是否展开
pub sidebar_open: bool,
/// 侧栏宽度(像素)
pub sidebar_width: u32,
/// 是否显示底部状态栏
pub show_status_bar: bool,
/// 分屏上限(1 = 不分屏,2 = 2×1,4 = 2×2)。
/// 上限刻意封在 4:分屏 × 标签 × 会话的组合复杂度会爆炸。
pub max_panes: u32,
}
impl Default for LayoutSettings {
fn default() -> Self {
Self {
confirm_close_running: true,
inherit_cwd: true,
sidebar_open: true,
sidebar_width: 220,
show_status_bar: true,
max_panes: 4,
}
}
}
// ===== 命令片段 =====
/// 一条命令片段。
///
/// # 为什么 `command` 里允许变量占位符
///
/// 常用命令的差异往往只在少数字段(路径、主机名、分支名)。若每条变体都要
/// 单独存一条,片段库会迅速退化成「一堆几乎一样的条目」,反而找不到东西。
/// 因此支持 `${name}` 形式占位符,执行前弹出表单逐个填写。
///
/// 占位符语法刻意用 `${name}` 而不是 `{name}`shell 自身大量使用 `{}`
/// `${VAR}`、`awk '{print}'`、brace expansion),单花括号会与用户的正常
/// 命令冲突,导致片段存进去就「被替换掉了」。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct CommandSnippet {
pub id: String,
/// 展示名称(列表主标题)
pub name: String,
/// 命令内容(可含 `${name}` 占位符)
pub command: String,
/// 说明(列表副标题,讲清这条命令做什么、有什么前提)
pub description: String,
/// 分组名(空串归入「未分组」)
pub group: String,
/// 占位符的默认值:name → 默认值。未列出的占位符默认空串。
pub defaults: std::collections::BTreeMap<String, String>,
/// 适用的 shell kind(空数组表示所有 shell 都适用)。
/// 例:`Get-ChildItem` 只对 powershell 有意义,不该出现在 cmd 的列表里。
pub shell_kinds: Vec<String>,
/// 仅对 SSH 会话显示(如 `sudo systemctl restart` 类远端操作)
pub ssh_only: bool,
/// 是否需要二次确认(危险命令,如 `rm -rf`)
pub confirm: bool,
/// 是否在片段面板中置顶
pub pinned: bool,
/// 创建时间(Unix 毫秒,用于列表排序)
pub created_at: u64,
}
impl Default for CommandSnippet {
fn default() -> Self {
Self {
id: String::new(),
name: String::new(),
command: String::new(),
description: String::new(),
group: String::new(),
defaults: std::collections::BTreeMap::new(),
shell_kinds: Vec::new(),
ssh_only: false,
confirm: false,
pinned: false,
created_at: 0,
}
}
}
/// 从命令文本中提取 `${name}` 占位符名(去重、保持出现顺序)。
///
/// 放在 Rust 侧而不是前端:占位符是**命令语义的一部分**,执行前的替换、
/// 校验与「哪些占位符还没填」的判断必须用同一套解析,
/// 两边各写一遍迟早会在边界情况(`$${x}`、`${a}${b}` 相邻)上分叉。
pub fn snippet_placeholders(command: &str) -> Vec<String> {
let bytes = command.as_bytes();
let mut out: Vec<String> = Vec::new();
let mut i = 0usize;
while i < bytes.len() {
// 找 `${`
if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
// `$${x}` 是字面量 `${x}`(转义),跳过
let escaped = i > 0 && bytes[i - 1] == b'$';
if !escaped {
if let Some(end) = command[i + 2..].find('}') {
let name = &command[i + 2..i + 2 + end];
// 占位符名限定为标识符形态,避免把 `${VAR:-default}` 这类
// shell 参数展开语法误当成占位符
if !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& !out.iter().any(|x| x == name)
{
out.push(name.to_string());
}
i = i + 2 + end + 1;
continue;
}
}
}
i += 1;
}
out
}
/// 用给定值替换命令里的占位符。
///
/// 未提供的占位符**保持原样**(不替换成空串):静默替换成空串会让
/// `rm -rf ${dir}` 变成 `rm -rf ` —— 一个参数缺失的命令可能比一个
/// 显式报错的命令危险得多。调用方应先校验所有占位符都有值。
pub fn snippet_render(command: &str, values: &std::collections::BTreeMap<String, String>) -> String {
let mut out = String::with_capacity(command.len());
let bytes = command.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
let escaped = i > 0 && bytes[i - 1] == b'$';
if !escaped {
if let Some(end) = command[i + 2..].find('}') {
let name = &command[i + 2..i + 2 + end];
if !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
match values.get(name) {
Some(v) => out.push_str(v),
None => out.push_str(&command[i..i + 2 + end + 1]),
}
i = i + 2 + end + 1;
continue;
}
}
}
}
// 逐字符拷贝,注意 UTF-8 多字节边界:这里直接按字节推进会让
// 中文字符被切断。故用 chars().next() 取整字符的长度。
let ch = command[i..].chars().next().unwrap_or(' ');
out.push(ch);
i += ch.len_utf8();
}
out
}
// ===== 密钥存储(仅元数据)=====
/// 密钥库的单个条目(元数据,**不含私钥内容**)。
///
/// 私钥本体存放在 `{app_data_dir}/terminal/keys/` 的独立文件里(可能是几 KB,
/// 塞进 Windows 凭据管理器不可靠——单条有大小上限),passphrase 才进凭据管理器。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct KeyMeta {
pub id: String,
/// 展示名称
pub name: String,
/// 算法:"ed25519" | "rsa" | "ecdsa"
pub algorithm: String,
/// 位数 / 曲线(RSA 2048/3072/4096ECDSA P-256/P-384/P-521ed25519 固定空串)
pub bits: u32,
/// 公钥指纹(SHA256OpenSSH 展示格式 `SHA256:xxxx`
pub fingerprint: String,
/// 公钥内容(`ssh-ed25519 AAAA... comment`),用于一键复制
pub public_key: String,
/// 注释
pub comment: String,
/// 私钥文件名(`keys/` 目录下,相对名)
pub file_name: String,
/// 创建时间(RFC3339
pub created_at: String,
/// 是否由 ssh-agent 托管(P1
pub in_agent: bool,
}
impl Default for KeyMeta {
fn default() -> Self {
Self {
id: String::new(),
name: String::new(),
algorithm: "ed25519".to_string(),
bits: 0,
fingerprint: String::new(),
public_key: String::new(),
comment: String::new(),
file_name: String::new(),
created_at: String::new(),
in_agent: false,
}
}
}
// ===== 安全 =====
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct SecuritySettings {
/// 主机密钥策略:"ask"(默认,首次连接必须显式确认指纹)。
///
/// **不提供 "auto-accept" 选项**TOFU 静默接受是 MITM 的入口,
/// 属于代码层不该给用户的开关。
pub host_key_policy: String,
/// 指纹变更时是否阻断(**默认 true**)。关掉会让中间人攻击无声通过,
/// 因此前端需以红色风险提示呈现该开关。
pub block_on_fingerprint_change: bool,
/// 是否记录连接审计日志(P2,默认关;开启后输入输出落盘,含脱敏)
pub audit_log: bool,
}
impl Default for SecuritySettings {
fn default() -> Self {
Self {
host_key_policy: "ask".to_string(),
block_on_fingerprint_change: true,
audit_log: false,
}
}
}
// ===== 根结构 =====
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct TerminalSettings {
/// 结构版本号(用于后续迁移判断)
pub version: u32,
/// 本地 Shell 配置(探测结果 + 用户自定义,合并存放)
pub shells: Vec<ShellProfile>,
/// SSH 主机条目
pub hosts: Vec<SshHost>,
/// 密钥元数据
pub keys: Vec<KeyMeta>,
pub appearance: AppearanceSettings,
pub layout: LayoutSettings,
pub shortcuts: Vec<ShortcutBinding>,
pub selection: TerminalSelectionSettings,
pub security: SecuritySettings,
/// 「关闭标签页时确认」等行为的白名单:某些会话可豁免确认
pub close_confirm_exempt: Vec<String>,
/// 上次使用的 Shell id(新建会话时的默认选中项)
pub last_shell_id: String,
/// 命令片段库
pub snippets: Vec<CommandSnippet>,
/// 会话模板(一键拉起一组会话 + 布局)
pub templates: Vec<SessionTemplate>,
}
impl Default for TerminalSettings {
fn default() -> Self {
Self {
version: 2,
shells: Vec::new(),
hosts: Vec::new(),
keys: Vec::new(),
appearance: AppearanceSettings::default(),
layout: LayoutSettings::default(),
shortcuts: default_shortcuts(),
selection: TerminalSelectionSettings::default(),
security: SecuritySettings::default(),
close_confirm_exempt: Vec::new(),
last_shell_id: String::new(),
snippets: Vec::new(),
templates: Vec::new(),
}
}
}
/// 会话模板:一键拉起一组会话并排成布局。
///
/// 拉起语义:`entries[0]` 作为主面板,其余依次以分屏面板加入
/// (受 `layout.maxPanes` 上限约束,超过 4 个的条目被忽略——
/// WebGL 上下文上限决定了可见面板不可能超过 4,见 useSessionStream 说明)。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct SessionTemplate {
pub id: String,
pub name: String,
/// 创建时间(Unix 毫秒;模板列表按此排序)
pub created_at: u64,
pub entries: Vec<TemplateEntry>,
}
impl Default for SessionTemplate {
fn default() -> Self {
Self {
id: String::new(),
name: String::new(),
created_at: 0,
entries: Vec::new(),
}
}
}
/// 模板中的一个会话条目。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct TemplateEntry {
/// `"local"`shell| `"ssh"`(主机)
pub kind: String,
/// 本地 = shell idSSH = host id。拉起时实时解析——
/// 模板只存引用,不快照账号密码(那些在凭据管理器里按 id 存取)。
pub target_id: String,
/// 保存时的展示名快照(仅用于模板列表显示;target 失效时前端据此标注)
pub label: String,
}
impl Default for TemplateEntry {
fn default() -> Self {
Self {
kind: "local".to_string(),
target_id: String::new(),
label: String::new(),
}
}
}
/// 默认终端内快捷键。
///
/// 键位选择依据(Windows Terminal 惯例 + 与项目既有全局快捷键避让):
/// - 复制粘贴用 `Ctrl+Shift+C/V` 而**不是** `Ctrl+C/V``Ctrl+C` 在终端里必须是
/// SIGINT,任何对它做复制映射的设计都会破坏 `^C` 中断,这是不可接受的。
/// - 新建/关闭标签用 `Ctrl+Shift+T/W`,与浏览器习惯一致。
/// - 跳转标签用 `Alt+1..9``Ctrl+数字` 已被翻译模块的 `Ctrl+2`(翻译面板)占用,
/// 而 `Alt+数字` 在终端里通常不产生控制字符,冲突面最小。
pub fn default_shortcuts() -> Vec<ShortcutBinding> {
let pairs: &[(&str, &str)] = &[
("copy", "Ctrl+Shift+C"),
("paste", "Ctrl+Shift+V"),
("newTab", "Ctrl+Shift+T"),
("closeTab", "Ctrl+Shift+W"),
("nextTab", "Ctrl+Tab"),
("prevTab", "Ctrl+Shift+Tab"),
("splitRight", "Ctrl+Shift+D"),
("splitDown", "Ctrl+Shift+E"),
("closePane", "Ctrl+Shift+Q"),
("search", "Ctrl+Shift+F"),
("clear", "Ctrl+Shift+K"),
("fontIncrease", "Ctrl+="),
("fontDecrease", "Ctrl+-"),
("fontReset", "Ctrl+0"),
("toggleSftp", "Ctrl+Shift+P"),
("snippets", "Ctrl+Shift+S"),
// 历史用 HHistory)。不与 `Ctrl+Shift+H`(替换)冲突 ——
// 终端里没有「替换」这个动作。
("history", "Ctrl+Shift+H"),
("renameTab", "F2"),
("sessionSwitcher", "Ctrl+Shift+O"),
];
pairs
.iter()
.map(|(action, keys)| ShortcutBinding {
action: action.to_string(),
keys: keys.to_string(),
enabled: true,
})
.collect()
}
impl TerminalSettings {
/// 按 id 找主机。
pub fn host(&self, id: &str) -> Option<&SshHost> {
self.hosts.iter().find(|h| h.id == id)
}
/// 按 id 找 Shell。
pub fn shell(&self, id: &str) -> Option<&ShellProfile> {
self.shells.iter().find(|s| s.id == id)
}
/// 自愈:修复失效引用、补齐缺失的默认值、推进结构版本。
///
/// 沿用 translate 模块确立的 `heal()` 约定:老配置缺字段取默认值,
/// 失效引用自动回落,返回是否发生变更(由调用方决定是否落盘)。
pub fn heal(&mut self) -> bool {
let mut changed = false;
// 快捷键表:补齐新增动作、剔除已废弃动作。用户改过的键位保留。
let defaults = default_shortcuts();
for d in &defaults {
if !self.shortcuts.iter().any(|s| s.action == d.action) {
self.shortcuts.push(d.clone());
changed = true;
}
}
let before = self.shortcuts.len();
// 只保留默认表里存在的 action,避免版本升级后残留无人消费的绑定
self.shortcuts
.retain(|s| defaults.iter().any(|d| d.action == s.action));
if self.shortcuts.len() != before {
changed = true;
}
// 主机的 key_id 指向已删除的密钥 → 清空并退回密码认证的提示由前端给,
// 这里只做数据层清理(不回退 auth_method,避免静默改变用户的认证选择)
let key_ids: Vec<String> = self.keys.iter().map(|k| k.id.clone()).collect();
for host in &mut self.hosts {
if !host.key_id.is_empty() && !key_ids.contains(&host.key_id) {
host.key_id.clear();
changed = true;
}
}
// 主机字段兜底:端口非法、用户名缺失等由前端表单保证,这里只防越界
for host in &mut self.hosts {
if host.port == 0 {
host.port = 22;
changed = true;
}
if host.connect_timeout_ms < 1000 {
host.connect_timeout_ms = 15_000;
changed = true;
}
if host.encoding.trim().is_empty() {
host.encoding = "utf-8".to_string();
changed = true;
}
}
// 外观:滚动缓冲与字体大小越界会直接导致渲染异常
if self.appearance.scrollback < 100 {
self.appearance.scrollback = 10_000;
changed = true;
}
if self.appearance.scrollback > 200_000 {
self.appearance.scrollback = 200_000;
changed = true;
}
if self.appearance.font_size < 8 || self.appearance.font_size > 40 {
self.appearance.font_size = 14;
changed = true;
}
// 布局:分屏上限封顶 4
if self.layout.max_panes == 0 || self.layout.max_panes > 4 {
self.layout.max_panes = 4;
changed = true;
}
// last_shell_id 指向已删除的 Shell → 清空,由前端选第一个可用
if !self.last_shell_id.is_empty() && self.shell(&self.last_shell_id).is_none() {
self.last_shell_id.clear();
changed = true;
}
if self.version < 1 {
self.version = 1;
changed = true;
}
// v2:引入命令片段库。给**空库**塞一批起步片段 ——
// 一个空片段面板对着用户等于没有这个功能,而「自己写第一条」的门槛
// 比「改一条现成的」高得多。只在空库时注入,用户删光后不会被重塞。
if self.version < 2 {
if self.snippets.is_empty() {
self.snippets = default_snippets();
}
self.version = 2;
changed = true;
}
changed
}
}
/// 起步命令片段。
///
/// 选取标准:**跨平台通用、参数化后确有复用价值、且不容易打错**的东西。
/// 刻意不放 `ls`/`cd` 这类过短命令 —— 它们手打比在列表里找更快,
/// 放进片段库只会稀释信噪比。
pub fn default_snippets() -> Vec<CommandSnippet> {
fn mk(
id: &str,
name: &str,
command: &str,
description: &str,
group: &str,
placeholders: &[(&str, &str)],
ssh_only: bool,
confirm: bool,
) -> CommandSnippet {
CommandSnippet {
id: id.to_string(),
name: name.to_string(),
command: command.to_string(),
description: description.to_string(),
group: group.to_string(),
defaults: placeholders
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
shell_kinds: Vec::new(),
ssh_only,
confirm,
pinned: false,
created_at: 0,
}
}
vec![
mk(
"snip-find-large",
"查找大文件",
"find ${dir} -type f -size +${size} -exec ls -lh {} \\;",
"列出指定目录下大于指定体积的文件。size 用 100M / 1G 这类写法。",
"文件",
&[("dir", "/"), ("size", "100M")],
false,
false,
),
mk(
"snip-grep-recursive",
"递归搜索内容",
"grep -rn --include=${pattern} '${keyword}' ${dir}",
"在指定目录下按文件名模式递归搜索关键字。",
"文件",
&[("pattern", "*.log"), ("keyword", ""), ("dir", ".")],
false,
false,
),
mk(
"snip-tar-extract",
"解压 tar.gz",
"tar -xzvf ${file} -C ${target}",
"解压到指定目录。target 留空则解到当前目录。",
"文件",
&[("file", ""), ("target", ".")],
false,
false,
),
mk(
"snip-df",
"磁盘占用概览",
"df -h | sort -k5 -hr | head -20",
"按使用率倒序列出挂载点。排查「磁盘满了」的第一步。",
"诊断",
&[],
false,
false,
),
mk(
"snip-port-owner",
"查端口占用",
"ss -tlnp | grep ${port}",
"查看监听指定端口的进程。老系统若无 ss,改用 netstat -tlnp。",
"诊断",
&[("port", "8080")],
false,
false,
),
mk(
"snip-top-cpu",
"CPU 占用前 10",
"ps aux --sort=-%cpu | head -11",
"按 CPU 占用倒序列出进程(含表头共 11 行)。",
"诊断",
&[],
false,
false,
),
mk(
"snip-tail-follow",
"跟踪日志",
"tail -f ${file}",
"实时跟随文件新增内容。Ctrl+C 退出。",
"运维",
&[("file", "")],
false,
false,
),
mk(
"snip-systemd-status",
"服务状态",
"systemctl status ${service} --no-pager",
"查看 systemd 服务状态。--no-pager 让输出直接落到终端而不是进 less。",
"运维",
&[("service", "")],
true,
false,
),
mk(
"snip-perm-fix",
"递归修正属主",
"chown -R ${owner}:${group} ${dir}",
"递归修改目录属主与属组。",
"运维",
&[("owner", ""), ("group", ""), ("dir", "")],
true,
true,
),
mk(
"snip-ssh-tunnel",
"建立 SSH 隧道",
"ssh -N -L ${localPort}:${remoteHost}:${remotePort} ${user}@${jumpHost}",
"本地端口转发。localPort 是你要在本机访问的端口。",
"网络",
&[
("localPort", "8080"),
("remoteHost", "127.0.0.1"),
("remotePort", "80"),
("user", ""),
("jumpHost", ""),
],
false,
false,
),
mk(
"snip-ssh-keygen",
"生成 SSH 密钥",
"ssh-keygen -t ed25519 -C \"${comment}\" -f ~/.ssh/${name}",
"生成 ed25519 密钥对。ed25519 比 RSA 短且更快,现代环境首选。",
"网络",
&[("comment", ""), ("name", "id_ed25519")],
false,
false,
),
]
}
+687
View File
@@ -0,0 +1,687 @@
//! 本地 Shell 探测、命令行组装与 cwd 跟踪 hook 注入。
//!
//! # cwd 为什么需要 hook
//!
//! ConPTY 拿不到子 shell 的真实工作目录:`GetCurrentDirectory` 返回的是**我们
//! 自己进程**的目录,不是子进程的;`NtQueryInformationProcess` 读 PEB 虽然可行,
//! 但需要每帧轮询且对已提权进程无权访问。业界通行做法是让 shell 在每次提示符
//! 绘制时输出 **OSC 7** 转义序列(`ESC ] 7 ; file://host/path BEL`),
//! Windows Terminal / VS Code Terminal 都走这条路。
//!
//! 这是「SFTP 跟随终端目录」的前置能力,因此 P0 就做进去,而不是等到 P1 再补。
use std::path::{Path, PathBuf};
use super::settings::{EnvVar, ShellProfile};
/// 探测结果:合并「自动发现的 Shell」与「用户已有的自定义配置」。
///
/// 合并策略:**以自动探测为权威源修正路径**(用户机器上升级了 PowerShell 7
/// 路径可能变化),但保留用户设置的名字、参数、环境变量。已不存在且非用户自定义
/// 的条目直接丢弃(`pwsh.exe` 卸载后不该留一个点不动的菜单项)。
pub fn detect_and_merge(existing: &[ShellProfile]) -> Vec<ShellProfile> {
let detected = detect_shells();
let mut result: Vec<ShellProfile> = Vec::with_capacity(detected.len() + 2);
for mut d in detected {
if let Some(old) = existing.iter().find(|s| s.id == d.id) {
// 保留用户的个性化字段,路径以探测结果为准
d.name = if old.name.trim().is_empty() {
d.name.clone()
} else {
old.name.clone()
};
d.args = old.args.clone();
d.cwd = old.cwd.clone();
d.env = old.env.clone();
d.enabled = old.enabled;
d.detected = true;
// 用户在自定义条目上填过的路径若仍存在,尊重用户选择
if !old.path.trim().is_empty() && Path::new(&old.path).exists() && !old.detected {
d.path = old.path.clone();
d.detected = false;
}
}
result.push(d);
}
// 追加用户手工新增的、当前探测不到的条目(prune 逻辑在前端确认删除后执行)
for old in existing {
if old.detected {
continue; // 自动探测项已在上面的循环里处理(含丢弃已失效的)
}
if result.iter().any(|s| s.id == old.id) {
continue;
}
result.push(old.clone());
}
result
}
/// 自动探测本机可用的 Shell。
///
/// 顺序即菜单顺序,也是新建会话时的默认选中顺序(按现代性与功能排序)。
pub fn detect_shells() -> Vec<ShellProfile> {
let mut list = Vec::new();
// PowerShell 7+(优先:跨平台、默认 UTF-8、语法现代)
if let Some(p) = find_in_path(&["pwsh.exe"]) {
list.push(ShellProfile::new("pwsh", "PowerShell 7", &p, "powershell"));
}
// Windows PowerShell(系统必带,作保底)
if let Some(p) = find_windows_powershell() {
list.push(ShellProfile::new(
"powershell",
"Windows PowerShell",
&p,
"powershell",
));
}
// cmd
if let Some(p) = find_in_path(&["cmd.exe"]).or_else(find_cmd_fallback) {
list.push(ShellProfile::new("cmd", "命令提示符", &p, "cmd"));
}
// Git Bash(从 git 的安装目录反推,比扫 PATH 可靠)
if let Some(p) = find_git_bash() {
list.push(ShellProfile::new("gitbash", "Git Bash", &p, "bash"));
}
// WSL 发行版:每个发行版一个条目
for distro in list_wsl_distros() {
let id = format!("wsl-{}", sanitize_id(&distro));
let name = format!("WSL · {distro}");
let mut profile = ShellProfile::new(&id, &name, "wsl.exe", "wsl");
profile.args = vec!["-d".to_string(), distro];
list.push(profile);
}
list
}
/// 在 PATH 中查找可执行文件。
fn find_in_path(names: &[&str]) -> Option<String> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
for name in names {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate.to_string_lossy().to_string());
}
}
}
None
}
/// 定位 Windows PowerShell。
///
/// 优先用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`(权威路径),
/// 而不是查 PATH——PATH 里可能有同名伪装程序。
fn find_windows_powershell() -> Option<String> {
let root = std::env::var_os("SystemRoot")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
let p = root
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe");
if p.is_file() {
return Some(p.to_string_lossy().to_string());
}
find_in_path(&["powershell.exe"])
}
fn find_cmd_fallback() -> Option<String> {
let root = std::env::var_os("SystemRoot")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
let p = root.join("System32").join("cmd.exe");
if p.is_file() {
return Some(p.to_string_lossy().to_string());
}
find_in_path(&["cmd.exe"])
}
/// 定位 Git Bash。
///
/// 先查 PATH 上的 `bash.exe`Git 安装时通常会把 `Git\bin` 或 `Git\usr\bin` 加进去),
/// 但更要紧的是排除掉 WSL 的 `bash.exe``System32\bash.exe`)——它不是 Git Bash
/// 混用会导致用户点「Git Bash」却进了 WSL。
fn find_git_bash() -> Option<String> {
let candidates = [
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
];
for c in candidates {
if Path::new(c).is_file() {
return Some(c.to_string());
}
}
// 从 HOME 下的常见位置反推(scoop / 便携版)
if let Some(home) = dirs::home_dir() {
for rel in [r"scoop\apps\git\current\bin\bash.exe", r"AppData\Local\Programs\Git\bin\bash.exe"] {
let p = home.join(rel);
if p.is_file() {
return Some(p.to_string_lossy().to_string());
}
}
}
// 最后才查 PATH,且必须排除 System32(那是 WSL 的 bash
find_in_path(&["bash.exe"]).filter(|p| {
let lower = p.to_lowercase();
!lower.contains("system32") && !lower.contains("windowsapps")
})
}
/// 枚举 WSL 发行版。
///
/// `wsl.exe -l -q` 输出 UTF-16LEWindows 上部分 wsl.exe 版本如此),
/// 先按 UTF-16 解,失败再按 UTF-8 解。输出每行一个发行版名。
fn list_wsl_distros() -> Vec<String> {
let out = match std::process::Command::new("wsl.exe")
.args(["-l", "-q"])
.creation_flags_no_window()
.output()
{
Ok(o) if o.status.success() => o.stdout,
_ => return Vec::new(),
};
let text = decode_wsl_output(&out);
text.lines()
.map(|l| l.trim().trim_matches('\0').to_string())
// 过滤空行与提示行("适用于 Linux 的 Windows 子系统..." 之类)
.filter(|l| !l.is_empty() && !l.contains(' ') )
.collect()
}
/// WSL 输出可能是 UTF-16LE 或 UTF-8,试两种。
fn decode_wsl_output(bytes: &[u8]) -> String {
// UTF-16LE 的特征:ASCII 字符之间夹 0x00,且长度为偶数
let looks_utf16 = bytes.len() >= 4 && bytes.len() % 2 == 0 && bytes.iter().skip(1).step_by(2).filter(|&&b| b == 0).count() > bytes.len() / 4;
if looks_utf16 {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&units)
} else {
String::from_utf8_lossy(bytes).to_string()
}
}
/// 把发行版名转成可用作 id 的字符串。
fn sanitize_id(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect()
}
/// 组装完整的命令行字符串。
///
/// Windows 的 `CreateProcessW` 在 `lpApplicationName = NULL` 时会自行解析命令行首段,
/// 因此**路径含空格必须加引号**`C:\Program Files\...` 不加引号会被切成
/// `C:\Program` + 参数)。这里统一处理。
pub fn build_command_line(profile: &ShellProfile) -> String {
let mut s = quote_if_needed(&profile.path);
for a in &profile.args {
s.push(' ');
s.push_str(&quote_if_needed(a));
}
// 启动后自动执行的命令:拼在参数之后,由 shell 自己解析
if !profile.cwd.is_empty() {
// cwd 由 CreateProcessW 的 lpCurrentDirectory 处理,不在这里拼
}
s
}
/// 需要时加引号。
fn quote_if_needed(s: &str) -> String {
if s.contains(' ') && !s.starts_with('"') {
format!("\"{s}\"")
} else {
s.to_string()
}
}
/// 把 [`ShellProfile::env`] 转成 `CreateProcessW` 需要的键值对。
pub fn env_pairs(env: &[EnvVar]) -> Vec<(String, String)> {
env.iter()
.filter(|e| !e.key.trim().is_empty())
.map(|e| (e.key.clone(), e.value.clone()))
.collect()
}
/// 为指定 Shell 生成 cwd 跟踪 hook 的注入参数。
///
/// 返回值是**额外的启动参数**,需要由调用方拼到命令行里。返回空表示该 Shell
/// 无法通过参数注入(如 cmd),此时退化为不跟踪 cwd。
///
/// # 各 Shell 的注入方式与理由
///
/// - **PowerShell / pwsh**`-NoExit -EncodedCommand <base64>`。脚本重定义 `prompt` 函数,
/// 在原有提示符前输出 OSC 7。用 `-NoExit` 是因为 `-Command` 默认会在脚本结束后
/// 退出 shell,而我们只要它执行一段初始化。**不覆盖用户已有的 profile**
/// `-Command` 在 profile 加载之后执行,属于叠加而非替换。
/// 用 `-EncodedCommand` 而不是 `-Command`:脚本里含**双引号**`Write-Host "..."`),
/// 而 `CreateProcessW` 的命令行是单一字符串,引号必须按 MSVCRT 规则转义;
/// 直接拼 `-Command "script"` 时脚本内的 `"` 会提前终结外层引号,
/// PowerShell 收到的是引号被剥掉的残缺脚本 → `Unexpected token '$('` 解析错误。
/// `-EncodedCommand` 接收 base64UTF-16LE),纯字母数字,与引号解析彻底无关。
/// - **Git Bash**`--init-file <文件>`。需要落一个临时脚本文件,因为 bash 的
/// `--init-file` 只接受文件路径。脚本里用 `PROMPT_COMMAND` 输出 OSC 7。
/// - **cmd**:无可靠注入点(`PROMPT` 环境变量不支持转义序列输出 ESC)。
/// 不跟踪,`cwd` 字段保持为空——这是能力边界,如实呈现而不糊弄。
/// - **WSL**:与 bash 同理,但需要写到 WSL 内部路径,成本高。P0 不跟踪。
pub fn cwd_hook_args(profile: &ShellProfile, hook_dir: &Path) -> (Vec<String>, Option<PathBuf>) {
match profile.kind.as_str() {
"powershell" => {
let script = powershell_prompt_hook();
(
vec![
"-NoExit".to_string(),
"-EncodedCommand".to_string(),
encode_powershell_command(&script),
],
None,
)
}
"bash" => {
let file = hook_dir.join("gitbash-cwd-hook.sh");
let content = bash_prompt_hook();
if std::fs::write(&file, content).is_err() {
return (Vec::new(), None);
}
(
vec!["--init-file".to_string(), file.to_string_lossy().to_string()],
Some(file),
)
}
// cmd / wsl:没有可靠注入点,不跟踪 cwd
_ => (Vec::new(), None),
}
}
/// 把脚本编码成 PowerShell `-EncodedCommand` 接受的 base64UTF-16LE)。
///
/// PowerShell5.1 与 7+)按 UTF-16LE 解码该参数;编码后不含空格与引号,
/// 经过 [`build_command_line`] 的引用规则时不会被改写。
fn encode_powershell_command(script: &str) -> String {
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
let utf16le: Vec<u8> = script
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect();
B64.encode(utf16le)
}
/// PowerShell 提示符 hook。
///
/// 关键点:
/// - 用 `$ExecutionContext.SessionState.Path.CurrentLocation` 取当前路径
/// - `file://` 后的主机名用 `$env:COMPUTERNAME`(本地会话无实际意义,但保持格式合法)
/// - 路径里的反斜杠要转成 `/`,且 `file://` 三段式后不能有多余斜杠
/// (否则部分解析器会把盘符吃掉)
/// - 结尾用 BEL`` `a ``)而不是 STBEL 兼容性最好,Windows Terminal 也用它
///
/// 注意 `$PWD` 在 PowerShell 里是 `PathInfo` 对象而非字符串,直接插值会得到
/// `Microsoft.PowerShell.Core\FileSystem::C:\...` 这种非预期内容,因此用
/// `ProviderPath` 显式取字符串路径。
///
/// # OSC 133 命令边界(命令历史的来源)
///
/// PowerShell 的提示符函数在「上一条命令执行完、即将显示新提示符」这个时刻被调用,
/// 因此这里输出的是 **D(上一条结束)** 而不是 C(即将开始)。
///
/// 退出码取自 `$LASTEXITCODE`(原生命令)或 `$?`cmdlet),两者语义不同:
/// cmdlet 成功时 `$LASTEXITCODE` 可能保留着**更早那条原生命令**的值。
/// 因此优先 `$LASTEXITCODE`(仅当其在本轮被设置过),否则用 `$?` 折算 0/1。
/// 无法拿到 `$?` 的历史值 —— 它会被提示符函数自身的第一条语句覆盖,
/// 所以这个取值必须在函数体**最开头**完成。
fn powershell_prompt_hook() -> String {
[
"$__thingOrigPrompt = $function:prompt;",
"function global:prompt {",
// 必须最先取:后面任何一条语句都会刷新 $?
" $__ok = $?;",
" $__code = $LASTEXITCODE;",
" if ($null -eq $__code) { $__code = if ($__ok) { 0 } else { 1 } }",
" Write-Host -NoNewline \"$([char]27)]133;D;$__code$([char]7)\";",
" $__p = $ExecutionContext.SessionState.Path.CurrentLocation;",
" $__loc = $__p.ProviderPath;",
" if ($__loc) {",
" $__u = $__loc -replace '\\\\','/';",
" if ($__u -notmatch '^/') { $__u = '/' + $__u }",
" Write-Host -NoNewline \"$([char]27)]7;file://$env:COMPUTERNAME$__u$([char]7)\";",
" }",
" if ($__thingOrigPrompt) { & $__thingOrigPrompt } else { 'PS ' + (Get-Location) + '> ' }",
"}",
]
.join(" ")
}
/// Git Bash 提示符 hook。
///
/// 用 `PROMPT_COMMAND` 而不是重定义 `PS1``PROMPT_COMMAND` 在每次绘制提示符前
/// 执行,且不干扰用户自己设置的 `PS1`(重定义 PS1 会覆盖用户的样式)。
///
/// # 命令历史的来源(OSC 133 + 1337
///
/// bash 没有「命令执行完」的钩子,但 `PROMPT_COMMAND` 恰好在同一时刻运行,
/// 且此时 `$?` 仍是上一条命令的退出码(任何语句都会覆盖它,所以先存后读)。
///
/// 命令文本取自 `history 1`:它返回 ` 123 <命令>`(前导空格 + 序号 + 空格)。
/// 用 `history 1` 而不是 `BASH_COMMAND` 或 `$1`
/// - `BASH_COMMAND` 在 `PROMPT_COMMAND` 里指向的是 `PROMPT_COMMAND` 自身
/// - `history 1` 给的是 **shell 最终执行的那条**,别名已展开、Tab 补全已生效
///
/// # 为什么 `history 1` 要去掉序号而不是按空格切
///
/// 序号与命令之间是**两个空格**分隔,但命令本身可能以空格开头
/// (用户刻意用前导空格隐藏命令)。用 `sed 's/^ *[0-9]* *//'` 会连用户的
/// 前导空格一起吃掉,导致「刻意隐藏的命令」变成普通命令被记进我们的历史 ——
/// 这正好违背用户意图。因此用「剥掉前导空白 + 数字 + 一个空格」的精确匹配,
/// 保留命令本身的任何前导空格... 但这样又与我们「前导空格不入库」的规则冲突。
///
/// 结论:**保留 shell 的原样输出交给 Rust 侧判断** —— hook 只管如实上报,
/// 「前导空格要不要记」是策略,由 `History::record` 统一决定(那里也是
/// `HISTCONTROL=ignorespace` 的落点)。hook 里做策略判断会散落成两处规则。
fn bash_prompt_hook() -> String {
[
"# Thing 终端:shell integration hook(由终端模块注入,可安全删除)",
"# 输出 OSC 7cwd)与 OSC 133/1337(命令边界与命令文本),",
"# 供文件管理器、cwd 继承与命令历史使用",
"__thing_osc7() {",
" local __code=$?",
" printf '\\033]133;D;%s\\007' \"$__code\"",
// 取最后一条历史:`history 1` 返回 ` 123 <命令>`
// (前导空格 + 序号 + 两个空格 + 命令本体)。
//
// 用 `read -r` 拆而不是参数展开剥前缀:`${x#"$y"}` 这种嵌套引号在
// Rust 字符串字面量里要转义到难以阅读,而且 `history` 的输出里
// 命令本体可能**含空格**,参数展开必须保留剩余全部内容。
// `read -r _ _ __cmd` 的语义恰好是「跳过前两段空白分隔的字段,
// 其余原样(含内部空格)收进 __cmd」—— 正是所需,且引号最少。
" local __cmd=''",
" read -r _ _ __cmd <<< \"$(HISTTIMEFORMAT= builtin history 1)\"",
" printf '\\033]1337;Cmd=%s\\007' \"$__cmd\"",
" printf '\\033]7;file://%s%s\\007' \"${HOSTNAME:-localhost}\" \"$PWD\"",
"}",
"if [[ -n \"$PROMPT_COMMAND\" ]]; then",
" PROMPT_COMMAND=\"__thing_osc7; $PROMPT_COMMAND\"",
"else",
" PROMPT_COMMAND=\"__thing_osc7\"",
"fi",
"",
]
.join("\n")
}
/// 清屏命令(按 Shell 类型区分)。
pub fn clear_command(kind: &str) -> &'static str {
match kind {
"cmd" => "cls\r",
"powershell" => "Clear-Host\r",
// bash / wsl`clear` 是 ANSI 序列,也可直接发 \x1bc 复位
_ => "clear\r",
}
}
use std::os::windows::process::CommandExt;
/// `Command::creation_flags` 的糖:隐藏控制台窗口。
trait NoWindow {
fn creation_flags_no_window(&mut self) -> &mut Self;
}
impl NoWindow for std::process::Command {
fn creation_flags_no_window(&mut self) -> &mut Self {
// CREATE_NO_WINDOW = 0x08000000,避免探测 wsl 时闪一个黑框
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
self.creation_flags(CREATE_NO_WINDOW)
}
}
/// 解析终端输出流中的 OSC 7(cwd)、OSC 0/2(标题)与 OSC 133(命令边界)序列。
///
/// 输入是原始字节流的一个片段,可能**不完整**(序列被切在中间)。因此本函数
/// 采用「窗口扫描 + 保留尾部」策略:
/// - 完整解析到的序列被消费掉
/// - 未闭合的序列从起始 ESC 开始保留到缓冲区末尾,等下一批数据拼接
///
/// 返回 [`ParsedSequences`],而不是继续扩元组 —— 再加一类序列就要变成 4 元组,
/// 调用点会退化成一串 `let (_, _, x, _) = ...`,加字段时无从判断哪里该改。
pub fn parse_control_sequences(data: &[u8]) -> ParsedSequences {
let mut out = ParsedSequences {
consumed: data.len(),
cwds: Vec::new(),
titles: Vec::new(),
marks: Vec::new(),
};
let mut i = 0usize;
// 最后一个「未消费但可能是序列开头」的位置
let mut safe_end = data.len();
while i < data.len() {
if data[i] != 0x1b {
i += 1;
continue;
}
// ESC ] ... 起点
if i + 1 >= data.len() {
safe_end = i;
break;
}
if data[i + 1] != b']' {
i += 1;
continue;
}
// 找终止符:BEL(0x07) 或 ST(ESC \)
let start = i + 2;
let mut j = start;
let mut terminated = false;
while j < data.len() {
if data[j] == 0x07 {
terminated = true;
break;
}
if data[j] == 0x1b && j + 1 < data.len() && data[j + 1] == b'\\' {
terminated = true;
break;
}
j += 1;
}
if !terminated {
// 序列被切断了,从 ESC 位置保留到末尾
safe_end = i;
break;
}
let body_end = j;
let payload = String::from_utf8_lossy(&data[start..body_end]).to_string();
// 消费的长度:ESC ] body 终止符(BEL 1 字节 / ST 2 字节)
let consumed_end = if data[body_end] == 0x07 { body_end + 1 } else { body_end + 2 };
i = consumed_end;
if let Some(rest) = payload.strip_prefix("7;") {
if let Some(cwd) = parse_osc7(rest) {
out.cwds.push(cwd);
}
} else if let Some(rest) = payload.strip_prefix("0;").or_else(|| payload.strip_prefix("2;")) {
if !rest.trim().is_empty() {
out.titles.push(rest.to_string());
}
} else if payload.starts_with("133;") {
if let Some(mark) = parse_osc133(&payload) {
out.marks.push(mark);
}
}
}
// 消费掉完整解析过的部分,保留尾部残留
let consumed = if safe_end < data.len() && i <= safe_end {
safe_end.max(i)
} else {
i.min(data.len())
};
out.consumed = consumed;
out
}
/// OSC 133 命令边界标记(shell integration 协议)。
///
/// # 为什么需要它才能记录命令历史
///
/// 终端应用要记录「用户执行了什么命令」,直觉做法是「把用户在键盘上敲的字符
/// 攒起来,遇到回车就存」。这条路**必然出错**:
/// - 退格、方向键、`Ctrl+U` 都会改写已敲内容,前端拿到的是一串 `\x7f` 与 `\x1b[D`
/// - Tab 补全的结果是由 shell 生成的,前端根本不知道补全成了什么
/// - 别名(`ll` → `ls -al`)、历史展开(`!!`)同理
///
/// OSC 133 由 **shell 自己**在提示符处输出,因此上述问题全部不存在 ——
/// 它报告的是 shell 最终真正执行的那条命令。
///
/// # 协议形态
///
/// - `OSC 133 ; A` — 提示符开始(准备接收输入)
/// - `OSC 133 ; B` — 输入区开始
/// - `OSC 133 ; C` — 命令开始执行
/// - `OSC 133 ; D ; <exit_code>` — 命令结束,可选携带退出码
/// - `OSC 133 ; D` — 命令结束,无退出码
///
/// 本模块只关心 `D`:它标志着「上一条命令执行完毕」,此刻可以上报。
///
/// # 关于「命令文本从哪来」
///
/// OSC 133 的 `C` 标记**不携带命令文本**(协议本身只管边界,不管内容)。
/// 要拿到文本,标准做法是 shell hook 里额外输出一个自定义序列
/// (如 `OSC 633 ; E ; <cmd>`VS Code 用这个)。这里沿用同一思路,
/// 用 `OSC 1337 ; Cmd=<cmd>`(见 `shell_integration_script`)。
///
/// 把「命令文本」与「边界」分开传输,是因为前者需要 shell 侧配合转义
/// (命令里可能含 `\a`、`\x1b`),而边界只要一个字符,两者可靠性诉求不同 ——
/// 混在一个序列里会让「命令含 BEL 字符」直接破坏边界解析。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandMark {
/// 命令开始执行(`C`
Start,
/// 命令结束(`D`),携带退出码(若有)
End(Option<i32>),
/// 即将执行的命令文本(自定义序列,见上)
Command(String),
}
/// 解析 OSC 133 / 1337 的载荷(**不含** `OSC ` 前缀)。
fn parse_osc133(payload: &str) -> Option<CommandMark> {
// 自定义序列必须**先**判断:`1337;Cmd=...` 与 `133;...` 共享 `133` 前缀,
// 若先走 `strip_prefix("133;")``1337;` 因第 4 字符是 `7` 而非 `;` 而失配,
// 结果是「命令文本永远收不到,且不报错」—— 静默失效最难查。
if let Some(cmd) = payload.strip_prefix("1337;Cmd=") {
return Some(CommandMark::Command(cmd.to_string()));
}
let rest = payload.strip_prefix("133;")?;
match rest.chars().next()? {
'C' => Some(CommandMark::Start),
'D' => {
// `D` / `D;0` / `D;1` —— 分号后的部分是退出码
let code = rest
.strip_prefix("D;")
.and_then(|s| s.trim().split(';').next())
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<i32>().ok());
Some(CommandMark::End(code))
}
// A / B 与历史记录无关(提示符与输入区起止),显式忽略而非报错:
// 它们由同一个 hook 输出,忽略掉比让调用方遍历时到处判类型更省事。
_ => None,
}
}
/// `parse_control_sequences` 的结果。
#[derive(Debug, Clone, Default)]
pub struct ParsedSequences {
/// 已完整解析、可从缓冲区丢弃的字节数
pub consumed: usize,
/// OSC 7 上报的 cwd(按出现顺序)
pub cwds: Vec<String>,
/// OSC 0/2 上报的标题
pub titles: Vec<String>,
/// OSC 133 命令边界标记(按出现顺序)
pub marks: Vec<CommandMark>,
}
/// 从 OSC 7 的载荷解析出本地路径。
///
/// 载荷形如 `file://HOST/C:/Users/foo` 或 `file:///home/user`。
/// Windows 上要处理 `file://HOST/C:/...` → `C:\...` 的还原:
/// 去掉开头的 `/`,把 `/` 换回 `\`,并把 `C:` 前面的多余斜杠去掉。
fn parse_osc7(payload: &str) -> Option<String> {
let rest = payload.strip_prefix("file://").unwrap_or(payload);
// 跳过主机名(第一个 '/' 之前的部分)
let path_part = match rest.find('/') {
Some(idx) => &rest[idx..],
None => rest,
};
if path_part.is_empty() {
return None;
}
let decoded = percent_decode(path_part);
// Windows 盘符形态:/C:/Users → C:\Users
let normalized = if decoded.len() >= 3
&& decoded.starts_with('/')
&& decoded.as_bytes()[2] == b':'
{
decoded[1..].replace('/', "\\")
} else {
decoded.replace('/', "\\")
};
Some(normalized)
}
/// 极简百分号解码(OSC 7 里的路径可能含 `%20` 等)。
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(v);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).to_string()
}
/// 默认工作目录:优先用户主目录,其次当前目录。
pub fn default_cwd() -> String {
dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string())
}
/// 校验用户给定的工作目录是否可用(不存在则退回默认,不报错阻断会话创建)。
pub fn resolve_cwd(requested: &str) -> Option<String> {
let r = requested.trim();
if r.is_empty() {
return Some(default_cwd());
}
if Path::new(r).is_dir() {
Some(r.to_string())
} else {
crate::logger::log_warn(
"terminal",
&format!("工作目录 {r} 不存在,退回默认目录"),
);
Some(default_cwd())
}
}
+475
View File
@@ -0,0 +1,475 @@
//! 端口转发引擎(P2):`-L` 本地转发与 `-R` 远程转发。
//!
//! # 两个方向的管线
//!
//! **-L(本地转发)**:本机开 `TcpListener`,每来一条连接就在 SSH 会话上开一条
//! `direct-tcpip` 通道指向目标,然后 `copy_bidirectional` 对拷:
//!
//! ```text
//! 本地应用 ──TCP──▶ TcpListener ──▶ direct-tcpip 通道 ──▶ 服务器 ──▶ target_host:port
//! ```
//!
//! **-R(远程转发)**:通过 `handle.tcpip_forward()` 请求**服务器**监听;
//! 服务器侧来连接时,russh 在 Handler 的
//! `server_channel_open_forwarded_tcpip` 回调里把通道交给我们,由我们连到目标:
//!
//! ```text
//! 远端访问者 ──▶ 服务器:bind_port ──forwarded-tcpip 通道──▶ 本机 ──TCP──▶ target_host:port
//! ```
//!
//! # 生命周期与所有权
//!
//! 转发规则挂在**会话**上(不持久化):会话关闭 = 全部转发消失,
//! 这与 ssh 客户端的直觉一致(连接断开转发即失效)。
//! `-L` 的监听任务句柄存进注册表,remove 时 `abort()` 释放端口;
//! `-R` 无本地任务(通道由 Handler 回调驱动),remove 时发 `cancel_tcpip_forward`。
//!
//! # 安全默认
//!
//! russh 对 `forwarded-tcpip` 通道的默认处理是**全部接受**——意味着只要服务器
//! 愿意,任何一条 forwarded 通道都会被接受并挂起等数据。本模块的 Handler
//! 覆写为**白名单匹配**:只有注册过的 `-R` 规则(按监听端口)才放行,其余拒绝。
//!
//! # 已知取舍
//!
//! - `-D`SOCKS5 动态转发)不在本模块:需要实现 SOCKS5 握手协议,独立成项再做;
//! - 转发规则不持久化:每次连接后按需添加。若后续要「主机级自动转发」,
//! 在主机配置里存模板并在会话 Established 后逐条调 `add_local`/`add_remote` 即可。
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use serde::Serialize;
use specta::Type;
use super::{Channel, Msg, SshInner};
use super::super::session::now_millis;
/// 转发规则视图(发往前端)。
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ForwardView {
pub id: String,
/// `"local"`-L| `"remote"`-R
pub kind: String,
/// -L:本机监听地址;-R:**服务器端**监听地址
pub bind_host: String,
pub bind_port: u16,
/// -L:从**服务器**视角要连接的目标;-R:从**服务器**视角连接的目标
pub target_host: String,
pub target_port: u16,
/// `"active"` | `"error"`
pub status: String,
/// 人类可读状态(实际监听地址 / 错误原因)
pub detail: String,
}
/// 注册表条目。
pub(crate) struct ForwardEntry {
pub rule: ForwardView,
/// `-L` 的监听循环任务(remove 时 abort 以释放端口);`-R` 为 None。
pub task: Option<tauri::async_runtime::JoinHandle<()>>,
}
/// 会话 → (转发 id → 条目)。
///
/// 全局静态表的理由与 `PENDING_HOST_KEYS` 相同:转发管道任务的 spawn 点
/// 分散在命令层与 Handler 回调里,拿不到统一的会话对象引用。
/// 键直接用 `String``SessionId` 是它的别名,此处不依赖别名语义)。
static REGISTRY: OnceLock<Mutex<HashMap<String, HashMap<String, ForwardEntry>>>> =
OnceLock::new();
fn registry() -> &'static Mutex<HashMap<String, HashMap<String, ForwardEntry>>> {
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
fn next_id() -> String {
format!("fw{}", now_millis())
}
/// 列出某会话的全部转发规则。
pub fn list_for_session(session_id: &str) -> Vec<ForwardView> {
registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(session_id)
.map(|m| m.values().map(|e| e.rule.clone()).collect())
.unwrap_or_default()
}
/// Handler 回调用:按监听端口做**全局**匹配 `-R` 规则。
///
/// # 为什么是全局而不是按会话
///
/// P2 连接复用之后,入站的 forwarded-tcpip 通道总是从**连接级** Handler
/// 回调进来,而该 Handler 的 session_id 属于**第一个**建立连接的会话;
/// 第二个会话添加的 -R 规则若只查自己的 session_id 就永远匹配不上。
/// 端口的全局唯一性在 `add_remote` 时已强制(重复绑定端口被拒绝),
/// 因此这里按端口全局查找是安全的。
///
/// 返回 `(规则所属会话 id, 目标主机, 目标端口)`;无匹配 = 服务器来了一条
/// 没有对应规则的转发连接,调用方应拒绝。
pub(crate) fn match_remote_rule(connected_port: u32) -> Option<(String, String, u16)> {
let reg = registry().lock().unwrap_or_else(|e| e.into_inner());
for entries in reg.values() {
for e in entries.values() {
if e.rule.kind == "remote"
&& e.rule.status == "active"
&& e.rule.bind_port as u32 == connected_port
{
return Some((
e.rule.id.clone(),
e.rule.target_host.clone(),
e.rule.target_port,
));
}
}
}
None
}
/// 检查远程监听端口是否已被(任何会话的)规则占用。
///
/// 共享连接下两个会话各自 -R 同一端口会让路由产生歧义,必须在添加时拒绝。
pub(crate) fn remote_port_taken(bind_port: u16) -> bool {
let reg = registry().lock().unwrap_or_else(|e| e.into_inner());
reg.values().any(|entries| {
entries.values().any(|e| {
e.rule.kind == "remote" && e.rule.status == "active" && e.rule.bind_port == bind_port
})
})
}
/// 标记规则出错(如 `-L` 监听套接字意外失效)。
fn mark_error(session_id: &str, forward_id: &str, detail: String) {
if let Some(e) = registry()
.lock()
.unwrap_or_else(|x| x.into_inner())
.get_mut(session_id)
.and_then(|m| m.get_mut(forward_id))
{
e.rule.status = "error".to_string();
e.rule.detail = detail;
}
}
/// 添加 `-L` 本地转发。
///
/// 先 `bind` 再 spawn:端口被占用时**立刻**报错返回(fail-fast),
/// 而不是存了一条永远没有流量的死规则。
pub async fn add_local(
session_id: &str,
inner: std::sync::Arc<SshInner>,
bind_host: &str,
bind_port: u16,
target_host: &str,
target_port: u16,
) -> Result<ForwardView, String> {
let listener = tokio::net::TcpListener::bind((bind_host, bind_port))
.await
.map_err(|e| {
format!(
"监听 {bind_host}:{bind_port} 失败: {e}。常见原因:端口已被其他程序占用。"
)
})?;
let actual = listener
.local_addr()
.map(|a| a.to_string())
.unwrap_or_else(|_| format!("{bind_host}:{bind_port}"));
let view = ForwardView {
id: next_id(),
kind: "local".to_string(),
bind_host: bind_host.to_string(),
bind_port,
target_host: target_host.to_string(),
target_port,
status: "active".to_string(),
detail: format!("本机监听 {actual}"),
};
let sid = session_id.to_string();
let target = target_host.to_string();
let fw_id = view.id.clone();
let task = tauri::async_runtime::spawn(async move {
local_accept_loop(&sid, &fw_id, listener, inner, &target, target_port).await;
});
registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(session_id.to_string())
.or_default()
.insert(
view.id.clone(),
ForwardEntry {
rule: view.clone(),
task: Some(task),
},
);
Ok(view)
}
/// `-L` 的接受循环:每条连接开一条独立 `direct-tcpip` 通道。
///
/// 通道开启用 sftp 同款的 `spawn_blocking + block_on` 模式借出
/// `Mutex` 里的 `Handle`(不可克隆、不能跨 `.await` 持锁)——
/// 每条连接只占用阻塞线程一个 RTT,数据对拷是纯异步的。
async fn local_accept_loop(
session_id: &str,
forward_id: &str,
listener: tokio::net::TcpListener,
inner: std::sync::Arc<SshInner>,
target_host: &str,
target_port: u16,
) {
loop {
let accepted = listener.accept().await;
let (tcp, peer) = match accepted {
Ok(v) => v,
Err(e) => {
// 监听套接字级错误(极少见,如句柄耗尽):标记错误并退出循环,
// 端口随即释放,前端列表里能看到 status 变为 error
mark_error(
session_id,
forward_id,
format!("监听异常,转发已停止: {e}"),
);
return;
}
};
let inner = inner.clone();
let target = target_host.to_string();
let peer_ip = peer.ip().to_string();
let peer_port = peer.port();
let target_port_u32 = target_port as u32;
tauri::async_runtime::spawn(async move {
match open_direct_tcpip(&inner, &target, target_port_u32, &peer_ip, peer_port).await {
Ok(channel) => {
// 通道转成流后与本地 TCP 对拷;任一侧关闭即结束
let mut ch = channel.into_stream();
let mut tcp = tcp;
if let Err(e) = tokio::io::copy_bidirectional(&mut ch, &mut tcp).await {
crate::logger::log_warn(
"terminal",
&format!("转发数据管道中断({peer}: {e}"),
);
}
}
Err(e) => {
// 开通道失败:直接 drop 本地 TCP,让发起方立刻看到连接被断开,
// 而不是挂死等超时
crate::logger::log_warn(
"terminal",
&format!("转发开通道失败({peer}{target}:{target_port_u32}: {e}"),
);
}
}
});
}
}
/// 在 SSH 会话上开一条 `direct-tcpip` 通道(sftp 同款的借锁模式)。
async fn open_direct_tcpip(
inner: &std::sync::Arc<SshInner>,
host: &str,
port: u32,
originator_ip: &str,
originator_port: u16,
) -> Result<Channel<Msg>, String> {
let inner = inner.clone();
let host = host.to_string();
let originator_ip = originator_ip.to_string();
tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
// 槽位为 tokio Mutex(P2 连接复用):锁的获取也放进 block_on
let guard = inner.handle.lock().await;
let handle = guard
.as_ref()
.ok_or_else(|| "SSH 会话已断开,转发不可用".to_string())?;
handle
.channel_open_direct_tcpip(host, port, originator_ip, originator_port as u32)
.await
.map_err(|e| format!("打开转发通道失败: {e}"))
})
})
.await
.map_err(|e| format!("转发任务异常: {e}"))?
}
/// 添加 `-R` 远程转发。
///
/// 请求**服务器**在 `bind_host:bind_port` 监听;后续连接经
/// `server_channel_open_forwarded_tcpip` 回调回到本机(见 Handler 覆写)。
pub async fn add_remote(
session_id: &str,
inner: &std::sync::Arc<SshInner>,
bind_host: &str,
bind_port: u16,
target_host: &str,
target_port: u16,
) -> Result<ForwardView, String> {
// 共享连接下监听端口是**全局**资源:另一个会话已用同一端口时,
// 入站路由无法区分归属,必须在添加时拒绝而不是静默错乱
if remote_port_taken(bind_port) {
return Err(format!(
"远程监听端口 {bind_port} 已被占用(可能是其他会话的远程转发)"
));
}
request_remote_listen(inner, bind_host, bind_port).await?;
let view = ForwardView {
id: next_id(),
kind: "remote".to_string(),
bind_host: bind_host.to_string(),
bind_port,
target_host: target_host.to_string(),
target_port,
status: "active".to_string(),
detail: format!("服务器监听 {bind_host}:{bind_port}"),
};
registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(session_id.to_string())
.or_default()
.insert(
view.id.clone(),
ForwardEntry {
rule: view.clone(),
task: None,
},
);
Ok(view)
}
/// 请求服务器开始监听(`tcpip_forward`)。
async fn request_remote_listen(
inner: &std::sync::Arc<SshInner>,
bind_host: &str,
bind_port: u16,
) -> Result<(), String> {
let inner = inner.clone();
let host = bind_host.to_string();
tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
// 槽位为 tokio Mutex(P2 连接复用):锁的获取也放进 block_on
let guard = inner.handle.lock().await;
let handle = guard
.as_ref()
.ok_or_else(|| "SSH 会话已断开,无法建立远程转发".to_string())?;
// 返回值是服务器确认的绑定端口(u32);我们只关心成败
handle
.tcpip_forward(&host, bind_port as u32)
.await
.map(|_| ())
.map_err(|e| {
format!(
"服务器拒绝在 {host}:{bind_port} 监听: {e}\
常见原因:端口已被占用、或服务器禁用了 TCP 转发(AllowTcpForwarding no)。"
)
})
})
})
.await
.map_err(|e| format!("转发任务异常: {e}"))?
}
/// 删除一条转发。
///
/// `-L`:abort 监听任务(端口立即释放);`-R`:向服务器发 `cancel_tcpip_forward`
/// (服务器停止监听;已建立的连接自然消亡)。
pub async fn remove(
inner: &std::sync::Arc<SshInner>,
session_id: &str,
forward_id: &str,
) -> Result<(), String> {
let removed = registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get_mut(session_id)
.and_then(|m| m.remove(forward_id));
let Some(entry) = removed else {
return Err(format!("转发规则 {forward_id} 不存在"));
};
if let Some(task) = entry.task {
task.abort(); // `-L`:监听循环停止,端口释放
} else if entry.rule.kind == "remote" {
// `-R`:取消服务器端监听。失败不阻断(会话断开时服务器也会清理),
// 但要记日志——否则「删了还在监听」的问题无从排查
let inner = inner.clone();
let host = entry.rule.bind_host.clone();
let port = entry.rule.bind_port;
let r = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
let guard = inner.handle.lock().await;
let Some(handle) = guard.as_ref() else {
return Ok(());
};
handle.cancel_tcpip_forward(&host, port as u32).await
})
})
.await
.map_err(|e| format!("转发任务异常: {e}"));
match r {
Ok(Ok(())) => {}
Ok(Err(e)) => crate::logger::log_warn(
"terminal",
&format!("取消远程转发 {}:{} 失败(会话断开时会自动清理): {e}", entry.rule.bind_host, port),
),
Err(e) => crate::logger::log_warn("terminal", &format!("取消远程转发任务异常: {e}")),
}
}
Ok(())
}
/// 会话关闭时的清理:abort 全部 `-L` 任务并清空条目。
///
/// `-R` 不需要显式 cancel:SSH 会话断开时服务器会停掉该会话的所有监听。
pub fn cleanup_session(session_id: &str) {
if let Some(m) = registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(session_id)
{
for (_, entry) in m {
if let Some(task) = entry.task {
task.abort();
}
}
}
}
/// Handler 回调里的管道任务:`forwarded-tcpip` 通道 → 本机目标。
///
/// 供 `ssh/mod.rs` 的 `server_channel_open_forwarded_tcpip` 覆写调用。
pub(crate) fn pipe_forwarded_channel(
channel: Channel<Msg>,
target_host: String,
target_port: u16,
) {
tauri::async_runtime::spawn(async move {
match tokio::net::TcpStream::connect((target_host.as_str(), target_port)).await {
Ok(mut tcp) => {
let mut ch = channel.into_stream();
if let Err(e) = tokio::io::copy_bidirectional(&mut ch, &mut tcp).await {
crate::logger::log_warn(
"terminal",
&format!("远程转发管道中断({target_host}:{target_port}: {e}"),
);
}
}
Err(e) => {
crate::logger::log_warn(
"terminal",
&format!("远程转发目标 {target_host}:{target_port} 连接失败: {e}"),
);
}
}
});
}
+405
View File
@@ -0,0 +1,405 @@
//! 主机密钥库(known_hosts)与指纹校验。
//!
//! # 为什么自己实现而不是复用 `~/.ssh/known_hosts`
//!
//! 三个理由:
//! 1. **写入冲突**OpenSSH 的 `known_hosts` 是追加式文本文件,多进程并发写入
//! 会互相破坏(这也是为什么 OpenSSH 自己做文件锁)。我们的应用与用户的
//! `ssh` 命令行会同时改它。
//! 2. **无法表达「拒绝」**:用户在我们的 UI 上选了「不接受」时,OpenSSH 格式里
//! 没有对应的记录形态(只能不写,等于下次又问)。
//! 3. **需要附加信息**:我们要记「首次见到时间」「上次确认时间」「变更历史」
//! 以便审计与提示,这些在 OpenSSH 格式里无处安放。
//!
//! 因此用自有 JSON 存储,同时**提供导入/导出到 OpenSSH 格式**的能力,
//! 让用户的既有记录可以迁移,且不与命令行工具形成两套互不相知的信任库。
//!
//! # 安全姿态
//!
//! - 指纹变更**默认阻断**(不是警告):TOFU 疲劳是 MITM 的主要入口,
//! 把它做成一个需要主动点开的红色阻断界面,是这里唯一有效的防御。
//! - 超时/未响应 = 拒绝(安全侧默认值)。
//! - known_hosts 是**非机密**数据,明文 JSON 存储、可导出、可人工审阅。
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
use specta::Type;
/// 单条已知主机记录。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct KnownHost {
/// 主机地址(不含端口,端口单独存)
pub host: String,
pub port: u16,
/// 密钥算法(如 "ssh-ed25519" / "rsa-sha2-512" / "ssh-rsa"
///
/// 同一主机可能有多种算法的密钥(服务器同时提供 ed25519 与 rsa),
/// 因此按 (host, port, key_type) 三元组建索引,而不是 (host, port)。
pub key_type: String,
/// SHA256 指纹(OpenSSH 展示格式,如 `SHA256:Abc...`
pub fingerprint: String,
/// 首次见到时间(RFC3339
pub first_seen: String,
/// 最近一次确认时间(RFC3339
pub last_confirmed: String,
/// 指纹变更历史(最新在前)。
///
/// 保留历史的价值:用户点「接受新指纹」之后,回看历史能判断这到底是
/// 服务器重装(一次性变更)还是持续的中间人(每次都变)。
pub history: Vec<FingerprintChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct FingerprintChange {
/// 被替换掉的旧指纹
pub old_fingerprint: String,
/// 变更发生时间
pub changed_at: String,
/// 用户是否接受了这次变更
pub accepted: bool,
}
impl Default for FingerprintChange {
fn default() -> Self {
Self {
old_fingerprint: String::new(),
changed_at: String::new(),
accepted: false,
}
}
}
impl Default for KnownHost {
fn default() -> Self {
Self {
host: String::new(),
port: 22,
key_type: String::new(),
fingerprint: String::new(),
first_seen: String::new(),
last_confirmed: String::new(),
history: Vec::new(),
}
}
}
/// 校验结论。
#[derive(Debug, Clone)]
pub enum Verdict {
/// 指纹与记录一致 → 可信,直接放行
Trusted,
/// 该主机对此算法**没有记录** → 首次连接,需用户确认
Unknown,
/// 有记录但指纹不同 → 高危,需用户显式确认
Changed { previous: String },
}
/// 主机密钥库(内存缓存 + 文件持久化)。
struct Store {
path: PathBuf,
/// `(host, port, key_type)` → 记录
entries: BTreeMap<String, KnownHost>,
/// 是否需要落盘
dirty: bool,
}
static STORE: OnceLock<Mutex<Option<Store>>> = OnceLock::new();
fn store_slot() -> &'static Mutex<Option<Store>> {
STORE.get_or_init(|| Mutex::new(None))
}
/// 初始化存储路径(应用启动时调用一次)。
pub fn init(path: PathBuf) {
let mut slot = store_slot().lock().unwrap_or_else(|e| e.into_inner());
if slot.is_some() {
return;
}
let entries = match std::fs::read_to_string(&path) {
Ok(raw) => match serde_json::from_str::<Vec<KnownHost>>(&raw) {
Ok(list) => list
.into_iter()
.map(|h| (key_of(&h.host, h.port, &h.key_type), h))
.collect(),
Err(e) => {
crate::logger::log_error(
"terminal",
&format!("known_hosts 解析失败(将以空库启动): {e}"),
);
BTreeMap::new()
}
},
Err(_) => BTreeMap::new(),
};
*slot = Some(Store {
path,
entries,
dirty: false,
});
}
fn key_of(host: &str, port: u16, key_type: &str) -> String {
format!("{host}:{port}:{key_type}")
}
/// 校验主机密钥。
///
/// 注意「同主机多算法」的处理:服务器同时提供 ed25519 与 rsa 时,我们按
/// `key_type` 分别记录。若用户上次连的是 ed25519、这次服务器(因客户端算法
/// 偏好变化)用了 rsa,**不应判为指纹变更**——那是不同算法的两把不同密钥,
/// 属于正常情况。因此这里的比对严格限定在同一 key_type 内。
pub fn verify(host: &str, port: u16, fingerprint: &str, key_type: &str) -> Verdict {
let slot = store_slot().lock().unwrap_or_else(|e| e.into_inner());
let Some(store) = slot.as_ref() else {
// 未初始化:保守起见按「未知」处理,要求用户确认
return Verdict::Unknown;
};
// 先查同算法记录
if let Some(rec) = store.entries.get(&key_of(host, port, key_type)) {
if fingerprints_equal(&rec.fingerprint, fingerprint) {
return Verdict::Trusted;
}
return Verdict::Changed {
previous: rec.fingerprint.clone(),
};
}
// 同算法无记录,但同主机其它算法有记录:说明这个主机我们见过,
// 只是这次协商出了不同算法。仍按「未知」处理(要求确认),
// 但这是正常现象,日志里降级为 info 而非 warn。
let has_other_algo = store
.entries
.keys()
.any(|k| k.starts_with(&format!("{host}:{port}:")));
if has_other_algo {
crate::logger::log_info(
"terminal",
&format!("主机 {host}:{port} 提供了新的密钥算法 {key_type},需确认指纹"),
);
}
Verdict::Unknown
}
/// 指纹比较:忽略大小写与前缀差异。
///
/// SHA256 指纹在不同工具里可能表现为 `SHA256:AbC...` / `AbC...` / 末尾带 `=`
/// 这些差异不该被当作「指纹不同」(那会让用户看到惊悚的变更告警)。
fn fingerprints_equal(a: &str, b: &str) -> bool {
let norm = |s: &str| {
s.trim()
.trim_start_matches("SHA256:")
.trim_start_matches("MD5:")
.trim_end_matches('=')
.replace(':', "")
.to_lowercase()
};
norm(a) == norm(b)
}
/// 接受并记录指纹(首次或变更后)。
pub fn accept(host: &str, port: u16, fingerprint: &str, key_type: &str) -> Result<(), String> {
let mut slot = store_slot().lock().unwrap_or_else(|e| e.into_inner());
let Some(store) = slot.as_mut() else {
return Err("known_hosts 存储未初始化".to_string());
};
let k = key_of(host, port, key_type);
let now = chrono::Local::now().to_rfc3339();
match store.entries.get_mut(&k) {
Some(rec) => {
if !fingerprints_equal(&rec.fingerprint, fingerprint) {
// 记入变更历史(保留最近 20 条,避免无限增长)
rec.history.insert(
0,
FingerprintChange {
old_fingerprint: rec.fingerprint.clone(),
changed_at: now.clone(),
accepted: true,
},
);
rec.history.truncate(20);
rec.fingerprint = fingerprint.to_string();
}
rec.last_confirmed = now;
}
None => {
store.entries.insert(
k,
KnownHost {
host: host.to_string(),
port,
key_type: key_type.to_string(),
fingerprint: fingerprint.to_string(),
first_seen: now.clone(),
last_confirmed: now,
history: Vec::new(),
},
);
}
}
store.dirty = true;
persist(store)
}
/// 记录一次「拒绝」(仅记历史,不改指纹)。
///
/// 价值:用户拒绝后,下次连接还会弹出提示。历史里留下「曾在某时刻拒绝过」,
/// 便于事后审计——「谁在什么时候试图用新指纹冒充这台主机」。
pub fn record_rejection(host: &str, port: u16, fingerprint: &str, key_type: &str) {
let mut slot = store_slot().lock().unwrap_or_else(|e| e.into_inner());
let Some(store) = slot.as_mut() else { return };
let k = key_of(host, port, key_type);
if let Some(rec) = store.entries.get_mut(&k) {
rec.history.insert(
0,
FingerprintChange {
old_fingerprint: fingerprint.to_string(),
changed_at: chrono::Local::now().to_rfc3339(),
accepted: false,
},
);
rec.history.truncate(20);
store.dirty = true;
let _ = persist(store);
}
}
/// 列出全部记录(含历史),供设置页展示。
pub fn list() -> Vec<KnownHost> {
let slot = store_slot().lock().unwrap_or_else(|e| e.into_inner());
slot.as_ref()
.map(|s| s.entries.values().cloned().collect())
.unwrap_or_default()
}
/// 删除某条记录(用户清理失效主机时用)。
pub fn forget(host: &str, port: u16, key_type: &str) -> Result<(), String> {
let mut slot = store_slot().lock().unwrap_or_else(|e| e.into_inner());
let Some(store) = slot.as_mut() else {
return Err("known_hosts 存储未初始化".to_string());
};
store.entries.remove(&key_of(host, port, key_type));
store.dirty = true;
persist(store)
}
/// 清空全部记录(危险操作,前端需二次确认)。
pub fn clear() -> Result<(), String> {
let mut slot = store_slot().lock().unwrap_or_else(|e| e.into_inner());
let Some(store) = slot.as_mut() else {
return Err("known_hosts 存储未初始化".to_string());
};
store.entries.clear();
store.dirty = true;
persist(store)
}
/// 落盘(先写临时文件再 rename,避免半截 JSON)。
fn persist(store: &Store) -> Result<(), String> {
let list: Vec<&KnownHost> = store.entries.values().collect();
let json =
serde_json::to_string_pretty(&list).map_err(|e| format!("序列化 known_hosts 失败: {e}"))?;
if let Some(parent) = store.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?;
}
let tmp = store.path.with_extension("json.tmp");
std::fs::write(&tmp, json).map_err(|e| format!("写入 known_hosts 失败: {e}"))?;
std::fs::rename(&tmp, &store.path).map_err(|e| format!("保存 known_hosts 失败: {e}"))
}
// ===== OpenSSH 格式互操作 =====
/// 导出为 OpenSSH `known_hosts` 文本格式。
///
/// 用途:(a) 用户可把记录带进命令行 ssh;(b) 便于人工审阅。
/// 输出是标准 `host:port keytype base64comment` 形态的 **hashed 形式**
/// 还是明文形式?这里选 **明文**:用户要能读懂、能 diff,才有审阅价值。
/// OpenSSH 本身也接受明文(`HashKnownHosts no`)。
///
/// 注意:我们只有指纹(SHA256 base64),没有完整公钥 blob,因此导出的
/// 第二列写 `SHA256:...` 形式的注释,**不是**可直接被 ssh 使用的完整格式。
/// 这一点必须在 UI 上说明,避免用户以为导出的文件能直接给 ssh 用。
pub fn export_openssh_text() -> String {
let mut out = String::from(
"# 由 Thing 终端模块导出\n\
# 注意:本文件仅用于人工审阅与记录迁移,第二列是指纹而非公钥 blob,\n\
# 不能直接作为 OpenSSH 的 known_hosts 使用。\n",
);
for h in list() {
out.push_str(&format!(
"{}:{} {} {}\n",
h.host, h.port, h.key_type, h.fingerprint
));
}
out
}
/// 从 OpenSSH `known_hosts` 文本导入指纹记录。
///
/// 支持的行形态(跳过注释与空行):
/// - `host:port keytype fingerprint`(本模块自己的导出格式)
/// - `host keytype fingerprint`
///
/// 返回成功导入的条数。
pub fn import_openssh_text(text: &str) -> Result<usize, String> {
let mut slot = store_slot().lock().unwrap_or_else(|e| e.into_inner());
let Some(store) = slot.as_mut() else {
return Err("known_hosts 存储未初始化".to_string());
};
let now = chrono::Local::now().to_rfc3339();
let mut count = 0usize;
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
continue;
}
// 解析 host[:port]
let (host, port) = match parts[0].rsplit_once(':') {
Some((h, p)) => match p.parse::<u16>() {
Ok(port) => (h.to_string(), port),
Err(_) => (parts[0].to_string(), 22),
},
None => (parts[0].to_string(), 22),
};
let key_type = parts[1].to_string();
let fingerprint = parts[2].to_string();
// 只接受指纹形态(SHA256:...)——完整公钥 blob 需要另外的解析路径,
// 且我们无法从它反推指纹而不引入更多依赖
if !fingerprint.starts_with("SHA256:") && !fingerprint.starts_with("MD5:") {
continue;
}
let k = key_of(&host, port, &key_type);
store.entries.insert(
k,
KnownHost {
host,
port,
key_type,
fingerprint,
first_seen: now.clone(),
last_confirmed: now.clone(),
history: Vec::new(),
},
);
count += 1;
}
store.dirty = true;
persist(store)?;
Ok(count)
}
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
//! SSH 连接池(P2 连接复用)。
//!
//! # 语义
//!
//! 同一「身份」(用户名 + 主机 + 端口 + 认证材料指纹)的多个会话
//! **共享同一条 SSH 连接**:第二个会话跳过 TCP / 握手 / 认证,直接在
//! 既有连接上开新的会话通道。与 OpenSSH ControlMaster 的行为一致:
//! - 打开:首个会话建立连接;
//! - 共享:后续会话引用计数 +1;
//! - 关闭:会话关闭只减引用并关闭**自己的 shell 通道**;
//! **最后一个引用释放时**才断开底层连接(含跳板机链)。
//!
//! # 为什么 Handle 必须经由池共享
//!
//! `russh::client::Handle` 不实现 `Clone`(内含 session actor 的接收端),
//! 此前每个 `SshSession` 独占一个 Handle,无从复用。池条目持有
//! `Arc<tokio::sync::Mutex<Option<Handle>>>` 槽位,会话间共享同一 Arc;
//! tokio Mutex(而非 std)是为了允许**连接建立期间跨 `.await` 持锁**——
//! 它天然串行化了「双击两个标签同时连同一主机」的竞态:后到者等待,
//! 先到者成功后直接复用。
//!
//! # 跳板机链的归属
//!
//! 经跳板链建立的连接,其跳板 Handle 挂在**池条目**上而不是首建会话上:
//! 否则首建会话关闭时会连带剪断仍在被其他会话使用的隧道。
//! 最后一个引用释放时,跳板与目标连接一起断开。
//!
//! # 已知取舍
//!
//! - 共享连接的 keepalive / 加密参数取自**首个**建立它的会话;
//! - 共享连接断开(网络故障)时,挂在上面的所有会话一起进入 Closed——
//! 这与「它们本来就在同一条 TCP 上」的物理事实一致;
//! - 远程转发(-R)的入站路由按端口做**全局**匹配(见 forward 模块),
//! 因为入站通道总是从属连接级 Handler,而 Handler 的 session_id 属于首建会话。
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use russh::client::Handle;
use russh::Disconnect;
use super::SshHandler;
/// 可克隆的池句柄(`TerminalManager` 持有一份,每个会话 clone 一份)。
#[derive(Clone, Default)]
pub struct ConnectionPool {
conns: Arc<Mutex<HashMap<String, PooledEntry>>>,
}
struct PooledEntry {
/// 共享槽位:`None` = 连接建立中(或失败);`Some` = 已就绪。
slot: Arc<tokio::sync::Mutex<Option<Handle<SshHandler>>>>,
/// 仍在使用此连接的会话数
refcount: usize,
/// 连接是否已就绪(同步可查;`has_handle` 用)
ready: bool,
/// 跳板机链的连接(经跳板建立时非空;随条目共享,最后释放时断开)
hops: Vec<Handle<SshHandler>>,
/// 日志用描述
label: String,
}
impl ConnectionPool {
/// 取(或创建)某身份的连接槽位,引用计数 +1。
///
/// 返回的 Arc 就是池条目里的槽位本身:会话把它存进 `SshInner.handle`
/// 连接建立后写 `Some(handle)`,同键的其他会话即刻可见。
pub fn slot(&self, key: &str) -> Arc<tokio::sync::Mutex<Option<Handle<SshHandler>>>> {
let mut conns = self.conns.lock().unwrap_or_else(|e| e.into_inner());
let entry = conns.entry(key.to_string()).or_insert_with(|| PooledEntry {
slot: Arc::new(tokio::sync::Mutex::new(None)),
refcount: 0,
ready: false,
hops: Vec::new(),
label: String::new(),
});
entry.refcount += 1;
entry.slot.clone()
}
/// 设置日志用描述(连接建立成功后调用)。
pub fn set_label(&self, key: &str, label: &str) {
if let Some(e) = self
.conns
.lock()
.unwrap_or_else(|x| x.into_inner())
.get_mut(key)
{
e.label = label.to_string();
}
}
/// 标记连接已就绪(do_connect 写入 Handle 之后)。
pub fn mark_ready(&self, key: &str) {
if let Some(e) = self
.conns
.lock()
.unwrap_or_else(|x| x.into_inner())
.get_mut(key)
{
e.ready = true;
}
}
/// 把跳板机连接挂到池条目上(fresh 连接路径、有跳板时调用一次)。
pub fn attach_hops(&self, key: &str, hops: Vec<Handle<SshHandler>>) {
if let Some(e) = self
.conns
.lock()
.unwrap_or_else(|x| x.into_inner())
.get_mut(key)
{
e.hops = hops;
}
}
/// 连接是否已就绪(同步可查;替代原 `SshSession::has_handle` 的语义)。
pub fn is_ready(&self, key: &str) -> bool {
self.conns
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(key)
.is_some_and(|e| e.ready)
}
/// 释放一个会话的引用。
///
/// 返回 `Some((槽位, 跳板连接))` 表示这是**最后一个**引用——调用方负责
/// 断开底层连接与跳板(异步任务里做,见 `kill`)。非最后引用返回 `None`
/// 调用方只需关闭自己的 shell 通道。
pub fn release(
&self,
key: &str,
) -> Option<(
Arc<tokio::sync::Mutex<Option<Handle<SshHandler>>>>,
Vec<Handle<SshHandler>>,
)> {
let mut conns = self.conns.lock().unwrap_or_else(|e| e.into_inner());
let Some(entry) = conns.get_mut(key) else {
return None;
};
entry.refcount = entry.refcount.saturating_sub(1);
if entry.refcount > 0 {
return None;
}
// 最后一个引用:移除条目并交出断开责任
let entry = conns.remove(key)?;
Some((entry.slot, entry.hops))
}
}
/// 连接池身份键:用户名 + 主机 + 端口 + 认证方式 + 认证材料指纹。
///
/// 认证材料(密码或私钥文本)取短哈希入键——同一主机配置两份不同密钥/密码时
/// 不应共享连接(那等于用 A 的身份看了 B 的会话)。
pub fn pool_key_of(
username: &str,
host: &str,
port: u16,
auth_method: &str,
auth_material: Option<&str>,
) -> String {
let marker = match auth_material {
Some(m) => short_hash(m),
None => "none".to_string(),
};
format!("{username}|{host}:{port}|{auth_method}|{marker}")
}
/// 材料指纹:SHA-256 前 8 字节的十六进制(16 字符)。
fn short_hash(material: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(material.as_bytes());
digest[..8].iter().map(|b| format!("{b:02x}")).collect()
}
/// 断开一个 Handle(kill 与最后引用释放共用的收尾动作)。
pub async fn disconnect(handle: Handle<SshHandler>) {
let _ = handle
.disconnect(Disconnect::ByApplication, "closed by user", "")
.await;
}
#[cfg(test)]
mod tests {
use super::*;
/// 条目级语义测试:不涉及真实 Handle(槽位保持 None 即可)。
#[test]
fn acquire_increments_and_release_removes_on_last() {
let pool = ConnectionPool::default();
let s1 = pool.slot("k");
let s2 = pool.slot("k");
// 同键两次 acquire 返回同一个 Arc(这才是「共享」)
assert!(Arc::ptr_eq(&s1, &s2));
assert!(pool.release("k").is_none(), "还有 1 个引用,不应触发拆除");
let (slot, hops) = pool.release("k").expect("最后一个引用应触发拆除");
assert!(Arc::ptr_eq(&slot, &s1));
assert!(hops.is_empty());
// 移除后再次 acquire 得到全新条目
let s3 = pool.slot("k");
assert!(!Arc::ptr_eq(&s3, &s1));
pool.release("k");
}
#[test]
fn independent_keys_are_independent() {
let pool = ConnectionPool::default();
let a = pool.slot("a");
let b = pool.slot("b");
assert!(!Arc::ptr_eq(&a, &b));
assert!(pool.release("a").is_some());
assert!(pool.release("b").is_some());
}
#[test]
fn ready_flag_and_hops_follow_entry_lifecycle() {
let pool = ConnectionPool::default();
assert!(!pool.is_ready("k"));
pool.slot("k");
pool.slot("k"); // 两个会话共享
pool.mark_ready("k");
assert!(pool.is_ready("k"));
pool.attach_hops("k", Vec::new());
// 释放一个引用后条目仍在(另一个会话还在用),ready 保持
assert!(pool.release("k").is_none());
assert!(pool.is_ready("k"));
// 最后一个引用释放后条目消失
assert!(pool.release("k").is_some());
assert!(!pool.is_ready("k"));
}
#[test]
fn over_release_is_safe() {
let pool = ConnectionPool::default();
assert!(pool.release("ghost").is_none());
pool.slot("k");
pool.release("k");
// 多余的 release 不应 panicsaturating 语义)
let _ = pool.release("k");
}
#[test]
fn pool_key_distinguishes_identity() {
let k1 = pool_key_of("ops", "srv", 22, "key", Some("keytext"));
let k2 = pool_key_of("ops", "srv", 22, "key", Some("other-key"));
let k3 = pool_key_of("ops", "srv", 22, "key", Some("keytext"));
let k4 = pool_key_of("root", "srv", 22, "key", Some("keytext"));
assert_ne!(k1, k2, "不同认证材料不应共享连接");
assert_eq!(k1, k3, "相同身份应命中同一池条目");
assert_ne!(k1, k4, "不同用户不应共享连接");
// 无认证材料(理论上不出现)也不与他人混淆
let k5 = pool_key_of("ops", "srv", 22, "password", None);
assert_ne!(k1, k5);
}
}
+663
View File
@@ -0,0 +1,663 @@
//! SFTP 文件管理:复用 SSH 会话连接的双栏文件传输。
//!
//! # 为什么 SFTP 挂在会话上而不是独立连接
//!
//! 一台主机开两个 SSH 连接(一个 shell、一个 SFTP)有三个实际代价:
//! 1. **认证两次**——公钥还好,密码/2FA 场景下用户要输两遍;
//! 2. **服务端 `MaxStartups` / `MaxSessions` 限制**——内网跳板机经常卡这条;
//! 3. 两条连接的主机密钥都要各自校验,known_hosts 里同一台机器两份记录。
//!
//! SSH 协议本身就为此设计了 **subsystem channel**:在已认证的连接上开新通道,
//! `request_subsystem(true, "sftp")` 即可。因此 SFTP 面板只对**活跃的 SSH 会话**
//! 开放——本地 ConPTY 会话没有这条路径(本地文件用系统的资源管理器更合适)。
//!
//! # 一个 SFTP 客户端只能串行用一条通道
//!
//! `SftpSession` 内部是「请求 → 等响应」的请求/响应模型,**并发调用会因为
//! 响应乱序而错配**。russh-sftp 内部做了请求 id 匹配,因此 `&SftpSession` 上
//! 并发 `.await` 是安全的;但同一时刻大量并发(如递归上传 1000 个文件全并发)
//! 会把服务端的窗口打满并触发限流。
//!
//! 因此上传/下载走**受控并发**:由 `SftpHandle::semaphore` 限制在 4 路。
//!
//! # 断点续传
//!
//! 用 `OpenFlags::WRITE | CREATE` 打开已存在的文件,再 `seek` 到本地已有的
//! 大小继续写。服务端不支持 `append` 语义时(部分紫光的 sftp-server),
//! 退化为「整文件重传」——由 `resume_supported` 探测决定。
use std::sync::Arc;
use dashmap::DashMap;
use russh::client::Handle;
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::OpenFlags;
use serde::{Deserialize, Serialize};
use specta::Type;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::sync::{Mutex, Semaphore};
use super::{SshHandler, SshSession};
/// 受控并发的上限。
///
/// 为什么是 4:单个 SFTP 通道的吞吐已接近链路带宽(有 32KB 报文窗口),
/// 再高的并发只是把服务端的 inflight 队列堆长,收益递减而内存占用线性增长。
/// 4 路足以让「大量小文件」这条慢路径(每文件一次 round-trip)提速约 3 倍。
const MAX_CONCURRENT_TRANSFERS: usize = 4;
/// 单次传输的分块大小。
///
/// 32KB 是 SFTP 协议默认的最大读报文(部分服务端放宽到 256KB,但 32KB
/// 是所有实现的**安全下界**)。取 32KB 而非更大:大块在丢包链路上重传代价高,
/// 而 32KB 已足够跑满千兆内网。
const CHUNK_SIZE: usize = 32 * 1024;
// ===== 数据模型 =====
/// 一个远端目录项(回传前端渲染)。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RemoteEntry {
pub name: String,
/// 完整路径(服务端形态,`/` 分隔)
pub path: String,
/// "file" | "dir" | "symlink" | "other"
pub kind: String,
pub size: u64,
/// 修改时间(Unix 毫秒;服务端未提供时为 None)
pub modified_at: Option<u64>,
/// 权限位的八进制展示(如 "755");无权限信息时为空串
pub permissions: String,
/// 符号链接的目标(仅 kind == "symlink" 时非空)
pub link_target: String,
}
/// 目录列举结果。
///
/// 单独包一层而不是直接返回 `Vec`:前端需要 `cwd` 来确认「服务端实际解析到
/// 的目录」——符号链接目录下 `pwd` 与用户点的路径可能不同,这个字段让面包屑
/// 可以显示真实位置而不是用户以为的位置。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RemoteDir {
/// 服务端规范化之后的目录(`canonicalize` 结果)
pub cwd: String,
pub entries: Vec<RemoteEntry>,
}
/// 传输进度事件负载。
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TransferProgress {
/// 传输任务 id(前端据此更新对应行的进度条)
pub id: String,
pub session_id: String,
/// "upload" | "download"
pub direction: String,
/// 源路径(展示用)
pub source: String,
/// 目标路径(展示用)
pub target: String,
/// 已传输字节
pub transferred: u64,
/// 总字节(未知时为 0
pub total: u64,
/// "running" | "done" | "failed" | "canceled"
pub state: String,
/// 失败原因
pub error: Option<String>,
}
// ===== 会话级 SFTP 句柄 =====
/// 一个会话的 SFTP 通道。
///
/// `SftpSession` 本身不是 `Sync` 的友好类型,且我们要在其上做「先查目录再写文件」
/// 这类多步操作,故用 `Mutex` 串行化**结构性操作**(建目录、删文件);
/// 大数据传输则只借用不可变引用(`&SftpSession` 的分块读写在 russh-sftp 内部
/// 有请求 id 匹配,可安全并发)。
pub struct SftpHandle {
/// 打开面板的会话 id。
///
/// 当前只有注册表的 key 用到会话 id,结构体内这份冗余字段暂无读者;
/// 保留它是为了 P2 的「跨会话传输」与日志定位(错误信息里需要会话上下文)。
#[allow(dead_code)]
pub session_id: String,
/// 串行闸门:保证「建目录 → 上传」这类有先后依赖的操作不会被乱序执行。
///
/// P0 的传输命令都是单步操作,尚无读者;P2 的组合操作(模板上传、
/// 递归同步)落地时启用。
#[allow(dead_code)]
pub gate: Mutex<()>,
pub sftp: SftpSession,
/// 并发闸门:限制同时在跑的大数据传输数量
pub semaphore: Semaphore,
/// 远端当前目录的缓存(`cwd` 跟随用;避免每次都 round-trip 取)
pub last_dir: Mutex<String>,
}
/// 所有活跃会话的 SFTP 通道表。
///
/// 与 `SessionRegistry` 同样的理由用 `DashMap`:查表极频繁(每个传输分块都要
/// 找 handle),而插入/删除只在打开/关闭面板时发生。
#[derive(Default)]
pub struct SftpRegistry {
handles: DashMap<String, Arc<SftpHandle>>,
}
impl SftpRegistry {
pub fn new() -> Self {
Self {
handles: DashMap::new(),
}
}
pub fn get(&self, session_id: &str) -> Option<Arc<SftpHandle>> {
self.handles.get(session_id).map(|e| e.value().clone())
}
pub fn insert(&self, session_id: String, handle: Arc<SftpHandle>) {
self.handles.insert(session_id, handle);
}
pub fn remove(&self, session_id: &str) -> Option<Arc<SftpHandle>> {
self.handles.remove(session_id).map(|(_, v)| v)
}
pub fn has(&self, session_id: &str) -> bool {
self.handles.contains_key(session_id)
}
/// 关闭某会话的 SFTP 通道(会话关闭时调用)。
pub fn close(&self, session_id: &str) {
self.handles.remove(session_id);
}
}
/// 在已有 SSH 会话上打开 SFTP subsystem。
///
/// # 前置条件
///
/// 会话必须已 `Established` 且持有可用的 `Handle`。若会话是本地 ConPTY
/// 或 SSH 尚未认证完成,这里会返回可读的错误而不是 panic。
///
/// # 为什么用 `spawn_blocking` 而不是直接 await
///
/// `Handle` 不实现 `Clone`,因此开通道必须**持有 `MutexGuard` 跨 await**。
/// 而 `std::sync::MutexGuard` 不是 `Send`,这让整个 future 也不是 `Send`
/// 无法交给 `tauri::async_runtime::spawn`Tauri 命令要求 `Send`)。
///
/// 解法:把「持锁 + 开通道」这一小段放进 `spawn_blocking`。它内部是
/// **阻塞的 tokio runtime block_on**,锁与 await 都限制在那个线程里,
/// 对外只返回值(`Channel` 是 `Send`)。代价是占一个线程约握手 RTT 的时间,
/// 而这只在用户点「打开文件管理器」时发生一次。
pub async fn open_subsystem(session: &SshSession) -> Result<Arc<SftpHandle>, String> {
// 先在外部确认会话可用(避免为一个必然失败的请求去占线程)
if !session.has_handle() {
return Err("SSH 会话尚未建立连接,无法打开文件管理器".to_string());
}
let channel = {
let inner = session.inner_shared();
// 把「加锁 → 开通道 → 请求 subsystem」整段移出 async 上下文。
// 槽位是 tokio MutexP2 连接复用),锁 `.await` 放进 block_on 里。
tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
let guard = inner.handle.lock().await;
let handle = guard
.as_ref()
.ok_or_else(|| "SSH 会话连接已断开,无法打开文件管理器".to_string())?;
let channel = handle
.channel_open_session()
.await
.map_err(|e| format!("打开 SFTP 通道失败: {e}"))?;
channel
.request_subsystem(true, "sftp")
.await
.map_err(|e| format!("请求 sftp 子系统失败(服务端可能未启用 SFTP): {e}"))?;
Ok::<_, String>(channel)
})
})
.await
.map_err(|e| format!("打开 SFTP 通道的任务异常退出: {e}"))?
}?;
let sftp = SftpSession::new(channel.into_stream())
.await
.map_err(|e| format!("初始化 SFTP 会话失败(可能是版本协商不兼容): {e}"))?;
// 初始目录:优先用户配置的 remote_cwd,否则用登录目录。
// `canonicalize(".")` 而不是直接用 ".":服务端的 SFTP 起点(chroot 场景下
// 是 `/`,普通场景下是 home)只有服务端知道,取回真实值前端才好画面包屑。
let initial = match sftp.canonicalize(".").await {
Ok(p) if !p.trim().is_empty() => p,
_ => ".".to_string(),
};
Ok(Arc::new(SftpHandle {
session_id: session.state.id.clone(),
gate: Mutex::new(()),
sftp,
semaphore: Semaphore::new(MAX_CONCURRENT_TRANSFERS),
last_dir: Mutex::new(initial),
}))
}
// ===== 目录操作 =====
/// 列举远端目录。
///
/// 排序在服务端做而不是让前端排:SFTP 的 `read_dir` 返回顺序是服务端的
/// 目录项物理顺序(通常是插入序),逐次调用结果不稳定;在这里排一次
/// 保证「刷新」不会让列表跳动。
pub async fn list_dir(handle: &SftpHandle, path: &str) -> Result<RemoteDir, String> {
// `read_dir` 返回 `ReadDir`(一个可迭代的句柄),**必须显式 collect**
// 它的迭代会持续向服务端发 READDIR 报文直到服务端返回 EOF,
// 不 collect 的话句柄被丢弃时可能留下未读完的报文,污染后续请求的响应队列。
let iter = handle
.sftp
.read_dir(path)
.await
.map_err(|e| format!("读取目录 {path} 失败: {e}"))?;
let mut list: Vec<RemoteEntry> = Vec::new();
for e in iter {
let name = e.file_name();
// `.` 与 `..` 由前端用面包屑表达,不混进列表(混进去会让「全选」误伤父目录)
if name == "." || name == ".." {
continue;
}
let meta = e.metadata();
// `file_type()` 来自协议 attrs`metadata()` 里**没有** `is_file()`
// ——`FileAttributes` 只提供 `is_dir()` / `is_symlink()` / `file_type()`。
// 因此「是不是普通文件」的判定必须落到 `file_type()` 上(`is_file()` 是
// `FileType` 的方法,不是 attrs 的)。
let ft = e.file_type();
let is_symlink = ft.is_symlink() || meta.is_symlink();
let kind = if is_symlink {
"symlink"
} else if ft.is_dir() || meta.is_dir() {
"dir"
} else if ft.is_file() {
"file"
} else {
"other"
};
// `mtime` 是 Unix 秒(SFTP v3 的 attrs 无亚秒精度),统一乘 1000 成毫秒。
// 服务端未提供时保持 None,前端显示为「—」而不是伪造的 1970 年。
let mtime = meta.mtime.map(|s| (s as u64) * 1000);
let perms = meta
.permissions
.map(|p| format!("{:o}", p & 0o7777))
.unwrap_or_default();
list.push(RemoteEntry {
name: name.clone(),
path: join_remote(path, &name),
kind: kind.to_string(),
size: meta.size.unwrap_or(0),
modified_at: mtime,
permissions: perms,
// `read_dir` 不带 link target,符号链接的目标要单独 `read_link`。
// 这里不逐条调用:一个含 200 个符号链接的目录会变成 200 次 round-trip。
// 前端在用户点击/悬停时再单独请求(见 `read_link` 命令)。
link_target: String::new(),
});
}
// 目录优先,其次按名称(用不区分大小写的比较,符合 Windows 用户直觉)
list.sort_by(|a, b| {
let a_dir = a.kind == "dir";
let b_dir = b.kind == "dir";
b_dir
.cmp(&a_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
let cwd = handle
.sftp
.canonicalize(path)
.await
.unwrap_or_else(|_| path.to_string());
*handle.last_dir.lock().await = cwd.clone();
Ok(RemoteDir {
cwd,
entries: list,
})
}
/// 拼接远端路径(POSIX 语义,注意不要产生 `//`)。
pub fn join_remote(base: &str, name: &str) -> String {
if base.is_empty() || base == "." {
return name.to_string();
}
if name.starts_with('/') {
return name.to_string();
}
if base.ends_with('/') {
format!("{base}{name}")
} else {
format!("{base}/{name}")
}
}
/// 取远端路径的父目录(用于「上一级」与面包屑)。
pub fn parent_remote(path: &str) -> String {
let trimmed = path.trim_end_matches('/');
// 根目录的父目录还是自己,避免前端无限上溯
if trimmed.is_empty() {
return "/".to_string();
}
match trimmed.rfind('/') {
Some(0) => "/".to_string(),
Some(i) => trimmed[..i].to_string(),
// 相对路径(服务端未 canonicalize 时):退化为当前目录
None => ".".to_string(),
}
}
/// 读符号链接的目标。
pub async fn read_link(handle: &SftpHandle, path: &str) -> Result<String, String> {
handle
.sftp
.read_link(path)
.await
.map_err(|e| format!("读取链接目标失败: {e}"))
}
/// 创建目录(递归)。
pub async fn create_dir_all(handle: &SftpHandle, path: &str) -> Result<(), String> {
// 逐级创建:SFTP 的 `create_dir` 不递归(与 `mkdir` 不同,没有 `-p`),
// 而 `create_dir_all` 在 russh-sftp 里不存在,只能自己走。
let mut cur = String::new();
for seg in path.trim_start_matches('/').split('/') {
if seg.is_empty() {
continue;
}
cur = if cur.is_empty() {
if path.starts_with('/') {
format!("/{seg}")
} else {
seg.to_string()
}
} else {
format!("{cur}/{seg}")
};
// 已存在是正常情况(多级创建的中途层级),忽略错误继续
let _ = handle.sftp.create_dir(&cur).await;
}
Ok(())
}
/// 删除远端文件。
pub async fn remove_file(handle: &SftpHandle, path: &str) -> Result<(), String> {
handle
.sftp
.remove_file(path)
.await
.map_err(|e| format!("删除文件失败: {e}"))
}
/// 递归删除远端目录。
///
/// 手写递归而不是 `remove_dir_all`:后者在部分服务端实现上对符号链接的处理
/// 不一致(有的会跟随链接删掉目标内容,这是**数据事故**)。这里显式判断
/// `symlink_metadata`,遇到符号链接只删链接本身。
pub async fn remove_dir_all(handle: &SftpHandle, path: &str) -> Result<u64, String> {
let mut removed: u64 = 0;
let iter = handle
.sftp
.read_dir(path)
.await
.map_err(|e| format!("读取目录 {path} 失败: {e}"))?;
let mut sub_dirs: Vec<String> = Vec::new();
let mut files: Vec<String> = Vec::new();
for e in iter {
let name = e.file_name();
if name == "." || name == ".." {
continue;
}
let full = join_remote(path, &name);
let ft = e.file_type();
let meta = e.metadata();
// 符号链接**先于** is_dir 判断:SFTP 的 attrs 对链接常会报告目标类型,
// 若先判 is_dir 会把链接当目录递归进去(删掉链接目标的真内容)。
if ft.is_symlink() || meta.is_symlink() {
files.push(full);
} else if ft.is_dir() || meta.is_dir() {
sub_dirs.push(full);
} else {
files.push(full);
}
}
// 先删文件再删子目录:先把当前层的文件清掉,收敛更快(失败时更容易定位)
for f in files {
handle
.sftp
.remove_file(&f)
.await
.map_err(|e| format!("删除 {f} 失败: {e}"))?;
removed += 1;
}
for d in sub_dirs {
removed += Box::pin(remove_dir_all(handle, &d)).await?;
}
handle
.sftp
.remove_dir(path)
.await
.map_err(|e| format!("删除目录 {path} 失败: {e}"))?;
Ok(removed + 1)
}
/// 重命名 / 移动。
pub async fn rename(handle: &SftpHandle, from: &str, to: &str) -> Result<(), String> {
handle
.sftp
.rename(from, to)
.await
.map_err(|e| format!("重命名失败: {e}"))
}
// ===== 上传 / 下载 =====
/// 上传本地文件到远端。
///
/// # 断点续传
///
/// 打开远端文件时**不加 `TRUNCATE`**,先 `metadata` 取已存在的大小,
/// 再 `seek` 到该位置继续写。若远端已有文件比本地大(本地被截断过),
/// 则退回整文件重传——继续写会得到一个「前长后短」的损坏文件。
pub async fn upload_file(
handle: &SftpHandle,
local: &str,
remote: &str,
on_progress: impl Fn(u64, u64),
) -> Result<u64, String> {
let _permit = handle
.semaphore
.acquire()
.await
.map_err(|e| format!("获取传输许可失败: {e}"))?;
let total = tokio::fs::metadata(local)
.await
.map_err(|e| format!("读取本地文件 {local} 失败: {e}"))?
.len();
// 远端已有大小(用于续传判断)
let existing = handle
.sftp
.metadata(remote)
.await
.ok()
.and_then(|m| m.size)
.unwrap_or(0);
let resume_from = if existing > 0 && existing < total {
existing
} else {
0
};
let flags = if resume_from > 0 {
OpenFlags::WRITE
} else {
OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE
};
let mut remote_file = handle
.sftp
.open_with_flags(remote, flags)
.await
.map_err(|e| format!("打开远端文件 {remote} 失败: {e}"))?;
let mut local_file = tokio::fs::File::open(local)
.await
.map_err(|e| format!("打开本地文件 {local} 失败: {e}"))?;
if resume_from > 0 {
local_file
.seek(std::io::SeekFrom::Start(resume_from))
.await
.map_err(|e| format!("定位本地文件失败: {e}"))?;
remote_file
.seek(std::io::SeekFrom::Start(resume_from))
.await
.map_err(|e| format!("定位远端文件失败: {e}"))?;
}
let mut buf = vec![0u8; CHUNK_SIZE];
let mut sent = resume_from;
on_progress(sent, total);
loop {
let n = local_file
.read(&mut buf)
.await
.map_err(|e| format!("读取本地文件失败: {e}"))?;
if n == 0 {
break;
}
remote_file
.write_all(&buf[..n])
.await
.map_err(|e| format!("写入远端失败: {e}"))?;
sent += n as u64;
on_progress(sent, total);
}
// `shutdown` 会把 SFTP 的 close 报文发出去;不调用的话服务端可能迟迟不落盘
// (尤其写的是网络文件系统上的文件)。
remote_file
.shutdown()
.await
.map_err(|e| format!("关闭远端文件失败(数据可能未完整落盘): {e}"))?;
Ok(sent)
}
/// 从远端下载文件到本地。
///
/// 断点续传逻辑与上传对称:本地已有一部分则从该偏移继续。
pub async fn download_file(
handle: &SftpHandle,
remote: &str,
local: &str,
on_progress: impl Fn(u64, u64),
) -> Result<u64, String> {
let _permit = handle
.semaphore
.acquire()
.await
.map_err(|e| format!("获取传输许可失败: {e}"))?;
let total = handle
.sftp
.metadata(remote)
.await
.ok()
.and_then(|m| m.size)
.unwrap_or(0);
let existing = tokio::fs::metadata(local)
.await
.map(|m| m.len())
.unwrap_or(0);
let resume_from = if existing > 0 && total > 0 && existing < total {
existing
} else {
0
};
let mut remote_file = handle
.sftp
.open(remote)
.await
.map_err(|e| format!("打开远端文件 {remote} 失败: {e}"))?;
// 确保父目录存在(下载到新目录时很常见)
if let Some(parent) = std::path::Path::new(local).parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let mut local_file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(resume_from == 0)
.open(local)
.await
.map_err(|e| format!("创建本地文件 {local} 失败: {e}"))?;
if resume_from > 0 {
remote_file
.seek(std::io::SeekFrom::Start(resume_from))
.await
.map_err(|e| format!("定位远端文件失败: {e}"))?;
local_file
.seek(std::io::SeekFrom::Start(resume_from))
.await
.map_err(|e| format!("定位本地文件失败: {e}"))?;
}
let mut buf = vec![0u8; CHUNK_SIZE];
let mut got = resume_from;
on_progress(got, total);
loop {
let n = remote_file
.read(&mut buf)
.await
.map_err(|e| format!("读取远端失败: {e}"))?;
if n == 0 {
break;
}
local_file
.write_all(&buf[..n])
.await
.map_err(|e| format!("写入本地文件失败: {e}"))?;
got += n as u64;
on_progress(got, total);
}
local_file
.flush()
.await
.map_err(|e| format!("刷新本地文件失败: {e}"))?;
Ok(got)
}
/// 类型占位:确保 `SshHandler` 与 `Handle<SshHandler>` 的关联在编译期成立。
#[allow(dead_code)]
fn _assert_handle_type(h: &Handle<SshHandler>) -> &Handle<SshHandler> {
h
}
+147
View File
@@ -0,0 +1,147 @@
//! 终端窗口管理。
//!
//! # 设计取舍:一个会话一个窗口,而非一个窗口多个会话
//!
//! 有两条路可走:
//!
//! | 方案 | 优势 | 代价 |
//! |---|---|---|
//! | 一个独立窗口承载全部会话(把主窗口的终端 UI 整体搬出去) | 实现简单,复用全部前端组件 | 无法「只把一个会话拖出来」;两处 UI 状态要同步 |
//! | **一个会话一个窗口** | 符合「拖出标签成窗」的直觉;窗口粒度与会话粒度一致,状态无歧义 | 每窗口一个 WebView,内存开销更大 |
//!
//! 选后者。理由:终端的核心使用场景就是「同时盯几台机器的输出」,把其中一个
//! 会话丢到第二块屏幕是所有终端工具的刚需;而窗口粒度与会话粒度一致,意味着
//! 「关闭窗口」= 「关闭会话」,没有隐藏状态,心智负担最小。
//!
//! 内存开销通过限制窗口数量([`MAX_DETACHED_WINDOWS`])来控制。
//!
//! # 会话与窗口的关系
//!
//! 会话**不随窗口创建而创建**。用户点「在新窗口打开」时,会话已经在主窗口里
//! 跑着(进程在 Rust 侧),窗口只是**另一个 attach 到这个会话的视图**。
//! 这带来两个后果:
//! 1. 主窗口关闭(隐藏到托盘)不影响终端窗口 —— 会话在 Rust 侧,与窗口无关。
//! 2. 同一个会话可以同时显示在主窗口与独立窗口(输出事件是广播的)。
//! 这是刻意的:用户可以在主窗口把某个会话放进分屏、同时另开一个窗口放大看。
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use super::session::SessionId;
use crate::constants::windows as W;
/// 同时存在的独立终端窗口上限。
///
/// 8 是个经验值:每个终端窗口都是一个独立 WebView(各自约 40~80MB),
/// 再多会明显吃内存;而「同时盯 8 个终端」已覆盖绝大多数实际需求。
/// 超出时明确报错而不是静默失败——用户需要知道是上限拦住了他。
pub const MAX_DETACHED_WINDOWS: usize = 8;
/// 计算会话对应的窗口 label。
pub fn label_for(session_id: &str) -> String {
format!("{}-{}", W::TERMINAL_WINDOW, session_id)
}
/// 当前有多少个终端独立窗口。
pub fn count_windows(app: &AppHandle) -> usize {
app.webview_windows()
.keys()
.filter(|k| k.starts_with(&format!("{}-", W::TERMINAL_WINDOW)))
.count()
}
/// 打开(或聚焦)某会话的独立窗口。
///
/// 幂等:窗口已存在时只做 `show` + `set_focus`,不重建 WebView。
/// 重建会丢失 xterm 的滚动缓冲(虽然内容可从会话快照恢复,但没必要多此一举)。
pub fn open_for_session(
app: &AppHandle,
session_id: &SessionId,
title: &str,
) -> Result<(), String> {
let label = label_for(session_id);
if let Some(win) = app.get_webview_window(&label) {
win.show().map_err(|e| format!("显示窗口失败: {e}"))?;
win.set_focus().map_err(|e| format!("聚焦窗口失败: {e}"))?;
return Ok(());
}
let n = count_windows(app);
if n >= MAX_DETACHED_WINDOWS {
return Err(format!(
"已达独立终端窗口上限({MAX_DETACHED_WINDOWS} 个)。\
请先关闭一些窗口,或改为在主窗口内使用标签页。"
));
}
// 窗口尺寸:终端是「宽而扁」的,给一个偏宽的默认值,接近常见终端习惯
let win = WebviewWindowBuilder::new(
app,
&label,
// 路由到前端的 terminal 独立窗口入口(main.ts 中按 hash 分派)
WebviewUrl::App(format!("index.html#terminal-window/{session_id}").into()),
)
.title(format!("终端 · {title}"))
.inner_size(1000.0, 620.0)
.min_inner_size(420.0, 240.0)
.resizable(true)
// 无系统边框:窗口内自绘标题栏(TerminalWindow.vue),系统标题栏会与之叠加
.decorations(false)
// 无边框窗口默认没有投影,加上以保持与系统窗口一致的层次感
.shadow(true)
.center()
.build()
.map_err(|e| format!("创建终端窗口失败: {e}"))?;
// 关闭窗口时:**只关窗口,不关会话**。
//
// 这是刻意的语义选择。若「关窗即关会话」,用户移动窗口时误点关闭就会
// 丢掉一个正在跑长任务的 SSH 连接;而保留会话的代价只是列表里多一个标签。
// 需要在窗口里显式提供「关闭会话」按钮,让两个动作分离。
let app_handle = app.clone();
let sid = session_id.clone();
win.on_window_event(move |event| {
if let tauri::WindowEvent::Destroyed = event {
// 把会话标记回「未分离」状态,前端的标签列表据此恢复显示
if let Ok(state) = super::manager(&app_handle) {
if let Some(s) = state.sessions.get(&sid) {
s.set_detached(false);
}
}
}
});
Ok(())
}
/// 关闭某会话的独立窗口(会话本身保留)。
pub fn close_for_session(app: &AppHandle, session_id: &SessionId) -> Result<(), String> {
let label = label_for(session_id);
if let Some(win) = app.get_webview_window(&label) {
win.close().map_err(|e| format!("关闭窗口失败: {e}"))?;
}
Ok(())
}
/// 把会话的 `detached` 标记与窗口状态对齐。
///
/// 应用启动后(或窗口被外部关闭后)可能存在不一致:标记说已分离但窗口不在。
/// 由命令层在查询会话列表前调用一次,保证前端拿到的状态是准确的。
///
/// 返回**是否有任何标记被修正**:调用方(`terminal_list_sessions`)据此决定
/// 是否需要再取一次列表——无变更时直接复用第一次的结果,省掉一次全表遍历。
pub fn reconcile_flags(app: &AppHandle, sessions: &[(SessionId, bool)]) -> bool {
let mut changed = false;
for (id, marked) in sessions {
let exists = app.get_webview_window(&label_for(id)).is_some();
if *marked != exists {
if let Ok(state) = super::manager(app) {
if let Some(s) = state.sessions.get(id) {
s.set_detached(exists);
changed = true;
}
}
}
}
changed
}
+91 -23
View File
@@ -43,12 +43,12 @@ impl AiEngine {
/// API 根地址。容错处理:用户常把完整端点(`.../chat/completions`)直接粘进来,
/// 若不在末尾剥掉,就会拼出 `.../chat/completions/chat/completions`
/// 而这类错误在上游表现为 404,排查成本远高于此处一行判断。
fn api_root(&self) -> Result<String, TranslateError> {
let raw = self.cfg.base_url.trim();
fn api_root(cfg: &TranslateEngineConfig) -> Result<String, TranslateError> {
let raw = cfg.base_url.trim();
if raw.is_empty() {
return Err(TranslateError::config(format!(
"引擎「{}」尚未配置 Base URL",
self.cfg.name
cfg.name
)));
}
if !(raw.starts_with("http://") || raw.starts_with("https://")) {
@@ -66,36 +66,36 @@ impl AiEngine {
Ok(root)
}
fn chat_endpoint(&self) -> Result<String, TranslateError> {
Ok(format!("{}/chat/completions", self.api_root()?))
fn chat_endpoint(cfg: &TranslateEngineConfig) -> Result<String, TranslateError> {
Ok(format!("{}/chat/completions", Self::api_root(cfg)?))
}
fn models_endpoint(&self) -> Result<String, TranslateError> {
Ok(format!("{}/models", self.api_root()?))
fn models_endpoint(cfg: &TranslateEngineConfig) -> Result<String, TranslateError> {
Ok(format!("{}/models", Self::api_root(cfg)?))
}
/// 密钥只从系统凭据管理器读,不进配置文件、不经前端。
fn api_key(&self) -> String {
crate::translate::engine_api_key(&self.cfg.id)
fn api_key(cfg: &TranslateEngineConfig) -> String {
crate::translate::engine_api_key(&cfg.id)
}
fn require_key(&self) -> Result<String, TranslateError> {
let key = self.api_key();
fn require_key(cfg: &TranslateEngineConfig) -> Result<String, TranslateError> {
let key = Self::api_key(cfg);
if key.trim().is_empty() {
return Err(TranslateError::auth(format!(
"引擎「{}」尚未配置 API Key,请在翻译设置中填写",
self.cfg.name
cfg.name
)));
}
Ok(key)
}
fn require_model(&self) -> Result<String, TranslateError> {
let model = self.cfg.model.trim();
fn require_model(cfg: &TranslateEngineConfig) -> Result<String, TranslateError> {
let model = cfg.model.trim();
if model.is_empty() {
return Err(TranslateError::config(format!(
"引擎「{}」尚未选择模型,可在设置中拉取模型列表后选择",
self.cfg.name
cfg.name
)));
}
Ok(model.to_string())
@@ -162,9 +162,9 @@ impl TranslateEngine for AiEngine {
if req.text.trim().is_empty() {
return Err(TranslateError::empty());
}
let key = self.require_key()?;
let model = self.require_model()?;
let endpoint = self.chat_endpoint()?;
let key = Self::require_key(&self.cfg)?;
let model = Self::require_model(&self.cfg)?;
let endpoint = Self::chat_endpoint(&self.cfg)?;
let mut body = serde_json::Map::new();
body.insert("model".to_string(), json!(model));
@@ -267,9 +267,9 @@ impl TranslateEngine for AiEngine {
if req.text.trim().is_empty() && req.image_png.is_none() {
return Err(TranslateError::empty());
}
let key = self.require_key()?;
let model = self.require_model()?;
let endpoint = self.chat_endpoint()?;
let key = Self::require_key(&self.cfg)?;
let model = Self::require_model(&self.cfg)?;
let endpoint = Self::chat_endpoint(&self.cfg)?;
let mut body = serde_json::Map::new();
body.insert("model".to_string(), json!(model));
@@ -389,8 +389,8 @@ impl TranslateEngine for AiEngine {
}
async fn list_models(&self) -> Result<Vec<String>, TranslateError> {
let key = self.require_key()?;
let endpoint = self.models_endpoint()?;
let key = Self::require_key(&self.cfg)?;
let endpoint = Self::models_endpoint(&self.cfg)?;
let resp = self
.client
.get(&endpoint)
@@ -591,3 +591,71 @@ struct ModelEntry {
#[serde(default)]
id: String,
}
/// 通用(非翻译语义)的对话补全入口:供终端 AI 助手等模块复用引擎配置。
///
/// 与翻译路径共享端点归一(剥 `/chat/completions` 后缀)、密钥存取
/// (凭据管理器)、`apply_common_params`temperature / max_tokens / extra_body
/// 与响应解析,但 **消息由调用方全量给定**——这里不含任何翻译提示词语义。
///
/// 刻意做成关联函数而不是 `AiEngine` 的实例方法:调用方(终端助手)只持有
/// `TranslateEngineConfig`,为它构造 `AiEngine` 还要 PromptTemplates 与 client
/// 属于无谓的耦合。
pub async fn chat_once(
cfg: &TranslateEngineConfig,
messages: Vec<(&str, String)>,
) -> Result<String, String> {
let key = AiEngine::require_key(cfg).map_err(|e| e.to_string())?;
let model = AiEngine::require_model(cfg).map_err(|e| e.to_string())?;
let endpoint = AiEngine::chat_endpoint(cfg).map_err(|e| e.to_string())?;
let mut body = serde_json::Map::new();
body.insert("model".to_string(), json!(model));
body.insert("stream".to_string(), json!(false));
body.insert(
"messages".to_string(),
json!(messages
.into_iter()
.map(|(role, content)| json!({ "role": role, "content": content }))
.collect::<Vec<_>>()),
);
apply_common_params(&mut body, cfg);
let client = reqwest::Client::new();
let resp = client
.post(&endpoint)
.bearer_auth(&key)
.timeout(Duration::from_millis(cfg.timeout_ms.max(1000)))
.json(&serde_json::Value::Object(body))
.send()
.await
.map_err(|e| classify_reqwest(e, &cfg.name).to_string())?;
let status = resp.status();
let raw = resp
.text()
.await
.map_err(|e| format!("读取「{}」响应失败: {e}", cfg.name))?;
if !status.is_success() {
return Err(classify_http(status.as_u16(), &raw, &cfg.name, &model).to_string());
}
let parsed: ChatResponse = serde_json::from_str(&raw)
.map_err(|e| format!("{}」响应不是预期的 JSON: {e}", cfg.name))?;
if let Some(err) = parsed.error {
let msg = err
.message
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| "上游返回了错误对象".to_string());
return Err(format!("{}」返回错误:{msg}", cfg.name));
}
parsed
.choices
.into_iter()
.next()
.and_then(|c| c.message.content)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| format!("{}」返回了空内容", cfg.name))
}
+4 -1
View File
@@ -50,9 +50,12 @@ pub use engines::{TranslateEngine, TranslateError, TranslateResult};
// `engines::` / `settings::` 下,等真正用到时再提升到此处——提前摆出一堆无人消费的再导出,
// 只会让「谁在用」更难判断。
pub use settings::TranslateSettings;
// 终端 AI 助手(terminal/assistant.rs)复用引擎配置与通用对话补全——
// 「现在真正用到了」,按上面的原则提升到此处。
pub use settings::TranslateEngineConfig;
pub use engines::ai::chat_once;
use engines::EngineRequest;
use settings::TranslateEngineConfig;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};