终端模块初版
This commit is contained in:
Generated
+1237
-144
File diff suppressed because it is too large
Load Diff
+72
-7
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"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
// terminal(41:39 业务 + 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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 shell(bash)。仅当用户明确提到 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");
|
||||
}
|
||||
}
|
||||
@@ -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 在 Drop(entry 被 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
@@ -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` 是纯 Rust(Firefox 的
|
||||
//! 实现抽出),且它按 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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
/// 服务端出示的指纹(SHA256,OpenSSH 展示格式)
|
||||
pub fingerprint: String,
|
||||
/// 本次是「首次见到」还是「与记录不符」。
|
||||
/// 后者是**高危信号**,前端必须以红色阻断式 UI 呈现。
|
||||
pub reason: String,
|
||||
/// 与记录不符时,给出先前记录的指纹以便用户对比
|
||||
pub previous_fingerprint: Option<String>,
|
||||
}
|
||||
@@ -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
@@ -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(¤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<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);
|
||||
}
|
||||
@@ -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` 返回 0(EOF)—— 正常结束
|
||||
/// 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 7(cwd)/ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! 终端进程后端。
|
||||
//!
|
||||
//! `conpty` 是本地 Shell 的实现(Windows ConPTY)。未来若需要支持非 Windows
|
||||
//! 平台,在此目录下新增 `unix_pty` 即可,上层只依赖 [`super::session::Session`]。
|
||||
|
||||
pub mod conpty;
|
||||
@@ -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 id,SSH 为 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 id,SSH 会话是主机 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)
|
||||
}
|
||||
@@ -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/4096;ECDSA P-256/P-384/P-521;ed25519 固定空串)
|
||||
pub bits: u32,
|
||||
/// 公钥指纹(SHA256,OpenSSH 展示格式 `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 id;SSH = 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"),
|
||||
// 历史用 H(History)。不与 `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,
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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-16LE(Windows 上部分 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("e_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` 接收 base64(UTF-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` 接受的 base64(UTF-16LE)。
|
||||
///
|
||||
/// PowerShell(5.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 ``)而不是 ST:BEL 兼容性最好,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 7(cwd)与 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())
|
||||
}
|
||||
}
|
||||
@@ -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}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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 不应 panic(saturating 语义)
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 Mutex(P2 连接复用),锁 `.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user