UI/细节调整

This commit is contained in:
zhongluofeng
2026-09-22 19:10:16 +08:00
parent b018abd922
commit bccd21fde5
135 changed files with 11393 additions and 5104 deletions
+4
View File
@@ -11,6 +11,7 @@
"dialog:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unminimize",
"core:window:allow-close",
"core:window:allow-toggle-maximize",
"core:window:allow-hide",
@@ -26,6 +27,9 @@
"core:window:allow-set-always-on-top",
"core:window:allow-set-skip-taskbar",
"core:window:allow-set-decorations",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered",
"snap-layout:default"
]
}
@@ -5,11 +5,13 @@
"windows": ["terminal-window-*"],
"permissions": [
"core:default",
"core:window:allow-show",
"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-set-title",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-set-background-color",
+34 -3
View File
@@ -625,8 +625,9 @@ pub fn hide_preview(app: &AppHandle) {
/// - 交互锁定模式(点击过预览窗的放大/缩小/复制等):弹窗已失焦不会再收到
/// Focused(false),检测"鼠标按下沿且按下位置在弹窗+预览之外"→ 点击外部,关闭弹窗。
///
/// 公共退出条件:弹窗已隐藏/销毁(其他路径关闭);弹窗重新获得焦点(用户点击
/// 弹窗,恢复正常失焦关闭路径,并复位交互锁定);超时兜底防线程泄漏
/// 退出条件:弹窗已隐藏/销毁(其他路径关闭);弹窗重新获得焦点(用户点击
/// 弹窗,恢复正常失焦关闭路径,并复位交互锁定);**光标回到弹窗内并停留片刻
/// (交互锁定解除,但看护继续以常规模式运行)**;超时兜底防线程泄漏
/// (常规 30s;交互锁定为事件驱动,放宽至 10min)。
fn spawn_popup_leave_watch(app: AppHandle) {
// 防重入:已有看护在运行则跳过
@@ -634,9 +635,12 @@ fn spawn_popup_leave_watch(app: AppHandle) {
return;
}
std::thread::spawn(move || {
let start = Instant::now();
// 超时兜底的起点:交互锁定解除时重置(那之后按常规模式的 30s 计)
let mut start = Instant::now();
let mut outside = 0u32;
let mut was_down = false;
// 交互锁定期间光标停在弹窗内的连续轮数(30ms/轮)
let mut back_in_popup = 0u32;
loop {
std::thread::sleep(Duration::from_millis(30));
// 弹窗已隐藏或被销毁:看护结束
@@ -654,6 +658,33 @@ fn spawn_popup_leave_watch(app: AppHandle) {
POPUP_INTERACTED.store(false, Ordering::SeqCst);
break;
}
// 交互锁定期间光标已回到弹窗内:用户离开预览窗、回到列表继续浏览 ——
// 解除锁定,让前端的悬停逻辑重新生效(否则 hide_preview 在锁定期间一律
// 被忽略,表现为「鼠标回到弹窗、悬停在不需要预览的条目上,预览窗不关」)。
//
// 这里**只解除锁定、不 break**:弹窗此刻没有焦点,不会再收到 Focused(false)
// 若连看护也退出,它就成了无人负责关闭的残留窗口。继续跑常规模式,
// 光标离开弹窗+预览区域约 300ms 后照常关闭。
// 连续计数(~210ms)用来排除「弹窗-预览之间的间隙」造成的瞬时误判。
if POPUP_INTERACTED.load(Ordering::SeqCst) {
if is_cursor_in_popup(&app) {
back_in_popup += 1;
if back_in_popup >= 7 {
POPUP_INTERACTED.store(false, Ordering::SeqCst);
back_in_popup = 0;
// 兜底超时从「解除锁定」这一刻重新计时
start = Instant::now();
// 顺势收起预览:用户回到弹窗意味着意图已从「看预览」变回
// 「浏览列表」。前端在悬停时发出的那次 hide_preview 落在锁定
// 期间、已被忽略,这里补上(否则预览会一直挂到下次悬停)。
hide_preview(&app);
}
} else {
back_in_popup = 0;
}
} else {
back_in_popup = 0;
}
let interacted = POPUP_INTERACTED.load(Ordering::SeqCst);
// 超长兜底:避免异常路径下看护线程无限轮询
let timeout = if interacted {
+6
View File
@@ -94,6 +94,12 @@ pub mod events {
// 终端模块:SFTP 传输进度(负载见 terminal::ssh::sftp::TransferProgress
// 节流后发送(约 200ms 一次),前端据此画进度条
pub const TERMINAL_TRANSFER_PROGRESS: &str = "terminal-transfer-progress";
// 终端模块:设置已保存(无负载)。
//
// 终端设置存在 Rust 侧,而每个窗口(主窗口 / 终端独立窗口 / 选择窗口)各有
// 一份前端副本 —— 没有这个事件,在主窗口改完外观后,已打开的独立窗口会一直
// 用旧配色,直到重开窗口。
pub const TERMINAL_SETTINGS: &str = "terminal-settings";
// 音乐模块:下载任务事件(桥接事件行 → 前端,负载见 bridge.py _emit_event
pub const MUSIC_DOWNLOAD_EVENT: &str = "music-download-event";
// 后端自动切换节点完成(前端据以刷新节点列表并提示)
+52 -8
View File
@@ -19,7 +19,7 @@ use std::collections::HashMap;
use serde::Serialize;
use specta::Type;
use tauri::{AppHandle, Manager};
use tauri::{AppHandle, Emitter, Manager};
use super::assistant;
use super::audit;
@@ -64,6 +64,15 @@ impl ActionOutcome {
// ===== 设置 =====
/// 广播「终端设置已保存」。
///
/// 终端设置存在 Rust 侧,而每个窗口(主窗口 / 终端独立窗口 / 选择窗口)各有自己的
/// 前端副本。没有这个事件时,在主窗口改完外观后,已经打开的独立窗口会一直用旧配色
/// (画布配色、字号、状态栏显隐都不同步),只能重开窗口。
fn notify_settings_changed(app: &AppHandle) {
let _ = app.emit(crate::constants::events::TERMINAL_SETTINGS, ());
}
/// 读取终端设置。
#[tauri::command]
#[specta::specta]
@@ -86,6 +95,7 @@ pub async fn terminal_save_settings(
) -> Result<TerminalSettings, String> {
let m = manager(&app)?;
m.save_settings(settings.clone())?;
notify_settings_changed(&app);
Ok(m.settings())
}
@@ -97,7 +107,9 @@ pub async fn terminal_save_appearance(
appearance: AppearanceSettings,
) -> Result<TerminalSettings, String> {
let m = manager(&app)?;
m.update_settings(|s| s.appearance = appearance)
let out = m.update_settings(|s| s.appearance = appearance)?;
notify_settings_changed(&app);
Ok(out)
}
/// 保存布局设置。
@@ -108,7 +120,9 @@ pub async fn terminal_save_layout(
layout: LayoutSettings,
) -> Result<TerminalSettings, String> {
let m = manager(&app)?;
m.update_settings(|s| s.layout = layout)
let out = m.update_settings(|s| s.layout = layout)?;
notify_settings_changed(&app);
Ok(out)
}
/// 保存终端内选中行为。
@@ -119,7 +133,9 @@ pub async fn terminal_save_selection(
selection: TerminalSelectionSettings,
) -> Result<TerminalSettings, String> {
let m = manager(&app)?;
m.update_settings(|s| s.selection = selection)
let out = m.update_settings(|s| s.selection = selection)?;
notify_settings_changed(&app);
Ok(out)
}
/// 保存安全设置。
@@ -130,7 +146,9 @@ pub async fn terminal_save_security(
security: SecuritySettings,
) -> Result<TerminalSettings, String> {
let m = manager(&app)?;
m.update_settings(|s| s.security = security)
let out = m.update_settings(|s| s.security = security)?;
notify_settings_changed(&app);
Ok(out)
}
/// 保存快捷键绑定(整组覆盖)。
@@ -154,7 +172,9 @@ pub async fn terminal_save_shortcuts(
}
seen.push((key, s.action.clone()));
}
m.update_settings(|st| st.shortcuts = shortcuts)
let out = m.update_settings(|st| st.shortcuts = shortcuts)?;
notify_settings_changed(&app);
Ok(out)
}
// ===== 本地 Shell =====
@@ -283,7 +303,9 @@ pub async fn terminal_open_local(
app.clone(),
id.clone(),
profile.id.clone(),
profile.name.clone(),
// 标签上的短名,与设置页的描述名(`profile.name`)刻意分开:
// 描述名可编辑、可很长,而标签要能一行放下多个会话(见 `session_label`
shell::session_label(&profile),
command_line,
work_dir,
shell::env_pairs(&profile.env),
@@ -467,6 +489,7 @@ pub async fn terminal_open_ssh(
},
jump_chain: jump_hops,
encoding: host.encoding.clone(),
inject_shell_integration: host.inject_shell_integration,
};
let session = ssh::SshSession::spawn(app.clone(), id, params, m.ssh_pool.clone());
@@ -564,6 +587,12 @@ pub async fn terminal_resize(
}
/// 重命名会话(同时更新标签与独立窗口标题)。
///
/// # 为什么用 `set_title_by_user` 而不是直接写 `title`
///
/// 重命名会把标题**锁定**,此后 shell 通过 OSC 0/2 上报的标题一律被忽略。
/// 不锁的话,用户重命名后下一条提示符就把名字覆盖回去了 ——
/// 表现为「重命名无效」,且只在有输出时才复现,很难归因。
#[tauri::command]
#[specta::specta]
pub async fn terminal_rename_session(
@@ -580,7 +609,7 @@ pub async fn terminal_rename_session(
if t.is_empty() {
return Err("标题不能为空".to_string());
}
s.set_title(t);
s.set_title_by_user(t);
// 独立窗口存在时同步标题,避免窗口标题与内部标签不一致
if let Some(win) = app.get_webview_window(&window::label_for(&session_id)) {
@@ -1001,6 +1030,7 @@ pub async fn terminal_import_ssh_config(app: AppHandle) -> Result<ActionOutcome,
use_proxy: false,
jump_ids: Vec::new(),
encoding: "utf-8".to_string(),
inject_shell_integration: true,
});
imported += 1;
}
@@ -1417,9 +1447,14 @@ pub async fn terminal_import_known_hosts(text: String) -> Result<ActionOutcome,
///
/// 由前端在用户点击「接受」/「拒绝」后调用。会话侧的握手正在阻塞等待这个回传,
/// 因此超时(120 秒)后会视为拒绝。
///
/// `app` 只用于安全事件日志(`security.auditLog`)——用户的每个决定都要留痕:
/// 「谁在什么时候接受了新指纹」是事后审计里最关键的一条。specta 会把
/// `AppHandle` 参数从生成的 JS 签名里剔除,因此前端调用方式不变。
#[tauri::command]
#[specta::specta]
pub async fn terminal_confirm_host_key(
app: AppHandle,
session_id: String,
accept: bool,
host: String,
@@ -1427,6 +1462,15 @@ pub async fn terminal_confirm_host_key(
key_type: String,
fingerprint: String,
) -> Result<(), String> {
if let Ok(m) = manager(&app) {
if m.settings().security.audit_log {
let action = if accept { "接受" } else { "拒绝" };
crate::logger::log_info(
"terminal",
&format!("用户{action}主机密钥:{host}:{port} {key_type} {fingerprint}"),
);
}
}
if !accept {
// 记一次「拒绝」以便事后审计
hostkey::record_rejection(&host, port, &fingerprint, &key_type);
+2 -2
View File
@@ -695,8 +695,8 @@ impl Session for ConPtySession {
.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_title_by_user(&self, title: &str) {
self.state.set_title_by_user(title);
}
fn set_detached(&self, detached: bool) {
+58 -15
View File
@@ -131,8 +131,12 @@ pub trait Session: Send + Sync {
/// 更新状态(供内部线程在握手/退出时调用)。
fn set_state(&self, state: SessionState, error: Option<String>);
/// 更新标题
fn set_title(&self, title: &str);
/// 由**用户**指定标题(重命名)
///
/// 名字里带 `_by_user` 是刻意的:它会锁定标题,之后 shell 通过 OSC 0/2
/// 上报的标题一律被忽略。任何「程序自动设置标题」的需求都不该走这里 ——
/// 那会把用户的命名覆盖掉(见 `LocalSessionState::title_locked`)。
fn set_title_by_user(&self, title: &str);
/// 标记是否由独立窗口承载。
fn set_detached(&self, detached: bool);
@@ -276,6 +280,17 @@ pub struct LocalSessionState {
/// 没有这个字段就只能靠 `target_id` 的形式去猜,那是不可靠的。
pub kind: SessionKind,
pub title: std::sync::Mutex<String>,
/// 标题是否已被用户手动指定(重命名)。
///
/// # 为什么需要这个标记
///
/// 会话标题有两个来源:创建时的 shell/主机名,以及 shell 通过 OSC 0/2
/// 上报的自定义标题(每次绘制提示符都可能刷新)。后者是**异步且随时可能到达**的,
/// 因此用户刚重命名完,下一条提示符就会把它覆盖回去 —— 表现为「重命名无效」,
/// 且因为只在有输出时才复现,很难判断是哪里出的问题。
///
/// 一旦用户显式命名,就把标题的所有权交给用户:后续 OSC 标题一律忽略。
pub title_locked: std::sync::atomic::AtomicBool,
pub state: std::sync::Mutex<SessionState>,
pub error: std::sync::Mutex<Option<String>>,
pub cwd: std::sync::Mutex<String>,
@@ -313,6 +328,7 @@ impl LocalSessionState {
target_id,
kind,
title: std::sync::Mutex::new(title),
title_locked: std::sync::atomic::AtomicBool::new(false),
state: std::sync::Mutex::new(SessionState::Idle),
error: std::sync::Mutex::new(None),
cwd: std::sync::Mutex::new(String::new()),
@@ -329,6 +345,19 @@ impl LocalSessionState {
matches!(self.kind, SessionKind::Ssh)
}
/// 用户手动指定标题(重命名)。同时锁定,见 `title_locked` 的说明。
pub fn set_title_by_user(&self, title: &str) {
*self.title.lock().unwrap_or_else(|e| e.into_inner()) = title.to_string();
self.title_locked
.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// 标题当前是否被用户锁定(锁定后忽略 shell 上报的 OSC 标题)。
pub fn is_title_locked(&self) -> bool {
self.title_locked
.load(std::sync::atomic::Ordering::Relaxed)
}
/// 当前编码(供读线程与命令层读取)。
pub fn encoding(&self) -> String {
self.encoding
@@ -444,20 +473,34 @@ pub fn scan_control_sequences(
}
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
// 两类标题一律丢弃,理由不同但都落在同一个判定点上:
//
// 1. 用户已重命名过(`title_locked`):标题所有权已交给用户,
// 否则下一条提示符就会把用户刚起的名字覆盖回去。
// 2. 标题是可执行文件全路径(`osc_title_is_executable_path`):
// cmd.exe / powershell.exe 启动时就是这么设控制台标题的,
// 把会话名顶成 `C:\Windows\System32\...\cmd.exe` 毫无信息量。
//
// 注意这里**不能提前 return** —— 函数尾部还有 OSC 133 命令边界的落库,
// 早退会把「同批次里带标题的命令」整条丢掉。
let ignored = state.is_title_locked()
|| super::shell::osc_title_is_executable_path(title);
if !ignored {
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),
);
}
};
if changed {
let _ = app.emit(
crate::terminal::events::TERMINAL_STATE,
local_info(state, state.kind),
);
}
}
+73 -10
View File
@@ -23,7 +23,11 @@ use specta::Type;
pub struct ShellProfile {
/// 唯一标识(同时是新建会话时的 shellKey)
pub id: String,
/// 展示名称,如 "PowerShell 7"
/// 展示名称,如 "PowerShell 7" / "Windows PowerShell"。
///
/// **设置页里的描述名,不是标签上显示的名字** —— 后者取
/// [`super::shell::session_label`](按可执行文件名归一的短名)。
/// 两者刻意分开:描述名可编辑、可很长,而标签要能一行放下多个会话。
pub name: String,
/// 可执行文件绝对路径
pub path: String,
@@ -147,6 +151,20 @@ pub struct SshHost {
pub jump_ids: Vec<String>,
/// 终端的字符编码("utf-8" 默认 | "gbk" 等)。老服务器常见 GBK,中文环境刚需。
pub encoding: String,
/// 是否在远端注入 shell 集成 hook(命令历史 + cwd 跟踪)。
///
/// # 为什么默认必须是 true
///
/// 本结构是容器级 `#[serde(default)]`:老配置里缺这个字段时,serde 不是填
/// 「零值」而是取 [`SshHost::default()`] 的值。因此**默认值决定老主机是否自动
/// 开启注入** —— 填 `false` 会让所有既有主机静默不注入(表现为历史永远为空,
/// 而开关在界面上看着是关的,用户根本不会去动它)。填 `true` 则无需改
/// `heal()`、无需升 `version`。
///
/// 关掉它的场景:远端 shell 集成会与 `startup_command` 里 `exec` 换 shell 的
/// 配置冲突,或服务器对会话环境有洁癖要求(注入会改本次会话的 `PROMPT_COMMAND`
/// `precmd`,但不落盘、不影响其它会话)。
pub inject_shell_integration: bool,
}
impl Default for SshHost {
@@ -170,6 +188,8 @@ impl Default for SshHost {
use_proxy: false,
jump_ids: Vec::new(),
encoding: "utf-8".to_string(),
// 必须 true,理由见字段文档(容器级 serde(default) 会用这里的值兜老配置)
inject_shell_integration: true,
}
}
}
@@ -206,7 +226,8 @@ pub struct AppearanceSettings {
impl Default for AppearanceSettings {
fn default() -> Self {
Self {
theme: "thing-dark".to_string(),
// 「跟随主应用」:配合默认开启的 follow_app_theme,开箱即随应用深浅切换
theme: "system".to_string(),
follow_app_theme: true,
font_family: "Cascadia Mono, Consolas, Microsoft YaHei Mono, monospace".to_string(),
font_size: 14,
@@ -296,10 +317,6 @@ 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)。
@@ -312,8 +329,6 @@ impl Default for LayoutSettings {
Self {
confirm_close_running: true,
inherit_cwd: true,
sidebar_open: true,
sidebar_width: 220,
show_status_bar: true,
max_panes: 4,
}
@@ -510,11 +525,25 @@ pub struct SecuritySettings {
///
/// **不提供 "auto-accept" 选项**TOFU 静默接受是 MITM 的入口,
/// 属于代码层不该给用户的开关。
///
/// 读取方(`ssh::SshHandler::check_server_key`**显式读取本字段而不是写死**:
/// 唯一支持的取值是 `"ask"`,其它取值(配置文件被手工改过)一律按 `"ask"`
/// 处理 —— 安全开关的失败方向必须朝更严的一侧,绝不能因为出现了一个
/// 没见过的字符串就放宽。
pub host_key_policy: String,
/// 指纹变更时是否阻断(**默认 true**)。关掉会让中间人攻击无声通过,
/// 因此前端需以红色风险提示呈现该开关。
///
/// 关闭时的行为不是「照旧弹窗」而是「自动接受并记入变更历史」:
/// 开关的语义是「不再拦截」,若关掉后仍弹阻断弹窗,用户会以为没生效。
pub block_on_fingerprint_change: bool,
/// 是否记录连接审计日志(P2,默认关;开启后输入输出落盘,含脱敏)
/// 是否把**安全事件**写入日志(默认开)。
///
/// 记录对象是主机密钥相关的事件:指纹已信任 / 首次见到被接受 / 指纹变更
/// 被接受或拒绝 / 自动接受(阻断关闭时)。与「会话日志」(输出字节流落盘,
/// 由工具栏的 `terminal_toggle_logging` 按会话显式开启,见 `audit` 模块的
/// 取舍说明)**是两件事**:这里只写事件行,不含屏幕内容,因此没有隐私代价,
/// 默认开启才能保证「发生过什么」事后可查。
pub audit_log: bool,
}
@@ -523,7 +552,7 @@ impl Default for SecuritySettings {
Self {
host_key_policy: "ask".to_string(),
block_on_fingerprint_change: true,
audit_log: false,
audit_log: true,
}
}
}
@@ -633,6 +662,9 @@ impl Default for TemplateEntry {
/// - 新建/关闭标签用 `Ctrl+Shift+T/W`,与浏览器习惯一致。
/// - 跳转标签用 `Alt+1..9``Ctrl+数字` 已被翻译模块的 `Ctrl+2`(翻译面板)占用,
/// 而 `Alt+数字` 在终端里通常不产生控制字符,冲突面最小。
/// - `newDetachedWindow``Ctrl+3`)是这张表里唯一**全局**生效的一条:它由前端
/// `TerminalQuickLaunch` 注册成系统级热键(任意应用前台可触发),放在这里只为
/// 跟着终端设置一起存与改。`Ctrl+2` 已被翻译面板占用,`Ctrl+3` 是空闲的相邻键位。
pub fn default_shortcuts() -> Vec<ShortcutBinding> {
let pairs: &[(&str, &str)] = &[
("copy", "Ctrl+Shift+C"),
@@ -656,6 +688,11 @@ pub fn default_shortcuts() -> Vec<ShortcutBinding> {
("history", "Ctrl+Shift+H"),
("renameTab", "F2"),
("sessionSwitcher", "Ctrl+Shift+O"),
// 把当前会话放到独立窗口。**必须列在这里**:`heal()` 只保留默认表里出现过的
// action(未知 action 会被剔除),前端设置表里有而这里没有 = 用户改完下次启动就被清掉。
("detachWindow", "Ctrl+Shift+N"),
// 全局生效的一条(见上方说明):新建独立窗口 + 选择会话
("newDetachedWindow", "Ctrl+3"),
];
pairs
.iter()
@@ -685,6 +722,24 @@ impl TerminalSettings {
pub fn heal(&mut self) -> bool {
let mut changed = false;
// 配色主题:只认前端 `terminalThemes.ts` 里存在的 key。
// 早期默认值是 `thing-dark`(一套从未实现的主题),解析时会静默回落到
// vscode-dark,用户看到的是「选了主题不生效」。未知 key 一律收敛到
// 「system」(跟随主应用)—— 它至少是用户能预期的行为。
const THEMES: &[&str] = &[
"vscode-dark",
"vscode-light",
"solarized-dark",
"solarized-light",
"dracula",
"one-dark",
"system",
];
if !THEMES.contains(&self.appearance.theme.as_str()) {
self.appearance.theme = "system".to_string();
changed = true;
}
// 快捷键表:补齐新增动作、剔除已废弃动作。用户改过的键位保留。
let defaults = default_shortcuts();
for d in &defaults {
@@ -747,6 +802,14 @@ impl TerminalSettings {
changed = true;
}
// 安全:主机密钥策略只有一个合法取值(见 SecuritySettings 的字段说明)。
// 手工改过的配置在这里收敛回 "ask",与 `check_server_key` 里
// 「未知取值按 ask 处理」构成两道保险:这道修数据,那道防空。
if self.security.host_key_policy != "ask" {
self.security.host_key_policy = "ask".to_string();
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();
+556 -14
View File
@@ -14,6 +14,25 @@ use std::path::{Path, PathBuf};
use super::settings::{EnvVar, ShellProfile};
/// 探测项的**历史默认名**。
///
/// 用途只有一个:`detect_and_merge` 的规则是「保留用户设置的名字」,因此
/// 默认名一旦调整,老用户的 `settings.json` 里仍存着旧名 —— 不显式迁移,
/// 新默认名**永远不生效**,表现为「改了代码但界面上丝毫没变」这类最费时间的假故障。
///
/// 因此这里登记「曾经作为默认名出现过」的全部取值:命中的视为「从未被用户改过」,
/// 直接采用当前默认名;用户真的改过名(不等于任何一个历史默认名)则照旧保留。
///
/// 注:`cmd` / `PowerShell` 这两个短名是 2026-09-20 那一版误当作展示名的取值,
/// 保留在列表里是为了把它们**收回**到描述性默认名。
pub fn legacy_default_names(id: &str) -> &'static [&'static str] {
match id {
"powershell" => &["Windows PowerShell", "PowerShell"],
"cmd" => &["命令提示符", "cmd"],
_ => &[],
}
}
/// 探测结果:合并「自动发现的 Shell」与「用户已有的自定义配置」。
///
/// 合并策略:**以自动探测为权威源修正路径**(用户机器上升级了 PowerShell 7
@@ -25,8 +44,13 @@ pub fn detect_and_merge(existing: &[ShellProfile]) -> Vec<ShellProfile> {
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() {
// 保留用户的个性化字段,路径以探测结果为准
// 名字的取舍见 `legacy_default_names`:空名与历史默认名都归探测结果,
// 其余(用户真的改过)保留用户值。
let old_name = old.name.trim();
let untouched = old_name.is_empty()
|| legacy_default_names(&d.id).contains(&old_name);
d.name = if untouched {
d.name.clone()
} else {
old.name.clone()
@@ -62,6 +86,12 @@ pub fn detect_and_merge(existing: &[ShellProfile]) -> Vec<ShellProfile> {
/// 自动探测本机可用的 Shell。
///
/// 顺序即菜单顺序,也是新建会话时的默认选中顺序(按现代性与功能排序)。
///
/// # 关于 `name`
///
/// `name` 是**设置页里的描述名**(可编辑,用于描述「这条配置是什么」),
/// 不是会话标签上显示的名字 —— 后者取 [`session_label`]。两者刻意分开:
/// 描述名可以长、可以含版本来源,而标签需要稳定、短、一眼可辨。
pub fn detect_shells() -> Vec<ShellProfile> {
let mut list = Vec::new();
@@ -335,6 +365,28 @@ fn encode_powershell_command(script: &str) -> String {
/// PowerShell 的提示符函数在「上一条命令执行完、即将显示新提示符」这个时刻被调用,
/// 因此这里输出的是 **D(上一条结束)** 而不是 C(即将开始)。
///
/// 命令文本单独用 `OSC 1337;Cmd=` 上报(协议里的 `C` 不带文本,见
/// [`CommandMark`]),取自 `Get-History -Count 1`:提示符函数运行时,刚刚执行的
/// 那条命令已经在会话历史里(PowerShell 在读取输入行时就写入历史,早于执行)。
///
/// **必须先发 `Cmd` 再发 `D`**Rust 侧在 `D` 到达时用暂存的文本落库
/// (见 `session::CommandAccumulator`),顺序反了会记成上一条命令。
///
/// `$global:__thingLastHistId` 做去重:提示符函数可能因续行提示符(`>>`)等原因
/// 在同一条命令后被调用多次,不加这个守卫会把同一条命令重复计数。
///
/// # 为什么要跳过第一个提示符(`$global:__thingSkipNextPrompt`
///
/// PowerShell 会把 `-EncodedCommand` 的**脚本文本本身**记进会话历史,于是本脚本
/// 注入后遇到的第一个提示符,`Get-History -Count 1` 拿到的是这段注入脚本 ——
/// 若不跳过,每个新建的 PowerShell 会话都会往历史里塞一条用户从没敲过的
/// 「巨型命令」(实测形态:一条以 `$__thingOrigPrompt = $function:prompt;` 开头的
/// 几百字符记录,且因去重键相同而不断累加 count)。
///
/// 跳过一次即可:第一个提示符之前用户不可能敲过任何命令,所以「跳过」不会误伤
/// 真实记录;即便某个 PowerShell 版本不把注入脚本记进历史,跳过也只是少一次
/// 无用调用。
///
/// 退出码取自 `$LASTEXITCODE`(原生命令)或 `$?`cmdlet),两者语义不同:
/// cmdlet 成功时 `$LASTEXITCODE` 可能保留着**更早那条原生命令**的值。
/// 因此优先 `$LASTEXITCODE`(仅当其在本轮被设置过),否则用 `$?` 折算 0/1。
@@ -343,11 +395,26 @@ fn encode_powershell_command(script: &str) -> String {
fn powershell_prompt_hook() -> String {
[
"$__thingOrigPrompt = $function:prompt;",
// 去重用的「最近一次已上报的历史条目 id」,见函数文档
"$global:__thingLastHistId = -1;",
// 「下一个提示符对应的是注入脚本自己」,见函数文档
"$global:__thingSkipNextPrompt = $true;",
"function global:prompt {",
// 必须最先取:后面任何一条语句都会刷新 $?
" $__ok = $?;",
" $__code = $LASTEXITCODE;",
" if ($null -eq $__code) { $__code = if ($__ok) { 0 } else { 1 } }",
// 命令文本:先于 D 输出(顺序见函数文档)
" if ($global:__thingSkipNextPrompt) {",
" $global:__thingSkipNextPrompt = $false",
" } else {",
" $__h = Get-History -Count 1;",
" if ($__h -and $__h.Id -ne $global:__thingLastHistId) {",
" $global:__thingLastHistId = $__h.Id;",
" $__c = $__h.CommandLine;",
" if ($__c) { Write-Host -NoNewline \"$([char]27)]1337;Cmd=$__c$([char]7)\"; }",
" }",
" }",
" Write-Host -NoNewline \"$([char]27)]133;D;$__code$([char]7)\";",
" $__p = $ExecutionContext.SessionState.Path.CurrentLocation;",
" $__loc = $__p.ProviderPath;",
@@ -388,6 +455,12 @@ fn powershell_prompt_hook() -> String {
/// 结论:**保留 shell 的原样输出交给 Rust 侧判断** —— hook 只管如实上报,
/// 「前导空格要不要记」是策略,由 `History::record` 统一决定(那里也是
/// `HISTCONTROL=ignorespace` 的落点)。hook 里做策略判断会散落成两处规则。
///
/// 例外一处:字段拆分(`read`)会把命令**自己的前导空格**一并吃掉 —— 序号后的
/// 分隔空格与命令的前导空格在文本上无法区分。于是「以空格开头」的命令会以普通
/// 命令的形态上报,由 `History::record` 记录。常见配置
/// `HISTCONTROL=ignorespace` 下这类命令压根不在 `history 1` 里,这条例外也就
/// 无从触发;不对抗它,如实记在这里。
fn bash_prompt_hook() -> String {
[
"# Thing 终端:shell integration hook(由终端模块注入,可安全删除)",
@@ -395,30 +468,170 @@ fn bash_prompt_hook() -> String {
"# 供文件管理器、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」—— 正是所需,且引号最少。
// **必须只跳过「序号」这一个字段**`read -r _ __cmd` 的语义是「第一个
// 字段给 `_`,其余字段连同它们之间的分隔符一起给 `__cmd`」。
//
// 曾经的写法 `read -r _ _ __cmd` 多跳了一个字段,后果按命令词数分三种:
// - 单字命令(`ls`):只剩序号 + 命令两个字段 → `__cmd` 为空 → 不发 Cmd
// → **一条都不落库**(实测就是「SSH 上跑了 ls,历史里什么都没有」);
// - 多字命令(`git status`):第一个词被 `_` 吃掉 → 记成 `status`
// - 恰好两字命令才是碰巧正确的。
//
// 用 `read` 而不是参数展开剥前缀:`history` 的输出里命令本体可能含空格,
// 参数展开(`${x##* }` 之类)会在命令内部出现连续空格时切错位置。
" local __cmd=''",
" read -r _ _ __cmd <<< \"$(HISTTIMEFORMAT= builtin history 1)\"",
" read -r _ __cmd <<< \"$(HISTTIMEFORMAT= builtin history 1)\"",
// 顺序要紧:Rust 侧在 D 到达时用暂存的文本落库(见 `CommandAccumulator`),
// 先 D 后 Cmd 会让每条命令都推迟到下一次提示符才记录,且最后一条永远丢失
" printf '\\033]1337;Cmd=%s\\007' \"$__cmd\"",
" printf '\\033]133;D;%s\\007' \"$__code\"",
" printf '\\033]7;file://%s%s\\007' \"${HOSTNAME:-localhost}\" \"$PWD\"",
"}",
"if [[ -n \"$PROMPT_COMMAND\" ]]; then",
" PROMPT_COMMAND=\"__thing_osc7; $PROMPT_COMMAND\"",
// bash 5.1+ 允许 `PROMPT_COMMAND` 是**数组**(每个元素都是一条要执行的命令)。
// 原来那句字符串拼接在数组场景下只会展开元素 0 并把整个数组覆盖成字符串 ——
// 用户自己的钩子(bash-preexec 之类)就此丢失。因此按 `declare -p` 的形态分流。
"if [[ \"$(declare -p PROMPT_COMMAND 2>/dev/null)\" == \"declare -a\"* ]]; then",
" PROMPT_COMMAND=(__thing_osc7 \"${PROMPT_COMMAND[@]}\")",
"else",
" PROMPT_COMMAND=\"__thing_osc7\"",
" PROMPT_COMMAND=\"__thing_osc7${PROMPT_COMMAND:+; $PROMPT_COMMAND}\"",
"fi",
"",
]
.join("\n")
}
/// 远端(SSHshell 集成脚本:**一行** POSIX sh,由连接期直接写进远端 shell。
///
/// # 为什么要注入、以及注入什么
///
/// 远端 shell 默认不会输出任何 OSC 标记,于是 SSH 会话既没有命令历史
/// (见 [`CommandMark`])也没有 cwd 跟踪。本脚本让远端 shell 在提示符处自行上报
/// `OSC 1337;Cmd=` / `OSC 133;D` / `OSC 7`,与本地 hook 输出完全同一套协议,
/// Rust 侧无需为 SSH 单独分流。
///
/// # 调用方的两个义务
///
/// 1. **以空格开头**`{script}` 前面加一个空格):`HISTCONTROL=ignorespace` 惯例,
/// 让这行注入命令不进远端 shell 的历史;`History::record` 也丢弃前导空格的命令。
/// 2. **以 `\n` 结尾**PTY 的行规程只在收到 NL 时才把这一行交给 shell。
///
/// # 单行脚本的硬约束(全是踩过的坑)
///
/// - **不能有 `#` 注释**:单行脚本里注释会吃掉行尾其余内容(本地 Git Bash 的 hook
/// 是多行文件,才能写注释)。
/// - **不能含裸 `\r`**:远端 PTY 的 ICRNL 会把 `\r` 当行结束,脚本被切成多条半截命令。
/// - **保持纯 ASCII**GBK 主机的回显才不会是乱码。
/// - `printf` 用 `\033` / `\007` 而不是 `\e`:部分 dash/busybox 不认 `\e`。
/// 命令文本一律作为 `%s` 参数传入(含单引号、`%` 都不会破坏格式串)。
///
/// # 两个 shell 分支的差异
///
/// - **bash**:钩子挂在 `PROMPT_COMMAND`,命令文本取自 `history 1`(别名已展开、
/// Tab 补全已生效)。挂载用**前插**:`$?` 必须在用户已有钩子之前取走,
/// 否则会被别人的语句覆盖。`PROMPT_COMMAND` 可能是数组,按形态分流。
/// - **zsh**:用 `preexec`(命令执行前,`$1` 就是最终命令行)发 `Cmd`、`precmd`
/// 发 `D` —— 两者天然配对,比 bash 更精确。同样前插,理由同上。
///
/// # 跳过第一个提示符(`__thing_skip`
///
/// 注入命令本身会进入远端会话历史,于是第一个提示符取到的「上一条命令」就是这行
/// 注入命令,不跳过就会把注入脚本记成一条用户命令(本地 PowerShell 已实测踩过)。
///
/// # 命令文本为空时不发 `Cmd`
///
/// `CommandAccumulator` 是**覆盖**语义(Cmd 到来即覆盖暂存),发一个空的 Cmd 会
/// 覆盖掉上一条待落库的文本。`HISTFILE=/dev/null` / `set +o history` 的场景就靠
/// 这个守卫降级为「cwd 有、历史空」,而不是让历史错位。
///
/// # 幂等
///
/// `__thing_installed` 守卫:同一会话里重复注入不会注册两次(否则每条命令入库两次)。
///
/// # 末尾附一次清屏:为什么是必须的,以及为什么不用 `clear`
///
/// 注入行是在远端还处于登录阶段时就到达的(`Last login:` 那一段,shell 还没开始读
/// 输入)。于是 tty 层先回显一次、readline 起来后从缓冲里读到再重绘一次 —— 用户会看到
/// **两遍**这行几百字符的命令,登录 banner / motd 也还在屏幕上。注入完清一次屏把这
/// 几行一起抹掉。
///
/// 直接写序列而不是调 `clear`:不依赖 `clear` 二进制(精简容器里可能没有)、字节固定、
/// 不随远端 TERM 变化。三个参数各有用处 —— `2J` 清屏、`3J` 清滚动缓冲(否则往上滚
/// 还能看到那行回显)、`H` 归位光标。代价是 motd 也一并被清掉:这是「干净的首屏」与
/// 「先看一眼 motd」之间的取舍,取前者(VS Code / Windows Terminal 的远端集成同样
/// 会刷新首屏)。
///
/// 清屏放在 `fi` 之后:它不是 shell 集成的一部分,`dash` 这类没有 hook 可挂的 shell
/// 也该清掉这行回显。
pub fn remote_shell_integration_script() -> String {
// bash 分支:`<<<`、`${v//a/b}`、数组都是 bashism,故先按 BASH_VERSION 分流再使用。
let bash = "__thing_osc7(){ local __code=$?; local __cmd=''; \
if [ -z \"${__thing_skip:-}\" ]; then \
read -r _ __cmd <<< \"$(HISTTIMEFORMAT= builtin history 1)\"; \
__cmd=${__cmd//[$'\\033\\007']/}; \
[ -n \"$__cmd\" ] && printf '\\033]1337;Cmd=%s\\007' \"$__cmd\"; \
else unset __thing_skip; fi; \
printf '\\033]133;D;%s\\007' \"$__code\"; \
printf '\\033]7;file://%s%s\\007' \"${HOSTNAME:-localhost}\" \"$PWD\"; }; \
if [ -z \"${__thing_installed:-}\" ]; then __thing_installed=1; __thing_skip=1; \
case \"$(declare -p PROMPT_COMMAND 2>/dev/null)\" in \
\"declare -a\"*) PROMPT_COMMAND=(__thing_osc7 \"${PROMPT_COMMAND[@]}\");; \
*) PROMPT_COMMAND=\"__thing_osc7${PROMPT_COMMAND:+; $PROMPT_COMMAND}\";; esac; fi";
// zsh 分支:precmd 只发 Dpreexec 发 Cmd(顺序见 `CommandAccumulator`)。
let zsh = "__thing_precmd(){ local __code=$?; \
printf '\\033]133;D;%s\\007' \"$__code\"; \
printf '\\033]7;file://%s%s\\007' \"${HOSTNAME:-${HOST:-localhost}}\" \"$PWD\"; }; \
__thing_preexec(){ if [ -n \"${__thing_skip:-}\" ]; then unset __thing_skip; return 0; fi; \
local __cmd=${1:-${2:-}}; \
if [ -n \"$__cmd\" ]; then printf '\\033]1337;Cmd=%s\\007' \"${__cmd//[$'\\033\\007']/}\"; fi; \
return 0; }; \
if [ -z \"${__thing_installed:-}\" ]; then __thing_installed=1; __thing_skip=1; \
typeset -ga precmd_functions preexec_functions; \
precmd_functions=(__thing_precmd ${precmd_functions[@]}); \
preexec_functions=(__thing_preexec ${preexec_functions[@]}); fi";
// 都不是 bash/zshdash、fish、tcsh…)时前半段是空操作;末尾的清屏对所有 shell 都执行。
format!(
"if [ -n \"${{BASH_VERSION:-}}\" ]; then {bash}; \
elif [ -n \"${{ZSH_VERSION:-}}\" ]; then {zsh}; fi; \
printf '\\033[2J\\033[3J\\033[H'"
)
}
/// 会话标签上显示的**短名**。
///
/// # 为什么不直接用 `ShellProfile::name`
///
/// `name` 是设置页里的描述名,用户可编辑(可能是 "Windows PowerShell"、
/// 也可能是自定的 "我的远程 PS")。标签的诉求完全不同:它要在一行里容下
/// 多个会话、要能一眼分辨,因此需要**短且稳定**的名字。两者分开之后,
/// 调整其中一个不会捎带动另一个。
///
/// # 取值来源是可执行文件名而不是 `kind`
///
/// `kind` 只区分注入方式(powershell / cmd / bash / wsl),pwsh 与
/// Windows PowerShell 的 kind 相同,靠它分不出「PowerShell 7」与
/// 「PowerShell」。文件名恰好是各自的身份:`pwsh.exe` / `powershell.exe`。
///
/// 认不出的一律退回 `name`:自定义 Shell(用户手工填路径)本来就该用他起的名字,
/// WSL 条目也是 —— 它的 name 是 "WSL · Ubuntu",比干巴巴的 "WSL" 有用。
pub fn session_label(profile: &ShellProfile) -> String {
let stem = Path::new(&profile.path)
.file_stem()
.map(|s| s.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
match stem.as_str() {
"pwsh" => "PowerShell 7".to_string(),
"powershell" => "PowerShell".to_string(),
"cmd" => "cmd".to_string(),
"bash" => "Git Bash".to_string(),
_ => profile.name.clone(),
}
}
/// 清屏命令(按 Shell 类型区分)。
pub fn clear_command(kind: &str) -> &'static str {
match kind {
@@ -430,7 +643,6 @@ pub fn clear_command(kind: &str) -> &'static str {
}
use std::os::windows::process::CommandExt;
/// `Command::creation_flags` 的糖:隐藏控制台窗口。
trait NoWindow {
fn creation_flags_no_window(&mut self) -> &mut Self;
@@ -513,7 +725,10 @@ pub fn parse_control_sequences(data: &[u8]) -> ParsedSequences {
if !rest.trim().is_empty() {
out.titles.push(rest.to_string());
}
} else if payload.starts_with("133;") {
} else if payload.starts_with("133;") || payload.starts_with("1337;") {
// **两个前缀都要放进来**`1337;Cmd=` 与 `133;D` 共享 `133` 前缀,
// 只放行 `133;` 会让命令文本(`1337;`)在这里就被挡掉,永远到不了
// `parse_osc133` —— 表现为「历史一条都不记」,而错误点离成因很远。
if let Some(mark) = parse_osc133(&payload) {
out.marks.push(mark);
}
@@ -614,6 +829,83 @@ pub struct ParsedSequences {
pub marks: Vec<CommandMark>,
}
/// 判断一个 OSC 0/2 标题是否是「Windows 可执行文件全路径」。
///
/// # 为什么需要过滤
///
/// `cmd.exe` 与 `powershell.exe` 启动时会把自己的可执行文件全路径设成控制台标题
/// `cmd.exe` 运行命令时还会追加 ` - <命令>`)。这个标题经 PTY 原样到达我们这里,
/// 若照单全收,会话标签与状态栏就会显示成
/// `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`。
///
/// Windows Terminal 与 VS Code 的终端同样要处理这件事(过滤这类标题,
/// 把标签留给 shell 名或用户自定义标题)。
///
/// # 提权前缀
///
/// 以管理员身份运行时,PowerShell / cmd 会在标题前加一个本地化的前缀 ——
/// 简体中文下是 `管理员: `(对应英文的 `Administrator: `)。**这是最容易漏掉的一种**:
/// 不带前缀的判定看起来完全正确,只有提权会话暴露出问题,而日常又很少提权运行。
/// 因此这里对「原串」与「剥掉前缀后的串」各判一次。
///
/// # 只认「绝对路径 + .exe/.com」,不做广义「像路径就丢」
///
/// WSL / Git Bash 上报的 `user@host: ~/dir`、`/home/foo` 这类标题是有用信息,
/// 必须保留。判据因此收紧到三个条件同时成立:必须是绝对路径(盘符或 UNC)、
/// 必须以 `.exe` / `.com` 结尾、可选的 ` - <命令>` 后缀不影响判定。
pub fn osc_title_is_executable_path(title: &str) -> bool {
let t = title.trim();
if t.is_empty() {
return false;
}
if looks_like_exe_path(t) {
return true;
}
match strip_leading_label(t) {
Some(rest) => looks_like_exe_path(rest),
None => false,
}
}
/// 判定单一字符串本身是否为可执行文件全路径(不含提权前缀处理)。
fn looks_like_exe_path(s: &str) -> bool {
// `C:\...\cmd.exe - ping 8.8.8.8` → 只看 ` - ` 之前的部分
let head = s.split(" - ").next().unwrap_or(s).trim();
let lower = head.to_ascii_lowercase();
if !lower.ends_with(".exe") && !lower.ends_with(".com") {
return false;
}
let b = head.as_bytes();
let drive_absolute = b.len() >= 3
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& (b[2] == b'\\' || b[2] == b'/');
let unc = head.starts_with(r"\\") || head.starts_with("//");
drive_absolute || unc
}
/// 剥掉 `管理员:` / `Administrator:` 这类前缀标签,返回剩余部分。
///
/// 三重防护,避免把正常标题误当成长路径:
/// - 分隔符前不能含路径分隔符(否则 `C:\...` 的盘号会被当成标签)
/// - 标签本身要短:上限 16 字符是量出来的 —— `Administrator`13+ 余量,
/// 而正文式的标题(`user@host` 之外还有内容的)通常远超这个量级
/// - 全角冒号 `:` 一并处理 —— 中文输入法下系统用的就是它
fn strip_leading_label(s: &str) -> Option<&str> {
/// 前缀标签的字符数上限。取值依据见函数文档:英文的 `Administrator` 是 13。
const MAX_LABEL_CHARS: usize = 16;
let idx = s.find(|c| c == ':' || c == '')?;
let head = &s[..idx];
if head.is_empty() || head.contains('\\') || head.contains('/') {
return None;
}
if head.chars().count() > MAX_LABEL_CHARS {
return None;
}
Some(s[idx..].trim_start_matches(|c| c == ':' || c == '' || c == ' '))
}
/// 从 OSC 7 的载荷解析出本地路径。
///
/// 载荷形如 `file://HOST/C:/Users/foo` 或 `file:///home/user`。
@@ -685,3 +977,253 @@ pub fn resolve_cwd(requested: &str) -> Option<String> {
Some(default_cwd())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 解析器必须真的把 `1337;Cmd=` 认成 mark。
///
/// 这条守着的是「标记被上游挡掉」的失效:`1337;Cmd=` 与 `133;D` 共享 `133`
/// 前缀,若只按 `133;` 放行,命令文本在进入 `parse_osc133` 之前就被丢掉了,
/// 结果是**历史一条都不记**,而现象(列表为空)与成因(解析分支没走到)之间
/// 隔着 shell hook、IPC、SQLite 三层,最难查。整条链路在此用真实字节流覆盖:
/// hook 输出的字节 → 解析出的标记序列。
#[test]
fn parse_command_text_and_end_mark_from_stream() {
let stream: Vec<u8> = b"\x1b]1337;Cmd=ls -la\x07\x1b]133;D;0\x07\x1b]7;file://host/C:/x\x07".to_vec();
let p = parse_control_sequences(&stream);
assert_eq!(
p.marks,
vec![
CommandMark::Command("ls -la".to_string()),
CommandMark::End(Some(0))
],
"先 Cmd 后 D 的顺序必须原样解析出来"
);
// 同批里的 OSC 7 也要一起解析(三类序列共用一次扫描)
assert_eq!(p.cwds, vec!["C:\\x".to_string()]);
assert_eq!(p.consumed, stream.len(), "完整序列应全部被消费");
}
/// 序列被切在两批数据之间时必须保留尾部,等下一批拼接后再解析 ——
/// 否则 `Cmd` 与 `D` 各被截断一次,历史同样一条不记。
#[test]
fn parse_marks_across_three_batches() {
let whole: Vec<u8> = b"\x1b]1337;Cmd=echo hi\x07\x1b]133;D;1\x07".to_vec();
let (a, b) = whole.split_at(9);
let (b, c) = b.split_at(6);
let first = parse_control_sequences(a);
assert!(first.marks.is_empty(), "半个序列不该产出标记");
assert!(first.consumed < a.len(), "未闭合序列的尾部必须保留");
let mut carry = a[first.consumed..].to_vec();
carry.extend_from_slice(b);
let second = parse_control_sequences(&carry);
assert!(second.marks.is_empty());
carry.drain(..second.consumed);
carry.extend_from_slice(c);
let third = parse_control_sequences(&carry);
assert_eq!(
third.marks,
vec![
CommandMark::Command("echo hi".to_string()),
CommandMark::End(Some(1))
]
);
}
/// 两个 hook 都必须**上报命令文本**,且 `Cmd` 必须**早于** `D`。
///
/// 这两条是命令历史能否落库的全部条件:Rust 侧在 `D` 到达时用暂存的文本落库
/// (见 `session::CommandAccumulator`),缺了 `Cmd` 会一条都不记,顺序反了
/// 会永远记成上一条 —— 两种失效都只在运行时表现为「历史是空的 / 少一条」。
#[test]
fn prompt_hooks_report_command_before_end_mark() {
for (name, script) in [
("PowerShell", powershell_prompt_hook()),
("Git Bash", bash_prompt_hook()),
] {
let cmd = script
.find("]1337;Cmd=")
.unwrap_or_else(|| panic!("{name} hook 未上报命令文本"));
let end = script
.find("]133;D")
.unwrap_or_else(|| panic!("{name} hook 未上报结束标记"));
assert!(cmd < end, "{name} hook 必须先发 Cmd 再发 D");
}
}
/// 注入脚本自身不得被当成用户命令上报(实测踩到过)。
///
/// PowerShell 会把 `-EncodedCommand` 的脚本文本记进会话历史,于是注入后第一个
/// 提示符的 `Get-History -Count 1` 就是这段脚本 —— 表现为「新建会话就多出一条
/// 几百字符的命令」,且因去重键相同而不断累加 count。
#[test]
fn powershell_hook_skips_its_own_injected_script() {
let script = powershell_prompt_hook();
assert!(
script.contains("$global:__thingSkipNextPrompt = $true"),
"hook 必须标记「第一个提示符对应注入脚本自身」"
);
assert!(
script.contains("$global:__thingSkipNextPrompt = $false"),
"该标记必须在提示符函数里被消费(否则会一直跳过真实命令)"
);
}
/// 远端脚本:一行、纯 ASCII、无 `#` 注释、无裸 `\r`,且 bash/zsh 两个分支都在。
///
/// 这些都是「静默失效」的来源:多行/裸 CR 会被远端 PTY 逐行或拦腰执行,
/// `#` 注释会吃掉它之后的所有内容(单行脚本里没有「下一行」可以续)。
#[test]
fn remote_script_is_single_line_ascii_with_both_branches() {
let s = remote_shell_integration_script();
assert!(!s.contains('\n'), "必须单行:多行下发会被远端 PTY 逐行执行");
assert!(!s.contains('\r'), "不能含裸 CR:ICRNL 会把它当行结束,脚本被切断");
assert!(!s.contains('#'), "单行脚本不能有 # 注释(会吃掉行尾其余内容)");
assert!(s.is_ascii(), "保持纯 ASCII,GBK 主机回显才不乱码");
assert!(s.contains("BASH_VERSION"), "缺少 bash 分支");
assert!(s.contains("ZSH_VERSION"), "缺少 zsh 分支");
assert!(
s.contains("declare -a"),
"bash 5.1+ 的 PROMPT_COMMAND 可能是数组,必须分流否则会覆盖用户钩子"
);
// 末尾清屏:抹掉注入行的双回显与登录 banner(3J 连滚动缓冲一起清)
assert!(
s.contains("[2J") && s.contains("[3J") && s.trim_end().ends_with("[H'"),
"末尾必须清屏,且要连滚动缓冲一起清(否则往上滚还能看到回显)"
);
}
/// 远端脚本必须「先 Cmd 后 D」,且**空命令文本时不发 Cmd**。
///
/// 顺序反了会永远记成上一条;而 `CommandAccumulator` 的 Cmd 是覆盖语义,
/// 空文本会把上一条待落库的文本冲掉(HISTFILE=/dev/null 之类的降级路径)。
#[test]
fn remote_script_reports_command_before_end_mark() {
let s = remote_shell_integration_script();
let cmd = s.find("]1337;Cmd=").expect("必须上报命令文本");
let end = s.find("]133;D").expect("必须上报结束标记");
assert!(cmd < end, "必须先发 Cmd 再发 D");
let guard = s.find("[ -n \"$__cmd\" ]").expect("缺少空文本守卫");
assert!(guard < cmd, "空命令文本时不得发出 Cmd");
}
/// 注入命令自身不得被记成用户命令,且重复注入必须幂等。
#[test]
fn remote_script_skips_injected_line_and_is_idempotent() {
let s = remote_shell_integration_script();
assert!(s.contains("__thing_installed"), "缺少幂等守卫(重复注入会入库两次)");
assert!(
s.matches("__thing_skip=1").count() >= 2,
"两个分支都要标记「跳过首个提示符」(注入命令本身会进远端历史)"
);
assert!(s.contains("unset __thing_skip"), "标记必须被消费,否则会一直跳过真实命令");
}
/// 两个 bash hook 取命令文本时**只能跳过序号一个字段**。
///
/// `read -r _ _ __cmd` 多跳一个字段:单字命令(`ls`)只剩「序号 + 命令」两个
/// 字段,`__cmd` 因此为空 → 不发 `Cmd` → 一条都不落库(实测:SSH 上跑了 `ls`,
/// 历史里什么都没有);多字命令(`git status`)会丢掉第一个词。只有恰好两字的
/// 命令碰巧正确 —— 这是最容易被「看起来能跑」掩盖的一类错误。
#[test]
fn bash_hooks_skip_only_the_history_index() {
for (name, script) in [
("本地 Git Bash hook", bash_prompt_hook()),
("远端 hook", remote_shell_integration_script()),
] {
assert!(
script.contains("read -r _ __cmd <<<"),
"{name}: 必须用 `read -r _ __cmd` 只跳过序号一个字段"
);
assert!(
!script.contains("read -r _ _ __cmd"),
"{name}: 多跳一个字段会让单字命令解析出空文本,历史一条都不记"
);
}
}
/// 提权会话的前缀是本模块最容易漏的一环:不带前缀的实现在日常(非提权)
/// 会话里表现完全正确,只有管理员运行时才暴露。
#[test]
fn osc_title_strips_elevation_prefix() {
assert!(osc_title_is_executable_path(
r"管理员: C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe"
));
assert!(osc_title_is_executable_path(
r"Administrator: C:\Windows\System32\cmd.exe"
));
// 全角冒号(中文输入法下系统用的就是它)与追加的当前命令
assert!(osc_title_is_executable_path(
r"管理员:C:\Windows\System32\cmd.exe - ping 8.8.8.8"
));
}
#[test]
fn osc_title_filters_plain_exe_paths() {
assert!(osc_title_is_executable_path(r"C:\Windows\System32\cmd.exe"));
assert!(osc_title_is_executable_path(r"C:\Windows\System32\cmd.exe - pause"));
assert!(osc_title_is_executable_path(r"\\server\share\cmd.exe"));
}
/// 过滤必须「宁可少丢」:把远端 shell 上报的有效标题丢掉,比留下一个丑标题更糟。
#[test]
fn osc_title_keeps_meaningful_titles() {
assert!(!osc_title_is_executable_path("user@host: ~/projects"));
assert!(!osc_title_is_executable_path("/home/dev/app"));
assert!(!osc_title_is_executable_path("PowerShell"));
assert!(!osc_title_is_executable_path(""));
// 相对路径不是 shell 自报家门的形式
assert!(!osc_title_is_executable_path(r"bin\cmd.exe"));
// 非 Windows 可执行后缀(SSH 到 Linux 时远端标题常是这种)
assert!(!osc_title_is_executable_path("/usr/bin/htop"));
}
#[test]
fn session_label_uses_exe_stem() {
let powershell = ShellProfile::new(
"powershell",
"Windows PowerShell",
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
"powershell",
);
assert_eq!(session_label(&powershell), "PowerShell");
let pwsh = ShellProfile::new(
"pwsh",
"PowerShell 7",
r"C:\Program Files\PowerShell\7\pwsh.exe",
"powershell",
);
assert_eq!(session_label(&pwsh), "PowerShell 7");
let cmd = ShellProfile::new("cmd", "命令提示符", r"C:\Windows\System32\cmd.exe", "cmd");
assert_eq!(session_label(&cmd), "cmd");
}
/// 认不出的可执行文件必须退回「设置里那个名字」:自定义 Shell 的名字是用户自己起的,
/// WSL 条目的名字里带着发行版(比 `wsl.exe` 有用)。
#[test]
fn session_label_falls_back_to_profile_name() {
let custom = ShellProfile::new("custom", "我的 Shell", r"C:\tools\myshell.exe", "bash");
assert_eq!(session_label(&custom), "我的 Shell");
let wsl = ShellProfile::new("wsl-ubuntu", "WSL · Ubuntu", "wsl.exe", "wsl");
assert_eq!(session_label(&wsl), "WSL · Ubuntu");
}
/// 历史默认名要同时覆盖两代取值,否则老 `settings.json` 里的名字永远不迁移。
#[test]
fn legacy_names_cover_settled_defaults() {
assert!(legacy_default_names("cmd").contains(&"命令提示符"));
assert!(legacy_default_names("cmd").contains(&"cmd"));
assert!(legacy_default_names("powershell").contains(&"Windows PowerShell"));
assert!(legacy_default_names("powershell").contains(&"PowerShell"));
// 未登记过的条目:用户改过的名字必须原样保留
assert!(legacy_default_names("pwsh").is_empty());
}
}
+110 -6
View File
@@ -30,6 +30,11 @@
//!
//! 防御 MITM 的基线:若 `check_server_key` 返回 `false` 是**默认路径**
//! 超时(120 秒)视为拒绝。用户没有任何回应就不继续握手。
//!
//! 上述行为受 `terminal::settings::SecuritySettings` 的三个开关约束(见该结构说明):
//! `host_key_policy`(唯一取值 `"ask"`,未知取值按 ask 处理)、
//! `block_on_fingerprint_change`(关闭后「与记录不符」不再弹确认,改为自动接受
//! 并写入变更历史)、`audit_log`(上述事件是否写入日志)。
pub mod forward;
pub mod hostkey;
@@ -50,6 +55,7 @@ use tokio::sync::oneshot;
use super::session::{
local_info, LocalSessionState, Session, SessionId, SessionInfo, SessionKind, SessionState,
};
use super::settings::SecuritySettings;
use crate::secrets;
use crate::terminal::events as EV;
@@ -202,13 +208,35 @@ impl client::Handler for SshHandler {
let fingerprint = key_data.fingerprint(russh::keys::HashAlg::Sha256).to_string();
let key_type = key_data.algorithm().to_string();
// 安全设置三项(策略 / 指纹变更阻断 / 安全事件日志)全部在这条路径上生效,
// 因此集中读一次。读不到(模块未初始化)时用默认值 ——
// 那是最严的一组:ask + 阻断 + 记录。
let sec = super::manager(&self.app)
.map(|m| m.settings().security)
.unwrap_or_default();
// 策略显式读取而不是写死(取值只有一个 "ask",见 SecuritySettings 说明):
// 让「策略」成为真正的驱动源,将来新增取值时分支已就位。
// 未知取值一律按 "ask" 处理,绝不放宽。
if sec.host_key_policy != "ask" {
self.audit(
&sec,
true,
&format!(
"未知的主机密钥策略「{}」,按 ask(逐个核对指纹)处理",
sec.host_key_policy
),
);
}
// 1. 与 known_hosts 比对
let verdict = hostkey::verify(&self.host, self.port, &fingerprint, &key_type);
match verdict {
hostkey::Verdict::Trusted => {
crate::logger::log_info(
"terminal",
self.audit(
&sec,
false,
&format!(
"主机密钥已信任:{}:{} {}",
self.host, self.port, fingerprint
@@ -218,13 +246,36 @@ impl client::Handler for SshHandler {
}
hostkey::Verdict::Changed { previous } => {
// 高危信号:可能是服务器重装,也可能是 MITM
crate::logger::log_warn(
"terminal",
self.audit(
&sec,
true,
&format!(
"主机密钥发生变化:{}:{} 新指纹 {}(旧 {}",
self.host, self.port, fingerprint, previous
),
);
// 「指纹变更时阻断连接」关闭 → 不再弹确认,直接接受新指纹。
// 旧指纹会由 `hostkey::accept` 记入变更历史,因此事后仍可查出
// 「这台主机的密钥曾在何时被悄悄换过」——这是关掉阻断后
// 唯一的追溯手段,也是这条分支不能只做 `Ok(true)` 的原因。
if !sec.block_on_fingerprint_change {
self.audit(
&sec,
true,
&format!(
"「指纹变更时阻断连接」已关闭,自动接受新指纹:{}:{} {}",
self.host, self.port, fingerprint
),
);
if let Err(e) =
hostkey::accept(&self.host, self.port, &fingerprint, &key_type)
{
crate::logger::log_error("terminal", &format!("更新 known_hosts 失败: {e}"));
}
return Ok(true);
}
let accepted = self
.ask_user(&key_type, &fingerprint, "changed", Some(previous))
.await;
@@ -239,6 +290,14 @@ impl client::Handler for SshHandler {
hostkey::Verdict::Unknown => {
let accepted = self.ask_user(&key_type, &fingerprint, "unknown", None).await;
if accepted {
self.audit(
&sec,
false,
&format!(
"首次连接并接受主机密钥:{}:{} {} {}",
self.host, self.port, key_type, fingerprint
),
);
if let Err(e) = hostkey::accept(&self.host, self.port, &fingerprint, &key_type)
{
crate::logger::log_error("terminal", &format!("新增 known_hosts 失败: {e}"));
@@ -293,6 +352,27 @@ impl client::Handler for SshHandler {
}
impl SshHandler {
/// 写一条主机密钥相关的**安全事件**日志(受 `security.auditLog` 控制)。
///
/// # 为什么收敛成单入口
///
/// 这条路径上共有五类事件(已信任 / 指纹变更 / 变更被接受 / 首次见到被接受 /
/// 阻断关闭时的自动接受),若每个分支各自判断开关,很容易漏掉一处 ——
/// 用户关掉「记录安全事件」却仍看到半数指纹事件,等同于开关坏了。
///
/// 注意:**运维类错误(如写 known_hosts 失败)不走这里**
/// 那类日志与「是否记录安全事件」无关,任何情况下都该留下。
fn audit(&self, sec: &SecuritySettings, warn: bool, msg: &str) {
if !sec.audit_log {
return;
}
if warn {
crate::logger::log_warn("terminal", msg);
} else {
crate::logger::log_info("terminal", msg);
}
}
/// 请求前端确认主机密钥。
///
/// 超时(或前端未响应)→ 返回 `false`(拒绝)。这是刻意的安全姿态:
@@ -392,6 +472,11 @@ pub struct SshConnectParams {
pub keepalive_secs: u64,
pub connect_timeout_ms: u64,
pub startup_command: String,
/// 是否在远端注入 shell 集成 hook(命令历史 + cwd 跟踪)。
///
/// 见 [`crate::terminal::shell::remote_shell_integration_script`]:不落盘,
/// 只在本次会话里改远端 shell 的 `PROMPT_COMMAND` / `precmd`。
pub inject_shell_integration: bool,
/// 代理:`None` 表示直连。
///
/// P0 不实现 SOCKS5 桥接,命令层在主机开了「走代理」时会填 `Some`,
@@ -655,6 +740,25 @@ impl SshSession {
.await
.map_err(|e| format!("启动远程 shell 失败: {e}"))?;
// ===== 注入远端 shell 集成 hook(命令历史 + cwd 跟踪)=====
//
// **必须排在 startup_command 之前**startup_command 可能是 `htop` / `tail -f`
// 这类接管输入的程序,后发的 hook 会被当成那个程序的按键;先发还能让
// startup_command 里 `cd` 之后的第一个提示符就上报正确的 cwd。
//
// 连接期只能直接写 `channel`:会话的写队列(`inner.input_tx`)要到
// `spawn_reader` 里才建立,此刻还没有队列可入。
//
// 发送失败**不影响连接**:hook 只是辅助能力,丢了它最多表现为
// 「历史为空、cwd 不跟随」,不该让用户连不上机器。
if params.inject_shell_integration {
// 前导空格(HISTCONTROL=ignorespace 惯例,也顺带让我们的落库跳过它)
// + 换行(PTY 的行规程只在收到 NL 时才把这一行交给 shell),
// 这两个约定写在 `remote_shell_integration_script` 的文档里。
let hook = format!(" {}\n", super::shell::remote_shell_integration_script());
let _ = channel.data_bytes(hook.into_bytes()).await;
}
// 执行登录后自动执行的命令
if !params.startup_command.trim().is_empty() {
let cmd = format!("{}\n", params.startup_command.trim());
@@ -1339,8 +1443,8 @@ impl Session for SshSession {
self.emit_state();
}
fn set_title(&self, title: &str) {
*self.state.title.lock().unwrap_or_else(|e| e.into_inner()) = title.to_string();
fn set_title_by_user(&self, title: &str) {
self.state.set_title_by_user(title);
}
fn set_detached(&self, detached: bool) {
+6
View File
@@ -90,6 +90,12 @@ pub fn open_for_session(
// 无边框窗口默认没有投影,加上以保持与系统窗口一致的层次感
.shadow(true)
.center()
// **隐藏创建**:窗口的初始底色是白的,而深浅色主题由前端读 localStorage 后
// 才应用 —— 直接显示会先闪一下纯白窗口再变深色(深色模式下尤其刺眼)。
// 因此这里先隐藏,由 TerminalWindow.vue 应用完主题(含 setBackgroundColor
// 再 show + setFocus。同 translate/clipboard 弹窗的预创建做法。
.visible(false)
.focused(false)
.build()
.map_err(|e| format!("创建终端窗口失败: {e}"))?;