终端模块初版

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
+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);
}