//! 终端模块: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>, /// 命令历史后台 writer 的入队端。 /// /// `record_command` 位于输出热路径上,此前每次都同步「settings 深拷贝 + /// SQLite 写 + prune」;现在改为 `mpsc::send` 入队,由 [`history::spawn_writer`] /// 的 writer 线程攒批落库。`None` 表示尚未启动(init_on_launch 时启动)。 history_tx: Mutex>>, /// 设置缓存。`None` 表示尚未加载(懒加载,避免启动时多做一次磁盘 IO) settings: Mutex>, } 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>` 而非 `&mut self`:命令层拿到的是 /// `State<'_, TerminalManager>`,多个并发命令(比如历史面板在搜、同时 /// 另一个会话在写新命令)都会调到它,`&mut` 会把两者串行化。 /// /// 打开失败**不缓存失败结果**(`left` 保持 `None`):磁盘临时不可用、 /// 目录权限刚被修好这类情况应当允许后续调用重试。若把 `Err` 也当成 /// 「已初始化」,用户就得重启应用才能恢复历史功能。 pub fn history(&self) -> Result>, 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(&self, f: F) -> Result 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::(&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> { self.settings.lock().unwrap_or_else(|e| e.into_inner()) } /// 刷新 Shell 探测结果并写回设置。 /// /// 返回完整的 Shell 列表(含用户自定义项)。用户在设置页点「重新探测」时调用。 pub fn refresh_shells(&self) -> Result, String> { let current = self.settings(); let merged = shell::detect_and_merge(¤t.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 = 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, String> { app.try_state::() .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, ) { 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); }