UI/细节调整
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
// 后端自动切换节点完成(前端据以刷新节点列表并提示)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
/// 远端(SSH)shell 集成脚本:**一行** 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 只发 D,preexec 发 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/zsh(dash、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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user