chore: prepare v1.13.0 release

This commit is contained in:
coso
2026-04-18 02:43:24 +08:00
parent 0c22408595
commit ce8590d8be
280 changed files with 16494 additions and 16868 deletions
+18 -46
View File
@@ -5101,7 +5101,7 @@ dependencies = [
[[package]]
name = "lime"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"anyhow",
"arboard",
@@ -5146,7 +5146,6 @@ dependencies = [
"lime-server-utils",
"lime-services",
"lime-skills",
"lime-terminal",
"lime-websocket",
"md5",
"mouse_position",
@@ -5206,7 +5205,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5235,7 +5234,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"chrono",
"futures",
@@ -5252,7 +5251,7 @@ dependencies = [
[[package]]
name = "lime-cli"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"clap",
"lime-core",
@@ -5264,7 +5263,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5280,7 +5279,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5320,7 +5319,7 @@ dependencies = [
[[package]]
name = "lime-credential"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5355,7 +5354,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"aes",
"axum 0.7.9",
@@ -5385,7 +5384,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5405,7 +5404,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5421,7 +5420,7 @@ dependencies = [
[[package]]
name = "lime-media-runtime"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -5452,7 +5451,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5471,7 +5470,7 @@ dependencies = [
[[package]]
name = "lime-providers"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5526,7 +5525,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5571,7 +5570,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5586,7 +5585,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5628,7 +5627,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5644,36 +5643,9 @@ dependencies = [
"tracing",
]
[[package]]
name = "lime-terminal"
version = "1.12.3"
dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"dashmap 5.5.3",
"dirs 5.0.1",
"lime-core",
"parking_lot",
"portable-pty",
"proptest",
"rusqlite",
"scopeguard",
"serde",
"serde_json",
"sha2",
"ssh2",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tracing",
"uuid",
"whoami",
]
[[package]]
name = "lime-websocket"
version = "1.12.3"
version = "1.13.0"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -4
View File
@@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"]
resolver = "2"
[workspace.package]
version = "1.12.3"
version = "1.13.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -17,7 +17,6 @@ lime-config = { path = "crates/config" }
lime-infra = { path = "crates/infra" }
lime-providers = { path = "crates/providers" }
lime-services = { path = "crates/services" }
lime-terminal = { path = "crates/terminal" }
lime-credential = { path = "crates/credential" }
lime-websocket = { path = "crates/websocket" }
lime-processor = { path = "crates/processor" }
@@ -189,7 +188,7 @@ version = "2.4"
[package]
name = "lime"
version = "1.12.3"
version = "1.13.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -211,7 +210,6 @@ lime-config.workspace = true
lime-infra.workspace = true
lime-providers.workspace = true
lime-services.workspace = true
lime-terminal.workspace = true
lime-credential.workspace = true
lime-websocket.workspace = true
lime-processor.workspace = true
@@ -41,6 +41,10 @@ impl ExtensionState for SessionExecutionRuntimeAccessMode {
}
impl SessionExecutionRuntimeAccessMode {
pub fn default_for_session() -> Self {
Self::FullAccess
}
pub fn as_str(&self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
@@ -1052,6 +1056,14 @@ mod tests {
);
}
#[test]
fn default_session_access_mode_is_full_access() {
assert_eq!(
SessionExecutionRuntimeAccessMode::default_for_session(),
SessionExecutionRuntimeAccessMode::FullAccess
);
}
#[test]
fn keeps_recent_team_selection_from_latest_turn_metadata() {
let now = Utc::now();
@@ -54,7 +54,7 @@ fn resolve_codex_runtime_policies() -> (String, String) {
let sandbox_policy = turn_context
.as_ref()
.and_then(|context| context.sandbox_policy.clone())
.unwrap_or_else(|| "workspace-write".to_string());
.unwrap_or_else(|| "danger-full-access".to_string());
(approval_policy, sandbox_policy)
}
@@ -999,7 +999,7 @@ mod tests {
assert_eq!(params["threadId"], json!("thread-1"));
assert_eq!(params["approvalPolicy"], json!("never"));
assert_eq!(params["sandboxPolicy"]["type"], json!("workspaceWrite"));
assert_eq!(params["sandboxPolicy"]["type"], json!("dangerFullAccess"));
assert_eq!(params["model"], json!("gpt-5.3-codex"));
assert_eq!(params["effort"], json!("high"));
assert_eq!(params["input"][0]["type"], json!("text"));
@@ -129,6 +129,30 @@ const WEB_FETCH_PREAPPROVED_HOSTS: &[&str] = &[
"httpd.apache.org",
];
fn current_turn_metadata_bool(keys: &[&str]) -> bool {
crate::session_context::current_turn_context()
.as_ref()
.and_then(|turn_context| {
keys.iter()
.find_map(|key| turn_context.metadata.get(*key))
.and_then(serde_json::Value::as_bool)
})
.unwrap_or(false)
}
fn current_turn_approval_policy_is_never() -> bool {
crate::session_context::current_turn_context()
.as_ref()
.and_then(|turn_context| turn_context.approval_policy.as_deref())
.map(str::trim)
.is_some_and(|policy| policy.eq_ignore_ascii_case("never"))
}
fn current_turn_allows_web_tools_without_confirmation() -> bool {
current_turn_metadata_bool(&["web_search_enabled", "webSearchEnabled"])
|| current_turn_approval_policy_is_never()
}
/// 缓存内容结构
#[derive(Debug, Clone)]
struct CachedContent {
@@ -1241,6 +1265,10 @@ impl Tool for WebFetchTool {
params: &serde_json::Value,
_context: &ToolContext,
) -> PermissionCheckResult {
if current_turn_allows_web_tools_without_confirmation() {
return PermissionCheckResult::allow();
}
let parsed_url = serde_json::from_value::<WebFetchInput>(params.clone())
.ok()
.and_then(|input| Url::parse(&input.url).ok());
@@ -2180,6 +2208,10 @@ impl Tool for WebSearchTool {
_params: &serde_json::Value,
_context: &ToolContext,
) -> PermissionCheckResult {
if current_turn_allows_web_tools_without_confirmation() {
return PermissionCheckResult::allow();
}
PermissionCheckResult::ask("WebSearch 将联网搜索最新信息,请确认后继续。")
}
@@ -2441,9 +2473,22 @@ pub fn clear_web_caches(cache: &WebCache) {
#[cfg(test)]
mod tests {
use super::*;
use crate::session::TurnContextOverride;
use crate::tools::base::PermissionBehavior;
use std::collections::HashMap;
fn turn_context_with_metadata(
entries: impl IntoIterator<Item = (&'static str, serde_json::Value)>,
) -> TurnContextOverride {
TurnContextOverride {
metadata: entries
.into_iter()
.map(|(key, value)| (key.to_string(), value))
.collect(),
..TurnContextOverride::default()
}
}
#[tokio::test]
async fn test_web_fetch_tool_creation() {
let tool = WebFetchTool::new();
@@ -2637,6 +2682,32 @@ mod tests {
assert!(result.message.is_none());
}
#[tokio::test]
async fn test_web_fetch_permissions_allow_when_turn_web_search_enabled() {
let tool = WebFetchTool::new();
let result = crate::session_context::with_turn_context(
Some(turn_context_with_metadata([(
"web_search_enabled",
serde_json::json!(true),
)])),
async {
tool.check_permissions(
&serde_json::json!({
"url": "https://example.com/docs",
"prompt": "总结内容"
}),
&ToolContext::default(),
)
.await
},
)
.await;
assert_eq!(result.behavior, PermissionBehavior::Allow);
assert!(result.message.is_none());
}
#[test]
fn test_web_fetch_preapproved_path_prefix_matches_exact_scope() {
assert!(is_preapproved_web_fetch_host(
@@ -2689,6 +2760,56 @@ mod tests {
);
}
#[tokio::test]
async fn test_web_search_permissions_allow_when_turn_web_search_enabled() {
let tool = WebSearchTool::new();
let result = crate::session_context::with_turn_context(
Some(turn_context_with_metadata([(
"webSearchEnabled",
serde_json::json!(true),
)])),
async {
tool.check_permissions(
&serde_json::json!({
"query": "latest ai news"
}),
&ToolContext::default(),
)
.await
},
)
.await;
assert_eq!(result.behavior, PermissionBehavior::Allow);
assert!(result.message.is_none());
}
#[tokio::test]
async fn test_web_search_permissions_allow_when_turn_approval_policy_is_never() {
let tool = WebSearchTool::new();
let result = crate::session_context::with_turn_context(
Some(TurnContextOverride {
approval_policy: Some("never".to_string()),
..TurnContextOverride::default()
}),
async {
tool.check_permissions(
&serde_json::json!({
"query": "latest ai news"
}),
&ToolContext::default(),
)
.await
},
)
.await;
assert_eq!(result.behavior, PermissionBehavior::Allow);
assert!(result.message.is_none());
}
#[tokio::test]
async fn test_web_search_rejects_short_query() {
let tool = WebSearchTool::new();
+56 -36
View File
@@ -722,16 +722,7 @@ pub struct NavigationConfig {
}
fn default_enabled_nav_items() -> Vec<String> {
vec![
"home-general".to_string(),
"claw".to_string(),
"video".to_string(),
"image-gen".to_string(),
"automation".to_string(),
"openclaw".to_string(),
"resources".to_string(),
"memory".to_string(),
]
Vec::new()
}
impl Default for NavigationConfig {
@@ -767,6 +758,28 @@ const LEGACY_DEFAULT_NAV_ITEM_SETS: &[&[&str]] = &[
&["home-general", "video", "image-gen", "terminal", "plugins"],
];
const STALE_DEFAULT_NAV_ITEM_SETS: &[&[&str]] = &[
&[
"home-general",
"claw",
"video",
"image-gen",
"automation",
"openclaw",
"resources",
"memory",
],
&[
"home-general",
"claw",
"video",
"automation",
"openclaw",
"resources",
"memory",
],
];
fn has_same_members(items: &[String], expected: &[&str]) -> bool {
if items.len() != expected.len() {
return false;
@@ -776,6 +789,12 @@ fn has_same_members(items: &[String], expected: &[&str]) -> bool {
expected.iter().all(|item| item_set.contains(item))
}
fn has_stale_default_nav_items(items: &[String]) -> bool {
STALE_DEFAULT_NAV_ITEM_SETS
.iter()
.any(|stale_items| has_same_members(items, stale_items))
}
fn should_upgrade_legacy_navigation_defaults(items: &[String]) -> bool {
if items.is_empty() {
return true;
@@ -2201,6 +2220,11 @@ impl Config {
changed = true;
}
if has_stale_default_nav_items(&self.navigation.enabled_items) {
self.navigation.enabled_items = default_enabled_nav_items();
changed = true;
}
if self.workspace_preferences.schema_version < current_version {
self.workspace_preferences.schema_version = current_version;
changed = true;
@@ -2748,19 +2772,7 @@ mod unit_tests {
assert!(!config.crash_reporting.send_pii);
assert_eq!(config.workspace_preferences.schema_version, 1);
assert_eq!(config.navigation.schema_version, 1);
assert_eq!(
config.navigation.enabled_items,
vec![
"home-general".to_string(),
"claw".to_string(),
"video".to_string(),
"image-gen".to_string(),
"automation".to_string(),
"openclaw".to_string(),
"resources".to_string(),
"memory".to_string(),
]
);
assert!(config.navigation.enabled_items.is_empty());
assert!(config.agent.tool_execution.tool_overrides.is_empty());
}
@@ -2844,19 +2856,7 @@ mod unit_tests {
assert!(changed);
assert_eq!(config.workspace_preferences.schema_version, 1);
assert_eq!(config.navigation.schema_version, 1);
assert_eq!(
config.navigation.enabled_items,
vec![
"home-general".to_string(),
"claw".to_string(),
"video".to_string(),
"image-gen".to_string(),
"automation".to_string(),
"openclaw".to_string(),
"resources".to_string(),
"memory".to_string(),
]
);
assert!(config.navigation.enabled_items.is_empty());
}
#[test]
@@ -2887,6 +2887,26 @@ mod unit_tests {
);
}
#[test]
fn test_normalize_workspace_preferences_clears_stale_sidebar_defaults_on_current_schema() {
let mut config = Config::default();
config.navigation.enabled_items = vec![
"home-general".to_string(),
"claw".to_string(),
"video".to_string(),
"image-gen".to_string(),
"automation".to_string(),
"openclaw".to_string(),
"resources".to_string(),
"memory".to_string(),
];
let changed = config.normalize_workspace_preferences();
assert!(changed);
assert!(config.navigation.enabled_items.is_empty());
}
#[test]
fn test_normalize_workspace_preferences_removes_api_server_entry_even_on_current_schema() {
let mut config = Config::default();
-51
View File
@@ -1,51 +0,0 @@
[package]
name = "lime-terminal"
version.workspace = true
edition.workspace = true
authors.workspace = true
repository.workspace = true
homepage.workspace = true
[dependencies]
# 项目内 crate
lime-core = { path = "../core" }
# 序列化
serde.workspace = true
serde_json.workspace = true
# 异步运行时
tokio.workspace = true
async-trait.workspace = true
# 错误处理
thiserror.workspace = true
# 日志
tracing.workspace = true
# 数据库
rusqlite.workspace = true
# 时间和 UUID
chrono.workspace = true
uuid.workspace = true
# 工具库
dirs.workspace = true
parking_lot.workspace = true
base64.workspace = true
sha2.workspace = true
dashmap.workspace = true
whoami.workspace = true
scopeguard.workspace = true
# 终端
portable-pty.workspace = true
# SSH
ssh2.workspace = true
[dev-dependencies]
proptest.workspace = true
tempfile.workspace = true
-41
View File
@@ -1,41 +0,0 @@
# lime-terminal
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
## 架构说明
终端核心模块独立 crate,采用**后端预创建 PTY**架构(参考 WaveTerm)。
通过 `TerminalEventEmit` trait 抽象事件发射,不直接依赖 Tauri。
**核心原则:**
- 后端是会话的唯一真相来源
- PTY 使用默认大小 (24x80) 预创建
- 前端连接后通过 resize 同步实际大小
- 统一的 BlockController 抽象层支持多种连接类型
- 通过 trait 抽象与 Tauri 解耦
## Trait 设计(两层抽象)
- `TerminalEventEmit`:基础 trait(dyn 兼容,不要求 Clone)
- `TerminalEventEmitter`:扩展 trait = `TerminalEventEmit + Clone`(blanket impl)
- `DynEmitter`:`Arc<dyn TerminalEventEmit>` newtype,自动获得 `TerminalEventEmitter`
- `NoOpEmitter`:空实现,用于测试
## 文件索引
- `src/lib.rs` - 模块声明和类型重导出
- `src/emitter.rs` - 事件发射器 trait 定义
- `src/emit_helper.rs` - 事件发射辅助函数
- `src/error.rs` - 错误类型定义
- `src/events.rs` - 事件定义
- `src/pty_session.rs` - PTY 会话封装
- `src/session_manager.rs` - 会话管理器
- `src/tests.rs` - 单元测试(187 个)
- `src/block_controller/` - 块控制器模块
- `src/connections/` - 连接模块(本地 PTY、SSH、WSL)
- `src/integration/` - 集成模块(OSC 解析、Shell 集成、状态重同步)
- `src/persistence/` - 持久化存储模块
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
@@ -1,24 +0,0 @@
//! 块控制器模块
//!
//! 提供统一的控制器抽象层,支持不同类型的终端连接(本地 Shell、SSH、WSL、命令执行)。
//!
//! ## 模块结构
//! - `traits` - BlockController trait 定义
//! - `registry` - 控制器注册表
//! - `shell_controller` - Shell/Cmd 控制器实现
//!
//! ## 功能
//! - 定义统一的 BlockController trait 接口
//! - 管理控制器生命周期(start、stop、send_input)
//! - 提供控制器注册表,支持按 block_id 查找
mod registry;
mod shell_controller;
mod traits;
pub use registry::ControllerRegistry;
pub use shell_controller::{ControllerStatusEvent, ShellController, CONTROLLER_STATUS_EVENT};
pub use traits::{
BlockController, BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, RuntimeOpts,
TermSize,
};
@@ -1,311 +0,0 @@
//! 控制器注册表
//!
//! 管理所有块控制器的注册和查找。
//!
//! ## 功能
//! - 按 block_id 注册控制器
//! - 按 block_id 查找控制器
//! - 删除控制器
//! - 列出所有控制器
//!
//! ## Requirements
//! - 1.6: 维护控制器注册表,支持按 block_id 查找控制器
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::traits::BlockController;
/// 控制器注册表
///
/// 使用 HashMap + RwLock 实现线程安全的控制器管理。
pub struct ControllerRegistry {
/// 控制器映射表: block_id -> BlockController
#[allow(clippy::type_complexity)]
controllers: RwLock<HashMap<String, Arc<RwLock<Box<dyn BlockController>>>>>,
}
impl Default for ControllerRegistry {
fn default() -> Self {
Self::new()
}
}
impl ControllerRegistry {
/// 创建新的控制器注册表
pub fn new() -> Self {
Self {
controllers: RwLock::new(HashMap::new()),
}
}
/// 注册控制器
///
/// # 参数
/// - `block_id`: 块 ID
/// - `controller`: 控制器实例
///
/// # 返回
/// 如果已存在同 block_id 的控制器,返回旧控制器
pub async fn register(
&self,
block_id: String,
controller: Box<dyn BlockController>,
) -> Option<Arc<RwLock<Box<dyn BlockController>>>> {
let mut controllers = self.controllers.write().await;
controllers.insert(block_id, Arc::new(RwLock::new(controller)))
}
/// 获取控制器
///
/// # 参数
/// - `block_id`: 块 ID
///
/// # 返回
/// 如果存在返回控制器的 Arc 引用,否则返回 None
pub async fn get(&self, block_id: &str) -> Option<Arc<RwLock<Box<dyn BlockController>>>> {
let controllers = self.controllers.read().await;
controllers.get(block_id).cloned()
}
/// 删除控制器
///
/// # 参数
/// - `block_id`: 块 ID
///
/// # 返回
/// 如果存在返回被删除的控制器,否则返回 None
pub async fn remove(&self, block_id: &str) -> Option<Arc<RwLock<Box<dyn BlockController>>>> {
let mut controllers = self.controllers.write().await;
controllers.remove(block_id)
}
/// 检查控制器是否存在
///
/// # 参数
/// - `block_id`: 块 ID
///
/// # 返回
/// 存在返回 true,否则返回 false
pub async fn contains(&self, block_id: &str) -> bool {
let controllers = self.controllers.read().await;
controllers.contains_key(block_id)
}
/// 获取所有块 ID
///
/// # 返回
/// 所有已注册的块 ID 列表
pub async fn list_block_ids(&self) -> Vec<String> {
let controllers = self.controllers.read().await;
controllers.keys().cloned().collect()
}
/// 获取控制器数量
///
/// # 返回
/// 已注册的控制器数量
pub async fn len(&self) -> usize {
let controllers = self.controllers.read().await;
controllers.len()
}
/// 检查注册表是否为空
///
/// # 返回
/// 为空返回 true,否则返回 false
pub async fn is_empty(&self) -> bool {
let controllers = self.controllers.read().await;
controllers.is_empty()
}
/// 清空所有控制器
pub async fn clear(&self) {
let mut controllers = self.controllers.write().await;
controllers.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::block_controller::traits::{
BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, RuntimeOpts,
};
use crate::error::TerminalError;
use async_trait::async_trait;
/// 测试用的 Mock 控制器
struct MockController {
block_id: String,
controller_type: String,
}
impl MockController {
fn new(block_id: &str, controller_type: &str) -> Self {
Self {
block_id: block_id.to_string(),
controller_type: controller_type.to_string(),
}
}
}
#[async_trait]
impl BlockController for MockController {
async fn start(
&mut self,
_block_meta: BlockMeta,
_rt_opts: Option<RuntimeOpts>,
_force: bool,
) -> Result<(), TerminalError> {
Ok(())
}
async fn stop(
&mut self,
_graceful: bool,
_new_status: String,
) -> Result<(), TerminalError> {
Ok(())
}
fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
BlockControllerRuntimeStatus::new(self.block_id.clone())
}
async fn send_input(&self, _input: &BlockInputUnion) -> Result<(), TerminalError> {
Ok(())
}
fn controller_type(&self) -> &str {
&self.controller_type
}
}
#[tokio::test]
async fn test_registry_register_and_get() {
let registry = ControllerRegistry::new();
let controller = MockController::new("block-1", "shell");
// 注册控制器
let old = registry
.register("block-1".to_string(), Box::new(controller))
.await;
assert!(old.is_none());
// 获取控制器
let ctrl = registry.get("block-1").await;
assert!(ctrl.is_some());
// 验证控制器类型
let ctrl = ctrl.unwrap();
let ctrl_guard = ctrl.read().await;
assert_eq!(ctrl_guard.controller_type(), "shell");
}
#[tokio::test]
async fn test_registry_remove() {
let registry = ControllerRegistry::new();
let controller = MockController::new("block-2", "cmd");
registry
.register("block-2".to_string(), Box::new(controller))
.await;
assert!(registry.contains("block-2").await);
// 删除控制器
let removed = registry.remove("block-2").await;
assert!(removed.is_some());
assert!(!registry.contains("block-2").await);
// 再次删除应返回 None
let removed_again = registry.remove("block-2").await;
assert!(removed_again.is_none());
}
#[tokio::test]
async fn test_registry_list_and_len() {
let registry = ControllerRegistry::new();
assert!(registry.is_empty().await);
assert_eq!(registry.len().await, 0);
registry
.register(
"block-a".to_string(),
Box::new(MockController::new("block-a", "shell")),
)
.await;
registry
.register(
"block-b".to_string(),
Box::new(MockController::new("block-b", "cmd")),
)
.await;
assert!(!registry.is_empty().await);
assert_eq!(registry.len().await, 2);
let ids = registry.list_block_ids().await;
assert!(ids.contains(&"block-a".to_string()));
assert!(ids.contains(&"block-b".to_string()));
}
#[tokio::test]
async fn test_registry_clear() {
let registry = ControllerRegistry::new();
registry
.register(
"block-x".to_string(),
Box::new(MockController::new("block-x", "shell")),
)
.await;
registry
.register(
"block-y".to_string(),
Box::new(MockController::new("block-y", "shell")),
)
.await;
assert_eq!(registry.len().await, 2);
registry.clear().await;
assert!(registry.is_empty().await);
assert_eq!(registry.len().await, 0);
}
#[tokio::test]
async fn test_registry_replace_controller() {
let registry = ControllerRegistry::new();
// 注册第一个控制器
let ctrl1 = MockController::new("block-z", "shell");
registry
.register("block-z".to_string(), Box::new(ctrl1))
.await;
// 验证类型
{
let ctrl = registry.get("block-z").await.unwrap();
let guard = ctrl.read().await;
assert_eq!(guard.controller_type(), "shell");
}
// 替换为新控制器
let ctrl2 = MockController::new("block-z", "cmd");
let old = registry
.register("block-z".to_string(), Box::new(ctrl2))
.await;
assert!(old.is_some());
// 验证新类型
{
let ctrl = registry.get("block-z").await.unwrap();
let guard = ctrl.read().await;
assert_eq!(guard.controller_type(), "cmd");
}
}
}
@@ -1,615 +0,0 @@
//! ShellController 实现
//!
//! 实现 BlockController trait,管理本地和远程 Shell 进程。
//!
//! ## 功能
//! - 实现 BlockController trait 接口
//! - 管理 Shell 进程生命周期(init、running、done)
//! - 支持 "shell" 和 "cmd" 两种控制器类型
//! - 状态更新事件广播
//!
//! ## Requirements
//! - 1.2: 创建本地终端时实例化 Shell_Controller 并设置 controller_type 为 "shell"
//! - 1.3: 创建命令执行终端时实例化 Shell_Controller 并设置 controller_type 为 "cmd"
//! - 2.7: 会话状态变更时通过事件广播状态更新到所有订阅者
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::{mpsc, RwLock};
use super::traits::{
BlockController, BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, RuntimeOpts,
};
use crate::connections::ShellProc;
use crate::emit_helper;
use crate::emitter::TerminalEventEmitter;
use crate::error::TerminalError;
use crate::persistence::BlockFile;
/// 控制器状态事件名称
pub const CONTROLLER_STATUS_EVENT: &str = "controller:status";
/// 控制器状态事件
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ControllerStatusEvent {
/// 块 ID
pub block_id: String,
/// 状态版本号
pub version: i32,
/// Shell 进程状态
pub shell_proc_status: String,
/// 连接名称
pub shell_proc_conn_name: Option<String>,
/// 退出码
pub shell_proc_exit_code: i32,
}
impl From<BlockControllerRuntimeStatus> for ControllerStatusEvent {
fn from(status: BlockControllerRuntimeStatus) -> Self {
Self {
block_id: status.block_id,
version: status.version,
shell_proc_status: status.shell_proc_status,
shell_proc_conn_name: status.shell_proc_conn_name,
shell_proc_exit_code: status.shell_proc_exit_code,
}
}
}
/// Shell 控制器
///
/// 实现 BlockController trait,管理本地和远程 Shell 进程。
/// 支持 "shell"(交互式 Shell)和 "cmd"(命令执行)两种模式。
///
/// ## Cmd 模式配置选项
/// - `cmd_run_on_start`: 启动时自动运行命令
/// - `cmd_run_once`: 仅运行一次(不自动重启)
/// - `cmd_clear_on_start`: 启动前清空输出
/// - `cmd_close_on_exit`: 退出后自动关闭
pub struct ShellController<E: TerminalEventEmitter> {
/// 控制器类型: "shell" | "cmd"
controller_type: String,
/// Tab ID
tab_id: String,
/// Block ID
block_id: String,
/// 运行锁,防止并发启动
run_lock: AtomicBool,
/// 进程状态: "init" | "running" | "done"
proc_status: RwLock<String>,
/// 进程退出码
proc_exit_code: AtomicI32,
/// 状态版本号,每次状态变更递增
status_version: AtomicI32,
/// 连接名称(用于 SSH/WSL)
conn_name: RwLock<Option<String>>,
/// Shell 进程
shell_proc: RwLock<Option<ShellProc<E>>>,
/// Shell 输入发送器
shell_input_tx: RwLock<Option<mpsc::Sender<BlockInputUnion>>>,
/// 事件发射器
app_handle: E,
/// 块文件存储
block_file: Option<Arc<BlockFile>>,
/// 是否已运行过(用于 cmd:runonce)
has_run: AtomicBool,
/// 当前块元数据(用于重启)
current_meta: RwLock<Option<BlockMeta>>,
}
impl<E: TerminalEventEmitter> ShellController<E> {
/// 创建新的 ShellController
///
/// # 参数
/// - `tab_id`: Tab ID
/// - `block_id`: Block ID
/// - `controller_type`: 控制器类型 ("shell" | "cmd")
/// - `app_handle`: 事件发射器
///
/// # 返回
/// 新的 ShellController 实例
///
/// _Requirements: 1.2, 1.3_
pub fn new(tab_id: String, block_id: String, controller_type: String, app_handle: E) -> Self {
tracing::info!(
"[ShellController] 创建控制器: block_id={}, type={}",
block_id,
controller_type
);
Self {
controller_type,
tab_id,
block_id,
run_lock: AtomicBool::new(false),
proc_status: RwLock::new("init".to_string()),
proc_exit_code: AtomicI32::new(0),
status_version: AtomicI32::new(0),
conn_name: RwLock::new(None),
shell_proc: RwLock::new(None),
shell_input_tx: RwLock::new(None),
app_handle,
block_file: None,
has_run: AtomicBool::new(false),
current_meta: RwLock::new(None),
}
}
/// 创建带块文件的 ShellController
///
/// # 参数
/// - `tab_id`: Tab ID
/// - `block_id`: Block ID
/// - `controller_type`: 控制器类型
/// - `app_handle`: 事件发射器
/// - `block_file`: 块文件存储
pub fn with_block_file(
tab_id: String,
block_id: String,
controller_type: String,
app_handle: E,
block_file: Arc<BlockFile>,
) -> Self {
let mut controller = Self::new(tab_id, block_id, controller_type, app_handle);
controller.block_file = Some(block_file);
controller
}
/// 获取 Tab ID
pub fn tab_id(&self) -> &str {
&self.tab_id
}
/// 获取 Block ID
pub fn block_id(&self) -> &str {
&self.block_id
}
/// 设置块文件
pub fn set_block_file(&mut self, block_file: Arc<BlockFile>) {
self.block_file = Some(block_file);
}
/// 获取块文件引用
pub fn block_file(&self) -> Option<&Arc<BlockFile>> {
self.block_file.as_ref()
}
/// 更新进程状态
///
/// 更新状态并递增版本号,然后广播状态更新事件。
///
/// # 参数
/// - `new_status`: 新状态 ("init" | "running" | "done")
///
/// _Requirements: 2.7_
async fn set_status(&self, new_status: &str) {
{
let mut status = self.proc_status.write().await;
*status = new_status.to_string();
}
self.status_version.fetch_add(1, Ordering::SeqCst);
self.send_status_update().await;
}
/// 设置退出码
///
/// # 参数
/// - `exit_code`: 进程退出码
fn set_exit_code(&self, exit_code: i32) {
self.proc_exit_code.store(exit_code, Ordering::SeqCst);
}
/// 设置连接名称
///
/// # 参数
/// - `conn_name`: 连接名称
async fn set_conn_name(&self, conn_name: Option<String>) {
let mut name = self.conn_name.write().await;
*name = conn_name;
}
/// 发送状态更新事件
///
/// 通过 Tauri 事件系统广播控制器状态更新。
///
/// _Requirements: 2.7_
async fn send_status_update(&self) {
let status = self.get_runtime_status();
let event = ControllerStatusEvent::from(status);
if let Err(e) = emit_helper::emit(&self.app_handle, CONTROLLER_STATUS_EVENT, &event) {
tracing::error!(
"[ShellController] 发送状态更新事件失败: block_id={}, error={}",
self.block_id,
e
);
} else {
tracing::debug!(
"[ShellController] 状态更新: block_id={}, status={}, version={}",
self.block_id,
event.shell_proc_status,
event.version
);
}
}
/// 尝试获取运行锁
///
/// # 返回
/// 成功获取返回 true,已被占用返回 false
fn try_acquire_run_lock(&self) -> bool {
self.run_lock
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
/// 释放运行锁
fn release_run_lock(&self) {
self.run_lock.store(false, Ordering::SeqCst);
}
/// 检查是否正在运行
pub async fn is_running(&self) -> bool {
let status = self.proc_status.read().await;
*status == "running"
}
/// 检查是否已完成
pub async fn is_done(&self) -> bool {
let status = self.proc_status.read().await;
*status == "done"
}
/// 检查是否为初始状态
pub async fn is_init(&self) -> bool {
let status = self.proc_status.read().await;
*status == "init"
}
/// 检查是否应该运行(考虑 cmd:runonce)
///
/// _Requirements: 16.6_
fn should_run(&self, block_meta: &BlockMeta) -> bool {
// 如果是 cmd 模式且设置了 runonce,检查是否已运行过
if self.controller_type == "cmd"
&& block_meta.cmd_run_once.unwrap_or(false)
&& self.has_run.load(Ordering::SeqCst)
{
tracing::debug!(
"[ShellController] cmd:runonce 已运行过,跳过: block_id={}",
self.block_id
);
return false;
}
true
}
/// 处理 cmd:clearonstart 配置
///
/// _Requirements: 16.7_
async fn handle_clear_on_start(&self, block_meta: &BlockMeta) {
if self.controller_type == "cmd" && block_meta.cmd_clear_on_start.unwrap_or(false) {
if let Some(ref bf) = self.block_file {
if let Err(e) = bf.truncate() {
tracing::warn!(
"[ShellController] cmd:clearonstart 清空块文件失败: block_id={}, error={}",
self.block_id,
e
);
} else {
tracing::debug!(
"[ShellController] cmd:clearonstart 已清空块文件: block_id={}",
self.block_id
);
}
}
}
}
/// 检查是否应该自动运行(cmd:runonstart)
///
/// _Requirements: 16.5_
fn should_auto_run(&self, block_meta: &BlockMeta) -> bool {
if self.controller_type == "cmd" {
// 默认 cmd 模式自动运行,除非明确设置为 false
block_meta.cmd_run_on_start.unwrap_or(true)
} else {
// shell 模式总是自动运行
true
}
}
/// 获取当前块元数据
pub async fn get_current_meta(&self) -> Option<BlockMeta> {
self.current_meta.read().await.clone()
}
/// 重启控制器(用于 cmd 模式重新运行)
///
/// _Requirements: 16.9_
pub async fn restart(&mut self) -> Result<(), TerminalError> {
let meta = self.current_meta.read().await.clone();
if let Some(block_meta) = meta {
// 重置 has_run 标志以允许重新运行
self.has_run.store(false, Ordering::SeqCst);
self.start(block_meta, None, true).await
} else {
Err(TerminalError::Internal("没有保存的块元数据".to_string()))
}
}
}
#[async_trait]
impl<E: TerminalEventEmitter> BlockController for ShellController<E> {
/// 启动控制器
///
/// 根据 block_meta 配置启动 Shell 或命令执行进程。
///
/// # 参数
/// - `block_meta`: 块元数据配置
/// - `rt_opts`: 运行时选项(终端大小等)
/// - `force`: 是否强制重启
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
///
/// _Requirements: 1.2, 1.3, 16.5, 16.6, 16.7_
async fn start(
&mut self,
block_meta: BlockMeta,
rt_opts: Option<RuntimeOpts>,
force: bool,
) -> Result<(), TerminalError> {
// 尝试获取运行锁
if !self.try_acquire_run_lock() {
tracing::warn!(
"[ShellController] 控制器已在运行中: block_id={}",
self.block_id
);
return Ok(());
}
// 确保在函数退出时释放锁
let _lock_guard = scopeguard::guard((), |_| {
self.release_run_lock();
});
// 检查当前状态
let current_status = self.proc_status.read().await.clone();
if current_status == "running" && !force {
tracing::debug!(
"[ShellController] 控制器已在运行: block_id={}",
self.block_id
);
return Ok(());
}
// 检查是否应该运行(cmd:runonce)
if !self.should_run(&block_meta) {
return Ok(());
}
// 检查是否应该自动运行(cmd:runonstart)
if !self.should_auto_run(&block_meta) && !force {
tracing::debug!(
"[ShellController] cmd:runonstart=false,跳过自动启动: block_id={}",
self.block_id
);
return Ok(());
}
// 如果强制重启,先停止现有进程
if force && current_status == "running" {
tracing::info!(
"[ShellController] 强制重启控制器: block_id={}",
self.block_id
);
// 停止现有进程
let mut shell_proc = self.shell_proc.write().await;
if let Some(proc) = shell_proc.take() {
proc.kill().await;
}
}
// 处理 cmd:clearonstart
self.handle_clear_on_start(&block_meta).await;
// 保存块元数据
{
let mut meta = self.current_meta.write().await;
*meta = Some(block_meta.clone());
}
// 更新连接名称
let conn_name = block_meta.connection.clone();
self.set_conn_name(conn_name.clone()).await;
// 获取终端大小
let term_size = rt_opts
.as_ref()
.map(|opts| opts.term_size)
.unwrap_or_default();
tracing::info!(
"[ShellController] 启动控制器: block_id={}, type={}, conn={:?}, size={}x{}",
self.block_id,
self.controller_type,
conn_name,
term_size.cols,
term_size.rows
);
// 创建输入通道
let (input_tx, input_rx) = mpsc::channel::<BlockInputUnion>(256);
{
let mut tx = self.shell_input_tx.write().await;
*tx = Some(input_tx);
}
// 创建 Shell 进程
let shell_proc = ShellProc::new(
self.block_id.clone(),
self.controller_type.clone(),
term_size.rows,
term_size.cols,
self.app_handle.clone(),
block_meta.clone(),
input_rx,
self.block_file.clone(),
)
.await?;
// 保存进程引用
{
let mut proc = self.shell_proc.write().await;
*proc = Some(shell_proc);
}
// 标记已运行(用于 cmd:runonce)
self.has_run.store(true, Ordering::SeqCst);
// 更新状态为运行中
self.set_status("running").await;
self.set_exit_code(0);
tracing::info!("[ShellController] 控制器已启动: block_id={}", self.block_id);
Ok(())
}
/// 停止控制器
///
/// 停止 Shell 进程并更新状态。
///
/// # 参数
/// - `graceful`: 是否优雅停止(发送 SIGTERM 而非 SIGKILL)
/// - `new_status`: 停止后的新状态
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
async fn stop(&mut self, graceful: bool, new_status: String) -> Result<(), TerminalError> {
tracing::info!(
"[ShellController] 停止控制器: block_id={}, graceful={}, new_status={}",
self.block_id,
graceful,
new_status
);
// 关闭输入通道
{
let mut tx = self.shell_input_tx.write().await;
*tx = None;
}
// 停止 Shell 进程
let exit_code = {
let mut shell_proc = self.shell_proc.write().await;
if let Some(proc) = shell_proc.take() {
if graceful {
proc.terminate().await
} else {
proc.kill().await;
-1
}
} else {
0
}
};
// 更新退出码和状态
self.set_exit_code(exit_code);
self.set_status(&new_status).await;
tracing::info!(
"[ShellController] 控制器已停止: block_id={}, exit_code={}",
self.block_id,
exit_code
);
Ok(())
}
/// 获取运行时状态
///
/// # 返回
/// 当前控制器的运行时状态
fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
// 使用 try_read 避免死锁,如果无法获取锁则返回默认值
let status = self
.proc_status
.try_read()
.map(|s| s.clone())
.unwrap_or_else(|_| "init".to_string());
let conn_name = self.conn_name.try_read().map(|n| n.clone()).unwrap_or(None);
BlockControllerRuntimeStatus {
block_id: self.block_id.clone(),
version: self.status_version.load(Ordering::SeqCst),
shell_proc_status: status,
shell_proc_conn_name: conn_name,
shell_proc_exit_code: self.proc_exit_code.load(Ordering::SeqCst),
}
}
/// 发送输入到控制器
///
/// 将输入数据发送到 Shell 进程。
///
/// # 参数
/// - `input`: 输入数据(键盘输入、信号、终端大小调整)
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
async fn send_input(&self, input: &BlockInputUnion) -> Result<(), TerminalError> {
let tx = self.shell_input_tx.read().await;
if let Some(sender) = tx.as_ref() {
sender
.send(input.clone())
.await
.map_err(|e| TerminalError::WriteFailed(format!("发送输入失败: {e}")))?;
Ok(())
} else {
Err(TerminalError::SessionClosed)
}
}
/// 获取控制器类型
///
/// # 返回
/// 控制器类型字符串: "shell" | "cmd"
fn controller_type(&self) -> &str {
&self.controller_type
}
}
#[cfg(test)]
mod tests {
use super::*;
// 注意:完整的测试需要 Tauri 运行时环境,这里只测试基本逻辑
#[test]
fn test_controller_status_event_from() {
let status = BlockControllerRuntimeStatus {
block_id: "test-block".to_string(),
version: 5,
shell_proc_status: "running".to_string(),
shell_proc_conn_name: Some("ssh://user@host".to_string()),
shell_proc_exit_code: 0,
};
let event = ControllerStatusEvent::from(status);
assert_eq!(event.block_id, "test-block");
assert_eq!(event.version, 5);
assert_eq!(event.shell_proc_status, "running");
assert_eq!(
event.shell_proc_conn_name,
Some("ssh://user@host".to_string())
);
assert_eq!(event.shell_proc_exit_code, 0);
}
}
@@ -1,319 +0,0 @@
//! BlockController trait 定义
//!
//! 定义统一的块控制器接口,所有控制器类型(Shell、Cmd、SSH、WSL)都必须实现此 trait。
//!
//! ## 功能
//! - 定义 BlockController trait 接口
//! - 定义 BlockControllerRuntimeStatus 运行时状态结构
//! - 定义 BlockInputUnion 输入联合类型
//!
//! ## Requirements
//! - 1.1: 定义统一的 trait 接口
//! - 1.8: 提供 get_runtime_status 方法
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::error::TerminalError;
/// 终端大小
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct TermSize {
/// 行数
pub rows: u16,
/// 列数
pub cols: u16,
}
impl Default for TermSize {
fn default() -> Self {
Self { rows: 24, cols: 80 }
}
}
/// 块控制器运行时状态
///
/// 包含控制器的当前状态信息,用于前端显示和状态同步。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockControllerRuntimeStatus {
/// 块 ID
pub block_id: String,
/// 状态版本号,每次状态变更递增
pub version: i32,
/// Shell 进程状态: "init" | "running" | "done"
pub shell_proc_status: String,
/// Shell 进程连接名称(用于 SSH/WSL)
pub shell_proc_conn_name: Option<String>,
/// Shell 进程退出码
pub shell_proc_exit_code: i32,
}
impl BlockControllerRuntimeStatus {
/// 创建初始状态
pub fn new(block_id: String) -> Self {
Self {
block_id,
version: 0,
shell_proc_status: "init".to_string(),
shell_proc_conn_name: None,
shell_proc_exit_code: 0,
}
}
/// 检查是否为初始状态
pub fn is_init(&self) -> bool {
self.shell_proc_status == "init"
}
/// 检查是否正在运行
pub fn is_running(&self) -> bool {
self.shell_proc_status == "running"
}
/// 检查是否已完成
pub fn is_done(&self) -> bool {
self.shell_proc_status == "done"
}
}
/// 块控制器输入联合类型
///
/// 封装发送给控制器的各种输入类型。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockInputUnion {
/// 输入数据(键盘输入等)
pub input_data: Option<Vec<u8>>,
/// 信号名称(如 "SIGINT", "SIGTERM")
pub sig_name: Option<String>,
/// 终端大小调整
pub term_size: Option<TermSize>,
}
impl BlockInputUnion {
/// 创建数据输入
pub fn data(data: Vec<u8>) -> Self {
Self {
input_data: Some(data),
sig_name: None,
term_size: None,
}
}
/// 创建信号输入
pub fn signal(sig_name: &str) -> Self {
Self {
input_data: None,
sig_name: Some(sig_name.to_string()),
term_size: None,
}
}
/// 创建终端大小调整输入
pub fn resize(rows: u16, cols: u16) -> Self {
Self {
input_data: None,
sig_name: None,
term_size: Some(TermSize { rows, cols }),
}
}
}
/// 块元数据
///
/// 存储块的配置信息。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BlockMeta {
/// 控制器类型: "shell" | "cmd"
pub controller: Option<String>,
/// 连接名称(用于 SSH/WSL)
pub connection: Option<String>,
/// 命令字符串
pub cmd: Option<String>,
/// 命令参数
pub cmd_args: Option<Vec<String>>,
/// 工作目录
pub cmd_cwd: Option<String>,
/// 环境变量
pub cmd_env: Option<HashMap<String, String>>,
/// 启动时自动运行
pub cmd_run_on_start: Option<bool>,
/// 仅运行一次
pub cmd_run_once: Option<bool>,
/// 启动前清空输出
pub cmd_clear_on_start: Option<bool>,
/// 退出后自动关闭
pub cmd_close_on_exit: Option<bool>,
/// 终端模式: "term" | "vdom"
pub term_mode: Option<String>,
/// 终端主题
pub term_theme: Option<String>,
/// 终端字体大小
pub term_font_size: Option<f32>,
/// 终端滚动缓冲区大小
pub term_scrollback: Option<i32>,
}
impl BlockMeta {
/// 获取字符串字段
pub fn get_string(&self, key: &str) -> String {
match key {
"controller" => self.controller.clone().unwrap_or_default(),
"connection" => self.connection.clone().unwrap_or_default(),
"cmd" => self.cmd.clone().unwrap_or_default(),
"cmd_cwd" => self.cmd_cwd.clone().unwrap_or_default(),
"term_mode" => self.term_mode.clone().unwrap_or_else(|| "term".to_string()),
"term_theme" => self.term_theme.clone().unwrap_or_default(),
_ => String::new(),
}
}
/// 获取清洗后的工作目录(去除 `\0`、首尾空白)
pub fn sanitized_cmd_cwd(&self) -> Option<String> {
let cwd = self.cmd_cwd.as_deref()?;
let cleaned = cwd.split('\0').next().unwrap_or_default().trim();
(!cleaned.is_empty()).then_some(cleaned.to_string())
}
}
/// 运行时选项
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RuntimeOpts {
/// 终端大小
pub term_size: TermSize,
}
/// 块控制器 trait
///
/// 所有控制器类型(Shell、Cmd、SSH、WSL)都必须实现此 trait。
/// 提供统一的接口用于管理终端会话的生命周期。
#[async_trait]
pub trait BlockController: Send + Sync {
/// 启动控制器
///
/// # 参数
/// - `block_meta`: 块元数据配置
/// - `rt_opts`: 运行时选项(终端大小等)
/// - `force`: 是否强制重启
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
async fn start(
&mut self,
block_meta: BlockMeta,
rt_opts: Option<RuntimeOpts>,
force: bool,
) -> Result<(), TerminalError>;
/// 停止控制器
///
/// # 参数
/// - `graceful`: 是否优雅停止(发送 SIGTERM 而非 SIGKILL)
/// - `new_status`: 停止后的新状态
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
async fn stop(&mut self, graceful: bool, new_status: String) -> Result<(), TerminalError>;
/// 获取运行时状态
///
/// # 返回
/// 当前控制器的运行时状态
fn get_runtime_status(&self) -> BlockControllerRuntimeStatus;
/// 发送输入到控制器
///
/// # 参数
/// - `input`: 输入数据(键盘输入、信号、终端大小调整)
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
async fn send_input(&self, input: &BlockInputUnion) -> Result<(), TerminalError>;
/// 获取控制器类型
///
/// # 返回
/// 控制器类型字符串: "shell" | "cmd"
fn controller_type(&self) -> &str;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_term_size_default() {
let size = TermSize::default();
assert_eq!(size.rows, 24);
assert_eq!(size.cols, 80);
}
#[test]
fn test_runtime_status_new() {
let status = BlockControllerRuntimeStatus::new("test-block".to_string());
assert_eq!(status.block_id, "test-block");
assert_eq!(status.version, 0);
assert_eq!(status.shell_proc_status, "init");
assert!(status.is_init());
assert!(!status.is_running());
assert!(!status.is_done());
}
#[test]
fn test_block_input_union_data() {
let input = BlockInputUnion::data(vec![0x1b, 0x5b, 0x41]); // ESC [ A
assert!(input.input_data.is_some());
assert!(input.sig_name.is_none());
assert!(input.term_size.is_none());
}
#[test]
fn test_block_input_union_signal() {
let input = BlockInputUnion::signal("SIGINT");
assert!(input.input_data.is_none());
assert_eq!(input.sig_name, Some("SIGINT".to_string()));
assert!(input.term_size.is_none());
}
#[test]
fn test_block_input_union_resize() {
let input = BlockInputUnion::resize(30, 100);
assert!(input.input_data.is_none());
assert!(input.sig_name.is_none());
assert_eq!(
input.term_size,
Some(TermSize {
rows: 30,
cols: 100
})
);
}
#[test]
fn test_block_meta_get_string() {
let meta = BlockMeta {
controller: Some("shell".to_string()),
connection: Some("ssh://user@host".to_string()),
..Default::default()
};
assert_eq!(meta.get_string("controller"), "shell");
assert_eq!(meta.get_string("connection"), "ssh://user@host");
assert_eq!(meta.get_string("cmd"), "");
assert_eq!(meta.get_string("term_mode"), "term");
}
#[test]
fn test_block_meta_sanitized_cmd_cwd() {
let meta = BlockMeta {
cmd_cwd: Some(" /tmp/demo\0ignored ".to_string()),
..Default::default()
};
assert_eq!(meta.sanitized_cmd_cwd(), Some("/tmp/demo".to_string()));
let empty = BlockMeta {
cmd_cwd: Some(" \0 ".to_string()),
..Default::default()
};
assert!(empty.sanitized_cmd_cwd().is_none());
}
}
@@ -1,583 +0,0 @@
//! 连接配置管理
//!
//! 管理用户保存的连接配置,支持本地和 SSH 连接。
//! 配置存储在 `~/.config/lime/connections.json`。
//!
//! ## 功能
//! - 加载/保存连接配置文件
//! - 读取系统 SSH 配置 (~/.ssh/config) 中的 Host 列表
//! - 合并用户配置和系统 SSH 配置
//!
//! ## 配置文件格式
//! ```json
//! {
//! "connections": {
//! "my-server": {
//! "type": "ssh",
//! "user": "root",
//! "host": "192.168.1.100",
//! "port": 22,
//! "identityFile": "~/.ssh/id_rsa"
//! }
//! }
//! }
//! ```
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use super::SSHConfigParser;
/// 连接类型
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ConnectionConfigType {
/// 本地终端
#[default]
Local,
/// SSH 远程连接
Ssh,
/// WSL 连接
Wsl,
}
/// 单个连接配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionConfig {
/// 连接类型
#[serde(rename = "type", default)]
pub conn_type: ConnectionConfigType,
/// SSH 用户名
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// SSH 主机名或 IP
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
/// SSH 端口(默认 22)
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
/// 身份文件路径(私钥)
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_file: Option<String>,
/// 身份文件列表(多个私钥)
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_files: Option<Vec<String>>,
/// 跳板机配置 (ProxyJump)
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_jump: Option<String>,
/// 显示顺序
#[serde(skip_serializing_if = "Option::is_none")]
pub display_order: Option<i32>,
/// 是否隐藏
#[serde(skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
/// WSL 发行版名称
#[serde(skip_serializing_if = "Option::is_none")]
pub wsl_distro: Option<String>,
}
impl Default for ConnectionConfig {
fn default() -> Self {
Self {
conn_type: ConnectionConfigType::Local,
user: None,
host: None,
port: None,
identity_file: None,
identity_files: None,
proxy_jump: None,
display_order: None,
hidden: None,
wsl_distro: None,
}
}
}
impl ConnectionConfig {
/// 创建本地连接配置
pub fn local() -> Self {
Self {
conn_type: ConnectionConfigType::Local,
..Default::default()
}
}
/// 创建 SSH 连接配置
pub fn ssh(host: impl Into<String>) -> Self {
Self {
conn_type: ConnectionConfigType::Ssh,
host: Some(host.into()),
..Default::default()
}
}
/// 设置用户名
pub fn with_user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
/// 设置端口
pub fn with_port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
/// 设置身份文件
pub fn with_identity_file(mut self, path: impl Into<String>) -> Self {
self.identity_file = Some(path.into());
self
}
}
/// 连接配置文件结构
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConnectionsFile {
/// 连接配置映射(名称 -> 配置)
#[serde(default)]
pub connections: HashMap<String, ConnectionConfig>,
}
impl ConnectionsFile {
/// 创建空的配置文件
pub fn new() -> Self {
Self::default()
}
/// 添加连接
pub fn add(&mut self, name: impl Into<String>, config: ConnectionConfig) {
self.connections.insert(name.into(), config);
}
/// 移除连接
pub fn remove(&mut self, name: &str) -> Option<ConnectionConfig> {
self.connections.remove(name)
}
/// 获取连接
pub fn get(&self, name: &str) -> Option<&ConnectionConfig> {
self.connections.get(name)
}
/// 获取所有连接名称
pub fn names(&self) -> Vec<String> {
self.connections.keys().cloned().collect()
}
}
/// 连接配置管理器
pub struct ConnectionConfigManager {
/// 配置文件路径
config_path: PathBuf,
}
impl ConnectionConfigManager {
/// 创建配置管理器
pub fn new() -> Self {
Self {
config_path: Self::default_config_path(),
}
}
/// 使用自定义路径创建
pub fn with_path(path: PathBuf) -> Self {
Self { config_path: path }
}
/// 获取默认配置文件路径
pub fn default_config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("lime")
.join("connections.json")
}
/// 获取配置文件路径
pub fn config_path(&self) -> &PathBuf {
&self.config_path
}
/// 加载连接配置
pub fn load(&self) -> Result<ConnectionsFile, String> {
if !self.config_path.exists() {
tracing::info!(
"[ConnectionConfig] 配置文件不存在,返回空配置: {:?}",
self.config_path
);
return Ok(ConnectionsFile::new());
}
let content =
fs::read_to_string(&self.config_path).map_err(|e| format!("读取配置文件失败: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("解析配置文件失败: {e}"))
}
/// 保存连接配置
pub fn save(&self, config: &ConnectionsFile) -> Result<(), String> {
// 确保父目录存在
if let Some(parent) = self.config_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {e}"))?;
}
}
let content =
serde_json::to_string_pretty(config).map_err(|e| format!("序列化配置失败: {e}"))?;
fs::write(&self.config_path, content).map_err(|e| format!("写入配置文件失败: {e}"))?;
tracing::info!("[ConnectionConfig] 配置已保存: {:?}", self.config_path);
Ok(())
}
/// 保存原始 JSON 内容
pub fn save_raw(&self, content: &str) -> Result<(), String> {
// 先验证 JSON 格式
let _: ConnectionsFile =
serde_json::from_str(content).map_err(|e| format!("无效的 JSON 格式: {e}"))?;
// 确保父目录存在
if let Some(parent) = self.config_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {e}"))?;
}
}
fs::write(&self.config_path, content).map_err(|e| format!("写入配置文件失败: {e}"))?;
tracing::info!("[ConnectionConfig] 原始配置已保存: {:?}", self.config_path);
Ok(())
}
/// 获取原始配置文件内容
pub fn load_raw(&self) -> Result<String, String> {
if !self.config_path.exists() {
// 返回默认空配置
return Ok(r#"{
"connections": {}
}"#
.to_string());
}
fs::read_to_string(&self.config_path).map_err(|e| format!("读取配置文件失败: {e}"))
}
/// 从系统 SSH 配置读取 Host 列表
pub fn load_ssh_hosts(&self) -> Vec<SSHHostEntry> {
let config_path = match SSHConfigParser::default_config_path() {
Some(path) => path,
None => return vec![],
};
if !config_path.exists() {
return vec![];
}
let content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(e) => {
tracing::warn!("[ConnectionConfig] 读取 SSH 配置失败: {}", e);
return vec![];
}
};
Self::parse_ssh_config_hosts(&content)
}
/// 解析 SSH 配置文件中的 Host 列表
fn parse_ssh_config_hosts(content: &str) -> Vec<SSHHostEntry> {
let mut hosts = Vec::new();
let mut current_host: Option<SSHHostEntry> = None;
for line in content.lines() {
let line = line.trim();
// 跳过空行和注释
if line.is_empty() || line.starts_with('#') {
continue;
}
// 分割 key value
let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
if parts.len() < 2 {
continue;
}
let key = parts[0].to_lowercase();
let value = parts[1].trim();
match key.as_str() {
"host" => {
// 保存之前的 Host
if let Some(host) = current_host.take() {
// 排除通配符 Host
if !host.pattern.contains('*') && !host.pattern.contains('?') {
hosts.push(host);
}
}
// 开始新的 Host
current_host = Some(SSHHostEntry {
pattern: value.to_string(),
hostname: None,
user: None,
port: None,
identity_file: None,
});
}
"hostname" => {
if let Some(ref mut host) = current_host {
host.hostname = Some(value.to_string());
}
}
"user" => {
if let Some(ref mut host) = current_host {
host.user = Some(value.to_string());
}
}
"port" => {
if let Some(ref mut host) = current_host {
host.port = value.parse().ok();
}
}
"identityfile" => {
if let Some(ref mut host) = current_host {
host.identity_file = Some(value.to_string());
}
}
_ => {}
}
}
// 保存最后一个 Host
if let Some(host) = current_host {
if !host.pattern.contains('*') && !host.pattern.contains('?') {
hosts.push(host);
}
}
hosts
}
/// 获取所有可用连接(用户配置 + SSH 配置)
pub fn list_all_connections(&self) -> Result<Vec<ConnectionListEntry>, String> {
let mut entries = Vec::new();
// 获取本地系统信息
let local_user = whoami::username();
let local_host = whoami::fallible::hostname().unwrap_or_else(|_| "localhost".to_string());
let local_label = format!("{local_user}@{local_host}");
// 添加本地连接
entries.push(ConnectionListEntry {
name: "local".to_string(),
conn_type: ConnectionConfigType::Local,
label: local_label,
source: ConnectionSource::BuiltIn,
host: Some(local_host),
user: Some(local_user),
port: None,
});
// 加载用户配置
let config = self.load()?;
for (name, conn) in config.connections {
if conn.hidden == Some(true) {
continue;
}
let label = match conn.conn_type {
ConnectionConfigType::Ssh => {
let user = conn.user.as_deref().unwrap_or("user");
let host = conn.host.as_deref().unwrap_or("unknown");
let port = conn.port.unwrap_or(22);
if port == 22 {
format!("{user}@{host}")
} else {
format!("{user}@{host}:{port}")
}
}
ConnectionConfigType::Wsl => {
let distro = conn.wsl_distro.as_deref().unwrap_or("default");
format!("WSL: {distro}")
}
ConnectionConfigType::Local => "Local".to_string(),
};
entries.push(ConnectionListEntry {
name: name.clone(),
conn_type: conn.conn_type.clone(),
label,
source: ConnectionSource::UserConfig,
host: conn.host,
user: conn.user,
port: conn.port,
});
}
// 加载 SSH 配置
let ssh_hosts = self.load_ssh_hosts();
for host in ssh_hosts {
// 检查是否已在用户配置中
let already_exists = entries.iter().any(|e| e.name == host.pattern);
if already_exists {
continue;
}
let label = if let Some(ref user) = host.user {
if let Some(ref hostname) = host.hostname {
format!("{user}@{hostname}")
} else {
format!("{}@{}", user, host.pattern)
}
} else if let Some(ref hostname) = host.hostname {
hostname.clone()
} else {
host.pattern.clone()
};
entries.push(ConnectionListEntry {
name: host.pattern.clone(),
conn_type: ConnectionConfigType::Ssh,
label,
source: ConnectionSource::SSHConfig,
host: host.hostname,
user: host.user,
port: host.port,
});
}
Ok(entries)
}
}
impl Default for ConnectionConfigManager {
fn default() -> Self {
Self::new()
}
}
/// SSH 配置中的 Host 条目
#[derive(Debug, Clone)]
pub struct SSHHostEntry {
/// Host 模式
pub pattern: String,
/// 实际主机名
pub hostname: Option<String>,
/// 用户名
pub user: Option<String>,
/// 端口
pub port: Option<u16>,
/// 身份文件
pub identity_file: Option<String>,
}
/// 连接来源
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ConnectionSource {
/// 内置连接
BuiltIn,
/// 用户配置文件
UserConfig,
/// SSH 配置文件
SSHConfig,
}
/// 连接列表条目(用于前端显示)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionListEntry {
/// 连接名称/标识
pub name: String,
/// 连接类型
#[serde(rename = "type")]
pub conn_type: ConnectionConfigType,
/// 显示标签
pub label: String,
/// 配置来源
pub source: ConnectionSource,
/// 主机名
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
/// 用户名
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// 端口
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connection_config_serialization() {
let config = ConnectionConfig::ssh("192.168.1.100")
.with_user("root")
.with_port(22);
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("\"type\":\"ssh\""));
assert!(json.contains("\"user\":\"root\""));
assert!(json.contains("\"host\":\"192.168.1.100\""));
}
#[test]
fn test_connections_file() {
let mut file = ConnectionsFile::new();
file.add("test-server", ConnectionConfig::ssh("test.example.com"));
assert_eq!(file.names().len(), 1);
assert!(file.get("test-server").is_some());
file.remove("test-server");
assert!(file.get("test-server").is_none());
}
#[test]
fn test_parse_ssh_config_hosts() {
let content = r#"
Host my-server
HostName 192.168.1.100
User root
Port 22
Host dev-*
User developer
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/github_key
"#;
let hosts = ConnectionConfigManager::parse_ssh_config_hosts(content);
// 应该有 2 个主机(排除了通配符 dev-*)
assert_eq!(hosts.len(), 2);
let my_server = hosts.iter().find(|h| h.pattern == "my-server").unwrap();
assert_eq!(my_server.hostname.as_deref(), Some("192.168.1.100"));
assert_eq!(my_server.user.as_deref(), Some("root"));
assert_eq!(my_server.port, Some(22));
let github = hosts.iter().find(|h| h.pattern == "github.com").unwrap();
assert_eq!(github.user.as_deref(), Some("git"));
assert!(github.identity_file.is_some());
}
}
@@ -1,429 +0,0 @@
//! 连接类型路由模块
//!
//! 根据连接名称自动选择连接类型,实现连接工厂模式。
//!
//! ## 功能
//! - 根据连接名称自动路由到正确的连接类型
//! - 提供连接工厂函数创建相应的连接
//! - 支持本地 PTY、SSH、WSL 三种连接类型
//!
//! ## Requirements
//! - 1.4: 创建 SSH 终端时使用 SSH_Connection 建立远程连接
//! - 1.5: 创建 WSL 终端时使用 WSL_Connection 建立连接
use serde::{Deserialize, Serialize};
use super::{is_local_conn_name, is_ssh_conn_name, is_wsl_conn_name};
use crate::error::TerminalError;
// ============================================================================
// 连接类型枚举
// ============================================================================
/// 连接类型
///
/// 表示终端会话可以使用的连接类型。
///
/// _Requirements: 1.4, 1.5_
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ConnectionType {
/// 本地 PTY 连接
#[default]
Local,
/// SSH 远程连接
SSH,
/// WSL 连接(仅 Windows)
WSL,
}
impl std::fmt::Display for ConnectionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Local => write!(f, "local"),
Self::SSH => write!(f, "ssh"),
Self::WSL => write!(f, "wsl"),
}
}
}
impl std::str::FromStr for ConnectionType {
type Err = TerminalError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"local" | "" => Ok(Self::Local),
"ssh" => Ok(Self::SSH),
"wsl" => Ok(Self::WSL),
_ => Err(TerminalError::InvalidConnectionType(s.to_string())),
}
}
}
// ============================================================================
// 连接路由器
// ============================================================================
/// 连接路由器
///
/// 根据连接名称自动选择连接类型。
///
/// ## 路由规则
/// 1. 空字符串或 "local" → Local
/// 2. 以 "wsl://" 开头或等于 "wsl" → WSL
/// 3. 以 "ssh://" 开头、包含 "@" 或其他非本地/WSL 格式 → SSH
///
/// _Requirements: 1.4, 1.5_
pub struct ConnectionRouter;
impl ConnectionRouter {
/// 根据连接名称确定连接类型
///
/// # 参数
/// - `conn_name`: 连接名称
///
/// # 返回
/// 对应的连接类型
///
/// # 示例
/// ```ignore
/// use lime_terminal::connections::{ConnectionRouter, ConnectionType};
///
/// assert_eq!(ConnectionRouter::route(""), ConnectionType::Local);
/// assert_eq!(ConnectionRouter::route("local"), ConnectionType::Local);
/// assert_eq!(ConnectionRouter::route("wsl://Ubuntu"), ConnectionType::WSL);
/// assert_eq!(ConnectionRouter::route("user@host"), ConnectionType::SSH);
/// ```
///
/// _Requirements: 1.4, 1.5_
pub fn route(conn_name: &str) -> ConnectionType {
let conn_name = conn_name.trim();
// 1. 检查是否为本地连接
if is_local_conn_name(conn_name) {
return ConnectionType::Local;
}
// 2. 检查是否为 WSL 连接
if is_wsl_conn_name(conn_name) {
return ConnectionType::WSL;
}
// 3. 检查是否为 SSH 连接
if is_ssh_conn_name(conn_name) {
return ConnectionType::SSH;
}
// 4. 默认为本地连接
ConnectionType::Local
}
/// 验证连接名称格式是否有效
///
/// # 参数
/// - `conn_name`: 连接名称
///
/// # 返回
/// - `Ok(ConnectionType)`: 连接名称有效,返回对应的连接类型
/// - `Err(TerminalError)`: 连接名称无效
pub fn validate(conn_name: &str) -> Result<ConnectionType, TerminalError> {
let conn_name = conn_name.trim();
let conn_type = Self::route(conn_name);
// 对于 SSH 连接,验证格式
if conn_type == ConnectionType::SSH {
// 尝试解析 SSH 连接字符串
use super::SSHOpts;
SSHOpts::parse(conn_name)?;
}
// 对于 WSL 连接,验证格式
if conn_type == ConnectionType::WSL {
use super::WSLOpts;
WSLOpts::parse(conn_name)?;
}
Ok(conn_type)
}
/// 检查连接类型是否在当前平台上可用
///
/// # 参数
/// - `conn_type`: 连接类型
///
/// # 返回
/// 连接类型是否可用
pub fn is_available(conn_type: ConnectionType) -> bool {
match conn_type {
ConnectionType::Local => true,
ConnectionType::SSH => true, // SSH 在所有平台上可用
ConnectionType::WSL => cfg!(target_os = "windows"), // WSL 仅在 Windows 上可用
}
}
/// 获取连接类型的描述
///
/// # 参数
/// - `conn_type`: 连接类型
///
/// # 返回
/// 连接类型的人类可读描述
pub fn description(conn_type: ConnectionType) -> &'static str {
match conn_type {
ConnectionType::Local => "本地终端",
ConnectionType::SSH => "SSH 远程连接",
ConnectionType::WSL => "Windows Subsystem for Linux",
}
}
}
// ============================================================================
// 连接信息
// ============================================================================
/// 连接信息
///
/// 包含解析后的连接详情。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionInfo {
/// 原始连接名称
pub conn_name: String,
/// 连接类型
pub conn_type: ConnectionType,
/// 是否在当前平台可用
pub available: bool,
/// 连接描述
pub description: String,
}
impl ConnectionInfo {
/// 从连接名称创建连接信息
///
/// # 参数
/// - `conn_name`: 连接名称
///
/// # 返回
/// 连接信息
pub fn from_conn_name(conn_name: &str) -> Self {
let conn_type = ConnectionRouter::route(conn_name);
Self {
conn_name: conn_name.to_string(),
conn_type,
available: ConnectionRouter::is_available(conn_type),
description: ConnectionRouter::description(conn_type).to_string(),
}
}
}
// ============================================================================
// 测试
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
// ========================================================================
// ConnectionType 测试
// ========================================================================
mod connection_type_tests {
use super::*;
#[test]
fn test_default() {
assert_eq!(ConnectionType::default(), ConnectionType::Local);
}
#[test]
fn test_display() {
assert_eq!(ConnectionType::Local.to_string(), "local");
assert_eq!(ConnectionType::SSH.to_string(), "ssh");
assert_eq!(ConnectionType::WSL.to_string(), "wsl");
}
#[test]
fn test_from_str() {
assert_eq!(
"local".parse::<ConnectionType>().unwrap(),
ConnectionType::Local
);
assert_eq!("".parse::<ConnectionType>().unwrap(), ConnectionType::Local);
assert_eq!(
"ssh".parse::<ConnectionType>().unwrap(),
ConnectionType::SSH
);
assert_eq!(
"SSH".parse::<ConnectionType>().unwrap(),
ConnectionType::SSH
);
assert_eq!(
"wsl".parse::<ConnectionType>().unwrap(),
ConnectionType::WSL
);
assert_eq!(
"WSL".parse::<ConnectionType>().unwrap(),
ConnectionType::WSL
);
}
#[test]
fn test_from_str_invalid() {
assert!("invalid".parse::<ConnectionType>().is_err());
}
}
// ========================================================================
// ConnectionRouter 测试
// ========================================================================
mod connection_router_tests {
use super::*;
#[test]
fn test_route_local() {
assert_eq!(ConnectionRouter::route(""), ConnectionType::Local);
assert_eq!(ConnectionRouter::route("local"), ConnectionType::Local);
assert_eq!(ConnectionRouter::route(" "), ConnectionType::Local);
assert_eq!(ConnectionRouter::route(" local "), ConnectionType::Local);
}
#[test]
fn test_route_wsl() {
assert_eq!(ConnectionRouter::route("wsl://Ubuntu"), ConnectionType::WSL);
assert_eq!(ConnectionRouter::route("wsl://Debian"), ConnectionType::WSL);
assert_eq!(ConnectionRouter::route("wsl://"), ConnectionType::WSL);
assert_eq!(ConnectionRouter::route("wsl"), ConnectionType::WSL);
assert_eq!(ConnectionRouter::route("WSL"), ConnectionType::WSL);
assert_eq!(
ConnectionRouter::route(" wsl://Ubuntu "),
ConnectionType::WSL
);
}
#[test]
fn test_route_ssh() {
assert_eq!(ConnectionRouter::route("user@host"), ConnectionType::SSH);
assert_eq!(ConnectionRouter::route("user@host:22"), ConnectionType::SSH);
assert_eq!(
ConnectionRouter::route("ssh://user@host"),
ConnectionType::SSH
);
assert_eq!(ConnectionRouter::route("ssh://host"), ConnectionType::SSH);
assert_eq!(ConnectionRouter::route("example.com"), ConnectionType::SSH);
assert_eq!(ConnectionRouter::route("192.168.1.1"), ConnectionType::SSH);
}
#[test]
fn test_route_with_whitespace() {
assert_eq!(
ConnectionRouter::route(" user@host "),
ConnectionType::SSH
);
assert_eq!(
ConnectionRouter::route(" wsl://Ubuntu "),
ConnectionType::WSL
);
assert_eq!(ConnectionRouter::route(" local "), ConnectionType::Local);
}
#[test]
fn test_validate_local() {
assert_eq!(
ConnectionRouter::validate("").unwrap(),
ConnectionType::Local
);
assert_eq!(
ConnectionRouter::validate("local").unwrap(),
ConnectionType::Local
);
}
#[test]
fn test_validate_ssh() {
assert_eq!(
ConnectionRouter::validate("user@host").unwrap(),
ConnectionType::SSH
);
assert_eq!(
ConnectionRouter::validate("user@host:22").unwrap(),
ConnectionType::SSH
);
}
#[test]
fn test_validate_wsl() {
assert_eq!(
ConnectionRouter::validate("wsl://Ubuntu").unwrap(),
ConnectionType::WSL
);
assert_eq!(
ConnectionRouter::validate("wsl").unwrap(),
ConnectionType::WSL
);
}
#[test]
fn test_is_available() {
assert!(ConnectionRouter::is_available(ConnectionType::Local));
assert!(ConnectionRouter::is_available(ConnectionType::SSH));
// WSL 可用性取决于平台
#[cfg(target_os = "windows")]
assert!(ConnectionRouter::is_available(ConnectionType::WSL));
#[cfg(not(target_os = "windows"))]
assert!(!ConnectionRouter::is_available(ConnectionType::WSL));
}
#[test]
fn test_description() {
assert_eq!(
ConnectionRouter::description(ConnectionType::Local),
"本地终端"
);
assert_eq!(
ConnectionRouter::description(ConnectionType::SSH),
"SSH 远程连接"
);
assert_eq!(
ConnectionRouter::description(ConnectionType::WSL),
"Windows Subsystem for Linux"
);
}
}
// ========================================================================
// ConnectionInfo 测试
// ========================================================================
mod connection_info_tests {
use super::*;
#[test]
fn test_from_conn_name_local() {
let info = ConnectionInfo::from_conn_name("");
assert_eq!(info.conn_name, "");
assert_eq!(info.conn_type, ConnectionType::Local);
assert!(info.available);
}
#[test]
fn test_from_conn_name_ssh() {
let info = ConnectionInfo::from_conn_name("user@host");
assert_eq!(info.conn_name, "user@host");
assert_eq!(info.conn_type, ConnectionType::SSH);
assert!(info.available);
}
#[test]
fn test_from_conn_name_wsl() {
let info = ConnectionInfo::from_conn_name("wsl://Ubuntu");
assert_eq!(info.conn_name, "wsl://Ubuntu");
assert_eq!(info.conn_type, ConnectionType::WSL);
// WSL 可用性取决于平台
#[cfg(target_os = "windows")]
assert!(info.available);
#[cfg(not(target_os = "windows"))]
assert!(!info.available);
}
}
}
@@ -1,730 +0,0 @@
//! 本地 PTY 连接实现
//!
//! 封装本地 PTY 进程,提供 ShellProc 结构体供 ShellController 使用。
//!
//! ## 功能
//! - 创建和管理本地 PTY 子进程
//! - 异步读取 PTY 输出并通过 Tauri Event 推送
//! - 处理 PTY 输入写入
//! - 监控进程退出状态
//! - 支持命令执行模式(cmd)
//! - 支持 Shell 集成脚本加载
//!
//! ## Requirements
//! - 17.1: 管理 Shell 进程的完整生命周期
//! - 17.2: 设置正确的环境变量
//! - 17.3: 支持优雅终止和强制终止
//! - 17.4: 记录退出码并更新状态
//! - 17.5: 支持自定义 Shell 路径和参数
//! - 17.8: zsh 使用 ZDOTDIR 指向集成目录
//! - 17.9: bash 使用 --rcfile 加载集成脚本
//! - 17.10: fish 使用 -C 参数 source 集成脚本
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Arc;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use parking_lot::Mutex;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use tokio::sync::mpsc;
use crate::emit_helper;
use crate::emitter::TerminalEventEmitter;
use crate::block_controller::{BlockInputUnion, BlockMeta};
use crate::error::TerminalError;
use crate::events::{event_names, SessionStatus, TerminalOutputEvent, TerminalStatusEvent};
use crate::integration::{ShellLaunchBuilder, ShellType};
use crate::persistence::BlockFile;
fn resolve_default_shell() -> String {
let shell_from_env = std::env::var("SHELL").ok().and_then(|value| {
let cleaned = value
.split('\0')
.next()
.unwrap_or_default()
.trim()
.to_string();
(!cleaned.is_empty()).then_some(cleaned)
});
#[cfg(target_os = "windows")]
{
let is_valid_windows_shell = |candidate: &str| -> bool {
let cleaned = candidate.trim();
if cleaned.is_empty() {
return false;
}
// 拒绝 Unix 风格路径(如 /bin/bash)
if cleaned.starts_with('/') {
return false;
}
let path = Path::new(cleaned);
if path.is_absolute() {
if !path.exists() {
return false;
}
let ext = path
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase());
return matches!(ext.as_deref(), Some("exe" | "cmd" | "bat" | "com"));
}
if cleaned.contains('/') || cleaned.contains('\\') {
return false;
}
true
};
if let Some(shell) = shell_from_env {
if is_valid_windows_shell(&shell) {
return shell;
}
}
if let Ok(comspec) = std::env::var("COMSPEC") {
let cleaned = comspec
.split('\0')
.next()
.unwrap_or_default()
.trim()
.to_string();
if is_valid_windows_shell(&cleaned) {
return cleaned;
}
}
"cmd.exe".to_string()
}
#[cfg(not(target_os = "windows"))]
{
if let Some(shell) = shell_from_env {
let path = Path::new(&shell);
if path.exists() {
return shell;
}
}
if Path::new("/bin/bash").exists() {
"/bin/bash".to_string()
} else {
"/bin/sh".to_string()
}
}
}
fn resolve_working_dir(cwd: Option<&str>) -> Option<PathBuf> {
let dir = cwd?;
let cleaned = dir
.split('\0')
.next()
.unwrap_or_default()
.trim()
.to_string();
if cleaned.is_empty() {
return None;
}
let expanded = if cleaned.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
home.join(&cleaned[2..])
} else {
PathBuf::from(&cleaned)
}
} else if cleaned == "~" {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(&cleaned))
} else {
PathBuf::from(&cleaned)
};
(expanded.exists() && expanded.is_dir()).then_some(expanded)
}
fn shell_command_flag(shell: &str) -> &'static str {
let executable = Path::new(shell)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(shell)
.to_ascii_lowercase();
if executable == "cmd" || executable == "cmd.exe" {
"/C"
} else if executable.contains("powershell") || executable == "pwsh" || executable == "pwsh.exe"
{
"-Command"
} else {
"-c"
}
}
/// Shell 进程封装
///
/// 封装 PTY 进程,提供输入输出和生命周期管理。
pub struct ShellProc<E: TerminalEventEmitter> {
/// Block ID
block_id: String,
/// 控制器类型
controller_type: String,
/// PTY 写入器
writer: Arc<Mutex<Box<dyn Write + Send>>>,
/// PTY Master(用于调整大小)
master: Arc<Mutex<Box<dyn portable_pty::MasterPty + Send>>>,
/// 关闭标志
shutdown_flag: Arc<AtomicBool>,
/// 进程退出码
exit_code: Arc<AtomicI32>,
/// 是否已退出
exited: Arc<AtomicBool>,
/// 泛型标记
_emitter: std::marker::PhantomData<E>,
}
impl<E: TerminalEventEmitter> ShellProc<E> {
/// 创建新的 Shell 进程
///
/// # 参数
/// - `block_id`: Block ID
/// - `controller_type`: 控制器类型 ("shell" | "cmd")
/// - `rows`: 终端行数
/// - `cols`: 终端列数
/// - `app_handle`: 事件发射器
/// - `block_meta`: 块元数据配置
/// - `input_rx`: 输入接收器
/// - `block_file`: 块文件存储(可选)
///
/// # 返回
/// - `Ok(ShellProc)`: 创建成功
/// - `Err(TerminalError)`: 创建失败
///
/// _Requirements: 17.1, 17.2, 17.8, 17.9, 17.10_
#[allow(clippy::too_many_arguments)]
pub async fn new(
block_id: String,
controller_type: String,
rows: u16,
cols: u16,
app_handle: E,
block_meta: BlockMeta,
input_rx: mpsc::Receiver<BlockInputUnion>,
block_file: Option<Arc<BlockFile>>,
) -> Result<Self, TerminalError> {
tracing::info!(
"[ShellProc] 创建进程: block_id={}, type={}, size={}x{}",
block_id,
controller_type,
cols,
rows
);
let pty_system = native_pty_system();
// 创建 PTY
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
// 构建命令(传递 app_handle 和 block_id 用于 Shell 集成)
let cmd = Self::build_command(&controller_type, &block_meta, &app_handle, &block_id)?;
// 启动子进程
let _child = pair
.slave
.spawn_command(cmd)
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
// 获取写入器
let writer = pair
.master
.take_writer()
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
// 获取读取器
let reader = pair
.master
.try_clone_reader()
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
// 创建共享状态
let shutdown_flag = Arc::new(AtomicBool::new(false));
let exit_code = Arc::new(AtomicI32::new(0));
let exited = Arc::new(AtomicBool::new(false));
let writer = Arc::new(Mutex::new(writer));
let master = Arc::new(Mutex::new(pair.master));
// 启动输出读取任务
Self::spawn_output_reader(
block_id.clone(),
reader,
app_handle.clone(),
shutdown_flag.clone(),
exit_code.clone(),
exited.clone(),
block_file,
);
// 启动输入处理任务
Self::spawn_input_handler(
block_id.clone(),
writer.clone(),
master.clone(),
input_rx,
shutdown_flag.clone(),
);
tracing::info!("[ShellProc] 进程已创建: block_id={}", block_id);
Ok(Self {
block_id,
controller_type,
writer,
master,
shutdown_flag,
exit_code,
exited,
_emitter: std::marker::PhantomData,
})
}
/// 构建命令
///
/// 根据控制器类型和块元数据构建要执行的命令。
///
/// # 参数
/// - `controller_type`: 控制器类型
/// - `block_meta`: 块元数据
/// - `app_handle`: 事件发射器
/// - `block_id`: Block ID
///
/// # 返回
/// 构建好的命令
///
/// _Requirements: 17.2, 17.5, 17.8, 17.9, 17.10_
fn build_command(
controller_type: &str,
block_meta: &BlockMeta,
app_handle: &E,
block_id: &str,
) -> Result<CommandBuilder, TerminalError> {
let mut cmd = if controller_type == "cmd" {
// 命令执行模式
Self::build_cmd_command(block_meta)?
} else {
// Shell 模式 - 使用集成脚本
Self::build_shell_command(block_meta, app_handle, block_id)?
};
// 设置工作目录
let sanitized_cwd = block_meta.sanitized_cmd_cwd();
if let Some(resolved_cwd) = resolve_working_dir(sanitized_cwd.as_deref()) {
cmd.cwd(resolved_cwd);
} else if let Some(raw_cwd) = block_meta.cmd_cwd.as_deref() {
tracing::warn!(
"[ShellProc] 工作目录无效或不存在: {:?}, 使用主目录",
raw_cwd
);
if let Some(home) = dirs::home_dir() {
cmd.cwd(home);
}
} else if let Some(home) = dirs::home_dir() {
cmd.cwd(home);
}
Ok(cmd)
}
/// 构建 Shell 命令
///
/// 使用 Shell 集成脚本构建启动命令。
///
/// _Requirements: 17.5, 17.7, 17.8, 17.9, 17.10_
fn build_shell_command(
block_meta: &BlockMeta,
app_handle: &E,
block_id: &str,
) -> Result<CommandBuilder, TerminalError> {
// 获取用户默认 shell
let shell = resolve_default_shell();
tracing::info!("[ShellProc] 使用 shell: {}", shell);
// 获取应用数据目录
let app_data_dir = app_handle
.app_data_dir()
.map_err(|e| TerminalError::Internal(format!("获取应用数据目录失败: {e}")))?;
// 使用 ShellLaunchBuilder 构建启动配置
let builder = ShellLaunchBuilder::new(&app_data_dir, block_id.to_string());
let launch_config = builder.build(&shell, block_meta.cmd_env.as_ref())?;
// 构建命令
let mut cmd = CommandBuilder::new(&launch_config.shell_path);
// 添加参数
for arg in &launch_config.args {
cmd.arg(arg);
}
// 设置环境变量
for (key, value) in &launch_config.env {
cmd.env(key, value);
}
// 检测 Shell 类型并记录
let shell_type = ShellType::from_path(&shell);
tracing::info!(
"[ShellProc] Shell 类型: {:?}, 参数: {:?}",
shell_type,
launch_config.args
);
Ok(cmd)
}
/// 构建命令执行命令
///
/// _Requirements: 16.1, 16.2, 16.3, 17.2_
fn build_cmd_command(block_meta: &BlockMeta) -> Result<CommandBuilder, TerminalError> {
let cmd_str = block_meta
.cmd
.as_ref()
.ok_or_else(|| TerminalError::PtyCreationFailed("cmd 模式需要指定命令".to_string()))?;
tracing::info!("[ShellProc] 执行命令: {}", cmd_str);
// 使用 shell 执行命令
let shell = resolve_default_shell();
let mut cmd = CommandBuilder::new(&shell);
cmd.arg(shell_command_flag(&shell));
// 构建完整命令字符串
let full_cmd = if let Some(args) = &block_meta.cmd_args {
format!("{} {}", cmd_str, args.join(" "))
} else {
cmd_str.clone()
};
cmd.arg(&full_cmd);
// 设置通用环境变量
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
// 设置自定义环境变量
if let Some(env_vars) = &block_meta.cmd_env {
for (key, value) in env_vars {
cmd.env(key, value);
}
}
Ok(cmd)
}
/// 启动输出读取任务
///
/// 在独立线程中读取 PTY 输出,并通过 Tauri 事件发送到前端。
fn spawn_output_reader(
block_id: String,
mut reader: Box<dyn Read + Send>,
app_handle: E,
shutdown_flag: Arc<AtomicBool>,
exit_code: Arc<AtomicI32>,
exited: Arc<AtomicBool>,
block_file: Option<Arc<BlockFile>>,
) {
std::thread::spawn(move || {
let mut buffer = [0u8; 4096];
loop {
// 检查关闭标志
if shutdown_flag.load(Ordering::Relaxed) {
tracing::debug!("[ShellProc] 收到关闭信号: block_id={}", block_id);
break;
}
// 读取输出
match reader.read(&mut buffer) {
Ok(0) => {
// EOF,进程已退出
tracing::info!("[ShellProc] 进程已退出: block_id={}", block_id);
exited.store(true, Ordering::SeqCst);
// 发送状态事件
let _ = emit_helper::emit(
&app_handle,
event_names::TERMINAL_STATUS,
&TerminalStatusEvent {
session_id: block_id.clone(),
status: SessionStatus::Done,
exit_code: Some(exit_code.load(Ordering::SeqCst)),
error: None,
},
);
break;
}
Ok(n) => {
let output_data = &buffer[..n];
// 保存到块文件
if let Some(ref bf) = block_file {
if let Err(e) = bf.append_data(output_data) {
tracing::warn!(
"[ShellProc] 写入块文件失败: block_id={}, error={}",
block_id,
e
);
}
}
// 发送输出事件
let data = BASE64.encode(output_data);
let _ = emit_helper::emit(
&app_handle,
event_names::TERMINAL_OUTPUT,
&TerminalOutputEvent {
session_id: block_id.clone(),
data,
},
);
}
Err(e) => {
// 检查是否是因为关闭导致的错误
if shutdown_flag.load(Ordering::Relaxed) {
break;
}
tracing::error!("[ShellProc] 读取错误: block_id={}, error={}", block_id, e);
exited.store(true, Ordering::SeqCst);
let _ = emit_helper::emit(
&app_handle,
event_names::TERMINAL_STATUS,
&TerminalStatusEvent {
session_id: block_id.clone(),
status: SessionStatus::Error,
exit_code: None,
error: Some(e.to_string()),
},
);
break;
}
}
}
});
}
/// 启动输入处理任务
///
/// 在独立任务中处理输入数据,包括键盘输入、信号和终端大小调整。
fn spawn_input_handler(
block_id: String,
writer: Arc<Mutex<Box<dyn Write + Send>>>,
master: Arc<Mutex<Box<dyn portable_pty::MasterPty + Send>>>,
mut input_rx: mpsc::Receiver<BlockInputUnion>,
shutdown_flag: Arc<AtomicBool>,
) {
tokio::spawn(async move {
while let Some(input) = input_rx.recv().await {
// 检查关闭标志
if shutdown_flag.load(Ordering::Relaxed) {
break;
}
// 处理输入数据
if let Some(data) = &input.input_data {
let mut w = writer.lock();
if let Err(e) = w.write_all(data) {
tracing::error!("[ShellProc] 写入失败: block_id={}, error={}", block_id, e);
continue;
}
if let Err(e) = w.flush() {
tracing::error!(
"[ShellProc] Flush 失败: block_id={}, error={}",
block_id,
e
);
}
}
// 处理终端大小调整
if let Some(size) = &input.term_size {
let m = master.lock();
if let Err(e) = m.resize(PtySize {
rows: size.rows,
cols: size.cols,
pixel_width: 0,
pixel_height: 0,
}) {
tracing::error!(
"[ShellProc] 调整大小失败: block_id={}, error={}",
block_id,
e
);
} else {
tracing::debug!(
"[ShellProc] 调整大小: block_id={}, size={}x{}",
block_id,
size.cols,
size.rows
);
}
}
// 处理信号
if let Some(sig_name) = &input.sig_name {
tracing::debug!(
"[ShellProc] 收到信号: block_id={}, signal={}",
block_id,
sig_name
);
// TODO: 实现信号发送
}
}
tracing::debug!("[ShellProc] 输入处理任务结束: block_id={}", block_id);
});
}
/// 获取 Block ID
pub fn block_id(&self) -> &str {
&self.block_id
}
/// 获取控制器类型
pub fn controller_type(&self) -> &str {
&self.controller_type
}
/// 检查进程是否已退出
pub fn is_exited(&self) -> bool {
self.exited.load(Ordering::SeqCst)
}
/// 获取退出码
pub fn get_exit_code(&self) -> i32 {
self.exit_code.load(Ordering::SeqCst)
}
/// 写入数据到 PTY
pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> {
let mut writer = self.writer.lock();
writer
.write_all(data)
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
writer
.flush()
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
Ok(())
}
/// 调整 PTY 大小
pub fn resize(&self, rows: u16, cols: u16) -> Result<(), TerminalError> {
let master = self.master.lock();
master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| TerminalError::ResizeFailed(e.to_string()))?;
tracing::debug!(
"[ShellProc] 调整大小: block_id={}, size={}x{}",
self.block_id,
cols,
rows
);
Ok(())
}
/// 优雅终止进程
///
/// 发送 SIGTERM 信号并等待进程退出。
///
/// # 返回
/// 进程退出码
///
/// _Requirements: 17.3_
pub async fn terminate(&self) -> i32 {
tracing::info!("[ShellProc] 优雅终止进程: block_id={}", self.block_id);
// 设置关闭标志
self.shutdown_flag.store(true, Ordering::SeqCst);
// TODO: 发送 SIGTERM 信号
// 目前 portable_pty 不直接支持发送信号,需要通过其他方式实现
// 等待一小段时间让进程退出
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
self.exit_code.load(Ordering::SeqCst)
}
/// 强制终止进程
///
/// 发送 SIGKILL 信号立即终止进程。
///
/// _Requirements: 17.3_
pub async fn kill(&self) {
tracing::info!("[ShellProc] 强制终止进程: block_id={}", self.block_id);
// 设置关闭标志
self.shutdown_flag.store(true, Ordering::SeqCst);
// TODO: 发送 SIGKILL 信号
}
}
impl<E: TerminalEventEmitter> Drop for ShellProc<E> {
fn drop(&mut self) {
// 确保关闭标志被设置
self.shutdown_flag.store(true, Ordering::SeqCst);
tracing::debug!("[ShellProc] 进程已销毁: block_id={}", self.block_id);
}
}
#[cfg(test)]
mod tests {
use super::{resolve_default_shell, resolve_working_dir, shell_command_flag};
#[test]
fn resolve_working_dir_should_strip_nul_suffix() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let raw = format!("{}\0", temp_dir.path().to_string_lossy());
let resolved = resolve_working_dir(Some(&raw));
assert_eq!(resolved.as_deref(), Some(temp_dir.path()));
}
#[test]
fn resolve_working_dir_should_reject_invalid_path() {
let resolved = resolve_working_dir(Some("/path/not-exists\0"));
assert!(resolved.is_none());
}
#[test]
fn resolve_default_shell_should_not_be_empty() {
let shell = resolve_default_shell();
assert!(!shell.trim().is_empty());
}
#[test]
fn shell_command_flag_should_match_common_shells() {
assert_eq!(shell_command_flag("cmd.exe"), "/C");
assert_eq!(shell_command_flag("pwsh"), "-Command");
assert_eq!(shell_command_flag("/bin/bash"), "-c");
}
}
@@ -1,36 +0,0 @@
//! 连接模块
//!
//! 提供不同类型的终端连接实现:本地 PTY、SSH、WSL。
//!
//! ## 模块结构
//! - `local_pty` - 本地 PTY 连接
//! - `ssh_connection` - SSH 远程连接
//! - `ssh_shell_proc` - SSH 远程 Shell 进程
//! - `wsl_connection` - WSL 连接(仅 Windows)
//! - `connection_router` - 连接类型路由
//! - `connection_config` - 连接配置持久化
pub mod connection_config;
pub mod connection_router;
pub mod local_pty;
pub mod ssh_connection;
pub mod ssh_shell_proc;
pub mod wsl_connection;
pub use connection_config::{
ConnectionConfig, ConnectionConfigManager, ConnectionConfigType, ConnectionListEntry,
ConnectionSource, ConnectionsFile, SSHHostEntry,
};
pub use connection_router::{ConnectionInfo, ConnectionRouter, ConnectionType};
pub use local_pty::ShellProc;
pub use ssh_connection::{
build_default_auth_methods, get_default_identity_files, is_local_conn_name,
is_ssh_agent_available, is_ssh_conn_name, ConnKeywords, ConnStatus, ConnectionState,
HostKeyVerification, NoOpAuthCallback, SSHAuthCallback, SSHAuthMethod, SSHConfigEntry,
SSHConfigParser, SSHConn, SSHOpts, DEFAULT_SSH_PORT, MAX_PROXY_JUMP_DEPTH,
};
pub use ssh_shell_proc::SSHShellProc;
pub use wsl_connection::{
is_wsl_conn_name, WSLConn, WSLDistro, WSLDistroState, WSLOpts, WSLShellProc,
DEFAULT_WSL_DISTRO, WSL_CONN_PREFIX,
};
File diff suppressed because it is too large Load Diff
@@ -1,704 +0,0 @@
//! SSH 远程 Shell 进程实现
//!
//! 封装 SSH 远程 PTY 进程,提供 SSHShellProc 结构体供 ShellController 使用。
//!
//! ## 功能
//! - 创建和管理远程 PTY 会话
//! - 异步读取远程 PTY 输出并通过 Tauri Event 推送
//! - 处理远程 PTY 输入写入
//! - 监控远程进程退出状态
//! - 终端大小同步
//!
//! ## Requirements
//! - 4.2: SSH 连接建立成功时创建远程 PTY 会话
//! - 4.7: 支持 ProxyJump 配置
//! - 4.11: 用户调整终端大小时同步调整远程 PTY 大小
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Arc;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use parking_lot::Mutex;
use ssh2::{Channel, Session};
use tokio::sync::mpsc;
use crate::emit_helper;
use crate::emitter::TerminalEventEmitter;
use crate::block_controller::{BlockInputUnion, BlockMeta, TermSize};
use crate::error::TerminalError;
use crate::events::{event_names, SessionStatus, TerminalOutputEvent, TerminalStatusEvent};
use crate::persistence::BlockFile;
use super::ssh_connection::SSHConn;
/// SSH Shell 进程封装
///
/// 封装 SSH 远程 PTY 进程,提供输入输出和生命周期管理。
///
/// _Requirements: 4.2, 4.11_
pub struct SSHShellProc {
/// Block ID
block_id: String,
/// 控制器类型
controller_type: String,
/// SSH Channel(用于数据传输)
channel: Arc<Mutex<Channel>>,
/// 关闭标志
shutdown_flag: Arc<AtomicBool>,
/// 进程退出码
exit_code: Arc<AtomicI32>,
/// 是否已退出
exited: Arc<AtomicBool>,
/// 当前终端大小
term_size: Arc<Mutex<TermSize>>,
}
impl SSHShellProc {
/// 创建新的 SSH Shell 进程
///
/// # 参数
/// - `block_id`: Block ID
/// - `controller_type`: 控制器类型 ("shell" | "cmd")
/// - `session`: SSH 会话
/// - `rows`: 终端行数
/// - `cols`: 终端列数
/// - `app_handle`: Tauri 应用句柄
/// - `block_meta`: 块元数据配置
/// - `input_rx`: 输入接收器
/// - `block_file`: 块文件存储(可选)
///
/// # 返回
/// - `Ok(SSHShellProc)`: 创建成功
/// - `Err(TerminalError)`: 创建失败
///
/// _Requirements: 4.2_
pub async fn new(
block_id: String,
controller_type: String,
session: &Session,
rows: u16,
cols: u16,
app_handle: impl TerminalEventEmitter,
block_meta: BlockMeta,
input_rx: mpsc::Receiver<BlockInputUnion>,
block_file: Option<Arc<BlockFile>>,
) -> Result<Self, TerminalError> {
tracing::info!(
"[SSHShellProc] 创建远程进程: block_id={}, type={}, size={}x{}",
block_id,
controller_type,
cols,
rows
);
// 创建 SSH Channel
let mut channel = session.channel_session().map_err(|e| {
TerminalError::SSHConnectionFailed(format!("创建 SSH Channel 失败: {e}"))
})?;
// 请求 PTY
// 使用 xterm-256color 终端类型
channel
.request_pty(
"xterm-256color",
None,
Some((cols as u32, rows as u32, 0, 0)),
)
.map_err(|e| TerminalError::SSHConnectionFailed(format!("请求远程 PTY 失败: {e}")))?;
// 根据控制器类型启动 Shell 或执行命令
if controller_type == "cmd" {
// 命令执行模式
let cmd = Self::build_remote_command(&block_meta)?;
tracing::info!("[SSHShellProc] 执行远程命令: {}", cmd);
channel.exec(&cmd).map_err(|e| {
TerminalError::SSHConnectionFailed(format!("执行远程命令失败: {e}"))
})?;
} else {
// Shell 模式 - 启动交互式 Shell
channel.shell().map_err(|e| {
TerminalError::SSHConnectionFailed(format!("启动远程 Shell 失败: {e}"))
})?;
}
// 设置非阻塞模式
session.set_blocking(false);
// 创建共享状态
let shutdown_flag = Arc::new(AtomicBool::new(false));
let exit_code = Arc::new(AtomicI32::new(0));
let exited = Arc::new(AtomicBool::new(false));
let channel = Arc::new(Mutex::new(channel));
let term_size = Arc::new(Mutex::new(TermSize { rows, cols }));
// 启动输出读取任务
Self::spawn_output_reader(
block_id.clone(),
channel.clone(),
app_handle.clone(),
shutdown_flag.clone(),
exit_code.clone(),
exited.clone(),
block_file,
);
// 启动输入处理任务
Self::spawn_input_handler(
block_id.clone(),
channel.clone(),
term_size.clone(),
input_rx,
shutdown_flag.clone(),
);
tracing::info!("[SSHShellProc] 远程进程已创建: block_id={}", block_id);
Ok(Self {
block_id,
controller_type,
channel,
shutdown_flag,
exit_code,
exited,
term_size,
})
}
/// 从 SSHConn 创建 SSH Shell 进程
///
/// 便捷方法,从已连接的 SSHConn 创建远程 Shell 进程。
///
/// # 参数
/// - `block_id`: Block ID
/// - `controller_type`: 控制器类型
/// - `ssh_conn`: SSH 连接管理器
/// - `rows`: 终端行数
/// - `cols`: 终端列数
/// - `app_handle`: Tauri 应用句柄
/// - `block_meta`: 块元数据配置
/// - `input_rx`: 输入接收器
/// - `block_file`: 块文件存储(可选)
///
/// # 返回
/// - `Ok(SSHShellProc)`: 创建成功
/// - `Err(TerminalError)`: 创建失败
pub async fn from_ssh_conn(
block_id: String,
controller_type: String,
ssh_conn: &SSHConn,
rows: u16,
cols: u16,
app_handle: impl TerminalEventEmitter,
block_meta: BlockMeta,
input_rx: mpsc::Receiver<BlockInputUnion>,
block_file: Option<Arc<BlockFile>>,
) -> Result<Self, TerminalError> {
let session = ssh_conn
.get_session()
.ok_or_else(|| TerminalError::SSHConnectionFailed("SSH 会话未建立".to_string()))?;
Self::new(
block_id,
controller_type,
&session,
rows,
cols,
app_handle,
block_meta,
input_rx,
block_file,
)
.await
}
/// 构建远程命令
///
/// 根据块元数据构建要在远程执行的命令。
///
/// _Requirements: 16.1, 16.2, 16.3_
fn build_remote_command(block_meta: &BlockMeta) -> Result<String, TerminalError> {
let cmd_str = block_meta.cmd.as_ref().ok_or_else(|| {
TerminalError::SSHConnectionFailed("cmd 模式需要指定命令".to_string())
})?;
// 构建完整命令字符串
let mut full_cmd = String::new();
// 如果指定了工作目录,先 cd 到该目录
if let Some(cwd) = block_meta.sanitized_cmd_cwd() {
full_cmd.push_str(&format!("cd {} && ", shell_escape(&cwd)));
}
// 设置环境变量
if let Some(env_vars) = &block_meta.cmd_env {
for (key, value) in env_vars {
full_cmd.push_str(&format!("export {}={} && ", key, shell_escape(value)));
}
}
// 添加命令和参数
full_cmd.push_str(cmd_str);
if let Some(args) = &block_meta.cmd_args {
for arg in args {
full_cmd.push(' ');
full_cmd.push_str(&shell_escape(arg));
}
}
Ok(full_cmd)
}
/// 启动输出读取任务
///
/// 在独立线程中读取远程 PTY 输出,并通过 Tauri 事件发送到前端。
///
/// _Requirements: 4.2_
fn spawn_output_reader(
block_id: String,
channel: Arc<Mutex<Channel>>,
app_handle: impl TerminalEventEmitter,
shutdown_flag: Arc<AtomicBool>,
exit_code: Arc<AtomicI32>,
exited: Arc<AtomicBool>,
block_file: Option<Arc<BlockFile>>,
) {
std::thread::spawn(move || {
let mut buffer = [0u8; 4096];
let mut consecutive_empty_reads = 0;
const MAX_EMPTY_READS: u32 = 100; // 防止空循环
loop {
// 检查关闭标志
if shutdown_flag.load(Ordering::Relaxed) {
tracing::debug!("[SSHShellProc] 收到关闭信号: block_id={}", block_id);
break;
}
// 读取输出
let read_result = {
let mut ch = channel.lock();
// 检查 Channel 是否已关闭
if ch.eof() {
// 获取退出状态
let code = ch.exit_status().unwrap_or(0);
exit_code.store(code, Ordering::SeqCst);
exited.store(true, Ordering::SeqCst);
tracing::info!(
"[SSHShellProc] 远程进程已退出: block_id={}, exit_code={}",
block_id,
code
);
// 发送状态事件
let _ = emit_helper::emit(
&app_handle,
event_names::TERMINAL_STATUS,
&TerminalStatusEvent {
session_id: block_id.clone(),
status: SessionStatus::Done,
exit_code: Some(code),
error: None,
},
);
break;
}
ch.read(&mut buffer)
};
match read_result {
Ok(0) => {
// 没有数据可读,短暂休眠后重试
consecutive_empty_reads += 1;
if consecutive_empty_reads > MAX_EMPTY_READS {
// 检查 Channel 状态
let ch = channel.lock();
if ch.eof() {
let code = ch.exit_status().unwrap_or(0);
exit_code.store(code, Ordering::SeqCst);
exited.store(true, Ordering::SeqCst);
let _ = emit_helper::emit(
&app_handle,
event_names::TERMINAL_STATUS,
&TerminalStatusEvent {
session_id: block_id.clone(),
status: SessionStatus::Done,
exit_code: Some(code),
error: None,
},
);
break;
}
consecutive_empty_reads = 0;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
Ok(n) => {
consecutive_empty_reads = 0;
let output_data = &buffer[..n];
// 保存到块文件
if let Some(ref bf) = block_file {
if let Err(e) = bf.append_data(output_data) {
tracing::warn!(
"[SSHShellProc] 写入块文件失败: block_id={}, error={}",
block_id,
e
);
}
}
// 发送输出事件
let data = BASE64.encode(output_data);
let _ = emit_helper::emit(
&app_handle,
event_names::TERMINAL_OUTPUT,
&TerminalOutputEvent {
session_id: block_id.clone(),
data,
},
);
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
// 非阻塞模式下没有数据,短暂休眠
consecutive_empty_reads += 1;
std::thread::sleep(std::time::Duration::from_millis(10));
}
Err(e) => {
// 检查是否是因为关闭导致的错误
if shutdown_flag.load(Ordering::Relaxed) {
break;
}
tracing::error!(
"[SSHShellProc] 读取错误: block_id={}, error={}",
block_id,
e
);
exited.store(true, Ordering::SeqCst);
let _ = emit_helper::emit(
&app_handle,
event_names::TERMINAL_STATUS,
&TerminalStatusEvent {
session_id: block_id.clone(),
status: SessionStatus::Error,
exit_code: None,
error: Some(e.to_string()),
},
);
break;
}
}
}
});
}
/// 启动输入处理任务
///
/// 在独立任务中处理输入数据,包括键盘输入、信号和终端大小调整。
///
/// _Requirements: 4.11_
fn spawn_input_handler(
block_id: String,
channel: Arc<Mutex<Channel>>,
term_size: Arc<Mutex<TermSize>>,
mut input_rx: mpsc::Receiver<BlockInputUnion>,
shutdown_flag: Arc<AtomicBool>,
) {
tokio::spawn(async move {
while let Some(input) = input_rx.recv().await {
// 检查关闭标志
if shutdown_flag.load(Ordering::Relaxed) {
break;
}
// 处理输入数据
if let Some(data) = &input.input_data {
let mut ch = channel.lock();
if let Err(e) = ch.write_all(data) {
tracing::error!(
"[SSHShellProc] 写入失败: block_id={}, error={}",
block_id,
e
);
continue;
}
if let Err(e) = ch.flush() {
tracing::error!(
"[SSHShellProc] Flush 失败: block_id={}, error={}",
block_id,
e
);
}
}
// 处理终端大小调整
if let Some(size) = &input.term_size {
// 更新本地记录的终端大小
{
let mut ts = term_size.lock();
*ts = *size;
}
// 发送 PTY 大小调整请求到远程
let mut ch = channel.lock();
if let Err(e) =
ch.request_pty_size(size.cols as u32, size.rows as u32, Some(0), Some(0))
{
tracing::error!(
"[SSHShellProc] 调整远程 PTY 大小失败: block_id={}, error={}",
block_id,
e
);
} else {
tracing::debug!(
"[SSHShellProc] 调整远程 PTY 大小: block_id={}, size={}x{}",
block_id,
size.cols,
size.rows
);
}
}
// 处理信号
if let Some(sig_name) = &input.sig_name {
tracing::debug!(
"[SSHShellProc] 收到信号: block_id={}, signal={}",
block_id,
sig_name
);
// SSH 协议支持发送信号,但 ssh2 crate 没有直接暴露此功能
// 对于 SIGINT,我们可以发送 Ctrl+C (0x03)
// 对于其他信号,记录日志但不执行操作
match sig_name.as_str() {
"SIGINT" => {
// 发送 Ctrl+C
let mut ch = channel.lock();
if let Err(e) = ch.write_all(&[0x03]) {
tracing::warn!(
"[SSHShellProc] 发送 Ctrl+C 失败: block_id={}, error={}",
block_id,
e
);
}
}
"SIGQUIT" => {
// 发送 Ctrl+\ (0x1C)
let mut ch = channel.lock();
if let Err(e) = ch.write_all(&[0x1C]) {
tracing::warn!(
"[SSHShellProc] 发送 Ctrl+\\ 失败: block_id={}, error={}",
block_id,
e
);
}
}
_ => {
tracing::warn!(
"[SSHShellProc] 不支持的信号: block_id={}, signal={}",
block_id,
sig_name
);
}
}
}
}
tracing::debug!("[SSHShellProc] 输入处理任务结束: block_id={}", block_id);
});
}
/// 获取 Block ID
pub fn block_id(&self) -> &str {
&self.block_id
}
/// 获取控制器类型
pub fn controller_type(&self) -> &str {
&self.controller_type
}
/// 检查进程是否已退出
pub fn is_exited(&self) -> bool {
self.exited.load(Ordering::SeqCst)
}
/// 获取退出码
pub fn get_exit_code(&self) -> i32 {
self.exit_code.load(Ordering::SeqCst)
}
/// 获取当前终端大小
pub fn get_term_size(&self) -> TermSize {
*self.term_size.lock()
}
/// 写入数据到远程 PTY
///
/// _Requirements: 4.2_
pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> {
let mut channel = self.channel.lock();
channel
.write_all(data)
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
channel
.flush()
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
Ok(())
}
/// 调整远程 PTY 大小
///
/// _Requirements: 4.11_
pub fn resize(&self, rows: u16, cols: u16) -> Result<(), TerminalError> {
// 更新本地记录
{
let mut ts = self.term_size.lock();
*ts = TermSize { rows, cols };
}
// 发送到远程
let mut channel = self.channel.lock();
channel
.request_pty_size(cols as u32, rows as u32, Some(0), Some(0))
.map_err(|e| TerminalError::ResizeFailed(e.to_string()))?;
tracing::debug!(
"[SSHShellProc] 调整远程 PTY 大小: block_id={}, size={}x{}",
self.block_id,
cols,
rows
);
Ok(())
}
/// 优雅终止进程
///
/// 发送 EOF 并等待进程退出。
///
/// # 返回
/// 进程退出码
pub async fn terminate(&self) -> i32 {
tracing::info!(
"[SSHShellProc] 优雅终止远程进程: block_id={}",
self.block_id
);
// 设置关闭标志
self.shutdown_flag.store(true, Ordering::SeqCst);
// 发送 Ctrl+C 尝试中断进程
{
let mut channel = self.channel.lock();
let _ = channel.write_all(&[0x03]); // Ctrl+C
}
// 等待一小段时间让进程响应
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// 关闭 Channel
{
let mut channel = self.channel.lock();
let _ = channel.send_eof();
let _ = channel.wait_close();
}
self.exit_code.load(Ordering::SeqCst)
}
/// 强制终止进程
///
/// 关闭 Channel 立即终止连接。
pub async fn kill(&self) {
tracing::info!(
"[SSHShellProc] 强制终止远程进程: block_id={}",
self.block_id
);
// 设置关闭标志
self.shutdown_flag.store(true, Ordering::SeqCst);
// 直接关闭 Channel
{
let mut channel = self.channel.lock();
let _ = channel.send_eof();
let _ = channel.close();
}
}
/// 发送 EOF 到远程
///
/// 用于通知远程进程输入已结束。
pub fn send_eof(&self) -> Result<(), TerminalError> {
let mut channel = self.channel.lock();
channel
.send_eof()
.map_err(|e| TerminalError::WriteFailed(format!("发送 EOF 失败: {e}")))?;
Ok(())
}
}
impl Drop for SSHShellProc {
fn drop(&mut self) {
// 确保关闭标志被设置
self.shutdown_flag.store(true, Ordering::SeqCst);
// 尝试关闭 Channel
if let Some(mut channel) = self.channel.try_lock() {
let _ = channel.send_eof();
let _ = channel.close();
}
tracing::debug!("[SSHShellProc] 远程进程已销毁: block_id={}", self.block_id);
}
}
/// Shell 转义辅助函数
///
/// 对字符串进行 Shell 转义,防止命令注入。
fn shell_escape(s: &str) -> String {
// 如果字符串只包含安全字符,直接返回
if s.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
{
return s.to_string();
}
// 否则用单引号包裹,并转义内部的单引号
format!("'{}'", s.replace('\'', "'\\''"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_shell_escape_simple() {
assert_eq!(shell_escape("hello"), "hello");
assert_eq!(shell_escape("hello_world"), "hello_world");
assert_eq!(shell_escape("/path/to/file"), "/path/to/file");
assert_eq!(shell_escape("file.txt"), "file.txt");
}
#[test]
fn test_shell_escape_special_chars() {
assert_eq!(shell_escape("hello world"), "'hello world'");
assert_eq!(shell_escape("hello'world"), "'hello'\\''world'");
assert_eq!(shell_escape("$HOME"), "'$HOME'");
assert_eq!(shell_escape("a;b"), "'a;b'");
}
#[test]
fn test_term_size_default() {
let size = TermSize::default();
assert_eq!(size.rows, 24);
assert_eq!(size.cols, 80);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,19 +0,0 @@
//! 事件发射辅助函数
//!
//! 提供类型安全的事件发射便捷方法,封装序列化逻辑。
use serde::Serialize;
use crate::emitter::TerminalEventEmit;
/// 发射序列化事件
///
/// 将 payload 序列化为 JSON 后通过 emitter 发射。
pub fn emit<E: TerminalEventEmit + ?Sized, T: Serialize>(
emitter: &E,
event: &str,
payload: &T,
) -> Result<(), String> {
let value = serde_json::to_value(payload).map_err(|e| format!("序列化事件数据失败: {e}"))?;
emitter.emit_event(event, &value)
}
-73
View File
@@ -1,73 +0,0 @@
//! 事件发射器抽象
//!
//! 定义事件发射器 trait 层次,用于将 Tauri 事件发射功能抽象化,
//! 使终端模块不直接依赖 Tauri。
//!
//! ## 设计
//! - `TerminalEventEmit`:基础 trait(dyn 兼容,不要求 Clone)
//! - `TerminalEventEmitter`:扩展 trait(要求 Clone,用于泛型参数)
//! - `DynEmitter`:`Arc<dyn TerminalEventEmit>` 的 newtype,实现 `TerminalEventEmitter`
use std::path::PathBuf;
use std::sync::Arc;
/// 基础事件发射 trait(dyn 兼容)
///
/// 不要求 `Clone`,可以用作 `dyn TerminalEventEmit`。
/// 主 crate 中为 `tauri::AppHandle` 实现此 trait。
pub trait TerminalEventEmit: Send + Sync + 'static {
/// 发射事件到前端
fn emit_event(&self, event: &str, payload: &serde_json::Value) -> Result<(), String>;
/// 获取应用数据目录
fn app_data_dir(&self) -> Result<PathBuf, String>;
}
/// 扩展事件发射器 trait(要求 Clone)
///
/// 用于泛型参数场景(如 `ShellController<E>`、`ShellProc<E>`)。
/// 所有实现了 `TerminalEventEmit + Clone` 的类型自动实现此 trait。
pub trait TerminalEventEmitter: TerminalEventEmit + Clone {}
/// 自动实现:任何 `TerminalEventEmit + Clone` 的类型都是 `TerminalEventEmitter`
impl<T: TerminalEventEmit + Clone> TerminalEventEmitter for T {}
/// 动态事件发射器包装
///
/// 使用 `Arc<dyn TerminalEventEmit>` 包装,实现 `Clone` + `TerminalEventEmit`,
/// 从而自动获得 `TerminalEventEmitter`。
///
/// 用于需要存储和传递发射器但不想泛型化的场景(如 `TerminalSessionManager`)。
#[derive(Clone)]
pub struct DynEmitter(pub Arc<dyn TerminalEventEmit>);
impl DynEmitter {
/// 从实现了 TerminalEventEmit 的类型创建
pub fn new(emitter: impl TerminalEventEmit) -> Self {
Self(Arc::new(emitter))
}
}
impl TerminalEventEmit for DynEmitter {
fn emit_event(&self, event: &str, payload: &serde_json::Value) -> Result<(), String> {
self.0.emit_event(event, payload)
}
fn app_data_dir(&self) -> Result<PathBuf, String> {
self.0.app_data_dir()
}
}
/// 空事件发射器(用于测试)
#[derive(Debug, Clone)]
pub struct NoOpEmitter;
impl TerminalEventEmit for NoOpEmitter {
fn emit_event(&self, _event: &str, _payload: &serde_json::Value) -> Result<(), String> {
Ok(())
}
fn app_data_dir(&self) -> Result<PathBuf, String> {
Ok(PathBuf::from(".lime"))
}
}
-103
View File
@@ -1,103 +0,0 @@
//! 终端模块错误类型
//!
//! 定义终端核心能力相关的错误类型。
//!
//! ## 功能
//! - 会话管理错误
//! - PTY 操作错误
//! - 块文件存储错误
//! - 数据库错误
//! - 序列化支持
use thiserror::Error;
/// 终端错误类型
#[derive(Debug, Error)]
pub enum TerminalError {
/// 会话不存在
#[error("会话不存在: {0}")]
SessionNotFound(String),
/// PTY 创建失败
#[error("PTY 创建失败: {0}")]
PtyCreationFailed(String),
/// 写入失败
#[error("写入失败: {0}")]
WriteFailed(String),
/// 调整大小失败
#[error("调整大小失败: {0}")]
ResizeFailed(String),
/// 会话已关闭
#[error("会话已关闭")]
SessionClosed,
/// Base64 解码失败
#[error("Base64 解码失败: {0}")]
Base64DecodeFailed(String),
/// 块文件错误
#[error("块文件错误: {0}")]
BlockFileError(String),
/// 数据库错误
#[error("数据库错误: {0}")]
DatabaseError(String),
/// 控制器未找到
#[error("控制器未找到: {0}")]
ControllerNotFound(String),
/// SSH 连接失败
#[error("SSH 连接失败: {0}")]
SSHConnectionFailed(String),
/// SSH 认证失败
#[error("SSH 认证失败: {0}")]
SSHAuthFailed(String),
/// WSL 连接失败
#[error("WSL 连接失败: {0}")]
WSLConnectionFailed(String),
/// 无效的 OSC 序列
#[error("无效的 OSC 序列: {0}")]
InvalidOSCSequence(String),
/// 连接超时
#[error("连接超时")]
ConnectionTimeout,
/// 用户取消
#[error("用户取消")]
UserCancelled,
/// 主机密钥验证失败
#[error("主机密钥验证失败: {0}")]
HostKeyVerificationFailed(String),
/// 内部错误
#[error("内部错误: {0}")]
Internal(String),
/// 无效的连接类型
#[error("无效的连接类型: {0}")]
InvalidConnectionType(String),
}
impl From<TerminalError> for String {
fn from(err: TerminalError) -> Self {
err.to_string()
}
}
impl serde::Serialize for TerminalError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
-82
View File
@@ -1,82 +0,0 @@
//! 终端事件定义
//!
//! 定义终端模块使用的 Tauri 事件类型。
//!
//! ## 事件列表
//! - `terminal:output` - 终端输出数据
//! - `terminal:status` - 终端状态变化
//! - `terminal:shell-integration` - Shell 集成状态变化
//! - `terminal:clipboard-write` - 剪贴板写入请求
//! - `terminal:conn-change` - 连接状态变化
use serde::{Deserialize, Serialize};
use crate::connections::ConnStatus;
/// 会话状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SessionStatus {
/// 正在连接
#[default]
Connecting,
/// 运行中
Running,
/// 已结束
Done,
/// 错误
Error,
}
/// 终端输出事件
///
/// Event name: `terminal:output`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminalOutputEvent {
/// 会话 ID
pub session_id: String,
/// 输出数据(Base64 编码)
pub data: String,
}
/// 终端状态事件
///
/// Event name: `terminal:status`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminalStatusEvent {
/// 会话 ID
pub session_id: String,
/// 会话状态
pub status: SessionStatus,
/// 退出码(仅当状态为 Done 时有效)
pub exit_code: Option<i32>,
/// 错误信息(仅当状态为 Error 时有效)
pub error: Option<String>,
}
/// 连接状态变更事件
///
/// Event name: `terminal:conn-change`
///
/// _Requirements: 7.3_
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnChangeEvent {
/// 连接名称
pub connection: String,
/// 连接状态详情
pub status: ConnStatus,
}
/// 事件名称常量
pub mod event_names {
/// 终端输出事件名
pub const TERMINAL_OUTPUT: &str = "terminal:output";
/// 终端状态事件名
pub const TERMINAL_STATUS: &str = "terminal:status";
/// Shell 集成状态事件名
pub const SHELL_INTEGRATION_STATUS: &str = "terminal:shell-integration";
/// 剪贴板写入事件名
pub const CLIPBOARD_WRITE: &str = "terminal:clipboard-write";
/// 连接状态变更事件名
pub const CONN_CHANGE: &str = "terminal:conn-change";
}
@@ -1,31 +0,0 @@
//! 集成模块
//!
//! 提供 Shell 集成、OSC 序列解析、状态重同步等功能。
//!
//! ## 模块结构
//! - `osc_parser` - OSC 序列解析器
//! - `shell_integration` - Shell 集成处理器
//! - `shell_scripts` - Shell 集成脚本管理
//! - `resync` - 状态重同步控制器
//!
//! ## 功能
//! - OSC 序列解析(OSC 7/52/133/16162)
//! - Shell 集成状态管理
//! - Shell 集成脚本安装和管理
//! - 终端状态重同步
pub mod osc_parser;
pub mod resync;
pub mod shell_integration;
pub mod shell_scripts;
// 重新导出常用类型
pub use osc_parser::{strip_osc_sequences, OSCParser, OSCSequence, ParsedOSC, PromptMarkType};
pub use resync::{
resync_controller, ResyncController, ResyncOptions, ResyncResult, TERMINAL_RESET_SEQUENCE,
TERMINAL_SOFT_RESET_SEQUENCE,
};
pub use shell_integration::{
CommandInfo, ShellIntegration, ShellIntegrationEvent, ShellIntegrationStatus, ShellType,
};
pub use shell_scripts::{ShellLaunchBuilder, ShellLaunchConfig, ShellScripts, TerminalEnvConfig};
@@ -1,691 +0,0 @@
//! OSC 序列解析器
//!
//! 解析终端输出中的 OSC(Operating System Command)序列,支持:
//! - OSC 7: 当前工作目录
//! - OSC 52: 剪贴板操作
//! - OSC 133: 命令提示符标记(Shell Integration)
//! - OSC 16162: Wave 特定命令
//!
//! ## 功能
//! - 从字节流中识别和解析 OSC 序列
//! - 支持多种 OSC 序列类型
//! - 无效序列容错处理
//!
//! ## Requirements
//! - 6.1: OSC 7 当前目录解析
//! - 6.2: OSC 52 剪贴板解析
//! - 6.3: OSC 133 命令提示符标记解析
//! - 6.4: OSC 16162 Wave 命令解析
//! - 6.7: 无效序列容错处理
use std::ops::Range;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
/// OSC 序列起始标记
const OSC_START: &[u8] = b"\x1b]";
/// OSC 序列结束标记 - BEL
const OSC_END_BEL: u8 = 0x07;
/// OSC 序列结束标记 - ST (String Terminator)
/// 预留:某些终端使用 ST 而不是 BEL 作为结束标记
#[allow(dead_code)]
const OSC_END_ST: &[u8] = b"\x1b\\";
/// OSC 序列类型
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OSCSequence {
/// OSC 7 - 当前工作目录
/// 格式: OSC 7 ; file://hostname/path ST
CurrentDirectory {
/// 主机名(可选)
hostname: Option<String>,
/// 路径
path: String,
},
/// OSC 52 - 剪贴板操作
/// 格式: OSC 52 ; selection ; base64-data ST
Clipboard {
/// 选择类型 (c=clipboard, p=primary, s=secondary, etc.)
selection: String,
/// Base64 编码的数据
data: String,
},
/// OSC 133 - 命令提示符标记(Shell Integration)
/// 格式: OSC 133 ; type ST
PromptMark {
/// 标记类型
mark_type: PromptMarkType,
},
/// OSC 16162 - Wave 特定命令
/// 格式: OSC 16162 ; command ST
WaveCommand {
/// 命令内容
command: String,
},
/// 未知的 OSC 序列
Unknown {
/// OSC 代码
code: String,
/// 参数
params: String,
},
}
/// 命令提示符标记类型(OSC 133)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptMarkType {
/// A - 提示符开始(Prompt Start)
PromptStart,
/// B - 命令开始(Command Start,用户输入开始)
CommandStart,
/// C - 命令执行(Command Executed)
CommandExecuted,
/// D - 命令结束(Command Finished)
CommandFinished,
/// 未知标记类型
Unknown(char),
}
impl PromptMarkType {
/// 从字符解析标记类型
pub fn from_char(c: char) -> Self {
match c {
'A' => Self::PromptStart,
'B' => Self::CommandStart,
'C' => Self::CommandExecuted,
'D' => Self::CommandFinished,
other => Self::Unknown(other),
}
}
/// 转换为字符
pub fn to_char(&self) -> char {
match self {
Self::PromptStart => 'A',
Self::CommandStart => 'B',
Self::CommandExecuted => 'C',
Self::CommandFinished => 'D',
Self::Unknown(c) => *c,
}
}
}
/// 解析结果
#[derive(Debug, Clone)]
pub struct ParsedOSC {
/// 解析出的 OSC 序列
pub sequence: OSCSequence,
/// 序列在原始数据中的位置范围
pub range: Range<usize>,
}
/// OSC 序列解析器
pub struct OSCParser;
impl OSCParser {
/// 从字节流中解析所有 OSC 序列
///
/// # 参数
/// - `data`: 输入字节流
///
/// # 返回
/// 解析出的 OSC 序列列表,每个元素包含序列和位置范围
///
/// # 示例
/// ```ignore
/// use lime_terminal::integration::osc_parser::{OSCParser, OSCSequence};
///
/// let data = b"\x1b]7;file://localhost/home/user\x07";
/// let results = OSCParser::parse(data);
/// assert_eq!(results.len(), 1);
/// ```
///
/// _Requirements: 6.1, 6.2, 6.3, 6.4, 6.7_
pub fn parse(data: &[u8]) -> Vec<ParsedOSC> {
let mut results = Vec::new();
let mut pos = 0;
while pos < data.len() {
// 查找 OSC 起始标记
if let Some(start_offset) = Self::find_osc_start(&data[pos..]) {
let start = pos + start_offset;
let content_start = start + OSC_START.len();
// 查找 OSC 结束标记
if let Some((end, terminator_len)) = Self::find_osc_end(&data[content_start..]) {
let content_end = content_start + end;
let sequence_end = content_end + terminator_len;
// 解析 OSC 内容
let content = &data[content_start..content_end];
if let Some(sequence) = Self::parse_osc_content(content) {
results.push(ParsedOSC {
sequence,
range: start..sequence_end,
});
}
pos = sequence_end;
} else {
// 没有找到结束标记,跳过这个起始标记
pos = content_start;
}
} else {
// 没有更多 OSC 序列
break;
}
}
results
}
/// 解析单个 OSC 序列
///
/// # 参数
/// - `data`: 完整的 OSC 序列(包含起始和结束标记)
///
/// # 返回
/// 解析成功返回 OSC 序列,失败返回 None
pub fn parse_single(data: &[u8]) -> Option<OSCSequence> {
// 检查起始标记
if !data.starts_with(OSC_START) {
return None;
}
let content_start = OSC_START.len();
// 查找结束标记
let (end, _) = Self::find_osc_end(&data[content_start..])?;
let content = &data[content_start..content_start + end];
Self::parse_osc_content(content)
}
/// 查找 OSC 起始标记
fn find_osc_start(data: &[u8]) -> Option<usize> {
data.windows(OSC_START.len()).position(|w| w == OSC_START)
}
/// 查找 OSC 结束标记
///
/// 返回 (结束位置, 终止符长度)
fn find_osc_end(data: &[u8]) -> Option<(usize, usize)> {
for (i, &byte) in data.iter().enumerate() {
// BEL 终止符
if byte == OSC_END_BEL {
return Some((i, 1));
}
// ST 终止符 (ESC \)
if byte == 0x1b && i + 1 < data.len() && data[i + 1] == b'\\' {
return Some((i, 2));
}
}
None
}
/// 解析 OSC 内容
fn parse_osc_content(content: &[u8]) -> Option<OSCSequence> {
// 转换为字符串
let content_str = String::from_utf8_lossy(content);
// 分割 OSC 代码和参数
let (code, params) = match content_str.find(';') {
Some(pos) => (&content_str[..pos], &content_str[pos + 1..]),
None => (content_str.as_ref(), ""),
};
// 根据 OSC 代码解析
match code {
"7" => Self::parse_osc_7(params),
"52" => Self::parse_osc_52(params),
"133" => Self::parse_osc_133(params),
"16162" => Self::parse_osc_16162(params),
_ => Some(OSCSequence::Unknown {
code: code.to_string(),
params: params.to_string(),
}),
}
}
/// 解析 OSC 7 - 当前工作目录
///
/// 格式: file://hostname/path 或 file:///path
///
/// _Requirements: 6.1_
fn parse_osc_7(params: &str) -> Option<OSCSequence> {
// 移除 file:// 前缀
let path_part = params.strip_prefix("file://")?;
// 解析主机名和路径
let (hostname, path) = if path_part.starts_with('/') {
// file:///path 格式(无主机名)
(None, path_part.to_string())
} else {
// file://hostname/path 格式
match path_part.find('/') {
Some(pos) => {
let host = &path_part[..pos];
let path = &path_part[pos..];
(
if host.is_empty() {
None
} else {
Some(host.to_string())
},
path.to_string(),
)
}
None => {
// 只有主机名,没有路径
(Some(path_part.to_string()), "/".to_string())
}
}
};
// URL 解码路径
let decoded_path = Self::url_decode(&path);
Some(OSCSequence::CurrentDirectory {
hostname,
path: decoded_path,
})
}
/// 解析 OSC 52 - 剪贴板操作
///
/// 格式: selection;base64-data
///
/// _Requirements: 6.2_
fn parse_osc_52(params: &str) -> Option<OSCSequence> {
let (selection, data) = match params.find(';') {
Some(pos) => (&params[..pos], &params[pos + 1..]),
None => (params, ""),
};
Some(OSCSequence::Clipboard {
selection: selection.to_string(),
data: data.to_string(),
})
}
/// 解析 OSC 133 - 命令提示符标记
///
/// 格式: type (A/B/C/D)
///
/// _Requirements: 6.3_
fn parse_osc_133(params: &str) -> Option<OSCSequence> {
let mark_char = params.chars().next()?;
let mark_type = PromptMarkType::from_char(mark_char);
Some(OSCSequence::PromptMark { mark_type })
}
/// 解析 OSC 16162 - Wave 命令
///
/// _Requirements: 6.4_
fn parse_osc_16162(params: &str) -> Option<OSCSequence> {
Some(OSCSequence::WaveCommand {
command: params.to_string(),
})
}
/// URL 解码
fn url_decode(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
// 尝试解析两个十六进制字符
let hex: String = chars.by_ref().take(2).collect();
if hex.len() == 2 {
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte as char);
continue;
}
}
// 解析失败,保留原样
result.push('%');
result.push_str(&hex);
} else {
result.push(c);
}
}
result
}
/// 从 OSC 52 数据中解码剪贴板内容
///
/// # 参数
/// - `data`: Base64 编码的数据
///
/// # 返回
/// 解码后的字符串,解码失败返回 None
pub fn decode_clipboard_data(data: &str) -> Option<String> {
if data == "?" {
// 查询请求
return None;
}
let decoded = BASE64.decode(data).ok()?;
String::from_utf8(decoded).ok()
}
/// 编码剪贴板内容为 OSC 52 格式
///
/// # 参数
/// - `selection`: 选择类型
/// - `content`: 要编码的内容
///
/// # 返回
/// 完整的 OSC 52 序列
pub fn encode_clipboard(selection: &str, content: &str) -> Vec<u8> {
let encoded = BASE64.encode(content.as_bytes());
format!("\x1b]52;{selection};{encoded}\x07").into_bytes()
}
/// 构建 OSC 7 序列
///
/// # 参数
/// - `hostname`: 主机名(可选)
/// - `path`: 路径
///
/// # 返回
/// 完整的 OSC 7 序列
pub fn build_osc_7(hostname: Option<&str>, path: &str) -> Vec<u8> {
let host = hostname.unwrap_or("");
format!("\x1b]7;file://{host}{path}\x07").into_bytes()
}
/// 构建 OSC 133 序列
///
/// # 参数
/// - `mark_type`: 标记类型
///
/// # 返回
/// 完整的 OSC 133 序列
pub fn build_osc_133(mark_type: PromptMarkType) -> Vec<u8> {
format!("\x1b]133;{}\x07", mark_type.to_char()).into_bytes()
}
}
/// 从数据流中过滤掉 OSC 序列,返回纯文本数据
///
/// # 参数
/// - `data`: 输入数据
///
/// # 返回
/// 过滤后的数据
pub fn strip_osc_sequences(data: &[u8]) -> Vec<u8> {
let parsed = OSCParser::parse(data);
if parsed.is_empty() {
return data.to_vec();
}
let mut result = Vec::with_capacity(data.len());
let mut last_end = 0;
for osc in parsed {
// 添加 OSC 序列之前的数据
if osc.range.start > last_end {
result.extend_from_slice(&data[last_end..osc.range.start]);
}
last_end = osc.range.end;
}
// 添加最后一个 OSC 序列之后的数据
if last_end < data.len() {
result.extend_from_slice(&data[last_end..]);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_osc_7_with_hostname() {
let data = b"\x1b]7;file://localhost/home/user\x07";
let results = OSCParser::parse(data);
assert_eq!(results.len(), 1);
match &results[0].sequence {
OSCSequence::CurrentDirectory { hostname, path } => {
assert_eq!(hostname.as_deref(), Some("localhost"));
assert_eq!(path, "/home/user");
}
_ => panic!("Expected CurrentDirectory"),
}
}
#[test]
fn test_parse_osc_7_without_hostname() {
let data = b"\x1b]7;file:///home/user\x07";
let results = OSCParser::parse(data);
assert_eq!(results.len(), 1);
match &results[0].sequence {
OSCSequence::CurrentDirectory { hostname, path } => {
assert!(hostname.is_none());
assert_eq!(path, "/home/user");
}
_ => panic!("Expected CurrentDirectory"),
}
}
#[test]
fn test_parse_osc_7_url_encoded() {
let data = b"\x1b]7;file:///home/user/my%20folder\x07";
let results = OSCParser::parse(data);
assert_eq!(results.len(), 1);
match &results[0].sequence {
OSCSequence::CurrentDirectory { path, .. } => {
assert_eq!(path, "/home/user/my folder");
}
_ => panic!("Expected CurrentDirectory"),
}
}
#[test]
fn test_parse_osc_52() {
let data = b"\x1b]52;c;SGVsbG8gV29ybGQ=\x07";
let results = OSCParser::parse(data);
assert_eq!(results.len(), 1);
match &results[0].sequence {
OSCSequence::Clipboard { selection, data } => {
assert_eq!(selection, "c");
assert_eq!(data, "SGVsbG8gV29ybGQ=");
// 验证解码
let decoded = OSCParser::decode_clipboard_data(data);
assert_eq!(decoded, Some("Hello World".to_string()));
}
_ => panic!("Expected Clipboard"),
}
}
#[test]
fn test_parse_osc_133() {
// 测试所有标记类型
let test_cases = [
(b"\x1b]133;A\x07".as_slice(), PromptMarkType::PromptStart),
(b"\x1b]133;B\x07".as_slice(), PromptMarkType::CommandStart),
(
b"\x1b]133;C\x07".as_slice(),
PromptMarkType::CommandExecuted,
),
(
b"\x1b]133;D\x07".as_slice(),
PromptMarkType::CommandFinished,
),
];
for (data, expected_type) in test_cases {
let results = OSCParser::parse(data);
assert_eq!(results.len(), 1);
match &results[0].sequence {
OSCSequence::PromptMark { mark_type } => {
assert_eq!(*mark_type, expected_type);
}
_ => panic!("Expected PromptMark"),
}
}
}
#[test]
fn test_parse_osc_16162() {
let data = b"\x1b]16162;setcwd /home/user\x07";
let results = OSCParser::parse(data);
assert_eq!(results.len(), 1);
match &results[0].sequence {
OSCSequence::WaveCommand { command } => {
assert_eq!(command, "setcwd /home/user");
}
_ => panic!("Expected WaveCommand"),
}
}
#[test]
fn test_parse_multiple_osc() {
let data = b"Hello\x1b]7;file:///home\x07World\x1b]133;A\x07End";
let results = OSCParser::parse(data);
assert_eq!(results.len(), 2);
assert!(matches!(
&results[0].sequence,
OSCSequence::CurrentDirectory { .. }
));
assert!(matches!(
&results[1].sequence,
OSCSequence::PromptMark { .. }
));
}
#[test]
fn test_parse_osc_with_st_terminator() {
let data = b"\x1b]7;file:///home/user\x1b\\";
let results = OSCParser::parse(data);
assert_eq!(results.len(), 1);
match &results[0].sequence {
OSCSequence::CurrentDirectory { path, .. } => {
assert_eq!(path, "/home/user");
}
_ => panic!("Expected CurrentDirectory"),
}
}
#[test]
fn test_parse_unknown_osc() {
let data = b"\x1b]999;some params\x07";
let results = OSCParser::parse(data);
assert_eq!(results.len(), 1);
match &results[0].sequence {
OSCSequence::Unknown { code, params } => {
assert_eq!(code, "999");
assert_eq!(params, "some params");
}
_ => panic!("Expected Unknown"),
}
}
#[test]
fn test_parse_no_osc() {
let data = b"Hello World";
let results = OSCParser::parse(data);
assert!(results.is_empty());
}
#[test]
fn test_strip_osc_sequences() {
let data = b"Hello\x1b]7;file:///home\x07World";
let stripped = strip_osc_sequences(data);
assert_eq!(stripped, b"HelloWorld");
}
#[test]
fn test_encode_clipboard() {
let encoded = OSCParser::encode_clipboard("c", "Hello");
let results = OSCParser::parse(&encoded);
assert_eq!(results.len(), 1);
match &results[0].sequence {
OSCSequence::Clipboard { selection, data } => {
assert_eq!(selection, "c");
let decoded = OSCParser::decode_clipboard_data(data);
assert_eq!(decoded, Some("Hello".to_string()));
}
_ => panic!("Expected Clipboard"),
}
}
#[test]
fn test_build_osc_7() {
let osc = OSCParser::build_osc_7(Some("localhost"), "/home/user");
let results = OSCParser::parse(&osc);
assert_eq!(results.len(), 1);
match &results[0].sequence {
OSCSequence::CurrentDirectory { hostname, path } => {
assert_eq!(hostname.as_deref(), Some("localhost"));
assert_eq!(path, "/home/user");
}
_ => panic!("Expected CurrentDirectory"),
}
}
#[test]
fn test_build_osc_133() {
let osc = OSCParser::build_osc_133(PromptMarkType::PromptStart);
let results = OSCParser::parse(&osc);
assert_eq!(results.len(), 1);
match &results[0].sequence {
OSCSequence::PromptMark { mark_type } => {
assert_eq!(*mark_type, PromptMarkType::PromptStart);
}
_ => panic!("Expected PromptMark"),
}
}
#[test]
fn test_prompt_mark_type_roundtrip() {
let types = [
PromptMarkType::PromptStart,
PromptMarkType::CommandStart,
PromptMarkType::CommandExecuted,
PromptMarkType::CommandFinished,
PromptMarkType::Unknown('X'),
];
for mark_type in types {
let c = mark_type.to_char();
let parsed = PromptMarkType::from_char(c);
assert_eq!(mark_type, parsed);
}
}
#[test]
fn test_parse_range() {
let data = b"ABC\x1b]7;file:///home\x07XYZ";
let results = OSCParser::parse(data);
assert_eq!(results.len(), 1);
assert_eq!(results[0].range.start, 3);
// OSC 序列: \x1b]7;file:///home\x07
// = ESC(1) + ](1) + "7;file:///home"(14) + BEL(1) = 17 bytes
// 所以 end = 3 + 17 = 20
assert_eq!(results[0].range.end, 20);
}
}
@@ -1,569 +0,0 @@
//! 状态重同步控制器
//!
//! 负责在连接恢复时重建终端状态,包括检查控制器状态、发送终端重置序列、恢复历史数据。
//!
//! ## 功能
//! - 检查当前控制器状态并决定是否需要重启
//! - 连接名称变更时停止旧控制器并创建新控制器
//! - 发送终端重置序列(重置属性、显示光标、禁用鼠标跟踪等)
//! - 从 BlockFile 读取历史输出并推送到前端
//!
//! ## Requirements
//! - 2.1: 检查当前控制器状态并决定是否需要重启
//! - 2.2: 连接名称变更时停止当前控制器并使用新连接重新创建
//! - 2.3: 发送终端重置序列
//! - 2.4: 从 BlockFile 读取历史输出并推送到前端
//! - 2.5: 控制器状态为 "init" 或 "done" 时启动新的控制器实例
//! - 2.6: force 参数为 true 时强制重启控制器
use std::sync::Arc;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use crate::block_controller::{
BlockController, BlockControllerRuntimeStatus, BlockMeta, ControllerRegistry, RuntimeOpts,
ShellController,
};
use crate::emit_helper;
use crate::emitter::TerminalEventEmitter;
use crate::error::TerminalError;
use crate::events::{event_names, TerminalOutputEvent};
use crate::persistence::BlockFile;
/// 终端重置序列
///
/// 用于在重同步时重置终端状态,包括:
/// - ESC c: 完全重置终端
/// - ESC [?25h: 显示光标
/// - ESC [?1000l: 禁用鼠标跟踪
/// - ESC [?1002l: 禁用按钮事件鼠标跟踪
/// - ESC [?1003l: 禁用任意事件鼠标跟踪
/// - ESC [?1006l: 禁用 SGR 鼠标模式
/// - ESC [0m: 重置所有属性
pub const TERMINAL_RESET_SEQUENCE: &[u8] =
b"\x1bc\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[0m";
/// 软重置序列(不完全重置终端)
///
/// 用于较轻量的重置,保留部分状态:
/// - ESC [!p: 软重置
/// - ESC [?25h: 显示光标
/// - ESC [0m: 重置所有属性
pub const TERMINAL_SOFT_RESET_SEQUENCE: &[u8] = b"\x1b[!p\x1b[?25h\x1b[0m";
/// 重同步选项
#[derive(Debug, Clone, Default)]
pub struct ResyncOptions {
/// 是否强制重启控制器
pub force: bool,
/// 是否发送完全重置序列(否则使用软重置)
pub full_reset: bool,
/// 是否恢复历史数据
pub restore_history: bool,
}
impl ResyncOptions {
/// 创建默认选项
pub fn new() -> Self {
Self {
force: false,
full_reset: false,
restore_history: true,
}
}
/// 设置强制重启
pub fn with_force(mut self, force: bool) -> Self {
self.force = force;
self
}
/// 设置完全重置
pub fn with_full_reset(mut self, full_reset: bool) -> Self {
self.full_reset = full_reset;
self
}
/// 设置是否恢复历史
pub fn with_restore_history(mut self, restore: bool) -> Self {
self.restore_history = restore;
self
}
}
/// 重同步结果
#[derive(Debug, Clone, Default)]
pub struct ResyncResult {
/// 是否创建了新控制器
pub controller_created: bool,
/// 是否重启了控制器
pub controller_restarted: bool,
/// 是否恢复了历史数据
pub history_restored: bool,
/// 恢复的历史数据大小(字节)
pub history_size: usize,
}
/// 状态重同步控制器
///
/// 提供终端状态重同步功能,用于在连接恢复或控制器状态变更时重建终端状态。
pub struct ResyncController;
impl ResyncController {
/// 重同步控制器
///
/// 检查当前控制器状态并决定是否需要重启、创建新控制器或恢复历史数据。
///
/// # 参数
/// - `registry`: 控制器注册表
/// - `tab_id`: Tab ID
/// - `block_id`: Block ID
/// - `block_meta`: 块元数据
/// - `rt_opts`: 运行时选项
/// - `app_handle`: Tauri 应用句柄
/// - `block_file`: 块文件(可选)
/// - `options`: 重同步选项
///
/// # 返回
/// - `Ok(ResyncResult)`: 重同步结果
/// - `Err(TerminalError)`: 重同步失败
///
/// _Requirements: 2.1, 2.2, 2.5, 2.6_
pub async fn resync_controller(
registry: &ControllerRegistry,
tab_id: &str,
block_id: &str,
block_meta: BlockMeta,
rt_opts: Option<RuntimeOpts>,
app_handle: impl TerminalEventEmitter,
block_file: Option<Arc<BlockFile>>,
options: ResyncOptions,
) -> Result<ResyncResult, TerminalError> {
let mut result = ResyncResult::default();
// 1. 获取控制器名称
let controller_name = block_meta.get_string("controller");
let conn_name = block_meta.connection.clone();
tracing::info!(
"[ResyncController] 开始重同步: block_id={}, controller={}, conn={:?}, force={}",
block_id,
controller_name,
conn_name,
options.force
);
// 2. 如果不需要控制器,停止现有的
if controller_name.is_empty() {
if let Some(ctrl) = registry.get(block_id).await {
tracing::info!(
"[ResyncController] 控制器名称为空,停止现有控制器: block_id={}",
block_id
);
let mut ctrl_guard = ctrl.write().await;
ctrl_guard.stop(true, "done".to_string()).await?;
drop(ctrl_guard);
registry.remove(block_id).await;
}
return Ok(result);
}
// 3. 获取现有控制器
let existing = registry.get(block_id).await;
// 4. 检查是否需要替换控制器类型
if let Some(ref ctrl) = existing {
let ctrl_guard = ctrl.read().await;
let status = ctrl_guard.get_runtime_status();
let current_type = ctrl_guard.controller_type();
// 检查控制器类型是否变更
let needs_replace = Self::check_needs_replace(current_type, &controller_name);
if needs_replace {
tracing::info!(
"[ResyncController] 控制器类型变更,需要替换: block_id={}, old={}, new={}",
block_id,
current_type,
controller_name
);
drop(ctrl_guard);
// 停止并删除旧控制器
let mut ctrl_guard = ctrl.write().await;
ctrl_guard.stop(true, "done".to_string()).await?;
drop(ctrl_guard);
registry.remove(block_id).await;
} else if controller_name == "shell" || controller_name == "cmd" {
// 检查连接是否变更
if status.shell_proc_status == "running" {
if let Some(ref new_conn) = conn_name {
if status.shell_proc_conn_name.as_ref() != Some(new_conn) {
tracing::info!(
"[ResyncController] 连接变更,需要重启: block_id={}, old={:?}, new={}",
block_id,
status.shell_proc_conn_name,
new_conn
);
drop(ctrl_guard);
// 停止控制器但不删除,设置状态为 init
let mut ctrl_guard = ctrl.write().await;
ctrl_guard.stop(true, "init".to_string()).await?;
result.controller_restarted = true;
}
}
}
}
}
// 5. 强制重启
if options.force {
if let Some(ctrl) = registry.get(block_id).await {
tracing::info!("[ResyncController] 强制重启控制器: block_id={}", block_id);
let mut ctrl_guard = ctrl.write().await;
ctrl_guard.stop(true, "init".to_string()).await?;
result.controller_restarted = true;
}
}
// 6. 创建或重用控制器
let controller = match registry.get(block_id).await {
Some(ctrl) => ctrl,
None => {
tracing::info!(
"[ResyncController] 创建新控制器: block_id={}, type={}",
block_id,
controller_name
);
let ctrl = Self::create_controller(
&controller_name,
tab_id,
block_id,
app_handle.clone(),
block_file.clone(),
)?;
registry.register(block_id.to_string(), ctrl).await;
result.controller_created = true;
registry
.get(block_id)
.await
.ok_or_else(|| TerminalError::Internal("控制器注册失败".to_string()))?
}
};
// 7. 检查是否需要启动
let status = {
let ctrl_guard = controller.read().await;
ctrl_guard.get_runtime_status()
};
if status.shell_proc_status == "init" || status.shell_proc_status == "done" {
tracing::info!(
"[ResyncController] 启动控制器: block_id={}, status={}",
block_id,
status.shell_proc_status
);
// 发送重置序列
Self::send_reset_sequence(&app_handle, block_id, options.full_reset)?;
// 恢复历史数据
if options.restore_history {
if let Some(ref bf) = block_file {
let history_size = Self::restore_history(&app_handle, block_id, bf)?;
if history_size > 0 {
result.history_restored = true;
result.history_size = history_size;
}
}
}
// 启动控制器
let mut ctrl_guard = controller.write().await;
ctrl_guard.start(block_meta, rt_opts, options.force).await?;
}
tracing::info!(
"[ResyncController] 重同步完成: block_id={}, created={}, restarted={}, history_restored={}",
block_id,
result.controller_created,
result.controller_restarted,
result.history_restored
);
Ok(result)
}
/// 检查是否需要替换控制器
///
/// # 参数
/// - `current_type`: 当前控制器类型
/// - `new_type`: 新控制器类型
///
/// # 返回
/// 需要替换返回 true
fn check_needs_replace(current_type: &str, new_type: &str) -> bool {
// 如果类型不同,需要替换
// shell 和 cmd 可以互相转换(都是 ShellController)
if current_type == new_type {
return false;
}
// shell 和 cmd 之间不需要替换控制器实例,只需要重启
if (current_type == "shell" || current_type == "cmd")
&& (new_type == "shell" || new_type == "cmd")
{
return false;
}
true
}
/// 创建控制器
///
/// # 参数
/// - `controller_name`: 控制器类型名称
/// - `tab_id`: Tab ID
/// - `block_id`: Block ID
/// - `app_handle`: Tauri 应用句柄
/// - `block_file`: 块文件(可选)
///
/// # 返回
/// 创建的控制器实例
fn create_controller(
controller_name: &str,
tab_id: &str,
block_id: &str,
app_handle: impl TerminalEventEmitter,
block_file: Option<Arc<BlockFile>>,
) -> Result<Box<dyn BlockController>, TerminalError> {
match controller_name {
"shell" | "cmd" => {
let controller = if let Some(bf) = block_file {
ShellController::with_block_file(
tab_id.to_string(),
block_id.to_string(),
controller_name.to_string(),
app_handle,
bf,
)
} else {
ShellController::new(
tab_id.to_string(),
block_id.to_string(),
controller_name.to_string(),
app_handle,
)
};
Ok(Box::new(controller))
}
_ => Err(TerminalError::Internal(format!(
"未知的控制器类型: {controller_name}"
))),
}
}
/// 发送终端重置序列
///
/// 通过 Tauri 事件发送终端重置序列到前端。
///
/// # 参数
/// - `app_handle`: Tauri 应用句柄
/// - `block_id`: Block ID
/// - `full_reset`: 是否使用完全重置序列
///
/// _Requirements: 2.3_
fn send_reset_sequence(
app_handle: &impl TerminalEventEmitter,
block_id: &str,
full_reset: bool,
) -> Result<(), TerminalError> {
let reset_data = if full_reset {
TERMINAL_RESET_SEQUENCE
} else {
TERMINAL_SOFT_RESET_SEQUENCE
};
let data = BASE64.encode(reset_data);
emit_helper::emit(
app_handle,
event_names::TERMINAL_OUTPUT,
&TerminalOutputEvent {
session_id: block_id.to_string(),
data,
},
)
.map_err(|e| TerminalError::Internal(format!("发送重置序列失败: {e}")))?;
tracing::debug!(
"[ResyncController] 发送重置序列: block_id={}, full={}",
block_id,
full_reset
);
Ok(())
}
/// 恢复历史数据
///
/// 从 BlockFile 读取历史数据并通过 Tauri 事件发送到前端。
///
/// # 参数
/// - `app_handle`: Tauri 应用句柄
/// - `block_id`: Block ID
/// - `block_file`: 块文件
///
/// # 返回
/// 恢复的数据大小(字节)
///
/// _Requirements: 2.4_
fn restore_history(
app_handle: &impl TerminalEventEmitter,
block_id: &str,
block_file: &BlockFile,
) -> Result<usize, TerminalError> {
let history_data = block_file.read_all()?;
if history_data.is_empty() {
tracing::debug!(
"[ResyncController] 无历史数据需要恢复: block_id={}",
block_id
);
return Ok(0);
}
let data_size = history_data.len();
let data = BASE64.encode(&history_data);
emit_helper::emit(
app_handle,
event_names::TERMINAL_OUTPUT,
&TerminalOutputEvent {
session_id: block_id.to_string(),
data,
},
)
.map_err(|e| TerminalError::Internal(format!("发送历史数据失败: {e}")))?;
tracing::info!(
"[ResyncController] 恢复历史数据: block_id={}, size={} bytes",
block_id,
data_size
);
Ok(data_size)
}
/// 停止并删除控制器
///
/// # 参数
/// - `registry`: 控制器注册表
/// - `block_id`: Block ID
/// - `graceful`: 是否优雅停止
pub async fn stop_and_remove_controller(
registry: &ControllerRegistry,
block_id: &str,
graceful: bool,
) -> Result<(), TerminalError> {
if let Some(ctrl) = registry.get(block_id).await {
let mut ctrl_guard = ctrl.write().await;
ctrl_guard.stop(graceful, "done".to_string()).await?;
drop(ctrl_guard);
registry.remove(block_id).await;
tracing::info!(
"[ResyncController] 停止并删除控制器: block_id={}, graceful={}",
block_id,
graceful
);
}
Ok(())
}
/// 获取控制器状态
///
/// # 参数
/// - `registry`: 控制器注册表
/// - `block_id`: Block ID
///
/// # 返回
/// 控制器运行时状态,如果控制器不存在返回 None
pub async fn get_controller_status(
registry: &ControllerRegistry,
block_id: &str,
) -> Option<BlockControllerRuntimeStatus> {
if let Some(ctrl) = registry.get(block_id).await {
let ctrl_guard = ctrl.read().await;
Some(ctrl_guard.get_runtime_status())
} else {
None
}
}
/// 检查是否需要重同步
///
/// 根据当前控制器状态和块元数据判断是否需要重同步。
///
/// # 参数
/// - `status`: 当前控制器状态(如果存在)
/// - `block_meta`: 块元数据
///
/// # 返回
/// 需要重同步返回 true
pub fn needs_resync(
status: Option<&BlockControllerRuntimeStatus>,
block_meta: &BlockMeta,
) -> bool {
let controller_name = block_meta.get_string("controller");
// 如果没有控制器状态,需要重同步
let Some(status) = status else {
return !controller_name.is_empty();
};
// 如果控制器名称为空但有控制器,需要重同步(停止控制器)
if controller_name.is_empty() {
return true;
}
// 如果状态为 init 或 done,需要重同步
if status.shell_proc_status == "init" || status.shell_proc_status == "done" {
return true;
}
// 如果连接变更,需要重同步
if let Some(ref new_conn) = block_meta.connection {
if status.shell_proc_conn_name.as_ref() != Some(new_conn) {
return true;
}
}
false
}
}
/// 便捷函数:执行简单的重同步
///
/// 使用默认选项执行重同步。
pub async fn resync_controller(
registry: &ControllerRegistry,
tab_id: &str,
block_id: &str,
block_meta: BlockMeta,
rt_opts: Option<RuntimeOpts>,
app_handle: impl TerminalEventEmitter,
block_file: Option<Arc<BlockFile>>,
force: bool,
) -> Result<ResyncResult, TerminalError> {
let options = ResyncOptions::new().with_force(force);
ResyncController::resync_controller(
registry, tab_id, block_id, block_meta, rt_opts, app_handle, block_file, options,
)
.await
}
@@ -1,713 +0,0 @@
//! Shell 集成处理器
//!
//! 处理 Shell 集成功能,包括:
//! - 当前工作目录跟踪
//! - 命令执行状态管理
//! - 命令时间记录
//! - OSC 序列处理
//!
//! ## 功能
//! - 处理 OSC 7 更新当前目录
//! - 处理 OSC 52 剪贴板操作
//! - 处理 OSC 133 命令提示符标记
//! - 处理 OSC 16162 Wave 命令
//!
//! ## Requirements
//! - 6.5: 支持 bash、zsh、fish、pwsh 四种 Shell 类型
//! - 6.6: Shell 集成状态变更事件通知
//! - 6.8: 命令开始和结束时间记录
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use super::osc_parser::{OSCParser, OSCSequence, PromptMarkType};
use crate::emit_helper;
use crate::emitter::TerminalEventEmit;
use crate::error::TerminalError;
use crate::events::event_names;
/// Shell 类型
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ShellType {
/// Bash shell
Bash,
/// Zsh shell
Zsh,
/// Fish shell
Fish,
/// PowerShell
Pwsh,
/// 未知 Shell
#[default]
Unknown,
}
impl ShellType {
/// 从 Shell 路径推断 Shell 类型
pub fn from_path(path: &str) -> Self {
let path_lower = path.to_lowercase();
if path_lower.contains("bash") {
Self::Bash
} else if path_lower.contains("zsh") {
Self::Zsh
} else if path_lower.contains("fish") {
Self::Fish
} else if path_lower.contains("pwsh") || path_lower.contains("powershell") {
Self::Pwsh
} else {
Self::Unknown
}
}
/// 获取 Shell 名称
pub fn name(&self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
Self::Fish => "fish",
Self::Pwsh => "pwsh",
Self::Unknown => "unknown",
}
}
}
/// Shell 集成状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ShellIntegrationStatus {
/// 就绪状态(等待用户输入)
Ready,
/// 正在执行命令
RunningCommand,
/// 未知状态
#[default]
Unknown,
}
/// 命令执行信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandInfo {
/// 命令开始时间(Unix 时间戳,毫秒)
pub start_time: i64,
/// 命令结束时间(Unix 时间戳,毫秒)
pub end_time: Option<i64>,
/// 命令持续时间(毫秒)
pub duration_ms: Option<i64>,
}
impl CommandInfo {
/// 创建新的命令信息
pub fn new() -> Self {
Self {
start_time: current_timestamp_ms(),
end_time: None,
duration_ms: None,
}
}
/// 标记命令结束
pub fn finish(&mut self) {
let end = current_timestamp_ms();
self.end_time = Some(end);
self.duration_ms = Some(end - self.start_time);
}
}
impl Default for CommandInfo {
fn default() -> Self {
Self::new()
}
}
/// Shell 集成状态变更事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellIntegrationEvent {
/// Block ID
pub block_id: String,
/// 新状态
pub status: ShellIntegrationStatus,
/// 当前目录(如果有变更)
pub current_dir: Option<String>,
/// 命令信息(如果有)
pub command_info: Option<CommandInfo>,
}
/// Shell 集成处理器
///
/// 管理单个终端会话的 Shell 集成状态。
pub struct ShellIntegration {
/// Block ID
block_id: String,
/// Shell 类型
shell_type: RwLock<ShellType>,
/// 当前工作目录
current_dir: RwLock<Option<String>>,
/// 集成状态
status: RwLock<ShellIntegrationStatus>,
/// 当前命令信息
current_command: RwLock<Option<CommandInfo>>,
/// 上次命令开始时间
last_command_start: AtomicI64,
/// 事件发射器(可选)
app_handle: Option<std::sync::Arc<dyn TerminalEventEmit>>,
}
impl ShellIntegration {
/// 创建新的 Shell 集成处理器
pub fn new(block_id: String) -> Self {
Self {
block_id,
shell_type: RwLock::new(ShellType::Unknown),
current_dir: RwLock::new(None),
status: RwLock::new(ShellIntegrationStatus::Unknown),
current_command: RwLock::new(None),
last_command_start: AtomicI64::new(0),
app_handle: None,
}
}
/// 创建带有事件发射器的 Shell 集成处理器
pub fn with_app_handle(block_id: String, app_handle: impl TerminalEventEmit) -> Self {
Self {
block_id,
shell_type: RwLock::new(ShellType::Unknown),
current_dir: RwLock::new(None),
status: RwLock::new(ShellIntegrationStatus::Unknown),
current_command: RwLock::new(None),
last_command_start: AtomicI64::new(0),
app_handle: Some(std::sync::Arc::new(app_handle)),
}
}
/// 设置 Shell 类型
///
/// # 参数
/// - `shell_type`: Shell 类型
pub fn set_shell_type(&self, shell_type: ShellType) {
let mut guard = self.shell_type.write().unwrap();
*guard = shell_type;
}
/// 从 Shell 路径设置 Shell 类型
///
/// # 参数
/// - `path`: Shell 可执行文件路径
pub fn set_shell_type_from_path(&self, path: &str) {
self.set_shell_type(ShellType::from_path(path));
}
/// 获取 Shell 类型
pub fn get_shell_type(&self) -> ShellType {
*self.shell_type.read().unwrap()
}
/// 获取当前工作目录
pub fn get_current_dir(&self) -> Option<String> {
self.current_dir.read().unwrap().clone()
}
/// 获取集成状态
pub fn get_status(&self) -> ShellIntegrationStatus {
*self.status.read().unwrap()
}
/// 获取当前命令信息
pub fn get_current_command(&self) -> Option<CommandInfo> {
self.current_command.read().unwrap().clone()
}
/// 处理 PTY 输出数据
///
/// 解析数据中的 OSC 序列并更新状态。
///
/// # 参数
/// - `data`: PTY 输出数据
///
/// # 返回
/// 处理的 OSC 序列数量
pub fn process_output(&self, data: &[u8]) -> usize {
let parsed = OSCParser::parse(data);
let count = parsed.len();
for osc in parsed {
if let Err(e) = self.process_osc(&osc.sequence) {
tracing::warn!(
"[ShellIntegration] 处理 OSC 序列失败: block_id={}, error={}",
self.block_id,
e
);
}
}
count
}
/// 处理单个 OSC 序列
///
/// # 参数
/// - `sequence`: OSC 序列
///
/// _Requirements: 6.1, 6.2, 6.3, 6.4_
pub fn process_osc(&self, sequence: &OSCSequence) -> Result<(), TerminalError> {
match sequence {
OSCSequence::CurrentDirectory { hostname: _, path } => {
self.update_current_dir(path.clone());
}
OSCSequence::Clipboard { selection, data } => {
self.handle_clipboard(selection, data)?;
}
OSCSequence::PromptMark { mark_type } => {
self.handle_prompt_mark(*mark_type);
}
OSCSequence::WaveCommand { command } => {
self.handle_wave_command(command)?;
}
OSCSequence::Unknown { code, params } => {
tracing::debug!(
"[ShellIntegration] 未知 OSC 序列: block_id={}, code={}, params={}",
self.block_id,
code,
params
);
}
}
Ok(())
}
/// 更新当前工作目录
///
/// _Requirements: 6.1_
fn update_current_dir(&self, path: String) {
let old_dir = {
let mut guard = self.current_dir.write().unwrap();
let old = guard.clone();
*guard = Some(path.clone());
old
};
if old_dir.as_ref() != Some(&path) {
tracing::debug!(
"[ShellIntegration] 目录变更: block_id={}, old={:?}, new={}",
self.block_id,
old_dir,
path
);
// 发送状态变更事件
self.send_status_event(Some(path), None);
}
}
/// 处理剪贴板操作
///
/// _Requirements: 6.2_
fn handle_clipboard(&self, selection: &str, data: &str) -> Result<(), TerminalError> {
if data == "?" {
// 查询请求,暂不支持
tracing::debug!(
"[ShellIntegration] 剪贴板查询请求: block_id={}, selection={}",
self.block_id,
selection
);
return Ok(());
}
// 解码剪贴板数据
if let Some(content) = OSCParser::decode_clipboard_data(data) {
tracing::debug!(
"[ShellIntegration] 剪贴板写入: block_id={}, selection={}, len={}",
self.block_id,
selection,
content.len()
);
// 发送剪贴板事件到前端
if let Some(ref app_handle) = self.app_handle {
let _ = emit_helper::emit(
app_handle.as_ref(),
event_names::CLIPBOARD_WRITE,
&serde_json::json!({
"block_id": self.block_id,
"selection": selection,
"content": content,
}),
);
}
}
Ok(())
}
/// 处理命令提示符标记
///
/// _Requirements: 6.3, 6.6, 6.8_
fn handle_prompt_mark(&self, mark_type: PromptMarkType) {
match mark_type {
PromptMarkType::PromptStart => {
// 提示符开始,命令已结束
self.finish_command();
self.set_status(ShellIntegrationStatus::Ready);
}
PromptMarkType::CommandStart => {
// 用户开始输入命令
// 状态保持 Ready
}
PromptMarkType::CommandExecuted => {
// 命令开始执行
self.start_command();
self.set_status(ShellIntegrationStatus::RunningCommand);
}
PromptMarkType::CommandFinished => {
// 命令执行完成
self.finish_command();
self.set_status(ShellIntegrationStatus::Ready);
}
PromptMarkType::Unknown(c) => {
tracing::debug!(
"[ShellIntegration] 未知提示符标记: block_id={}, mark={}",
self.block_id,
c
);
}
}
}
/// 处理 Wave 命令
///
/// _Requirements: 6.4_
fn handle_wave_command(&self, command: &str) -> Result<(), TerminalError> {
tracing::debug!(
"[ShellIntegration] Wave 命令: block_id={}, command={}",
self.block_id,
command
);
// 解析命令
let parts: Vec<&str> = command.splitn(2, ' ').collect();
let cmd = parts.first().unwrap_or(&"");
let args = parts.get(1).unwrap_or(&"");
match *cmd {
"setcwd" => {
// 设置当前目录
if !args.is_empty() {
self.update_current_dir(args.to_string());
}
}
"setshell" => {
// 设置 Shell 类型
if !args.is_empty() {
self.set_shell_type_from_path(args);
}
}
_ => {
tracing::debug!(
"[ShellIntegration] 未知 Wave 命令: block_id={}, cmd={}",
self.block_id,
cmd
);
}
}
Ok(())
}
/// 设置状态
fn set_status(&self, new_status: ShellIntegrationStatus) {
let old_status = {
let mut guard = self.status.write().unwrap();
let old = *guard;
*guard = new_status;
old
};
if old_status != new_status {
tracing::debug!(
"[ShellIntegration] 状态变更: block_id={}, old={:?}, new={:?}",
self.block_id,
old_status,
new_status
);
// 发送状态变更事件
self.send_status_event(None, self.get_current_command());
}
}
/// 开始命令
///
/// _Requirements: 6.8_
fn start_command(&self) {
let now = current_timestamp_ms();
self.last_command_start.store(now, Ordering::SeqCst);
let mut guard = self.current_command.write().unwrap();
*guard = Some(CommandInfo::new());
tracing::debug!(
"[ShellIntegration] 命令开始: block_id={}, time={}",
self.block_id,
now
);
}
/// 结束命令
///
/// _Requirements: 6.8_
fn finish_command(&self) {
let mut guard = self.current_command.write().unwrap();
if let Some(ref mut cmd) = *guard {
cmd.finish();
tracing::debug!(
"[ShellIntegration] 命令结束: block_id={}, duration_ms={:?}",
self.block_id,
cmd.duration_ms
);
}
}
/// 发送状态变更事件
///
/// _Requirements: 6.6_
fn send_status_event(&self, current_dir: Option<String>, command_info: Option<CommandInfo>) {
if let Some(ref app_handle) = self.app_handle {
let event = ShellIntegrationEvent {
block_id: self.block_id.clone(),
status: self.get_status(),
current_dir: current_dir.or_else(|| self.get_current_dir()),
command_info,
};
if let Err(e) = emit_helper::emit(
app_handle.as_ref(),
event_names::SHELL_INTEGRATION_STATUS,
&event,
) {
tracing::warn!(
"[ShellIntegration] 发送状态事件失败: block_id={}, error={}",
self.block_id,
e
);
}
}
}
/// 重置状态
pub fn reset(&self) {
{
let mut guard = self.current_dir.write().unwrap();
*guard = None;
}
{
let mut guard = self.status.write().unwrap();
*guard = ShellIntegrationStatus::Unknown;
}
{
let mut guard = self.current_command.write().unwrap();
*guard = None;
}
self.last_command_start.store(0, Ordering::SeqCst);
tracing::debug!("[ShellIntegration] 状态重置: block_id={}", self.block_id);
}
}
/// 获取当前时间戳(毫秒)
fn current_timestamp_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_shell_type_from_path() {
assert_eq!(ShellType::from_path("/bin/bash"), ShellType::Bash);
assert_eq!(ShellType::from_path("/usr/bin/zsh"), ShellType::Zsh);
assert_eq!(ShellType::from_path("/usr/local/bin/fish"), ShellType::Fish);
assert_eq!(ShellType::from_path("/usr/bin/pwsh"), ShellType::Pwsh);
assert_eq!(
ShellType::from_path("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
ShellType::Pwsh
);
assert_eq!(ShellType::from_path("/bin/sh"), ShellType::Unknown);
}
#[test]
fn test_shell_integration_status_default() {
let integration = ShellIntegration::new("test-block".to_string());
assert_eq!(integration.get_status(), ShellIntegrationStatus::Unknown);
assert!(integration.get_current_dir().is_none());
}
#[test]
fn test_process_osc_7() {
let integration = ShellIntegration::new("test-block".to_string());
let osc = OSCSequence::CurrentDirectory {
hostname: Some("localhost".to_string()),
path: "/home/user".to_string(),
};
integration.process_osc(&osc).unwrap();
assert_eq!(
integration.get_current_dir(),
Some("/home/user".to_string())
);
}
#[test]
fn test_process_osc_133_prompt_start() {
let integration = ShellIntegration::new("test-block".to_string());
// 先设置为 RunningCommand
let osc_exec = OSCSequence::PromptMark {
mark_type: PromptMarkType::CommandExecuted,
};
integration.process_osc(&osc_exec).unwrap();
assert_eq!(
integration.get_status(),
ShellIntegrationStatus::RunningCommand
);
// 然后 PromptStart 应该切换到 Ready
let osc_prompt = OSCSequence::PromptMark {
mark_type: PromptMarkType::PromptStart,
};
integration.process_osc(&osc_prompt).unwrap();
assert_eq!(integration.get_status(), ShellIntegrationStatus::Ready);
}
#[test]
fn test_process_osc_133_command_executed() {
let integration = ShellIntegration::new("test-block".to_string());
let osc = OSCSequence::PromptMark {
mark_type: PromptMarkType::CommandExecuted,
};
integration.process_osc(&osc).unwrap();
assert_eq!(
integration.get_status(),
ShellIntegrationStatus::RunningCommand
);
assert!(integration.get_current_command().is_some());
}
#[test]
fn test_process_osc_133_command_finished() {
let integration = ShellIntegration::new("test-block".to_string());
// 先执行命令
let osc_exec = OSCSequence::PromptMark {
mark_type: PromptMarkType::CommandExecuted,
};
integration.process_osc(&osc_exec).unwrap();
// 等待一小段时间
std::thread::sleep(std::time::Duration::from_millis(10));
// 命令结束
let osc_finish = OSCSequence::PromptMark {
mark_type: PromptMarkType::CommandFinished,
};
integration.process_osc(&osc_finish).unwrap();
assert_eq!(integration.get_status(), ShellIntegrationStatus::Ready);
let cmd_info = integration.get_current_command().unwrap();
assert!(cmd_info.end_time.is_some());
assert!(cmd_info.duration_ms.is_some());
assert!(cmd_info.duration_ms.unwrap() >= 10);
}
#[test]
fn test_process_wave_command_setcwd() {
let integration = ShellIntegration::new("test-block".to_string());
let osc = OSCSequence::WaveCommand {
command: "setcwd /home/user/projects".to_string(),
};
integration.process_osc(&osc).unwrap();
assert_eq!(
integration.get_current_dir(),
Some("/home/user/projects".to_string())
);
}
#[test]
fn test_process_wave_command_setshell() {
let integration = ShellIntegration::new("test-block".to_string());
let osc = OSCSequence::WaveCommand {
command: "setshell /usr/bin/zsh".to_string(),
};
integration.process_osc(&osc).unwrap();
assert_eq!(integration.get_shell_type(), ShellType::Zsh);
}
#[test]
fn test_process_output() {
let integration = ShellIntegration::new("test-block".to_string());
// 包含多个 OSC 序列的数据
let data = b"Hello\x1b]7;file:///home/user\x07World\x1b]133;A\x07End";
let count = integration.process_output(data);
assert_eq!(count, 2);
assert_eq!(
integration.get_current_dir(),
Some("/home/user".to_string())
);
assert_eq!(integration.get_status(), ShellIntegrationStatus::Ready);
}
#[test]
fn test_reset() {
let integration = ShellIntegration::new("test-block".to_string());
// 设置一些状态
let osc_dir = OSCSequence::CurrentDirectory {
hostname: None,
path: "/home/user".to_string(),
};
integration.process_osc(&osc_dir).unwrap();
let osc_exec = OSCSequence::PromptMark {
mark_type: PromptMarkType::CommandExecuted,
};
integration.process_osc(&osc_exec).unwrap();
// 重置
integration.reset();
assert!(integration.get_current_dir().is_none());
assert_eq!(integration.get_status(), ShellIntegrationStatus::Unknown);
assert!(integration.get_current_command().is_none());
}
#[test]
fn test_command_info() {
let mut cmd = CommandInfo::new();
assert!(cmd.start_time > 0);
assert!(cmd.end_time.is_none());
assert!(cmd.duration_ms.is_none());
std::thread::sleep(std::time::Duration::from_millis(5));
cmd.finish();
assert!(cmd.end_time.is_some());
assert!(cmd.duration_ms.is_some());
assert!(cmd.duration_ms.unwrap() >= 5);
}
}
@@ -1,988 +0,0 @@
//! Shell 集成脚本管理
//!
//! 提供各种 Shell 的集成脚本安装和管理功能。
//!
//! ## 功能
//! - 生成 Shell 集成脚本
//! - 安装脚本到用户目录
//! - 构建带集成的 Shell 启动命令
//!
//! ## 支持的 Shell
//! - Bash (--rcfile)
//! - Zsh (ZDOTDIR)
//! - Fish (-C source)
//! - PowerShell (pwsh)
//!
//! ## Requirements
//! - 17.5: 支持自定义 Shell 路径和参数
//! - 17.6: 支持自定义初始化脚本
//! - 17.7: 支持环境变量配置
//! - 17.8: zsh 使用 ZDOTDIR 指向集成目录
//! - 17.9: bash 使用 --rcfile 加载集成脚本
//! - 17.10: fish 使用 -C 参数 source 集成脚本
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::error::TerminalError;
use crate::integration::shell_integration::ShellType;
/// Shell 集成脚本目录名
const SHELL_INTEGRATION_DIR: &str = "shell-integration";
/// Bash 集成脚本内容
const BASH_INTEGRATION_SCRIPT: &str = r#"# Lime Shell Integration for Bash
# This script provides shell integration features
# 保存原始 PS1
if [ -z "$_LIME_ORIG_PS1" ]; then
_LIME_ORIG_PS1="$PS1"
fi
# OSC 7 - 报告当前工作目录
__lime_osc7() {
printf '\033]7;file://%s%s\033\\' "${HOSTNAME:-localhost}" "$PWD"
}
# OSC 133 - 命令提示符标记
__lime_prompt_start() {
printf '\033]133;A\033\\'
}
__lime_command_start() {
printf '\033]133;B\033\\'
}
__lime_command_executed() {
printf '\033]133;C\033\\'
}
__lime_command_finished() {
printf '\033]133;D;%s\033\\' "$?"
}
# 设置 PROMPT_COMMAND
__lime_precmd() {
local exit_code=$?
__lime_command_finished
__lime_osc7
__lime_prompt_start
return $exit_code
}
__lime_preexec() {
__lime_command_executed
}
# 安装 preexec 钩子(如果可用)
if [ -n "$BASH_VERSION" ]; then
# 使用 DEBUG trap 模拟 preexec
__lime_debug_trap() {
if [ -n "$COMP_LINE" ]; then
return
fi
if [ "$BASH_COMMAND" = "$PROMPT_COMMAND" ]; then
return
fi
__lime_preexec
}
trap '__lime_debug_trap' DEBUG
fi
# 设置 PROMPT_COMMAND
if [ -z "$PROMPT_COMMAND" ]; then
PROMPT_COMMAND="__lime_precmd"
else
PROMPT_COMMAND="__lime_precmd;$PROMPT_COMMAND"
fi
# 加载用户的 .bashrc(如果存在且我们是通过 --rcfile 启动的)
if [ -n "$_LIME_LOAD_BASHRC" ] && [ -f "$HOME/.bashrc" ]; then
source "$HOME/.bashrc"
fi
# 标记集成已加载
export LIME_SHELL_INTEGRATION=1
"#;
/// Zsh 集成脚本内容 (.zshrc)
const ZSH_INTEGRATION_SCRIPT: &str = r#"# Lime Shell Integration for Zsh
# This script provides shell integration features
# OSC 7 - 报告当前工作目录
__lime_osc7() {
printf '\033]7;file://%s%s\033\\' "${HOST:-localhost}" "$PWD"
}
# OSC 133 - 命令提示符标记
__lime_prompt_start() {
printf '\033]133;A\033\\'
}
__lime_command_start() {
printf '\033]133;B\033\\'
}
__lime_command_executed() {
printf '\033]133;C\033\\'
}
__lime_command_finished() {
printf '\033]133;D;%s\033\\' "$?"
}
# precmd 钩子 - 命令执行后
__lime_precmd() {
local exit_code=$?
__lime_command_finished
__lime_osc7
__lime_prompt_start
return $exit_code
}
# preexec 钩子 - 命令执行前
__lime_preexec() {
__lime_command_executed
}
# 注册钩子
autoload -Uz add-zsh-hook
add-zsh-hook precmd __lime_precmd
add-zsh-hook preexec __lime_preexec
# 加载用户的原始配置
if [ -n "$_LIME_ORIG_ZDOTDIR" ]; then
if [ -f "$_LIME_ORIG_ZDOTDIR/.zshrc" ]; then
source "$_LIME_ORIG_ZDOTDIR/.zshrc"
fi
elif [ -f "$HOME/.zshrc" ]; then
source "$HOME/.zshrc"
fi
# 标记集成已加载
export LIME_SHELL_INTEGRATION=1
"#;
/// Zsh .zshenv 内容(用于设置 ZDOTDIR)
const ZSH_ZSHENV_SCRIPT: &str = r#"# Lime Zsh Environment
# 保存原始 ZDOTDIR
if [ -z "$_LIME_ORIG_ZDOTDIR" ]; then
export _LIME_ORIG_ZDOTDIR="${ZDOTDIR:-$HOME}"
fi
# 加载原始 .zshenv
if [ -f "$_LIME_ORIG_ZDOTDIR/.zshenv" ]; then
source "$_LIME_ORIG_ZDOTDIR/.zshenv"
fi
"#;
/// Fish 集成脚本内容
const FISH_INTEGRATION_SCRIPT: &str = r#"# Lime Shell Integration for Fish
# This script provides shell integration features
# OSC 7 - 报告当前工作目录
function __lime_osc7 --on-variable PWD
printf '\033]7;file://%s%s\033\\' (hostname) $PWD
end
# OSC 133 - 命令提示符标记
function __lime_prompt_start
printf '\033]133;A\033\\'
end
function __lime_command_executed
printf '\033]133;C\033\\'
end
function __lime_command_finished
printf '\033]133;D;%s\033\\' $status
end
# Fish 事件钩子
function __lime_fish_prompt --on-event fish_prompt
__lime_command_finished
__lime_osc7
__lime_prompt_start
end
function __lime_fish_preexec --on-event fish_preexec
__lime_command_executed
end
# 初始化
__lime_osc7
# 标记集成已加载
set -gx LIME_SHELL_INTEGRATION 1
"#;
/// PowerShell 集成脚本内容
const PWSH_INTEGRATION_SCRIPT: &str = r#"# Lime Shell Integration for PowerShell
# This script provides shell integration features
# OSC 7 - 报告当前工作目录
function Send-LimeOsc7 {
$hostname = [System.Net.Dns]::GetHostName()
$pwd = $PWD.Path -replace '\\', '/'
Write-Host -NoNewline "`e]7;file://$hostname$pwd`e\"
}
# OSC 133 - 命令提示符标记
function Send-LimePromptStart {
Write-Host -NoNewline "`e]133;A`e\"
}
function Send-LimeCommandExecuted {
Write-Host -NoNewline "`e]133;C`e\"
}
function Send-LimeCommandFinished {
param([int]$ExitCode = 0)
Write-Host -NoNewline "`e]133;D;$ExitCode`e\"
}
# 保存原始 prompt 函数
if (-not (Test-Path Function:\__LimeOriginalPrompt)) {
if (Test-Path Function:\prompt) {
Copy-Item Function:\prompt Function:\__LimeOriginalPrompt
} else {
function __LimeOriginalPrompt { "PS $($PWD.Path)> " }
}
}
# 自定义 prompt 函数
function prompt {
$exitCode = $LASTEXITCODE
Send-LimeCommandFinished -ExitCode $exitCode
Send-LimeOsc7
Send-LimePromptStart
$LASTEXITCODE = $exitCode
__LimeOriginalPrompt
}
# PSReadLine 钩子(如果可用)
if (Get-Module -ListAvailable -Name PSReadLine) {
$existingHandler = (Get-PSReadLineOption).AddToHistoryHandler
Set-PSReadLineOption -AddToHistoryHandler {
param([string]$line)
Send-LimeCommandExecuted
if ($existingHandler) {
return & $existingHandler $line
}
return $true
}
}
# 标记集成已加载
$env:LIME_SHELL_INTEGRATION = "1"
"#;
/// Shell 集成脚本管理器
pub struct ShellScripts {
/// 集成脚本目录
integration_dir: PathBuf,
}
impl ShellScripts {
/// 创建新的 Shell 脚本管理器
///
/// # 参数
/// - `app_data_dir`: 应用数据目录
///
/// # 返回
/// 新的 ShellScripts 实例
pub fn new(app_data_dir: &Path) -> Self {
Self {
integration_dir: app_data_dir.join(SHELL_INTEGRATION_DIR),
}
}
/// 获取集成脚本目录
pub fn integration_dir(&self) -> &Path {
&self.integration_dir
}
/// 确保集成脚本目录存在
fn ensure_integration_dir(&self) -> Result<(), TerminalError> {
if !self.integration_dir.exists() {
fs::create_dir_all(&self.integration_dir)
.map_err(|e| TerminalError::Internal(format!("创建集成脚本目录失败: {e}")))?;
}
Ok(())
}
/// 安装所有 Shell 集成脚本
///
/// 将集成脚本写入应用数据目录。
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
pub fn install_all(&self) -> Result<(), TerminalError> {
self.ensure_integration_dir()?;
// 安装 Bash 脚本
self.install_bash_scripts()?;
// 安装 Zsh 脚本
self.install_zsh_scripts()?;
// 安装 Fish 脚本
self.install_fish_scripts()?;
// 安装 PowerShell 脚本
self.install_pwsh_scripts()?;
tracing::info!(
"[ShellScripts] 所有集成脚本已安装: dir={}",
self.integration_dir.display()
);
Ok(())
}
/// 安装 Bash 集成脚本
///
/// _Requirements: 17.9_
fn install_bash_scripts(&self) -> Result<(), TerminalError> {
let bash_dir = self.integration_dir.join("bash");
fs::create_dir_all(&bash_dir)
.map_err(|e| TerminalError::Internal(format!("创建 bash 目录失败: {e}")))?;
let script_path = bash_dir.join("lime.bash");
fs::write(&script_path, BASH_INTEGRATION_SCRIPT)
.map_err(|e| TerminalError::Internal(format!("写入 bash 脚本失败: {e}")))?;
tracing::debug!("[ShellScripts] Bash 脚本已安装: {}", script_path.display());
Ok(())
}
/// 安装 Zsh 集成脚本
///
/// _Requirements: 17.8_
fn install_zsh_scripts(&self) -> Result<(), TerminalError> {
let zsh_dir = self.integration_dir.join("zsh");
fs::create_dir_all(&zsh_dir)
.map_err(|e| TerminalError::Internal(format!("创建 zsh 目录失败: {e}")))?;
// 写入 .zshenv
let zshenv_path = zsh_dir.join(".zshenv");
fs::write(&zshenv_path, ZSH_ZSHENV_SCRIPT)
.map_err(|e| TerminalError::Internal(format!("写入 .zshenv 失败: {e}")))?;
// 写入 .zshrc
let zshrc_path = zsh_dir.join(".zshrc");
fs::write(&zshrc_path, ZSH_INTEGRATION_SCRIPT)
.map_err(|e| TerminalError::Internal(format!("写入 .zshrc 失败: {e}")))?;
tracing::debug!("[ShellScripts] Zsh 脚本已安装: {}", zsh_dir.display());
Ok(())
}
/// 安装 Fish 集成脚本
///
/// _Requirements: 17.10_
fn install_fish_scripts(&self) -> Result<(), TerminalError> {
let fish_dir = self.integration_dir.join("fish");
fs::create_dir_all(&fish_dir)
.map_err(|e| TerminalError::Internal(format!("创建 fish 目录失败: {e}")))?;
let script_path = fish_dir.join("lime.fish");
fs::write(&script_path, FISH_INTEGRATION_SCRIPT)
.map_err(|e| TerminalError::Internal(format!("写入 fish 脚本失败: {e}")))?;
tracing::debug!("[ShellScripts] Fish 脚本已安装: {}", script_path.display());
Ok(())
}
/// 安装 PowerShell 集成脚本
fn install_pwsh_scripts(&self) -> Result<(), TerminalError> {
let pwsh_dir = self.integration_dir.join("pwsh");
fs::create_dir_all(&pwsh_dir)
.map_err(|e| TerminalError::Internal(format!("创建 pwsh 目录失败: {e}")))?;
let script_path = pwsh_dir.join("lime.ps1");
fs::write(&script_path, PWSH_INTEGRATION_SCRIPT)
.map_err(|e| TerminalError::Internal(format!("写入 pwsh 脚本失败: {e}")))?;
tracing::debug!(
"[ShellScripts] PowerShell 脚本已安装: {}",
script_path.display()
);
Ok(())
}
/// 获取 Bash 集成脚本路径
pub fn bash_script_path(&self) -> PathBuf {
self.integration_dir.join("bash").join("lime.bash")
}
/// 获取 Zsh 集成目录路径(用于 ZDOTDIR)
pub fn zsh_integration_dir(&self) -> PathBuf {
self.integration_dir.join("zsh")
}
/// 获取 Fish 集成脚本路径
pub fn fish_script_path(&self) -> PathBuf {
self.integration_dir.join("fish").join("lime.fish")
}
/// 获取 PowerShell 集成脚本路径
pub fn pwsh_script_path(&self) -> PathBuf {
self.integration_dir.join("pwsh").join("lime.ps1")
}
/// 检查集成脚本是否已安装
pub fn is_installed(&self) -> bool {
self.bash_script_path().exists()
&& self.zsh_integration_dir().join(".zshrc").exists()
&& self.fish_script_path().exists()
&& self.pwsh_script_path().exists()
}
}
/// Shell 启动配置
///
/// 包含启动 Shell 所需的命令和环境变量配置。
#[derive(Debug, Clone)]
pub struct ShellLaunchConfig {
/// Shell 可执行文件路径
pub shell_path: String,
/// Shell 参数
pub args: Vec<String>,
/// 环境变量
pub env: HashMap<String, String>,
}
impl ShellLaunchConfig {
/// 创建新的 Shell 启动配置
pub fn new(shell_path: String) -> Self {
Self {
shell_path,
args: Vec::new(),
env: HashMap::new(),
}
}
/// 添加参数
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
/// 添加环境变量
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
}
/// Shell 启动配置构建器
///
/// 根据 Shell 类型和集成脚本路径构建启动配置。
pub struct ShellLaunchBuilder {
/// Shell 脚本管理器
scripts: ShellScripts,
/// Block ID(用于环境变量)
block_id: String,
}
impl ShellLaunchBuilder {
/// 创建新的启动配置构建器
///
/// # 参数
/// - `app_data_dir`: 应用数据目录
/// - `block_id`: Block ID
pub fn new(app_data_dir: &Path, block_id: String) -> Self {
Self {
scripts: ShellScripts::new(app_data_dir),
block_id,
}
}
/// 确保集成脚本已安装
pub fn ensure_scripts_installed(&self) -> Result<(), TerminalError> {
if !self.scripts.is_installed() {
self.scripts.install_all()?;
}
Ok(())
}
/// 构建 Shell 启动配置
///
/// 根据 Shell 类型构建带有集成脚本的启动配置。
///
/// # 参数
/// - `shell_path`: Shell 可执行文件路径
/// - `custom_env`: 自定义环境变量
///
/// # 返回
/// Shell 启动配置
///
/// _Requirements: 17.5, 17.6, 17.7, 17.8, 17.9, 17.10_
pub fn build(
&self,
shell_path: &str,
custom_env: Option<&HashMap<String, String>>,
) -> Result<ShellLaunchConfig, TerminalError> {
// 确保脚本已安装
self.ensure_scripts_installed()?;
let shell_type = ShellType::from_path(shell_path);
let mut config = ShellLaunchConfig::new(shell_path.to_string());
// 设置通用环境变量
config = self.set_common_env(config);
// 根据 Shell 类型设置特定配置
config = match shell_type {
ShellType::Bash => self.configure_bash(config)?,
ShellType::Zsh => self.configure_zsh(config)?,
ShellType::Fish => self.configure_fish(config)?,
ShellType::Pwsh => self.configure_pwsh(config)?,
ShellType::Unknown => {
tracing::warn!(
"[ShellLaunchBuilder] 未知 Shell 类型,不加载集成脚本: {}",
shell_path
);
config
}
};
// 添加自定义环境变量
if let Some(env) = custom_env {
for (key, value) in env {
config.env.insert(key.clone(), value.clone());
}
}
Ok(config)
}
/// 设置通用环境变量
///
/// 设置所有 Shell 类型共用的环境变量,包括:
/// - TERM: 终端类型
/// - COLORTERM: 颜色支持
/// - LIME_BLOCKID: 块 ID(与 WAVETERM_BLOCKID 兼容)
/// - WAVETERM_BLOCKID: Waveterm 兼容的块 ID
/// - LIME_VERSION: 应用版本
/// - LANG: 语言设置(如果未设置)
///
/// _Requirements: 17.2, 17.7_
fn set_common_env(&self, config: ShellLaunchConfig) -> ShellLaunchConfig {
let mut config = config
.env("TERM", "xterm-256color")
.env("COLORTERM", "truecolor")
.env("LIME_BLOCKID", &self.block_id)
// Waveterm 兼容性
.env("WAVETERM_BLOCKID", &self.block_id)
.env("LIME_VERSION", env!("CARGO_PKG_VERSION"));
// 设置 LANG(如果未设置)
if std::env::var("LANG").is_err() {
config = config.env("LANG", "en_US.UTF-8");
}
// 设置 LC_ALL(如果未设置)
if std::env::var("LC_ALL").is_err() {
config = config.env("LC_ALL", "en_US.UTF-8");
}
config
}
/// 配置 Bash 启动
///
/// 使用 --rcfile 参数加载集成脚本。
///
/// _Requirements: 17.9_
fn configure_bash(
&self,
config: ShellLaunchConfig,
) -> Result<ShellLaunchConfig, TerminalError> {
let script_path = self.scripts.bash_script_path();
if !script_path.exists() {
tracing::warn!(
"[ShellLaunchBuilder] Bash 集成脚本不存在: {}",
script_path.display()
);
return Ok(config);
}
let script_path_str = script_path.to_string_lossy().to_string();
Ok(config
.arg("--rcfile")
.arg(&script_path_str)
.env("_LIME_LOAD_BASHRC", "1"))
}
/// 配置 Zsh 启动
///
/// 使用 ZDOTDIR 环境变量指向集成目录。
///
/// _Requirements: 17.8_
fn configure_zsh(&self, config: ShellLaunchConfig) -> Result<ShellLaunchConfig, TerminalError> {
let zsh_dir = self.scripts.zsh_integration_dir();
if !zsh_dir.join(".zshrc").exists() {
tracing::warn!(
"[ShellLaunchBuilder] Zsh 集成脚本不存在: {}",
zsh_dir.display()
);
return Ok(config);
}
let zsh_dir_str = zsh_dir.to_string_lossy().to_string();
Ok(config.env("ZDOTDIR", &zsh_dir_str))
}
/// 配置 Fish 启动
///
/// 使用 -C 参数 source 集成脚本。
///
/// _Requirements: 17.10_
fn configure_fish(
&self,
config: ShellLaunchConfig,
) -> Result<ShellLaunchConfig, TerminalError> {
let script_path = self.scripts.fish_script_path();
if !script_path.exists() {
tracing::warn!(
"[ShellLaunchBuilder] Fish 集成脚本不存在: {}",
script_path.display()
);
return Ok(config);
}
let source_cmd = format!("source {}", script_path.to_string_lossy());
Ok(config.arg("-C").arg(&source_cmd))
}
/// 配置 PowerShell 启动
fn configure_pwsh(
&self,
config: ShellLaunchConfig,
) -> Result<ShellLaunchConfig, TerminalError> {
let script_path = self.scripts.pwsh_script_path();
if !script_path.exists() {
tracing::warn!(
"[ShellLaunchBuilder] PowerShell 集成脚本不存在: {}",
script_path.display()
);
return Ok(config);
}
let script_path_str = script_path.to_string_lossy().to_string();
// PowerShell 使用 -NoExit 保持会话,-Command 执行脚本
Ok(config
.arg("-NoExit")
.arg("-Command")
.arg(format!(". '{script_path_str}'")))
}
}
/// 终端环境变量配置
///
/// 提供终端环境变量的配置和管理功能。
///
/// ## 标准环境变量
/// - `TERM`: 终端类型(默认 xterm-256color)
/// - `COLORTERM`: 颜色支持(默认 truecolor)
/// - `LIME_BLOCKID`: 块 ID
/// - `WAVETERM_BLOCKID`: Waveterm 兼容的块 ID
/// - `LIME_VERSION`: 应用版本
/// - `LANG`: 语言设置
/// - `LC_ALL`: 区域设置
///
/// _Requirements: 17.2, 17.7_
#[derive(Debug, Clone, Default)]
pub struct TerminalEnvConfig {
/// 环境变量映射
env: HashMap<String, String>,
}
impl TerminalEnvConfig {
/// 创建新的环境变量配置
pub fn new() -> Self {
Self {
env: HashMap::new(),
}
}
/// 创建带有默认环境变量的配置
///
/// # 参数
/// - `block_id`: 块 ID
///
/// # 返回
/// 包含默认环境变量的配置
///
/// _Requirements: 17.2_
pub fn with_defaults(block_id: &str) -> Self {
let mut config = Self::new();
// 终端类型
config.set("TERM", "xterm-256color");
config.set("COLORTERM", "truecolor");
// 块标识
config.set("LIME_BLOCKID", block_id);
config.set("WAVETERM_BLOCKID", block_id); // Waveterm 兼容
// 版本信息
config.set("LIME_VERSION", env!("CARGO_PKG_VERSION"));
// 语言设置(如果未设置)
if std::env::var("LANG").is_err() {
config.set("LANG", "en_US.UTF-8");
}
if std::env::var("LC_ALL").is_err() {
config.set("LC_ALL", "en_US.UTF-8");
}
config
}
/// 设置环境变量
///
/// # 参数
/// - `key`: 环境变量名
/// - `value`: 环境变量值
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.env.insert(key.into(), value.into());
}
/// 获取环境变量
///
/// # 参数
/// - `key`: 环境变量名
///
/// # 返回
/// 环境变量值(如果存在)
pub fn get(&self, key: &str) -> Option<&String> {
self.env.get(key)
}
/// 移除环境变量
///
/// # 参数
/// - `key`: 环境变量名
///
/// # 返回
/// 被移除的值(如果存在)
pub fn remove(&mut self, key: &str) -> Option<String> {
self.env.remove(key)
}
/// 合并自定义环境变量
///
/// 自定义环境变量会覆盖已有的同名变量。
///
/// # 参数
/// - `custom_env`: 自定义环境变量
///
/// _Requirements: 17.7_
pub fn merge(&mut self, custom_env: &HashMap<String, String>) {
for (key, value) in custom_env {
self.env.insert(key.clone(), value.clone());
}
}
/// 获取所有环境变量
///
/// # 返回
/// 环境变量映射的引用
pub fn all(&self) -> &HashMap<String, String> {
&self.env
}
/// 转换为 HashMap
///
/// # 返回
/// 环境变量的 HashMap
pub fn into_map(self) -> HashMap<String, String> {
self.env
}
/// 检查是否包含指定的环境变量
///
/// # 参数
/// - `key`: 环境变量名
///
/// # 返回
/// 是否包含该环境变量
pub fn contains(&self, key: &str) -> bool {
self.env.contains_key(key)
}
/// 获取环境变量数量
pub fn len(&self) -> usize {
self.env.len()
}
/// 检查是否为空
pub fn is_empty(&self) -> bool {
self.env.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_shell_scripts_install() {
let temp_dir = TempDir::new().unwrap();
let scripts = ShellScripts::new(temp_dir.path());
scripts.install_all().unwrap();
assert!(scripts.bash_script_path().exists());
assert!(scripts.zsh_integration_dir().join(".zshrc").exists());
assert!(scripts.zsh_integration_dir().join(".zshenv").exists());
assert!(scripts.fish_script_path().exists());
assert!(scripts.pwsh_script_path().exists());
assert!(scripts.is_installed());
}
#[test]
fn test_shell_launch_builder_bash() {
let temp_dir = TempDir::new().unwrap();
let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string());
let config = builder.build("/bin/bash", None).unwrap();
assert_eq!(config.shell_path, "/bin/bash");
assert!(config.args.contains(&"--rcfile".to_string()));
assert!(config.env.contains_key("TERM"));
assert!(config.env.contains_key("LIME_BLOCKID"));
assert!(config.env.contains_key("WAVETERM_BLOCKID"));
assert_eq!(config.env.get("_LIME_LOAD_BASHRC"), Some(&"1".to_string()));
}
#[test]
fn test_shell_launch_builder_zsh() {
let temp_dir = TempDir::new().unwrap();
let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string());
let config = builder.build("/bin/zsh", None).unwrap();
assert_eq!(config.shell_path, "/bin/zsh");
assert!(config.env.contains_key("ZDOTDIR"));
assert!(config.env.contains_key("TERM"));
assert!(config.env.contains_key("WAVETERM_BLOCKID"));
}
#[test]
fn test_shell_launch_builder_fish() {
let temp_dir = TempDir::new().unwrap();
let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string());
let config = builder.build("/usr/bin/fish", None).unwrap();
assert_eq!(config.shell_path, "/usr/bin/fish");
assert!(config.args.contains(&"-C".to_string()));
assert!(config.env.contains_key("TERM"));
assert!(config.env.contains_key("WAVETERM_BLOCKID"));
}
#[test]
fn test_shell_launch_builder_pwsh() {
let temp_dir = TempDir::new().unwrap();
let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string());
let config = builder.build("/usr/bin/pwsh", None).unwrap();
assert_eq!(config.shell_path, "/usr/bin/pwsh");
assert!(config.args.contains(&"-NoExit".to_string()));
assert!(config.args.contains(&"-Command".to_string()));
assert!(config.env.contains_key("TERM"));
assert!(config.env.contains_key("WAVETERM_BLOCKID"));
}
#[test]
fn test_shell_launch_builder_custom_env() {
let temp_dir = TempDir::new().unwrap();
let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string());
let mut custom_env = HashMap::new();
custom_env.insert("MY_VAR".to_string(), "my_value".to_string());
let config = builder.build("/bin/bash", Some(&custom_env)).unwrap();
assert_eq!(config.env.get("MY_VAR"), Some(&"my_value".to_string()));
}
#[test]
fn test_shell_launch_config_builder_pattern() {
let config = ShellLaunchConfig::new("/bin/bash".to_string())
.arg("--login")
.arg("-i")
.env("FOO", "bar")
.env("BAZ", "qux");
assert_eq!(config.shell_path, "/bin/bash");
assert_eq!(config.args, vec!["--login", "-i"]);
assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
assert_eq!(config.env.get("BAZ"), Some(&"qux".to_string()));
}
#[test]
fn test_terminal_env_config_defaults() {
let config = TerminalEnvConfig::with_defaults("test-block");
assert_eq!(config.get("TERM"), Some(&"xterm-256color".to_string()));
assert_eq!(config.get("COLORTERM"), Some(&"truecolor".to_string()));
assert_eq!(config.get("LIME_BLOCKID"), Some(&"test-block".to_string()));
assert_eq!(
config.get("WAVETERM_BLOCKID"),
Some(&"test-block".to_string())
);
assert!(config.contains("LIME_VERSION"));
}
#[test]
fn test_terminal_env_config_merge() {
let mut config = TerminalEnvConfig::with_defaults("test-block");
let mut custom = HashMap::new();
custom.insert("MY_VAR".to_string(), "my_value".to_string());
custom.insert("TERM".to_string(), "xterm".to_string()); // 覆盖默认值
config.merge(&custom);
assert_eq!(config.get("MY_VAR"), Some(&"my_value".to_string()));
assert_eq!(config.get("TERM"), Some(&"xterm".to_string())); // 被覆盖
}
#[test]
fn test_terminal_env_config_operations() {
let mut config = TerminalEnvConfig::new();
config.set("KEY1", "value1");
config.set("KEY2", "value2");
assert_eq!(config.len(), 2);
assert!(!config.is_empty());
assert!(config.contains("KEY1"));
assert!(!config.contains("KEY3"));
let removed = config.remove("KEY1");
assert_eq!(removed, Some("value1".to_string()));
assert_eq!(config.len(), 1);
}
}
-64
View File
@@ -1,64 +0,0 @@
//! Lime 终端模块
//!
//! 提供 PTY 管理和会话管理能力,独立于 Tauri 框架。
//!
//! ## 模块结构
//! - `emitter` - 事件发射器抽象 trait
//! - `emit_helper` - 事件发射辅助函数
//! - `error` - 错误类型定义
//! - `events` - 事件定义
//! - `pty_session` - PTY 会话封装
//! - `session_manager` - 会话管理器
//! - `persistence` - 持久化存储(块文件、会话元数据)
//! - `block_controller` - 块控制器抽象层
//! - `connections` - 连接模块(本地 PTY、SSH、WSL)
//! - `integration` - 集成模块(Shell 集成、OSC 解析、状态重同步)
#![allow(clippy::too_many_arguments)]
#![allow(clippy::manual_strip)]
#![allow(clippy::derivable_impls)]
// 核心抽象
pub mod emit_helper;
pub mod emitter;
// 基础类型
pub mod error;
pub mod events;
// 会话管理
pub mod pty_session;
pub mod session_manager;
// 持久化
pub mod persistence;
// 块控制器
pub mod block_controller;
// 连接
pub mod connections;
// 集成
pub mod integration;
#[cfg(test)]
mod tests;
// 重新导出常用类型
pub use emitter::{DynEmitter, NoOpEmitter, TerminalEventEmitter};
pub use error::TerminalError;
pub use events::{SessionStatus, TerminalOutputEvent, TerminalStatusEvent};
pub use pty_session::{PtySession, DEFAULT_COLS, DEFAULT_ROWS};
pub use session_manager::{SessionMetadata, TerminalSessionManager};
pub use block_controller::{
BlockController, BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, ControllerRegistry,
ControllerStatusEvent, RuntimeOpts, ShellController, TermSize, CONTROLLER_STATUS_EVENT,
};
pub use connections::ShellProc;
pub use integration::{
resync_controller, ResyncController, ResyncOptions, ResyncResult, TERMINAL_RESET_SEQUENCE,
TERMINAL_SOFT_RESET_SEQUENCE,
};
pub use persistence::{BlockFile, SessionMetadataStore, SessionRecord};
@@ -1,336 +0,0 @@
//! 块文件循环缓冲存储
//!
//! 实现终端输出历史的文件存储,使用循环缓冲策略管理文件大小。
//!
//! ## 功能
//! - 循环缓冲写入(超过最大大小时覆盖旧数据)
//! - 文件读取和截断
//! - 可配置最大文件大小
//!
//! ## 设计说明
//! 采用简单的循环缓冲策略:当文件大小超过配置的最大值时,
//! 保留最新的数据,丢弃最旧的数据。
//!
//! _Requirements: 3.1, 3.2, 3.3, 3.4, 3.7_
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use parking_lot::RwLock;
use crate::error::TerminalError;
/// 默认终端块文件最大大小 (256KB)
pub const DEFAULT_TERM_MAX_FILE_SIZE: usize = 256 * 1024;
/// 块文件管理器
///
/// 管理单个终端会话的输出历史文件,使用循环缓冲策略。
pub struct BlockFile {
/// 块 ID(通常是会话 ID)
block_id: String,
/// 文件路径
file_path: PathBuf,
/// 最大文件大小
max_size: usize,
/// 当前写入位置(用于循环缓冲)
write_pos: AtomicUsize,
/// 当前文件大小
current_size: AtomicUsize,
/// 是否已经开始循环(文件已满过一次)
is_wrapped: RwLock<bool>,
/// 文件句柄(用于写入)
file: RwLock<Option<File>>,
}
impl BlockFile {
/// 创建新的块文件
///
/// # 参数
/// - `block_id`: 块 ID(通常是会话 ID)
/// - `base_dir`: 基础目录路径
/// - `max_size`: 最大文件大小(字节)
///
/// # 返回
/// - `Ok(BlockFile)`: 创建成功
/// - `Err(TerminalError)`: 创建失败
///
/// _Requirements: 3.1, 3.3_
pub fn new(block_id: &str, base_dir: &Path, max_size: usize) -> Result<Self, TerminalError> {
let file_path = base_dir.join(format!("{block_id}.block"));
// 确保目录存在
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
TerminalError::BlockFileError(format!("无法创建目录 {parent:?}: {e}"))
})?;
}
// 检查文件是否已存在,获取当前大小
let (current_size, is_wrapped) = if file_path.exists() {
let metadata = fs::metadata(&file_path)
.map_err(|e| TerminalError::BlockFileError(format!("无法读取文件元数据: {e}")))?;
let size = metadata.len() as usize;
// 如果文件大小已经达到最大值,说明已经循环过
(size, size >= max_size)
} else {
(0, false)
};
// 打开或创建文件
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&file_path)
.map_err(|e| TerminalError::BlockFileError(format!("无法打开文件: {e}")))?;
tracing::debug!(
"[BlockFile] 创建块文件: {} (max_size: {}, current_size: {})",
block_id,
max_size,
current_size
);
Ok(Self {
block_id: block_id.to_string(),
file_path,
max_size,
write_pos: AtomicUsize::new(current_size),
current_size: AtomicUsize::new(current_size),
is_wrapped: RwLock::new(is_wrapped),
file: RwLock::new(Some(file)),
})
}
/// 使用默认最大大小创建块文件
///
/// # 参数
/// - `block_id`: 块 ID
/// - `base_dir`: 基础目录路径
pub fn with_default_size(block_id: &str, base_dir: &Path) -> Result<Self, TerminalError> {
Self::new(block_id, base_dir, DEFAULT_TERM_MAX_FILE_SIZE)
}
/// 获取块文件存储的默认基础目录
pub fn default_base_dir() -> Result<PathBuf, TerminalError> {
let home = dirs::home_dir()
.ok_or_else(|| TerminalError::BlockFileError("无法获取主目录".to_string()))?;
Ok(home.join(".lime").join("terminal_blocks"))
}
/// 获取块 ID
pub fn block_id(&self) -> &str {
&self.block_id
}
/// 获取文件路径
pub fn file_path(&self) -> &PathBuf {
&self.file_path
}
/// 获取最大文件大小
pub fn max_size(&self) -> usize {
self.max_size
}
/// 获取当前文件大小
///
/// _Requirements: 3.3_
pub fn size(&self) -> usize {
self.current_size.load(Ordering::Relaxed)
}
/// 追加数据到块文件
///
/// 使用循环缓冲策略:当文件大小超过最大值时,覆盖最旧的数据。
///
/// # 参数
/// - `data`: 要追加的数据
///
/// # 返回
/// - `Ok(())`: 追加成功
/// - `Err(TerminalError)`: 追加失败
///
/// _Requirements: 3.2, 3.4_
pub fn append_data(&self, data: &[u8]) -> Result<(), TerminalError> {
if data.is_empty() {
return Ok(());
}
let mut file_guard = self.file.write();
let file = file_guard
.as_mut()
.ok_or_else(|| TerminalError::BlockFileError("文件已关闭".to_string()))?;
// 如果数据本身就超过最大大小,只保留最后 max_size 字节
let data_to_write = if data.len() >= self.max_size {
&data[data.len() - self.max_size..]
} else {
data
};
let current_size = self.current_size.load(Ordering::Relaxed);
let new_total = current_size + data_to_write.len();
if new_total <= self.max_size {
// 文件未满,直接追加
file.seek(SeekFrom::End(0))
.map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {e}")))?;
file.write_all(data_to_write)
.map_err(|e| TerminalError::BlockFileError(format!("写入失败: {e}")))?;
file.flush()
.map_err(|e| TerminalError::BlockFileError(format!("Flush 失败: {e}")))?;
self.current_size.store(new_total, Ordering::Relaxed);
self.write_pos.store(new_total, Ordering::Relaxed);
} else {
// 文件将超过最大大小,需要使用循环缓冲策略
// 策略:读取现有数据,保留最新的部分,然后重写文件
self.apply_circular_buffer(file, data_to_write)?;
}
Ok(())
}
/// 应用循环缓冲策略
///
/// 当新数据会导致文件超过最大大小时调用。
/// 保留最新的数据,丢弃最旧的数据。
fn apply_circular_buffer(&self, file: &mut File, new_data: &[u8]) -> Result<(), TerminalError> {
// 读取现有数据
file.seek(SeekFrom::Start(0))
.map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {e}")))?;
let current_size = self.current_size.load(Ordering::Relaxed);
let mut existing_data = vec![0u8; current_size];
file.read_exact(&mut existing_data)
.map_err(|e| TerminalError::BlockFileError(format!("读取失败: {e}")))?;
// 合并数据
let mut combined = existing_data;
combined.extend_from_slice(new_data);
// 只保留最后 max_size 字节
let final_data = if combined.len() > self.max_size {
&combined[combined.len() - self.max_size..]
} else {
&combined[..]
};
// 重写文件
file.seek(SeekFrom::Start(0))
.map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {e}")))?;
file.write_all(final_data)
.map_err(|e| TerminalError::BlockFileError(format!("写入失败: {e}")))?;
file.set_len(final_data.len() as u64)
.map_err(|e| TerminalError::BlockFileError(format!("截断失败: {e}")))?;
file.flush()
.map_err(|e| TerminalError::BlockFileError(format!("Flush 失败: {e}")))?;
self.current_size.store(final_data.len(), Ordering::Relaxed);
self.write_pos.store(final_data.len(), Ordering::Relaxed);
*self.is_wrapped.write() = true;
Ok(())
}
/// 读取所有数据
///
/// # 返回
/// - `Ok(Vec<u8>)`: 文件中的所有数据
/// - `Err(TerminalError)`: 读取失败
///
/// _Requirements: 3.6_
pub fn read_all(&self) -> Result<Vec<u8>, TerminalError> {
let mut file_guard = self.file.write();
let file = file_guard
.as_mut()
.ok_or_else(|| TerminalError::BlockFileError("文件已关闭".to_string()))?;
let current_size = self.current_size.load(Ordering::Relaxed);
if current_size == 0 {
return Ok(Vec::new());
}
file.seek(SeekFrom::Start(0))
.map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {e}")))?;
let mut data = vec![0u8; current_size];
file.read_exact(&mut data)
.map_err(|e| TerminalError::BlockFileError(format!("读取失败: {e}")))?;
Ok(data)
}
/// 截断文件(清空内容)
///
/// # 返回
/// - `Ok(())`: 截断成功
/// - `Err(TerminalError)`: 截断失败
///
/// _Requirements: 3.7_
pub fn truncate(&self) -> Result<(), TerminalError> {
let mut file_guard = self.file.write();
let file = file_guard
.as_mut()
.ok_or_else(|| TerminalError::BlockFileError("文件已关闭".to_string()))?;
file.set_len(0)
.map_err(|e| TerminalError::BlockFileError(format!("截断失败: {e}")))?;
file.seek(SeekFrom::Start(0))
.map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {e}")))?;
file.flush()
.map_err(|e| TerminalError::BlockFileError(format!("Flush 失败: {e}")))?;
self.current_size.store(0, Ordering::Relaxed);
self.write_pos.store(0, Ordering::Relaxed);
*self.is_wrapped.write() = false;
tracing::debug!("[BlockFile] 截断块文件: {}", self.block_id);
Ok(())
}
/// 删除块文件
///
/// 关闭文件句柄并删除文件。
pub fn delete(self) -> Result<(), TerminalError> {
// 先关闭文件句柄
{
let mut file_guard = self.file.write();
*file_guard = None;
}
// 删除文件
if self.file_path.exists() {
fs::remove_file(&self.file_path)
.map_err(|e| TerminalError::BlockFileError(format!("删除文件失败: {e}")))?;
}
tracing::debug!("[BlockFile] 删除块文件: {}", self.block_id);
Ok(())
}
/// 检查文件是否存在
pub fn exists(&self) -> bool {
self.file_path.exists()
}
/// 检查是否已经循环过(文件曾经满过)
pub fn is_wrapped(&self) -> bool {
*self.is_wrapped.read()
}
}
impl Drop for BlockFile {
fn drop(&mut self) {
// 确保文件句柄被正确关闭
let mut file_guard = self.file.write();
if let Some(ref mut file) = *file_guard {
let _ = file.flush();
}
*file_guard = None;
}
}
@@ -1,18 +0,0 @@
//! 终端持久化模块
//!
//! 提供终端会话数据的持久化存储能力。
//!
//! ## 模块结构
//! - `block_file` - 块文件循环缓冲存储
//! - `session_store` - 会话元数据 SQLite 存储
//!
//! ## 功能
//! - 终端输出历史的文件存储(循环缓冲)
//! - 会话元数据的数据库存储
//! - 会话恢复支持
pub mod block_file;
pub mod session_store;
pub use block_file::BlockFile;
pub use session_store::{SessionMetadataStore, SessionRecord};
@@ -1,428 +0,0 @@
//! 会话元数据存储
//!
//! 使用 SQLite 存储终端会话的元数据信息。
//!
//! ## 功能
//! - 会话元数据的 CRUD 操作
//! - 会话状态查询
//! - 会话恢复支持
//!
//! _Requirements: 3.5, 3.9_
use chrono::Utc;
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
use crate::error::TerminalError;
use lime_core::database::DbConnection;
/// 会话记录(存储在 SQLite)
///
/// _Requirements: 3.5_
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRecord {
/// 会话 ID
pub id: String,
/// 块 ID(用于关联 BlockFile)
pub block_id: String,
/// 标签页 ID
pub tab_id: String,
/// 控制器类型(shell/cmd)
pub controller_type: String,
/// 连接名称(本地/SSH/WSL)
pub connection: Option<String>,
/// 会话状态(running/done/error)
pub status: String,
/// 创建时间(Unix 时间戳,毫秒)
pub created_at: i64,
/// 更新时间(Unix 时间戳,毫秒)
pub updated_at: i64,
/// 退出码
pub exit_code: Option<i32>,
}
impl SessionRecord {
/// 创建新的会话记录
pub fn new(
id: String,
block_id: String,
tab_id: String,
controller_type: String,
connection: Option<String>,
) -> Self {
let now = Utc::now().timestamp_millis();
Self {
id,
block_id,
tab_id,
controller_type,
connection,
status: "running".to_string(),
created_at: now,
updated_at: now,
exit_code: None,
}
}
}
/// 会话元数据存储服务
///
/// 提供会话元数据的 SQLite 存储和查询功能。
///
/// _Requirements: 3.5, 3.9_
pub struct SessionMetadataStore {
db: DbConnection,
}
impl SessionMetadataStore {
/// 创建新的会话存储服务
pub fn new(db: DbConnection) -> Self {
Self { db }
}
/// 初始化数据库表
///
/// 创建 terminal_sessions 表(如果不存在)。
pub fn init_tables(&self) -> Result<(), TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS terminal_sessions (
id TEXT PRIMARY KEY,
block_id TEXT NOT NULL,
tab_id TEXT NOT NULL,
controller_type TEXT NOT NULL,
connection TEXT,
status TEXT NOT NULL DEFAULT 'running',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
exit_code INTEGER
)",
[],
)
.map_err(|e| TerminalError::DatabaseError(format!("创建表失败: {e}")))?;
// 创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_terminal_sessions_block_id ON terminal_sessions(block_id)",
[],
)
.map_err(|e| TerminalError::DatabaseError(format!("创建索引失败: {e}")))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_terminal_sessions_tab_id ON terminal_sessions(tab_id)",
[],
)
.map_err(|e| TerminalError::DatabaseError(format!("创建索引失败: {e}")))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_terminal_sessions_status ON terminal_sessions(status)",
[],
)
.map_err(|e| TerminalError::DatabaseError(format!("创建索引失败: {e}")))?;
tracing::debug!("[SessionStore] 数据库表初始化完成");
Ok(())
}
/// 保存会话记录
///
/// 如果记录已存在则更新,否则插入新记录。
///
/// _Requirements: 3.5_
pub fn save(&self, record: &SessionRecord) -> Result<(), TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
conn.execute(
"INSERT OR REPLACE INTO terminal_sessions
(id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
record.id,
record.block_id,
record.tab_id,
record.controller_type,
record.connection,
record.status,
record.created_at,
record.updated_at,
record.exit_code,
],
)
.map_err(|e| TerminalError::DatabaseError(format!("保存会话失败: {e}")))?;
tracing::debug!("[SessionStore] 保存会话: {}", record.id);
Ok(())
}
/// 根据 ID 获取会话记录
pub fn get_by_id(&self, id: &str) -> Result<Option<SessionRecord>, TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
let result = conn
.query_row(
"SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code
FROM terminal_sessions WHERE id = ?1",
params![id],
|row| {
Ok(SessionRecord {
id: row.get(0)?,
block_id: row.get(1)?,
tab_id: row.get(2)?,
controller_type: row.get(3)?,
connection: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
exit_code: row.get(8)?,
})
},
)
.optional()
.map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {e}")))?;
Ok(result)
}
/// 根据块 ID 获取会话记录
pub fn get_by_block_id(&self, block_id: &str) -> Result<Option<SessionRecord>, TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
let result = conn
.query_row(
"SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code
FROM terminal_sessions WHERE block_id = ?1",
params![block_id],
|row| {
Ok(SessionRecord {
id: row.get(0)?,
block_id: row.get(1)?,
tab_id: row.get(2)?,
controller_type: row.get(3)?,
connection: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
exit_code: row.get(8)?,
})
},
)
.optional()
.map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {e}")))?;
Ok(result)
}
/// 获取所有会话记录
pub fn get_all(&self) -> Result<Vec<SessionRecord>, TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
let mut stmt = conn
.prepare(
"SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code
FROM terminal_sessions ORDER BY created_at DESC",
)
.map_err(|e| TerminalError::DatabaseError(format!("准备查询失败: {e}")))?;
let records = stmt
.query_map([], |row| {
Ok(SessionRecord {
id: row.get(0)?,
block_id: row.get(1)?,
tab_id: row.get(2)?,
controller_type: row.get(3)?,
connection: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
exit_code: row.get(8)?,
})
})
.map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {e}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| TerminalError::DatabaseError(format!("读取会话失败: {e}")))?;
Ok(records)
}
/// 获取指定状态的会话记录
pub fn get_by_status(&self, status: &str) -> Result<Vec<SessionRecord>, TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
let mut stmt = conn
.prepare(
"SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code
FROM terminal_sessions WHERE status = ?1 ORDER BY created_at DESC",
)
.map_err(|e| TerminalError::DatabaseError(format!("准备查询失败: {e}")))?;
let records = stmt
.query_map(params![status], |row| {
Ok(SessionRecord {
id: row.get(0)?,
block_id: row.get(1)?,
tab_id: row.get(2)?,
controller_type: row.get(3)?,
connection: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
exit_code: row.get(8)?,
})
})
.map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {e}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| TerminalError::DatabaseError(format!("读取会话失败: {e}")))?;
Ok(records)
}
/// 获取指定标签页的会话记录
pub fn get_by_tab_id(&self, tab_id: &str) -> Result<Vec<SessionRecord>, TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
let mut stmt = conn
.prepare(
"SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code
FROM terminal_sessions WHERE tab_id = ?1 ORDER BY created_at DESC",
)
.map_err(|e| TerminalError::DatabaseError(format!("准备查询失败: {e}")))?;
let records = stmt
.query_map(params![tab_id], |row| {
Ok(SessionRecord {
id: row.get(0)?,
block_id: row.get(1)?,
tab_id: row.get(2)?,
controller_type: row.get(3)?,
connection: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
exit_code: row.get(8)?,
})
})
.map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {e}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| TerminalError::DatabaseError(format!("读取会话失败: {e}")))?;
Ok(records)
}
/// 更新会话状态
///
/// _Requirements: 3.9_
pub fn update_status(
&self,
id: &str,
status: &str,
exit_code: Option<i32>,
) -> Result<(), TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
let now = Utc::now().timestamp_millis();
conn.execute(
"UPDATE terminal_sessions SET status = ?1, exit_code = ?2, updated_at = ?3 WHERE id = ?4",
params![status, exit_code, now, id],
)
.map_err(|e| TerminalError::DatabaseError(format!("更新会话状态失败: {e}")))?;
tracing::debug!("[SessionStore] 更新会话状态: {} -> {}", id, status);
Ok(())
}
/// 删除会话记录
pub fn delete(&self, id: &str) -> Result<(), TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
conn.execute("DELETE FROM terminal_sessions WHERE id = ?1", params![id])
.map_err(|e| TerminalError::DatabaseError(format!("删除会话失败: {e}")))?;
tracing::debug!("[SessionStore] 删除会话: {}", id);
Ok(())
}
/// 删除指定标签页的所有会话记录
pub fn delete_by_tab_id(&self, tab_id: &str) -> Result<usize, TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
let count = conn
.execute(
"DELETE FROM terminal_sessions WHERE tab_id = ?1",
params![tab_id],
)
.map_err(|e| TerminalError::DatabaseError(format!("删除会话失败: {e}")))?;
tracing::debug!("[SessionStore] 删除标签页 {} 的 {} 个会话", tab_id, count);
Ok(count)
}
/// 清理已完成的旧会话
///
/// 删除状态为 "done" 且创建时间早于指定时间的会话。
pub fn cleanup_old_sessions(&self, before_timestamp: i64) -> Result<usize, TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
let count = conn
.execute(
"DELETE FROM terminal_sessions WHERE status = 'done' AND created_at < ?1",
params![before_timestamp],
)
.map_err(|e| TerminalError::DatabaseError(format!("清理会话失败: {e}")))?;
if count > 0 {
tracing::info!("[SessionStore] 清理了 {} 个旧会话", count);
}
Ok(count)
}
/// 获取会话数量
pub fn count(&self) -> Result<usize, TerminalError> {
let conn = self
.db
.lock()
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?;
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM terminal_sessions", [], |row| {
row.get(0)
})
.map_err(|e| TerminalError::DatabaseError(format!("查询会话数量失败: {e}")))?;
Ok(count as usize)
}
}
@@ -1,504 +0,0 @@
//! PTY 会话封装
//!
//! 封装单个 PTY 进程,处理输入输出和生命周期管理。
//!
//! ## 功能
//! - 创建和管理 PTY 子进程(使用默认大小预创建)
//! - 异步读取 PTY 输出并通过 Tauri Event 推送
//! - 处理 PTY 输入写入
//! - 监控进程退出状态
//! - 保存输出历史(循环缓冲区)
//!
//! ## 架构说明
//! PTY 在后端预创建,使用默认大小 (24x80)。前端连接后通过 resize 同步实际大小。
//! 输出历史保存在循环缓冲区中,前端连接时可以获取历史数据。
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use parking_lot::Mutex;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use tokio::sync::RwLock;
use crate::emit_helper;
use crate::emitter::TerminalEventEmit;
use crate::error::TerminalError;
use crate::events::{event_names, SessionStatus, TerminalOutputEvent, TerminalStatusEvent};
/// 默认终端行数
pub const DEFAULT_ROWS: u16 = 24;
/// 默认终端列数
pub const DEFAULT_COLS: u16 = 80;
/// 输出历史缓冲区最大大小 (1MB)
const OUTPUT_BUFFER_MAX_SIZE: usize = 1024 * 1024;
/// 循环缓冲区,用于存储终端输出历史
struct CircularBuffer {
data: Vec<u8>,
max_size: usize,
}
impl CircularBuffer {
fn new(max_size: usize) -> Self {
Self {
data: Vec::with_capacity(max_size),
max_size,
}
}
fn append(&mut self, new_data: &[u8]) {
// 如果新数据超过最大大小,只保留最后 max_size 字节
if new_data.len() >= self.max_size {
self.data.clear();
self.data
.extend_from_slice(&new_data[new_data.len() - self.max_size..]);
return;
}
// 追加新数据
self.data.extend_from_slice(new_data);
// 如果超过最大大小,移除开头的数据
if self.data.len() > self.max_size {
let excess = self.data.len() - self.max_size;
self.data.drain(0..excess);
}
}
fn get_all(&self) -> Vec<u8> {
self.data.clone()
}
}
/// PTY 会话
pub struct PtySession {
/// 会话 ID
id: String,
/// PTY 写入器(使用 Mutex 保证线程安全)
writer: Arc<Mutex<Box<dyn Write + Send>>>,
/// PTY Master(用于调整大小,使用 Mutex 保证线程安全)
master: Arc<Mutex<Box<dyn portable_pty::MasterPty + Send>>>,
/// 会话状态
status: Arc<RwLock<SessionStatus>>,
/// 关闭标志
shutdown_flag: Arc<AtomicBool>,
/// 输出历史缓冲区
output_buffer: Arc<Mutex<CircularBuffer>>,
}
impl PtySession {
#[cfg(target_os = "windows")]
fn is_valid_windows_shell(shell: &str) -> bool {
let cleaned = shell.trim();
if cleaned.is_empty() {
return false;
}
// 拒绝 Unix 风格路径(如 /bin/bash),避免在 Windows 下误判为可执行 shell
if cleaned.starts_with('/') {
return false;
}
let path = Path::new(cleaned);
if path.is_absolute() {
if !path.exists() {
return false;
}
let ext = path
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase());
return matches!(ext.as_deref(), Some("exe" | "cmd" | "bat" | "com"));
}
// 相对路径包含分隔符时通常不可控,直接拒绝;仅允许命令名(由 PATH 解析)
if cleaned.contains('/') || cleaned.contains('\\') {
return false;
}
true
}
fn resolve_default_shell() -> String {
let shell_from_env = std::env::var("SHELL").ok().and_then(|value| {
let cleaned = value
.split('\0')
.next()
.unwrap_or_default()
.trim()
.to_string();
(!cleaned.is_empty()).then_some(cleaned)
});
#[cfg(target_os = "windows")]
{
if let Some(shell) = shell_from_env {
if Self::is_valid_windows_shell(&shell) {
return shell;
}
}
if let Ok(comspec) = std::env::var("COMSPEC") {
let cleaned = comspec
.split('\0')
.next()
.unwrap_or_default()
.trim()
.to_string();
if Self::is_valid_windows_shell(&cleaned) {
return cleaned;
}
}
"cmd.exe".to_string()
}
#[cfg(not(target_os = "windows"))]
{
if let Some(shell) = shell_from_env {
let path = Path::new(&shell);
if path.exists() {
return shell;
}
}
if Path::new("/bin/bash").exists() {
"/bin/bash".to_string()
} else {
"/bin/sh".to_string()
}
}
}
fn resolve_working_dir(cwd: Option<String>) -> Option<PathBuf> {
let dir = cwd?;
let cleaned = dir
.split('\0')
.next()
.unwrap_or_default()
.trim()
.to_string();
if cleaned.is_empty() {
return None;
}
let expanded = if let Some(stripped) = cleaned.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
home.join(stripped)
} else {
PathBuf::from(&cleaned)
}
} else if cleaned == "~" {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(&cleaned))
} else {
PathBuf::from(&cleaned)
};
(expanded.exists() && expanded.is_dir()).then_some(expanded)
}
/// 创建新的 PTY 会话(使用默认大小)
///
/// PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resize 同步实际大小。
///
/// # 参数
/// - `id`: 会话 ID
/// - `app_handle`: Tauri 应用句柄
///
/// # 返回
/// - `Ok(PtySession)`: 创建成功
/// - `Err(TerminalError)`: 创建失败
pub fn new(id: String, app_handle: impl TerminalEventEmit) -> Result<Self, TerminalError> {
Self::with_size(id, DEFAULT_ROWS, DEFAULT_COLS, app_handle)
}
/// 创建新的 PTY 会话(指定大小)
///
/// # 参数
/// - `id`: 会话 ID
/// - `rows`: 终端行数
/// - `cols`: 终端列数
/// - `app_handle`: Tauri 应用句柄
///
/// # 返回
/// - `Ok(PtySession)`: 创建成功
/// - `Err(TerminalError)`: 创建失败
pub fn with_size(
id: String,
rows: u16,
cols: u16,
app_handle: impl TerminalEventEmit,
) -> Result<Self, TerminalError> {
Self::with_size_and_cwd(id, rows, cols, None, app_handle)
}
/// 创建新的 PTY 会话(指定大小和工作目录)
///
/// # 参数
/// - `id`: 会话 ID
/// - `rows`: 终端行数
/// - `cols`: 终端列数
/// - `cwd`: 工作目录(可选,默认为用户主目录)
/// - `app_handle`: Tauri 应用句柄
///
/// # 返回
/// - `Ok(PtySession)`: 创建成功
/// - `Err(TerminalError)`: 创建失败
pub fn with_size_and_cwd(
id: String,
rows: u16,
cols: u16,
cwd: Option<String>,
app_handle: impl TerminalEventEmit,
) -> Result<Self, TerminalError> {
tracing::info!(
"[终端] 创建 PTY 会话 {}, 大小: {}x{}, cwd: {:?}",
id,
cols,
rows,
cwd
);
let pty_system = native_pty_system();
// 创建 PTY
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
// 获取用户默认 shell(Windows 优先使用 COMSPEC/cmd.exe)
let shell = Self::resolve_default_shell();
tracing::info!("[终端] 使用 shell: {}", shell);
// 构建命令
let mut cmd = CommandBuilder::new(&shell);
cmd.env("TERM", "xterm-256color");
// 设置工作目录
if let Some(expanded_dir) = Self::resolve_working_dir(cwd.clone()) {
tracing::info!("[终端] 设置工作目录: {:?}", expanded_dir);
cmd.cwd(expanded_dir);
} else if let Some(raw) = cwd {
tracing::warn!("[终端] 工作目录无效或不存在: {:?}, 使用主目录", raw);
if let Some(home) = dirs::home_dir() {
cmd.cwd(home);
}
} else if let Some(home) = dirs::home_dir() {
cmd.cwd(home);
}
// 启动子进程
let _child = pair
.slave
.spawn_command(cmd)
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
// 获取写入器
let writer = pair
.master
.take_writer()
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
// 获取读取器
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
let status = Arc::new(RwLock::new(SessionStatus::Running));
let status_clone = status.clone();
let id_clone = id.clone();
// 创建关闭标志
let shutdown_flag = Arc::new(AtomicBool::new(false));
let shutdown_flag_clone = shutdown_flag.clone();
// 创建输出缓冲区
let output_buffer = Arc::new(Mutex::new(CircularBuffer::new(OUTPUT_BUFFER_MAX_SIZE)));
let output_buffer_clone = output_buffer.clone();
// 获取当前 tokio runtime handle(在主线程中获取)
let runtime_handle = tokio::runtime::Handle::current();
// 启动输出读取任务(使用独立线程)
std::thread::spawn(move || {
let mut buffer = [0u8; 4096];
loop {
// 检查关闭标志
if shutdown_flag_clone.load(Ordering::Relaxed) {
tracing::debug!("[终端] 会话 {} 收到关闭信号", id_clone);
break;
}
// 读取输出
match reader.read(&mut buffer) {
Ok(0) => {
// EOF,进程已退出
tracing::info!("[终端] 会话 {} 进程已退出", id_clone);
runtime_handle.block_on(async {
*status_clone.write().await = SessionStatus::Done;
});
// 发送状态事件
let _ = emit_helper::emit(
&app_handle,
event_names::TERMINAL_STATUS,
&TerminalStatusEvent {
session_id: id_clone.clone(),
status: SessionStatus::Done,
exit_code: Some(0),
error: None,
},
);
break;
}
Ok(n) => {
let output_data = &buffer[..n];
// 保存到输出缓冲区
output_buffer_clone.lock().append(output_data);
// 发送输出事件
let data = BASE64.encode(output_data);
let _ = emit_helper::emit(
&app_handle,
event_names::TERMINAL_OUTPUT,
&TerminalOutputEvent {
session_id: id_clone.clone(),
data,
},
);
}
Err(e) => {
// 检查是否是因为关闭导致的错误
if shutdown_flag_clone.load(Ordering::Relaxed) {
break;
}
tracing::error!("[终端] 会话 {} 读取错误: {}", id_clone, e);
runtime_handle.block_on(async {
*status_clone.write().await = SessionStatus::Error;
});
let _ = emit_helper::emit(
&app_handle,
event_names::TERMINAL_STATUS,
&TerminalStatusEvent {
session_id: id_clone.clone(),
status: SessionStatus::Error,
exit_code: None,
error: Some(e.to_string()),
},
);
break;
}
}
}
});
tracing::info!("[终端] 会话 {} 已创建 ({}x{})", id, cols, rows);
Ok(Self {
id,
writer: Arc::new(Mutex::new(writer)),
master: Arc::new(Mutex::new(pair.master)),
status,
shutdown_flag,
output_buffer,
})
}
/// 获取会话 ID
pub fn id(&self) -> &str {
&self.id
}
/// 写入数据到 PTY
pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> {
let mut writer = self.writer.lock();
writer
.write_all(data)
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
writer
.flush()
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
Ok(())
}
/// 调整 PTY 大小
pub fn resize(&self, rows: u16, cols: u16) -> Result<(), TerminalError> {
let master = self.master.lock();
master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| TerminalError::ResizeFailed(e.to_string()))?;
tracing::debug!("[终端] 会话 {} 调整大小为 {}x{}", self.id, cols, rows);
Ok(())
}
/// 获取当前状态
pub async fn status(&self) -> SessionStatus {
*self.status.read().await
}
/// 获取输出历史数据(Base64 编码)
pub fn get_output_history(&self) -> String {
let buffer = self.output_buffer.lock();
let data = buffer.get_all();
BASE64.encode(&data)
}
/// 关闭会话
pub async fn close(&self) -> Result<(), TerminalError> {
// 设置关闭标志
self.shutdown_flag.store(true, Ordering::Relaxed);
// 更新状态
*self.status.write().await = SessionStatus::Done;
tracing::info!("[终端] 会话 {} 已关闭", self.id);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::PtySession;
#[test]
fn resolve_working_dir_should_strip_nul_suffix() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let raw = format!("{}\0", temp_dir.path().to_string_lossy());
let resolved = PtySession::resolve_working_dir(Some(raw));
assert_eq!(resolved.as_deref(), Some(temp_dir.path()));
}
#[test]
fn resolve_working_dir_should_reject_invalid_path() {
let resolved = PtySession::resolve_working_dir(Some("/path/not-exists\0".to_string()));
assert!(resolved.is_none());
}
#[test]
fn resolve_default_shell_should_not_be_empty() {
let shell = PtySession::resolve_default_shell();
assert!(!shell.trim().is_empty());
}
}
@@ -1,502 +0,0 @@
//! 终端会话管理器
//!
//! 管理所有终端会话的完整生命周期,集成 BlockController 和 BlockFile。
//!
//! ## 功能
//! - 会话创建、恢复、关闭的完整流程
//! - 集成 BlockController 进行进程管理
//! - 集成 BlockFile 进行输出持久化
//! - 集成 SessionMetadataStore 进行元数据存储
//! - 支持会话状态生命周期管理
//!
//! ## Requirements
//! - 3.1: 终端会话创建时创建对应的 Block_File
//! - 3.5: 应用启动时从数据库加载已保存的会话元数据
//! - 3.6: 用户请求恢复会话时从 Block_File 读取历史数据并重建会话
//! - 3.8: Block_File 读取失败时返回错误并允许创建新会话
//! - 3.9: 会话关闭时更新会话元数据状态为已完成
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use uuid::Uuid;
use lime_core::database::DbConnection;
use crate::block_controller::ControllerRegistry;
use crate::emitter::{DynEmitter, TerminalEventEmit};
use crate::error::TerminalError;
use crate::events::SessionStatus;
use crate::persistence::{BlockFile, SessionMetadataStore, SessionRecord};
use crate::pty_session::{PtySession, DEFAULT_COLS, DEFAULT_ROWS};
/// 会话元数据(用于前端展示)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMetadata {
/// 会话 ID
pub id: String,
/// 块 ID(与会话 ID 相同)
pub block_id: String,
/// 标签页 ID
pub tab_id: String,
/// 控制器类型
pub controller_type: String,
/// 连接名称
pub connection: Option<String>,
/// 会话状态
pub status: SessionStatus,
/// 创建时间(Unix 时间戳,毫秒)
pub created_at: i64,
/// 终端行数
pub rows: u16,
/// 终端列数
pub cols: u16,
/// 退出码
pub exit_code: Option<i32>,
}
impl SessionMetadata {
/// 从 SessionRecord 创建
pub fn from_record(record: &SessionRecord, rows: u16, cols: u16) -> Self {
let status = match record.status.as_str() {
"running" => SessionStatus::Running,
"done" => SessionStatus::Done,
"error" => SessionStatus::Error,
_ => SessionStatus::Connecting,
};
Self {
id: record.id.clone(),
block_id: record.block_id.clone(),
tab_id: record.tab_id.clone(),
controller_type: record.controller_type.clone(),
connection: record.connection.clone(),
status,
created_at: record.created_at,
rows,
cols,
exit_code: record.exit_code,
}
}
}
/// 内部会话数据
struct SessionData {
/// 会话元数据
metadata: SessionMetadata,
/// 块文件存储
block_file: Arc<BlockFile>,
/// 旧版 PTY 会话(兼容模式)
legacy_pty: Option<PtySession>,
}
/// 终端会话管理器
///
/// 管理所有终端会话的完整生命周期,集成 BlockController 和 BlockFile。
///
/// ## 架构说明
/// - 使用 ControllerRegistry 管理所有 BlockController
/// - 使用 SessionMetadataStore 持久化会话元数据
/// - 使用 BlockFile 持久化终端输出
/// - 支持新旧两种模式:
/// - 新模式:使用 BlockController + BlockFile
/// - 兼容模式:使用旧版 PtySession
pub struct TerminalSessionManager {
/// 会话映射表
sessions: Arc<RwLock<HashMap<String, SessionData>>>,
/// 控制器注册表
controller_registry: Arc<ControllerRegistry>,
/// 会话元数据存储
session_store: Option<Arc<SessionMetadataStore>>,
/// 块文件基础目录
block_file_base_dir: PathBuf,
/// Tauri 应用句柄(抽象为事件发射器)
app_handle: DynEmitter,
}
impl TerminalSessionManager {
/// 创建新的会话管理器
///
/// # 参数
/// - `app_handle`: 事件发射器(实现 TerminalEventEmit trait)
pub fn new(app_handle: impl TerminalEventEmit) -> Self {
let block_file_base_dir = BlockFile::default_base_dir()
.unwrap_or_else(|_| PathBuf::from(".lime/terminal_blocks"));
tracing::info!(
"[终端] 会话管理器已初始化,块文件目录: {:?}",
block_file_base_dir
);
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
controller_registry: Arc::new(ControllerRegistry::new()),
session_store: None,
block_file_base_dir,
app_handle: DynEmitter::new(app_handle),
}
}
/// 创建带数据库连接的会话管理器
///
/// # 参数
/// - `app_handle`: 事件发射器(实现 TerminalEventEmit trait)
/// - `db`: 数据库连接
///
/// _Requirements: 3.5_
pub fn with_database(
app_handle: impl TerminalEventEmit,
db: DbConnection,
) -> Result<Self, TerminalError> {
let mut manager = Self::new(app_handle);
// 创建会话存储服务
let session_store = SessionMetadataStore::new(db);
session_store.init_tables()?;
manager.session_store = Some(Arc::new(session_store));
tracing::info!("[终端] 会话管理器已初始化(带数据库支持)");
Ok(manager)
}
/// 获取控制器注册表
pub fn controller_registry(&self) -> &Arc<ControllerRegistry> {
&self.controller_registry
}
/// 获取会话存储服务
pub fn session_store(&self) -> Option<&Arc<SessionMetadataStore>> {
self.session_store.as_ref()
}
/// 创建新的终端会话
///
/// 使用默认大小 (24x80) 创建 PTY 会话。
///
/// # 返回
/// - `Ok(String)`: 会话 ID
/// - `Err(TerminalError)`: 创建失败
///
/// _Requirements: 3.1_
pub async fn create_session(&self) -> Result<String, TerminalError> {
self.create_session_with_size(DEFAULT_ROWS, DEFAULT_COLS)
.await
}
/// 创建新的终端会话(指定大小)
///
/// # 参数
/// - `rows`: 终端行数
/// - `cols`: 终端列数
///
/// # 返回
/// - `Ok(String)`: 会话 ID
/// - `Err(TerminalError)`: 创建失败
///
/// _Requirements: 3.1_
pub async fn create_session_with_size(
&self,
rows: u16,
cols: u16,
) -> Result<String, TerminalError> {
self.create_session_with_options(rows, cols, None).await
}
/// 创建新的终端会话(指定大小和工作目录)
///
/// # 参数
/// - `rows`: 终端行数
/// - `cols`: 终端列数
/// - `cwd`: 工作目录(可选)
///
/// # 返回
/// - `Ok(String)`: 会话 ID
/// - `Err(TerminalError)`: 创建失败
///
/// _Requirements: 3.1_
pub async fn create_session_with_options(
&self,
rows: u16,
cols: u16,
cwd: Option<String>,
) -> Result<String, TerminalError> {
let session_id = Uuid::new_v4().to_string();
let block_id = session_id.clone();
let tab_id = "default".to_string(); // TODO: 支持多标签页
tracing::info!(
"[终端] 创建会话 {}, 大小: {}x{}, cwd: {:?}",
session_id,
cols,
rows,
cwd
);
// 创建块文件
let block_file = BlockFile::with_default_size(&block_id, &self.block_file_base_dir)?;
let block_file = Arc::new(block_file);
// 创建旧版 PTY 会话(兼容模式)
let pty_session = PtySession::with_size_and_cwd(
session_id.clone(),
rows,
cols,
cwd,
self.app_handle.clone(),
)?;
// 创建会话元数据
let metadata = SessionMetadata {
id: session_id.clone(),
block_id: block_id.clone(),
tab_id: tab_id.clone(),
controller_type: "shell".to_string(),
connection: None,
status: SessionStatus::Running,
created_at: Utc::now().timestamp_millis(),
rows,
cols,
exit_code: None,
};
// 保存到数据库
if let Some(store) = &self.session_store {
let record = SessionRecord {
id: session_id.clone(),
block_id: block_id.clone(),
tab_id: tab_id.clone(),
controller_type: "shell".to_string(),
connection: None,
status: "running".to_string(),
created_at: metadata.created_at,
updated_at: metadata.created_at,
exit_code: None,
};
store.save(&record)?;
}
// 创建会话数据
let session_data = SessionData {
metadata,
block_file,
legacy_pty: Some(pty_session),
};
// 添加到会话映射表
let mut sessions = self.sessions.write().await;
sessions.insert(session_id.clone(), session_data);
tracing::info!("[终端] 会话 {} 创建成功", session_id);
Ok(session_id)
}
/// 向会话写入数据(Base64 编码)
///
/// # 参数
/// - `session_id`: 会话 ID
/// - `data`: Base64 编码的数据
///
/// _Requirements: 3.2_
pub async fn write_to_session_base64(
&self,
session_id: &str,
data: &str,
) -> Result<(), TerminalError> {
let decoded = BASE64
.decode(data)
.map_err(|e| TerminalError::WriteFailed(format!("Base64 解码失败: {e}")))?;
self.write_to_session(session_id, &decoded).await
}
/// 向会话写入数据
///
/// # 参数
/// - `session_id`: 会话 ID
/// - `data`: 原始数据
///
/// _Requirements: 3.2_
pub async fn write_to_session(
&self,
session_id: &str,
data: &[u8],
) -> Result<(), TerminalError> {
let sessions = self.sessions.read().await;
let session = sessions
.get(session_id)
.ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?;
// 使用旧版 PTY 会话写入
if let Some(pty) = &session.legacy_pty {
pty.write(data)?;
}
// 同时写入块文件(用于持久化)
session.block_file.append_data(data)?;
Ok(())
}
/// 调整会话终端大小
///
/// # 参数
/// - `session_id`: 会话 ID
/// - `rows`: 新的行数
/// - `cols`: 新的列数
pub async fn resize_session(
&self,
session_id: &str,
rows: u16,
cols: u16,
) -> Result<(), TerminalError> {
let mut sessions = self.sessions.write().await;
let session = sessions
.get_mut(session_id)
.ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?;
// 使用旧版 PTY 会话调整大小
if let Some(pty) = &session.legacy_pty {
pty.resize(rows, cols)?;
}
// 更新元数据
session.metadata.rows = rows;
session.metadata.cols = cols;
tracing::debug!("[终端] 会话 {} 调整大小为 {}x{}", session_id, cols, rows);
Ok(())
}
/// 关闭会话
///
/// # 参数
/// - `session_id`: 会话 ID
///
/// _Requirements: 3.9_
pub async fn close_session(&self, session_id: &str) -> Result<(), TerminalError> {
let mut sessions = self.sessions.write().await;
if let Some(mut session) = sessions.remove(session_id) {
// 关闭旧版 PTY 会话
if let Some(pty) = session.legacy_pty.take() {
pty.close().await?;
}
// 更新数据库状态
if let Some(store) = &self.session_store {
store.update_status(session_id, "done", None)?;
}
tracing::info!("[终端] 会话 {} 已关闭", session_id);
}
Ok(())
}
/// 获取所有会话列表
pub async fn list_sessions(&self) -> Vec<SessionMetadata> {
let sessions = self.sessions.read().await;
sessions.values().map(|s| s.metadata.clone()).collect()
}
/// 获取单个会话信息
///
/// # 参数
/// - `session_id`: 会话 ID
pub async fn get_session(&self, session_id: &str) -> Option<SessionMetadata> {
let sessions = self.sessions.read().await;
sessions.get(session_id).map(|s| s.metadata.clone())
}
/// 恢复会话(从持久化存储)
///
/// # 参数
/// - `session_id`: 会话 ID
///
/// _Requirements: 3.6, 3.8_
pub async fn restore_session(
&self,
session_id: &str,
) -> Result<SessionMetadata, TerminalError> {
// 从数据库加载会话记录
let store = self
.session_store
.as_ref()
.ok_or_else(|| TerminalError::DatabaseError("会话存储未初始化".to_string()))?;
let record = store
.get_by_id(session_id)?
.ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?;
// 检查块文件是否存在
let block_file_path = self.block_file_base_dir.join(format!("{session_id}.block"));
if !block_file_path.exists() {
return Err(TerminalError::BlockFileError(format!(
"块文件不存在: {block_file_path:?}"
)));
}
// 创建块文件引用
let block_file = BlockFile::with_default_size(&record.block_id, &self.block_file_base_dir)?;
let block_file = Arc::new(block_file);
// 读取历史数据
let _history = block_file.read_all()?;
// 创建新的 PTY 会话
let rows = DEFAULT_ROWS;
let cols = DEFAULT_COLS;
let pty_session =
PtySession::with_size(session_id.to_string(), rows, cols, self.app_handle.clone())?;
// 创建会话元数据
let metadata = SessionMetadata::from_record(&record, rows, cols);
// 创建会话数据
let session_data = SessionData {
metadata: metadata.clone(),
block_file,
legacy_pty: Some(pty_session),
};
// 添加到会话映射表
let mut sessions = self.sessions.write().await;
sessions.insert(session_id.to_string(), session_data);
// 更新数据库状态
store.update_status(session_id, "running", None)?;
tracing::info!("[终端] 会话 {} 已恢复", session_id);
Ok(metadata)
}
/// 加载所有已保存的会话(应用启动时调用)
///
/// _Requirements: 3.5_
pub async fn load_saved_sessions(&self) -> Result<Vec<SessionMetadata>, TerminalError> {
let store = match &self.session_store {
Some(s) => s,
None => return Ok(vec![]),
};
let records = store.get_all()?;
let mut result = Vec::new();
for record in records {
// 只加载运行中的会话
if record.status == "running" {
let metadata = SessionMetadata::from_record(&record, DEFAULT_ROWS, DEFAULT_COLS);
result.push(metadata);
}
}
tracing::info!("[终端] 加载了 {} 个已保存的会话", result.len());
Ok(result)
}
}
-354
View File
@@ -1,354 +0,0 @@
//! 终端模块单元测试
//!
//! 测试终端核心能力的各个组件。
//!
//! ## 测试覆盖
//! - 错误类型序列化
//! - 会话状态转换
//! - 事件结构序列化
#[cfg(test)]
mod unit_tests {
use super::super::error::TerminalError;
use super::super::events::{SessionStatus, TerminalOutputEvent, TerminalStatusEvent};
// ========================================================================
// 错误类型测试
// ========================================================================
#[test]
fn test_terminal_error_session_not_found() {
let err = TerminalError::SessionNotFound("test-session-123".to_string());
assert_eq!(err.to_string(), "会话不存在: test-session-123");
}
#[test]
fn test_terminal_error_pty_creation_failed() {
let err = TerminalError::PtyCreationFailed("spawn failed".to_string());
assert_eq!(err.to_string(), "PTY 创建失败: spawn failed");
}
#[test]
fn test_terminal_error_write_failed() {
let err = TerminalError::WriteFailed("broken pipe".to_string());
assert_eq!(err.to_string(), "写入失败: broken pipe");
}
#[test]
fn test_terminal_error_resize_failed() {
let err = TerminalError::ResizeFailed("invalid size".to_string());
assert_eq!(err.to_string(), "调整大小失败: invalid size");
}
#[test]
fn test_terminal_error_session_closed() {
let err = TerminalError::SessionClosed;
assert_eq!(err.to_string(), "会话已关闭");
}
#[test]
fn test_terminal_error_base64_decode_failed() {
let err = TerminalError::Base64DecodeFailed("invalid base64".to_string());
assert_eq!(err.to_string(), "Base64 解码失败: invalid base64");
}
#[test]
fn test_terminal_error_internal() {
let err = TerminalError::Internal("unexpected error".to_string());
assert_eq!(err.to_string(), "内部错误: unexpected error");
}
#[test]
fn test_terminal_error_to_string_conversion() {
let err = TerminalError::SessionNotFound("abc".to_string());
let s: String = err.into();
assert_eq!(s, "会话不存在: abc");
}
#[test]
fn test_terminal_error_serialize() {
let err = TerminalError::SessionNotFound("test".to_string());
let json = serde_json::to_string(&err).unwrap();
assert_eq!(json, "\"会话不存在: test\"");
}
// ========================================================================
// 会话状态测试
// ========================================================================
#[test]
fn test_session_status_default() {
let status = SessionStatus::default();
assert_eq!(status, SessionStatus::Connecting);
}
#[test]
fn test_session_status_serialize() {
assert_eq!(
serde_json::to_string(&SessionStatus::Connecting).unwrap(),
"\"connecting\""
);
assert_eq!(
serde_json::to_string(&SessionStatus::Running).unwrap(),
"\"running\""
);
assert_eq!(
serde_json::to_string(&SessionStatus::Done).unwrap(),
"\"done\""
);
assert_eq!(
serde_json::to_string(&SessionStatus::Error).unwrap(),
"\"error\""
);
}
#[test]
fn test_session_status_deserialize() {
assert_eq!(
serde_json::from_str::<SessionStatus>("\"connecting\"").unwrap(),
SessionStatus::Connecting
);
assert_eq!(
serde_json::from_str::<SessionStatus>("\"running\"").unwrap(),
SessionStatus::Running
);
assert_eq!(
serde_json::from_str::<SessionStatus>("\"done\"").unwrap(),
SessionStatus::Done
);
assert_eq!(
serde_json::from_str::<SessionStatus>("\"error\"").unwrap(),
SessionStatus::Error
);
}
#[test]
fn test_session_status_equality() {
assert_eq!(SessionStatus::Running, SessionStatus::Running);
assert_ne!(SessionStatus::Running, SessionStatus::Done);
}
#[test]
fn test_session_status_clone() {
let status = SessionStatus::Running;
let cloned = status;
assert_eq!(status, cloned);
}
// ========================================================================
// 事件结构测试
// ========================================================================
#[test]
fn test_terminal_output_event_serialize() {
let event = TerminalOutputEvent {
session_id: "session-123".to_string(),
data: "SGVsbG8gV29ybGQ=".to_string(), // "Hello World" in Base64
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"session_id\":\"session-123\""));
assert!(json.contains("\"data\":\"SGVsbG8gV29ybGQ=\""));
}
#[test]
fn test_terminal_output_event_deserialize() {
let json = r#"{"session_id":"abc","data":"dGVzdA=="}"#;
let event: TerminalOutputEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.session_id, "abc");
assert_eq!(event.data, "dGVzdA==");
}
#[test]
fn test_terminal_status_event_serialize_done() {
let event = TerminalStatusEvent {
session_id: "session-456".to_string(),
status: SessionStatus::Done,
exit_code: Some(0),
error: None,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"status\":\"done\""));
assert!(json.contains("\"exit_code\":0"));
}
#[test]
fn test_terminal_status_event_serialize_error() {
let event = TerminalStatusEvent {
session_id: "session-789".to_string(),
status: SessionStatus::Error,
exit_code: None,
error: Some("Connection refused".to_string()),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"status\":\"error\""));
assert!(json.contains("\"error\":\"Connection refused\""));
}
#[test]
fn test_terminal_status_event_deserialize() {
let json = r#"{"session_id":"test","status":"running","exit_code":null,"error":null}"#;
let event: TerminalStatusEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.session_id, "test");
assert_eq!(event.status, SessionStatus::Running);
assert!(event.exit_code.is_none());
assert!(event.error.is_none());
}
// ========================================================================
// 事件名称常量测试
// ========================================================================
#[test]
fn test_event_names() {
use super::super::events::event_names;
assert_eq!(event_names::TERMINAL_OUTPUT, "terminal:output");
assert_eq!(event_names::TERMINAL_STATUS, "terminal:status");
}
}
// ========================================================================
// 属性测试 - ShellController
// ========================================================================
/// **Feature: terminal-enhancement, Property 1: 控制器类型一致性**
/// **Validates: Requirements 1.2, 1.3**
///
/// *对于任意* 控制器创建请求,如果请求指定 controller_type 为 "shell",
/// 则创建的控制器实例的 controller_type 字段应为 "shell";
/// 如果请求指定为 "cmd",则应为 "cmd"。
#[cfg(test)]
mod property_tests {
use super::super::block_controller::{BlockControllerRuntimeStatus, BlockMeta};
use proptest::prelude::*;
/// 生成有效的控制器类型
fn arb_controller_type() -> impl Strategy<Value = String> {
prop_oneof![Just("shell".to_string()), Just("cmd".to_string()),]
}
/// 生成有效的 block_id
fn arb_block_id() -> impl Strategy<Value = String> {
"[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}".prop_map(|s| s.to_string())
}
proptest! {
/// **Feature: terminal-enhancement, Property 1: 控制器类型一致性**
/// **Validates: Requirements 1.2, 1.3**
///
/// 测试 BlockControllerRuntimeStatus 的创建和状态一致性
#[test]
fn prop_controller_type_consistency(
block_id in arb_block_id(),
_controller_type in arb_controller_type(),
) {
// 创建运行时状态
let status = BlockControllerRuntimeStatus::new(block_id.clone());
// 验证初始状态
prop_assert_eq!(&status.block_id, &block_id);
prop_assert_eq!(status.version, 0);
prop_assert_eq!(&status.shell_proc_status, "init");
prop_assert!(status.shell_proc_conn_name.is_none());
prop_assert_eq!(status.shell_proc_exit_code, 0);
// 验证状态检查方法
prop_assert!(status.is_init());
prop_assert!(!status.is_running());
prop_assert!(!status.is_done());
}
/// **Feature: terminal-enhancement, Property 1: 控制器类型一致性**
/// **Validates: Requirements 1.2, 1.3**
///
/// 测试 BlockMeta 的控制器类型字段一致性
#[test]
fn prop_block_meta_controller_type_consistency(
controller_type in arb_controller_type(),
) {
// 创建 BlockMeta
let meta = BlockMeta {
controller: Some(controller_type.clone()),
..Default::default()
};
// 验证 get_string 返回正确的控制器类型
prop_assert_eq!(meta.get_string("controller"), controller_type);
}
/// **Feature: terminal-enhancement, Property 4: 控制器类型变更正确性**
/// **Validates: Requirements 1.7**
///
/// 测试状态转换的有效性
#[test]
fn prop_controller_status_transitions(
block_id in arb_block_id(),
) {
// 创建初始状态
let mut status = BlockControllerRuntimeStatus::new(block_id.clone());
prop_assert!(status.is_init());
// 模拟状态转换到 running
status.shell_proc_status = "running".to_string();
prop_assert!(status.is_running());
prop_assert!(!status.is_init());
prop_assert!(!status.is_done());
// 模拟状态转换到 done
status.shell_proc_status = "done".to_string();
prop_assert!(status.is_done());
prop_assert!(!status.is_init());
prop_assert!(!status.is_running());
}
/// **Feature: terminal-enhancement, Property 4: 控制器类型变更正确性**
/// **Validates: Requirements 1.7**
///
/// 测试版本号递增
#[test]
fn prop_controller_version_increment(
block_id in arb_block_id(),
increments in 1..100usize,
) {
let mut status = BlockControllerRuntimeStatus::new(block_id);
prop_assert_eq!(status.version, 0);
// 模拟多次状态更新
for i in 1..=increments {
status.version = i as i32;
prop_assert_eq!(status.version, i as i32);
}
}
}
/// 测试 BlockMeta 默认值
#[test]
fn test_block_meta_defaults() {
let meta = BlockMeta::default();
assert!(meta.controller.is_none());
assert!(meta.connection.is_none());
assert!(meta.cmd.is_none());
assert!(meta.cmd_args.is_none());
assert!(meta.cmd_cwd.is_none());
assert!(meta.cmd_env.is_none());
assert!(meta.cmd_run_on_start.is_none());
assert!(meta.cmd_run_once.is_none());
assert!(meta.cmd_clear_on_start.is_none());
assert!(meta.cmd_close_on_exit.is_none());
}
/// 测试 BlockMeta get_string 默认值
#[test]
fn test_block_meta_get_string_defaults() {
let meta = BlockMeta::default();
assert_eq!(meta.get_string("controller"), "");
assert_eq!(meta.get_string("connection"), "");
assert_eq!(meta.get_string("cmd"), "");
assert_eq!(meta.get_string("cmd_cwd"), "");
assert_eq!(meta.get_string("term_mode"), "term");
assert_eq!(meta.get_string("term_theme"), "");
assert_eq!(meta.get_string("unknown_field"), "");
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。
- 更新检查支持提醒频控(同版本 24h 一次、关闭退避)与埋点计数
- `streaming/` - 流式响应处理
- `telemetry/` - 遥测和统计
- `terminal/` - 终端核心模块(PTY 管理、会话管理)
- `terminal/` - 已移除的终端历史模块(仅保留治理语义,不再参与编译)
- `tray/` - 系统托盘
- `websocket/` - WebSocket 支持
- `workspace/` - Workspace 工作目录管理
-6
View File
@@ -17,7 +17,6 @@ use crate::commands::plugin_install_cmd::PluginInstallerState;
use crate::commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoolServiceState};
use crate::commands::session_files_cmd::SessionFilesState;
use crate::commands::skill_cmd::SkillServiceState;
use crate::commands::terminal_cmd::TerminalManagerState;
use crate::commands::webview_cmd::{
ChromeProfileManagerWrapper, WebviewManagerState, WebviewManagerWrapper,
};
@@ -64,7 +63,6 @@ pub struct AppStates {
pub connect_state: ConnectStateWrapper,
pub model_registry: ModelRegistryState,
pub global_config_manager: GlobalConfigManagerState,
pub terminal_manager: TerminalManagerState,
pub webview_manager: WebviewManagerWrapper,
pub chrome_profile_manager: ChromeProfileManagerWrapper,
pub update_check_service: UpdateCheckServiceState,
@@ -198,9 +196,6 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
// 初始化 Model Registry 状态(延迟初始化,在 setup hook 中完成)
let model_registry_state: ModelRegistryState = Arc::new(RwLock::new(None));
// 初始化终端管理器状态(延迟初始化,在 setup hook 中完成)
let terminal_manager_state = TerminalManagerState(Arc::new(RwLock::new(None)));
// 初始化 Webview 管理器状态
let webview_manager_state =
WebviewManagerWrapper(Arc::new(RwLock::new(WebviewManagerState::new())));
@@ -289,7 +284,6 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
connect_state,
model_registry: model_registry_state,
global_config_manager: global_config_manager_state,
terminal_manager: terminal_manager_state,
webview_manager: webview_manager_state,
chrome_profile_manager: chrome_profile_manager_state,
update_check_service: update_check_service_state,
+1 -31
View File
@@ -157,7 +157,6 @@ pub fn run() {
connect_state,
model_registry: model_registry_state,
global_config_manager: global_config_manager_state,
terminal_manager: terminal_manager_state,
webview_manager: webview_manager_state,
chrome_profile_manager: chrome_profile_manager_state,
update_check_service: update_check_service_state,
@@ -267,7 +266,6 @@ pub fn run() {
.manage(connect_state)
.manage(model_registry_state)
.manage(global_config_manager_state)
.manage(terminal_manager_state)
.manage(webview_manager_state)
.manage(chrome_profile_manager_state)
.manage(update_check_service_state)
@@ -654,17 +652,6 @@ pub fn run() {
});
}
// 初始化终端会话管理器
{
let app_handle = app.handle().clone();
let terminal_manager = lime_terminal::TerminalSessionManager::new(crate::terminal::TauriEmitter(app_handle.clone()));
if let Some(state) = app_handle.try_state::<crate::commands::terminal_cmd::TerminalManagerState>() {
let mut guard = state.inner().0.blocking_write();
*guard = Some(terminal_manager);
tracing::info!("[启动] 终端会话管理器初始化成功");
}
}
// 注册 Deep Link 事件处理器(桌面端)
// _Requirements: 1.4_
#[cfg(desktop)]
@@ -1282,6 +1269,7 @@ pub fn run() {
commands::sceneapp_cmd::sceneapp_list_catalog,
commands::sceneapp_cmd::sceneapp_get_descriptor,
commands::sceneapp_cmd::sceneapp_plan_launch,
commands::sceneapp_cmd::sceneapp_save_context_baseline,
commands::sceneapp_cmd::sceneapp_create_automation_job,
commands::sceneapp_cmd::sceneapp_list_runs,
commands::sceneapp_cmd::sceneapp_get_run_summary,
@@ -1572,24 +1560,6 @@ pub fn run() {
commands::model_cmd::get_all_available_models,
commands::model_cmd::refresh_all_credential_models,
commands::model_cmd::get_default_models_for_provider,
// Terminal commands
commands::terminal_cmd::terminal_create_session,
commands::terminal_cmd::terminal_write,
commands::terminal_cmd::terminal_resize,
commands::terminal_cmd::terminal_close,
commands::terminal_cmd::terminal_list_sessions,
commands::terminal_cmd::terminal_get_session,
// Connection commands
commands::connection_cmd::connection_list,
commands::connection_cmd::connection_add,
commands::connection_cmd::connection_update,
commands::connection_cmd::connection_delete,
commands::connection_cmd::connection_get,
commands::connection_cmd::connection_get_config_path,
commands::connection_cmd::connection_get_raw_config,
commands::connection_cmd::connection_save_raw_config,
commands::connection_cmd::connection_test,
commands::connection_cmd::connection_import_ssh_host,
// WebSocket commands
commands::websocket_cmd::get_websocket_status,
commands::websocket_cmd::get_websocket_connections,
@@ -1,4 +1,6 @@
use super::*;
use crate::sceneapp::application::SceneAppService;
use crate::services::execution_tracker_service::ExecutionTracker;
use crate::services::runtime_analysis_handoff_service::{
export_runtime_analysis_handoff, RuntimeAnalysisHandoffExportResult,
};
@@ -519,7 +521,7 @@ pub async fn agent_runtime_save_review_decision(
let context =
load_runtime_export_context(&runtime, &session_id, "保存 review decision 前").await?;
save_runtime_review_decision(
let saved = save_runtime_review_decision(
&context.detail,
&context.thread_read,
&context.workspace_root,
@@ -535,7 +537,22 @@ pub async fn agent_runtime_save_review_decision(
regression_requirements: request.regression_requirements,
notes: request.notes,
},
)
)?;
let tracker = ExecutionTracker::new(runtime.db().clone());
if let Err(error) = SceneAppService::sync_review_decision_feedback_for_session(
&tracker,
&session_id,
&saved.decision,
) {
tracing::warn!(
"[AsterAgent] 保存 review decision 后同步 SceneApp feedback 失败: session_id={}, error={}",
session_id,
error
);
}
Ok(saved)
}
/// 统一运行时:导出当前会话的 replay case。
@@ -2939,7 +2939,14 @@ fn resolve_runtime_access_mode_from_request(
}
fn backfill_runtime_access_policies(request: &mut AsterChatRequest) {
let Some(access_mode) = resolve_runtime_access_mode_from_request(request) else {
let access_mode = resolve_runtime_access_mode_from_request(request).or_else(|| {
if request.approval_policy.is_none() && request.sandbox_policy.is_none() {
Some(lime_agent::SessionExecutionRuntimeAccessMode::default_for_session())
} else {
None
}
});
let Some(access_mode) = access_mode else {
return;
};
@@ -5398,6 +5405,41 @@ mod tests {
);
}
#[test]
fn backfill_runtime_access_policies_should_default_to_full_access_when_request_missing() {
let mut request = AsterChatRequest {
message: "继续执行".to_string(),
session_id: "session-access-default".to_string(),
event_name: "agent_stream".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: None,
workspace_id: "workspace-access".to_string(),
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt: None,
metadata: None,
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
};
backfill_runtime_access_policies(&mut request);
assert_eq!(request.approval_policy.as_deref(), Some("never"));
assert_eq!(
request.sandbox_policy.as_deref(),
Some("danger-full-access")
);
}
#[tokio::test]
async fn update_compaction_session_metrics_should_move_summary_tokens_to_current_window() {
ensure_runtime_turn_test_session_manager().await;
@@ -205,7 +205,7 @@ pub(crate) async fn create_runtime_session_internal(
.map(ToString::to_string)
.or_else(|| Some(workspace_root.clone()));
AsterAgentWrapper::create_session_sync(
let session_id = AsterAgentWrapper::create_session_sync(
db,
name,
resolved_working_dir,
@@ -216,7 +216,15 @@ pub(crate) async fn create_runtime_session_internal(
.as_db_value()
.to_string(),
),
)?;
AsterAgentWrapper::persist_session_recent_access_mode(
&session_id,
lime_agent::SessionExecutionRuntimeAccessMode::default_for_session(),
)
.await?;
Ok(session_id)
}
pub(crate) fn update_runtime_session_execution_strategy_internal(
@@ -405,6 +405,7 @@ struct PreparedRuntimeSubagentSession {
session: aster::session::Session,
customization: Option<SubagentCustomizationState>,
system_prompt: Option<String>,
access_mode: lime_agent::SessionExecutionRuntimeAccessMode,
}
fn build_subagent_runtime_event_name(session_id: &str) -> String {
@@ -421,6 +422,15 @@ fn parse_subagent_runtime_event_session_id(event_name: &str) -> Option<&str> {
.and_then(|rest| rest.strip_prefix(':'))
}
fn resolve_subagent_request_access_mode(
session_id: &str,
session: &aster::session::Session,
) -> lime_agent::SessionExecutionRuntimeAccessMode {
lime_agent::build_session_execution_runtime(session_id, Some(session), None, None, None)
.and_then(|runtime| runtime.recent_access_mode)
.unwrap_or_else(lime_agent::SessionExecutionRuntimeAccessMode::default_for_session)
}
fn should_emit_subagent_status_for_runtime_event(event: &RuntimeAgentEvent) -> bool {
matches!(
event,
@@ -666,6 +676,7 @@ async fn create_runtime_subagent_session(
.as_ref()
.and_then(|state| state.profile_name.as_deref());
let role_hint = resolve_subagent_role_hint(request, customization.as_ref());
let access_mode = resolve_subagent_request_access_mode(&parent_session_id, &parent_session);
let working_dir =
resolve_spawn_working_dir(parent_session.working_dir.as_path(), request.cwd.clone())?;
@@ -717,6 +728,7 @@ async fn create_runtime_subagent_session(
"写入 subagent session metadata",
)
.await?;
AsterAgentWrapper::persist_session_recent_access_mode(&session.id, access_mode).await?;
if let (Some(team_name), Some(teammate_name)) = (team_name, teammate_name.clone()) {
register_spawned_teammate(
&parent_session_id,
@@ -740,6 +752,7 @@ async fn create_runtime_subagent_session(
session,
customization,
system_prompt,
access_mode,
})
}
@@ -771,6 +784,7 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal(
session: child_session,
customization,
system_prompt,
access_mode,
} = create_runtime_subagent_session(runtime, &request).await?;
let child_session_id = child_session.id.clone();
let workspace_id =
@@ -786,8 +800,8 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal(
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
approval_policy: Some(access_mode.approval_policy().to_string()),
sandbox_policy: Some(access_mode.sandbox_policy().to_string()),
project_id: None,
workspace_id,
web_search: None,
@@ -853,6 +867,7 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal(
}
let (session, _) = read_subagent_control_state(&session_id).await?;
let access_mode = resolve_subagent_request_access_mode(&session_id, &session);
let customization = SubagentCustomizationState::from_session(&session);
let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?;
if request.interrupt {
@@ -873,8 +888,8 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal(
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
approval_policy: Some(access_mode.approval_policy().to_string()),
sandbox_policy: Some(access_mode.sandbox_policy().to_string()),
project_id: None,
workspace_id,
web_search: None,
@@ -1161,4 +1176,28 @@ mod tests {
"isolation is not supported in the current runtime"
);
}
#[test]
fn test_resolve_subagent_request_access_mode_defaults_to_full_access() {
let session = aster::session::Session::default();
assert_eq!(
resolve_subagent_request_access_mode("child-1", &session),
lime_agent::SessionExecutionRuntimeAccessMode::FullAccess
);
}
#[test]
fn test_resolve_subagent_request_access_mode_prefers_session_recent_access_mode() {
let mut session = aster::session::Session::default();
session.id = "child-2".to_string();
lime_agent::SessionExecutionRuntimeAccessMode::ReadOnly
.to_extension_data(&mut session.extension_data)
.expect("persist access mode");
assert_eq!(
resolve_subagent_request_access_mode("child-2", &session),
lime_agent::SessionExecutionRuntimeAccessMode::ReadOnly
);
}
}
-344
View File
@@ -1,344 +0,0 @@
//! 连接管理 Tauri 命令
//!
//! 提供连接配置的 CRUD 操作接口。
//!
//! ## 命令列表
//! - `connection_list` - 获取所有连接(本地 + SSH 配置)
//! - `connection_add` - 添加新连接
//! - `connection_update` - 更新连接
//! - `connection_delete` - 删除连接
//! - `connection_get_config_path` - 获取配置文件路径
//! - `connection_get_raw_config` - 获取原始配置内容
//! - `connection_save_raw_config` - 保存原始配置内容
use lime_terminal::connections::{
ConnectionConfig, ConnectionConfigManager, ConnectionConfigType, ConnectionListEntry,
};
use serde::{Deserialize, Serialize};
/// 添加连接的请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddConnectionRequest {
/// 连接名称
pub name: String,
/// 连接类型
#[serde(rename = "type")]
pub conn_type: ConnectionConfigType,
/// 用户名(SSH)
pub user: Option<String>,
/// 主机名(SSH)
pub host: Option<String>,
/// 端口(SSH)
pub port: Option<u16>,
/// 身份文件
pub identity_file: Option<String>,
/// 跳板机
pub proxy_jump: Option<String>,
/// WSL 发行版
pub wsl_distro: Option<String>,
}
/// 更新连接的请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateConnectionRequest {
/// 连接名称
pub name: String,
/// 连接配置
pub config: ConnectionConfig,
}
/// 通用操作响应
#[derive(Debug, Serialize)]
pub struct ConnectionResponse {
/// 是否成功
pub success: bool,
/// 错误信息
pub error: Option<String>,
}
impl ConnectionResponse {
pub fn ok() -> Self {
Self {
success: true,
error: None,
}
}
pub fn err(msg: impl Into<String>) -> Self {
Self {
success: false,
error: Some(msg.into()),
}
}
}
/// 获取所有可用连接
///
/// 返回本地连接 + 用户配置 + SSH 配置中的 Host 列表
#[tauri::command]
pub fn connection_list() -> Result<Vec<ConnectionListEntry>, String> {
let manager = ConnectionConfigManager::new();
manager.list_all_connections()
}
/// 添加新连接
#[tauri::command]
pub fn connection_add(request: AddConnectionRequest) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
// 加载现有配置
let mut config = match manager.load() {
Ok(c) => c,
Err(e) => return ConnectionResponse::err(e),
};
// 检查名称是否已存在
if config.connections.contains_key(&request.name) {
return ConnectionResponse::err(format!("连接 '{}' 已存在", request.name));
}
// 创建新连接配置
let conn_config = ConnectionConfig {
conn_type: request.conn_type,
user: request.user,
host: request.host,
port: request.port,
identity_file: request.identity_file,
identity_files: None,
proxy_jump: request.proxy_jump,
display_order: None,
hidden: None,
wsl_distro: request.wsl_distro,
};
// 添加并保存
config.add(request.name.clone(), conn_config);
match manager.save(&config) {
Ok(_) => {
tracing::info!("[Connection] 添加连接成功: {}", request.name);
ConnectionResponse::ok()
}
Err(e) => ConnectionResponse::err(e),
}
}
/// 更新连接
#[tauri::command]
pub fn connection_update(request: UpdateConnectionRequest) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
// 加载现有配置
let mut config = match manager.load() {
Ok(c) => c,
Err(e) => return ConnectionResponse::err(e),
};
// 检查连接是否存在
if !config.connections.contains_key(&request.name) {
return ConnectionResponse::err(format!("连接 '{}' 不存在", request.name));
}
// 更新配置
config
.connections
.insert(request.name.clone(), request.config);
match manager.save(&config) {
Ok(_) => {
tracing::info!("[Connection] 更新连接成功: {}", request.name);
ConnectionResponse::ok()
}
Err(e) => ConnectionResponse::err(e),
}
}
/// 删除连接
#[tauri::command]
pub fn connection_delete(name: String) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
// 加载现有配置
let mut config = match manager.load() {
Ok(c) => c,
Err(e) => return ConnectionResponse::err(e),
};
// 检查连接是否存在
if !config.connections.contains_key(&name) {
return ConnectionResponse::err(format!("连接 '{name}' 不存在"));
}
// 删除并保存
config.remove(&name);
match manager.save(&config) {
Ok(_) => {
tracing::info!("[Connection] 删除连接成功: {}", name);
ConnectionResponse::ok()
}
Err(e) => ConnectionResponse::err(e),
}
}
/// 获取连接配置
#[tauri::command]
pub fn connection_get(name: String) -> Result<Option<ConnectionConfig>, String> {
let manager = ConnectionConfigManager::new();
let config = manager.load()?;
Ok(config.get(&name).cloned())
}
/// 获取配置文件路径
#[tauri::command]
pub fn connection_get_config_path() -> String {
ConnectionConfigManager::default_config_path()
.to_string_lossy()
.to_string()
}
/// 获取原始配置文件内容(用于 JSON 编辑器)
#[tauri::command]
pub fn connection_get_raw_config() -> Result<String, String> {
let manager = ConnectionConfigManager::new();
manager.load_raw()
}
/// 保存原始配置文件内容(用于 JSON 编辑器)
#[tauri::command]
pub fn connection_save_raw_config(content: String) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
match manager.save_raw(&content) {
Ok(_) => {
tracing::info!("[Connection] 原始配置保存成功");
ConnectionResponse::ok()
}
Err(e) => ConnectionResponse::err(e),
}
}
/// 测试连接(异步)
#[tauri::command]
pub async fn connection_test(name: String) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
// 加载配置
let config = match manager.load() {
Ok(c) => c,
Err(e) => return ConnectionResponse::err(e),
};
// 获取连接
let conn = match config.get(&name) {
Some(c) => c,
None => return ConnectionResponse::err(format!("连接 '{name}' 不存在")),
};
// 根据连接类型测试
match conn.conn_type {
ConnectionConfigType::Local => {
// 本地连接总是可用
ConnectionResponse::ok()
}
ConnectionConfigType::Ssh => {
// SSH 连接测试
let host = match &conn.host {
Some(h) => h,
None => return ConnectionResponse::err("SSH 连接缺少主机名"),
};
let port = conn.port.unwrap_or(22);
// 简单的 TCP 连接测试
match tokio::net::TcpStream::connect(format!("{host}:{port}")).await {
Ok(_) => {
tracing::info!("[Connection] SSH 连接测试成功: {}:{}", host, port);
ConnectionResponse::ok()
}
Err(e) => {
tracing::warn!("[Connection] SSH 连接测试失败: {} - {}", host, e);
ConnectionResponse::err(format!("连接失败: {e}"))
}
}
}
ConnectionConfigType::Wsl => {
// WSL 连接测试
#[cfg(target_os = "windows")]
{
// 检查 wsl.exe 是否可用
match std::process::Command::new("wsl")
.arg("--list")
.arg("--quiet")
.output()
{
Ok(output) => {
if output.status.success() {
ConnectionResponse::ok()
} else {
ConnectionResponse::err("WSL 命令执行失败")
}
}
Err(e) => ConnectionResponse::err(format!("WSL 不可用: {}", e)),
}
}
#[cfg(not(target_os = "windows"))]
{
ConnectionResponse::err("WSL 仅在 Windows 上可用")
}
}
}
}
/// 导入 SSH 配置中的 Host 到用户配置
#[tauri::command]
pub fn connection_import_ssh_host(host_name: String) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
// 加载 SSH hosts
let ssh_hosts = manager.load_ssh_hosts();
// 查找指定的 host
let ssh_host = match ssh_hosts.into_iter().find(|h| h.pattern == host_name) {
Some(h) => h,
None => return ConnectionResponse::err(format!("SSH Host '{host_name}' 不存在")),
};
// 加载用户配置
let mut config = match manager.load() {
Ok(c) => c,
Err(e) => return ConnectionResponse::err(e),
};
// 检查是否已存在
if config.connections.contains_key(&host_name) {
return ConnectionResponse::err(format!("连接 '{host_name}' 已存在"));
}
// 创建连接配置
let conn_config = ConnectionConfig {
conn_type: ConnectionConfigType::Ssh,
user: ssh_host.user,
host: ssh_host.hostname.or(Some(ssh_host.pattern.clone())),
port: ssh_host.port,
identity_file: ssh_host.identity_file,
identity_files: None,
proxy_jump: None,
display_order: None,
hidden: None,
wsl_distro: None,
};
// 添加并保存
config.add(host_name.clone(), conn_config);
match manager.save(&config) {
Ok(_) => {
tracing::info!("[Connection] 导入 SSH Host 成功: {}", host_name);
ConnectionResponse::ok()
}
Err(e) => ConnectionResponse::err(e),
}
}
-2
View File
@@ -13,7 +13,6 @@ pub mod channels_cmd;
pub mod companion_cmd;
pub mod config_cmd;
pub mod connect_cmd;
pub mod connection_cmd;
pub mod content_cmd;
pub mod context_memory;
pub mod document_import_cmd;
@@ -58,7 +57,6 @@ pub mod skill_error;
pub mod skill_exec_cmd;
pub mod telegram_remote_cmd;
pub mod telemetry_cmd;
pub mod terminal_cmd;
pub mod theme_context_cmd;
pub mod tray_cmd;
pub mod unified_memory_cmd;
+8
View File
@@ -31,6 +31,14 @@ pub async fn sceneapp_plan_launch(
SceneAppService::plan_launch(db.inner(), intent)
}
#[tauri::command]
pub async fn sceneapp_save_context_baseline(
db: State<'_, DbConnection>,
intent: SceneAppLaunchIntent,
) -> Result<SceneAppPlanResult, String> {
SceneAppService::save_context_baseline(db.inner(), intent)
}
#[tauri::command]
pub async fn sceneapp_create_automation_job(
automation_state: State<'_, AutomationServiceState>,
-155
View File
@@ -1,155 +0,0 @@
//! 终端 Tauri 命令
//!
//! 提供终端核心能力的 Tauri 命令接口。
//!
//! ## 架构说明
//! PTY 在后端预创建,使用默认大小 (24x80)。前端连接后通过 resize 同步实际大小。
//!
//! ## 命令列表
//! - `terminal_create_session` - 创建终端会话(使用默认大小)
//! - `terminal_write` - 向终端发送输入
//! - `terminal_resize` - 调整终端大小
//! - `terminal_close` - 关闭终端会话
//! - `terminal_list_sessions` - 获取所有会话列表
use std::sync::Arc;
use serde::Serialize;
use tauri::State;
use tokio::sync::RwLock;
use lime_terminal::{SessionMetadata, TerminalSessionManager};
/// 终端会话管理器状态包装
pub struct TerminalManagerState(pub Arc<RwLock<Option<TerminalSessionManager>>>);
/// 创建终端会话响应
#[derive(Debug, Serialize)]
pub struct CreateSessionResponse {
/// 会话 ID
pub session_id: String,
}
/// 创建终端会话(使用默认大小)
///
/// PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resize 同步实际大小。
///
/// # 参数
/// - `cwd`: 工作目录(可选)
///
/// # 返回
/// - `Ok(CreateSessionResponse)`: 包含会话 ID
/// - `Err(String)`: 错误信息
#[tauri::command]
pub async fn terminal_create_session(
state: State<'_, TerminalManagerState>,
cwd: Option<String>,
) -> Result<CreateSessionResponse, String> {
let guard = state.inner().0.read().await;
let manager = guard
.as_ref()
.ok_or_else(|| "终端管理器未初始化".to_string())?;
let session_id = manager
.create_session_with_options(24, 80, cwd)
.await
.map_err(|e| e.to_string())?;
Ok(CreateSessionResponse { session_id })
}
/// 向终端发送输入
///
/// # 参数
/// - `session_id`: 会话 ID
/// - `data`: Base64 编码的输入数据
#[tauri::command]
pub async fn terminal_write(
state: State<'_, TerminalManagerState>,
session_id: String,
data: String,
) -> Result<(), String> {
let guard = state.inner().0.read().await;
let manager = guard
.as_ref()
.ok_or_else(|| "终端管理器未初始化".to_string())?;
manager
.write_to_session_base64(&session_id, &data)
.await
.map_err(|e| e.to_string())
}
/// 调整终端大小
///
/// # 参数
/// - `session_id`: 会话 ID
/// - `rows`: 新的行数
/// - `cols`: 新的列数
#[tauri::command]
pub async fn terminal_resize(
state: State<'_, TerminalManagerState>,
session_id: String,
rows: u16,
cols: u16,
) -> Result<(), String> {
let guard = state.inner().0.read().await;
let manager = guard
.as_ref()
.ok_or_else(|| "终端管理器未初始化".to_string())?;
manager
.resize_session(&session_id, rows, cols)
.await
.map_err(|e| e.to_string())
}
/// 关闭终端会话
///
/// # 参数
/// - `session_id`: 会话 ID
#[tauri::command]
pub async fn terminal_close(
state: State<'_, TerminalManagerState>,
session_id: String,
) -> Result<(), String> {
let guard = state.inner().0.read().await;
let manager = guard
.as_ref()
.ok_or_else(|| "终端管理器未初始化".to_string())?;
manager
.close_session(&session_id)
.await
.map_err(|e| e.to_string())
}
/// 获取所有终端会话
#[tauri::command]
pub async fn terminal_list_sessions(
state: State<'_, TerminalManagerState>,
) -> Result<Vec<SessionMetadata>, String> {
let guard = state.inner().0.read().await;
let manager = guard
.as_ref()
.ok_or_else(|| "终端管理器未初始化".to_string())?;
Ok(manager.list_sessions().await)
}
/// 获取单个终端会话信息
///
/// # 参数
/// - `session_id`: 会话 ID
#[tauri::command]
pub async fn terminal_get_session(
state: State<'_, TerminalManagerState>,
session_id: String,
) -> Result<Option<SessionMetadata>, String> {
let guard = state.inner().0.read().await;
let manager = guard
.as_ref()
.ok_or_else(|| "终端管理器未初始化".to_string())?;
Ok(manager.get_session(&session_id).await)
}
+26 -1
View File
@@ -770,7 +770,7 @@ fn load_existing_memories_by_session(
Ok(map)
}
fn get_memory_by_id(
pub(crate) fn get_memory_by_id(
conn: &rusqlite::Connection,
id: &str,
) -> Result<Option<UnifiedMemory>, String> {
@@ -789,6 +789,31 @@ fn get_memory_by_id(
}
}
pub(crate) fn load_unified_memories_by_ids(
conn: &rusqlite::Connection,
ids: &[String],
) -> Result<Vec<UnifiedMemory>, String> {
let normalized_ids = ids
.iter()
.map(|id| id.trim())
.filter(|id| !id.is_empty())
.fold(Vec::<String>::new(), |mut acc, id| {
if !acc.iter().any(|existing| existing == id) {
acc.push(id.to_string());
}
acc
});
let mut memories = Vec::new();
for id in normalized_ids {
if let Some(memory) = get_memory_by_id(conn, id.as_str())? {
memories.push(memory);
}
}
Ok(memories)
}
fn insert_unified_memory(
conn: &rusqlite::Connection,
memory: &UnifiedMemory,
+164
View File
@@ -718,6 +718,170 @@ mod tests {
assert!(value["version"].is_string());
}
#[tokio::test]
async fn sceneapp_get_descriptor_is_bridged() {
let state = make_test_state();
let value = handle_command(
&state,
"sceneapp_get_descriptor",
Some(serde_json::json!({
"id": "story-video-suite"
})),
)
.await
.unwrap();
assert_eq!(value["id"], "story-video-suite");
assert_eq!(value["deliveryContract"], "project_pack");
}
#[tokio::test]
async fn sceneapp_plan_launch_is_bridged() {
let state = make_test_state();
let value = handle_command(
&state,
"sceneapp_plan_launch",
Some(serde_json::json!({
"intent": {
"sceneappId": "story-video-suite",
"entrySource": "sceneapp_detail_preview",
"workspaceId": "workspace-default",
"projectId": "project-devbridge",
"userInput": "继续整理成 30 秒新品短视频方案",
"referenceMemoryIds": [],
"slots": {}
}
})),
)
.await
.unwrap();
assert_eq!(value["descriptor"]["id"], "story-video-suite");
assert!(value["contextOverlay"].is_object());
assert!(value["projectPackPlan"].is_object());
}
#[tokio::test]
async fn sceneapp_save_context_baseline_is_bridged() {
let state = make_test_state();
let temp_dir = TempDir::new().unwrap();
let root_path = temp_dir.path().join("sceneapp-context-workspace");
let workspace_value = handle_command(
&state,
"workspace_create",
Some(serde_json::json!({
"request": {
"name": "SceneApp Context Workspace",
"rootPath": root_path.to_string_lossy().to_string(),
"workspaceType": "general"
}
})),
)
.await
.unwrap();
let workspace_id = workspace_value["id"].as_str().unwrap().to_string();
let value = handle_command(
&state,
"sceneapp_save_context_baseline",
Some(serde_json::json!({
"intent": {
"sceneappId": "story-video-suite",
"entrySource": "sceneapp_detail_save_context_baseline",
"workspaceId": workspace_id.clone(),
"projectId": workspace_id,
"userInput": "继续整理成 30 秒新品短视频方案",
"referenceMemoryIds": [],
"slots": {}
}
})),
)
.await
.unwrap();
assert_eq!(value["descriptor"]["id"], "story-video-suite");
assert!(value["contextOverlay"]["snapshot"]["referenceItems"].is_array());
assert!(value["contextOverlay"]["compilerPlan"]["notes"]
.as_array()
.is_some_and(|notes| notes.iter().any(|note| {
note.as_str()
.is_some_and(|value| value.contains("已写入项目级 Context Snapshot"))
})));
}
#[tokio::test]
async fn sceneapp_list_runs_is_bridged() {
let state = make_test_state();
let error = handle_command(
&state,
"sceneapp_list_runs",
Some(serde_json::json!({
"sceneappId": "story-video-suite"
})),
)
.await
.expect_err("missing app handle should fail after bridge routing");
assert!(error.to_string().contains("Dev Bridge 未持有 AppHandle"));
}
#[tokio::test]
async fn sceneapp_get_run_summary_is_bridged() {
let state = make_test_state();
let value = handle_command(
&state,
"sceneapp_get_run_summary",
Some(serde_json::json!({
"runId": "sceneapp-run-story-video-seed"
})),
)
.await
.unwrap();
assert_eq!(value["runId"], "sceneapp-run-story-video-seed");
assert_eq!(value["sceneappId"], "story-video-suite");
}
#[tokio::test]
async fn sceneapp_prepare_run_governance_artifact_is_bridged() {
let state = make_test_state();
let value = handle_command(
&state,
"sceneapp_prepare_run_governance_artifact",
Some(serde_json::json!({
"runId": "sceneapp-run-story-video-seed",
"kind": "review_decision_json"
})),
)
.await
.unwrap();
assert!(value.is_null());
}
#[tokio::test]
async fn sceneapp_get_scorecard_is_bridged() {
let state = make_test_state();
let error = handle_command(
&state,
"sceneapp_get_scorecard",
Some(serde_json::json!({
"sceneappId": "story-video-suite"
})),
)
.await
.expect_err("missing app handle should fail after bridge routing");
assert!(error.to_string().contains("Dev Bridge 未持有 AppHandle"));
}
#[tokio::test]
async fn gateway_channel_status_is_bridged() {
let state = make_test_state();
+107 -5
View File
@@ -1,19 +1,121 @@
use super::{args_or_default, get_db, get_string_arg, parse_nested_arg, require_app_handle};
use crate::dev_bridge::DevBridgeState;
use crate::sceneapp::application::SceneAppService;
use crate::services::execution_tracker_service::ExecutionTracker;
use serde_json::Value as JsonValue;
use tauri::Manager;
type DynError = Box<dyn std::error::Error>;
pub(super) async fn try_handle(
_state: &DevBridgeState,
state: &DevBridgeState,
cmd: &str,
_args: Option<&JsonValue>,
args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, DynError> {
if cmd != "sceneapp_list_catalog" {
if !matches!(
cmd,
"sceneapp_list_catalog"
| "sceneapp_get_descriptor"
| "sceneapp_plan_launch"
| "sceneapp_save_context_baseline"
| "sceneapp_create_automation_job"
| "sceneapp_list_runs"
| "sceneapp_get_run_summary"
| "sceneapp_prepare_run_governance_artifact"
| "sceneapp_get_scorecard"
) {
return Ok(None);
}
let result =
serde_json::to_value(crate::commands::sceneapp_cmd::sceneapp_list_catalog().await?)?;
let args = args_or_default(args);
let result = match cmd {
"sceneapp_list_catalog" => {
serde_json::to_value(crate::commands::sceneapp_cmd::sceneapp_list_catalog().await?)?
}
"sceneapp_get_descriptor" => {
let id = get_string_arg(&args, "id", "id")?;
serde_json::to_value(crate::commands::sceneapp_cmd::sceneapp_get_descriptor(id).await?)?
}
"sceneapp_plan_launch" => {
let db = get_db(state)?;
let intent =
parse_nested_arg::<crate::sceneapp::dto::SceneAppLaunchIntent>(&args, "intent")?;
serde_json::to_value(SceneAppService::plan_launch(db, intent)?)?
}
"sceneapp_save_context_baseline" => {
let db = get_db(state)?;
let intent =
parse_nested_arg::<crate::sceneapp::dto::SceneAppLaunchIntent>(&args, "intent")?;
serde_json::to_value(SceneAppService::save_context_baseline(db, intent)?)?
}
"sceneapp_create_automation_job" => {
let app_handle = require_app_handle(state)?;
let automation_state =
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
let intent = parse_nested_arg::<crate::sceneapp::dto::SceneAppAutomationIntent>(
&args, "intent",
)?;
let service = automation_state.0.read().await;
serde_json::to_value(SceneAppService::create_automation_job(&service, intent).await?)?
}
"sceneapp_list_runs" => {
let db = get_db(state)?.clone();
let app_handle = require_app_handle(state)?;
let automation_state =
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
let sceneapp_id = args
.get("sceneappId")
.or_else(|| args.get("sceneapp_id"))
.and_then(|value| value.as_str())
.map(ToString::to_string);
let service = automation_state.0.read().await;
let tracker = ExecutionTracker::new(db);
serde_json::to_value(SceneAppService::collect_runs(
&tracker,
&service,
sceneapp_id.as_deref(),
)?)?
}
"sceneapp_get_run_summary" => {
let db = get_db(state)?.clone();
let run_id = get_string_arg(&args, "runId", "run_id")?;
let tracker = ExecutionTracker::new(db);
if let Some(summary) =
SceneAppService::get_run_summary_from_tracker(&tracker, run_id.as_str())?
{
serde_json::to_value(Some(summary))?
} else {
serde_json::to_value(SceneAppService::get_run_summary(run_id.as_str()))?
}
}
"sceneapp_prepare_run_governance_artifact" => {
let db = get_db(state)?.clone();
let run_id = get_string_arg(&args, "runId", "run_id")?;
let kind = parse_nested_arg::<crate::sceneapp::dto::SceneAppGovernanceArtifactKind>(
&args, "kind",
)?;
let tracker = ExecutionTracker::new(db);
serde_json::to_value(SceneAppService::prepare_run_governance_artifact(
&tracker,
run_id.as_str(),
&kind,
)?)?
}
"sceneapp_get_scorecard" => {
let db = get_db(state)?;
let app_handle = require_app_handle(state)?;
let automation_state =
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
let sceneapp_id = get_string_arg(&args, "sceneappId", "sceneapp_id")?;
let service = automation_state.0.read().await;
serde_json::to_value(SceneAppService::get_scorecard(
db,
&service,
sceneapp_id.as_str(),
)?)?
}
_ => unreachable!("已通过前置 matches! 过滤 sceneapp 命令"),
};
Ok(Some(result))
}
-1
View File
@@ -46,7 +46,6 @@ pub mod sceneapp;
pub mod screenshot;
pub mod services;
pub mod skills;
pub mod terminal;
pub mod tray;
pub mod voice;
+15
View File
@@ -119,6 +119,7 @@ fn build_sceneapp_launch_harness_value(
"entry_source": launch_intent.entry_source.clone(),
"workspace_id": launch_intent.workspace_id.clone(),
"project_id": launch_intent.project_id.clone(),
"reference_memory_ids": launch_intent.reference_memory_ids.clone(),
"linked_service_skill_id": descriptor.linked_service_skill_id.clone(),
"linked_scene_key": descriptor.linked_scene_key.clone(),
})
@@ -200,6 +201,12 @@ fn build_sceneapp_metadata_root(
json!(launch_intent.slots.clone()),
);
}
if !launch_intent.reference_memory_ids.is_empty() {
root.insert(
"sceneapp_reference_memory_ids".to_string(),
json!(launch_intent.reference_memory_ids.clone()),
);
}
root
}
@@ -303,6 +310,7 @@ fn build_sceneapp_runtime_request_metadata(
"workspace_id": launch_intent.workspace_id.clone(),
"project_id": launch_intent.project_id.clone(),
"user_input": launch_intent.user_input.clone(),
"reference_memory_ids": launch_intent.reference_memory_ids.clone(),
"slots": launch_intent.slots.clone(),
}
}),
@@ -362,6 +370,7 @@ fn build_sceneapp_runtime_request_metadata(
"project_id": launch_intent.project_id.clone(),
"workspace_id": launch_intent.workspace_id.clone(),
"user_input": launch_intent.user_input.clone(),
"reference_memory_ids": launch_intent.reference_memory_ids.clone(),
"slots": launch_intent.slots.clone(),
}),
);
@@ -1478,6 +1487,7 @@ pub fn build_sceneapp_runtime_adapter_plan(
"project_id": launch_intent.project_id.clone(),
"entry_source": launch_intent.entry_source.clone().unwrap_or_else(|| "sceneapp_plan".to_string()),
"user_input": launch_intent.user_input.clone(),
"reference_memory_ids": launch_intent.reference_memory_ids.clone(),
"slots": launch_intent.slots.clone(),
})
}
@@ -1527,6 +1537,7 @@ pub fn build_sceneapp_runtime_adapter_plan(
"workspace_id": launch_intent.workspace_id.clone(),
"project_id": launch_intent.project_id.clone(),
"user_input": launch_intent.user_input.clone(),
"reference_memory_ids": launch_intent.reference_memory_ids.clone(),
"slots": launch_intent.slots.clone(),
"runtime_context": launch_intent.runtime_context.clone(),
},
@@ -1545,6 +1556,7 @@ pub fn build_sceneapp_runtime_adapter_plan(
"workspace_id": launch_intent.workspace_id.clone(),
"project_id": launch_intent.project_id.clone(),
"user_input": launch_intent.user_input.clone(),
"reference_memory_ids": launch_intent.reference_memory_ids.clone(),
"slots": launch_intent.slots.clone(),
})
}
@@ -1559,6 +1571,7 @@ pub fn build_sceneapp_runtime_adapter_plan(
"message": launch_intent.user_input.clone().unwrap_or_default(),
"workspace_id": launch_intent.workspace_id.clone(),
"project_id": launch_intent.project_id.clone(),
"reference_memory_ids": launch_intent.reference_memory_ids.clone(),
"slots": launch_intent.slots.clone(),
})
}
@@ -1677,6 +1690,8 @@ pub fn build_sceneapp_automation_draft(
.unwrap_or("agent_turn")
)),
web_search: false,
approval_policy: Some("never".to_string()),
sandbox_policy: Some("danger-full-access".to_string()),
request_metadata: Some(build_sceneapp_request_metadata(descriptor, intent)),
content_id: None,
},
File diff suppressed because it is too large Load Diff
+221 -7
View File
@@ -4,6 +4,7 @@ use super::dto::{
};
use super::store::PersistedSceneAppContext;
use crate::sceneapp::dto::{SceneAppDescriptor, SceneAppLaunchIntent, SceneAppPattern};
use lime_memory::UnifiedMemory;
use std::collections::{hash_map::DefaultHasher, BTreeSet};
use std::hash::{Hash, Hasher};
@@ -50,6 +51,46 @@ fn stable_reference_item_id(prefix: &str, key: &str, value: &str) -> String {
format!("{prefix}-{key}-{:x}", hasher.finish())
}
fn stable_memory_reference_item_id(memory_id: &str) -> String {
format!("memory:{memory_id}")
}
fn build_reference_content_type(memory: &UnifiedMemory) -> String {
if extract_url_candidate(memory.content.as_str()).is_some() {
return "url".to_string();
}
match memory.category {
lime_memory::MemoryCategory::Identity => "style_memory".to_string(),
lime_memory::MemoryCategory::Preference => "preference_memory".to_string(),
lime_memory::MemoryCategory::Context => "reference_memory".to_string(),
lime_memory::MemoryCategory::Experience => "outcome_memory".to_string(),
lime_memory::MemoryCategory::Activity => "collection_memory".to_string(),
}
}
pub fn build_reference_library_items(memories: &[UnifiedMemory]) -> Vec<ReferenceItem> {
memories
.iter()
.map(|memory| ReferenceItem {
id: stable_memory_reference_item_id(memory.id.as_str()),
label: memory.title.trim().to_string(),
source_kind: ContextLayerSourceKind::ReferenceLibrary,
content_type: build_reference_content_type(memory),
uri: extract_url_candidate(memory.content.as_str()),
summary: Some(if memory.summary.trim().is_empty() {
truncate_summary(memory.content.as_str(), 80)
} else {
truncate_summary(memory.summary.as_str(), 80)
}),
selected: true,
usage_count: None,
last_used_at: None,
last_feedback_label: None,
})
.collect()
}
fn build_reference_items(intent: &SceneAppLaunchIntent) -> Vec<ReferenceItem> {
let mut items = Vec::new();
@@ -67,6 +108,9 @@ fn build_reference_items(intent: &SceneAppLaunchIntent) -> Vec<ReferenceItem> {
uri: extract_url_candidate(user_input),
summary: Some(truncate_summary(user_input, 80)),
selected: true,
usage_count: None,
last_used_at: None,
last_feedback_label: None,
});
}
}
@@ -88,20 +132,69 @@ fn build_reference_items(intent: &SceneAppLaunchIntent) -> Vec<ReferenceItem> {
uri: extract_url_candidate(normalized),
summary: Some(truncate_summary(normalized, 80)),
selected: true,
usage_count: None,
last_used_at: None,
last_feedback_label: None,
})
}));
items
}
fn merge_reference_item_feedback(
mut item: ReferenceItem,
persisted_item: &ReferenceItem,
) -> ReferenceItem {
if item.summary.is_none() {
item.summary = persisted_item.summary.clone();
}
if item.uri.is_none() {
item.uri = persisted_item.uri.clone();
}
if item.usage_count.is_none() {
item.usage_count = persisted_item.usage_count;
}
if item.last_used_at.is_none() {
item.last_used_at = persisted_item.last_used_at.clone();
}
if item.last_feedback_label.is_none() {
item.last_feedback_label = persisted_item.last_feedback_label.clone();
}
item.selected = item.selected || persisted_item.selected;
item
}
fn merge_reference_items(
explicit_reference_items: Vec<ReferenceItem>,
input_items: Vec<ReferenceItem>,
persisted_context: Option<&PersistedSceneAppContext>,
) -> Vec<ReferenceItem> {
let mut seen = BTreeSet::new();
let mut merged = Vec::new();
for item in input_items {
for mut item in explicit_reference_items {
if let Some(persisted_item) = persisted_context.and_then(|context| {
context
.reference_items
.iter()
.find(|existing| existing.id == item.id)
}) {
item = merge_reference_item_feedback(item, persisted_item);
}
if seen.insert(item.id.clone()) {
merged.push(item);
}
}
for mut item in input_items {
if let Some(persisted_item) = persisted_context.and_then(|context| {
context
.reference_items
.iter()
.find(|existing| existing.id == item.id)
}) {
item = merge_reference_item_feedback(item, persisted_item);
}
if seen.insert(item.id.clone()) {
merged.push(item);
}
@@ -206,6 +299,14 @@ fn build_taste_profile(
push_unique(&mut keywords, Some(keyword.clone()));
}
}
for item in reference_items.iter().filter(|item| {
matches!(
item.source_kind,
ContextLayerSourceKind::ReferenceLibrary | ContextLayerSourceKind::Project
)
}) {
push_unique(&mut keywords, Some(item.label.clone()));
}
for alias in descriptor.aliases.iter().take(2) {
push_unique(&mut keywords, Some(alias.clone()));
}
@@ -268,6 +369,13 @@ fn build_taste_profile(
0.56
},
),
feedback_summary: persisted_taste_profile
.and_then(|profile| profile.feedback_summary.clone()),
feedback_signals: persisted_taste_profile
.map(|profile| profile.feedback_signals.clone())
.unwrap_or_default(),
last_feedback_at: persisted_taste_profile
.and_then(|profile| profile.last_feedback_at.clone()),
})
}
@@ -275,9 +383,14 @@ pub fn build_sceneapp_context_overlay(
descriptor: &SceneAppDescriptor,
intent: &SceneAppLaunchIntent,
persisted_context: Option<&PersistedSceneAppContext>,
explicit_reference_items: &[ReferenceItem],
) -> SceneAppContextOverlay {
let input_reference_items = build_reference_items(intent);
let reference_items = merge_reference_items(input_reference_items.clone(), persisted_context);
let reference_items = merge_reference_items(
explicit_reference_items.to_vec(),
input_reference_items.clone(),
persisted_context,
);
let memory_refs = build_memory_refs(intent);
let tool_refs = build_tool_refs(descriptor, intent);
let skill_refs = build_skill_refs(descriptor);
@@ -308,8 +421,24 @@ pub fn build_sceneapp_context_overlay(
restored_reference_count
));
}
if input_reference_items.is_empty() && reference_items.is_empty() {
if explicit_reference_items.is_empty()
&& input_reference_items.is_empty()
&& reference_items.is_empty()
{
notes.push("当前尚未选中显式参考素材,将主要依赖用户输入与场景画像。".to_string());
} else if !explicit_reference_items.is_empty() && !input_reference_items.is_empty() {
notes.push(format!(
"本次显式带入 {} 条灵感对象,并新增 {} 条输入参考,当前 planning 共带上 {} 条参考。",
explicit_reference_items.len(),
input_reference_items.len(),
reference_items.len()
));
} else if !explicit_reference_items.is_empty() {
notes.push(format!(
"本次显式带入 {} 条灵感对象,当前 planning 共带上 {} 条参考。",
explicit_reference_items.len(),
reference_items.len()
));
} else if !input_reference_items.is_empty() {
notes.push(format!(
"本次新增 {} 条参考输入,当前 planning 共带上 {} 条参考。",
@@ -328,7 +457,8 @@ pub fn build_sceneapp_context_overlay(
{
notes.push("当前已复用项目级 TasteProfile,并按最新输入继续更新。".to_string());
} else if taste_profile.is_some() {
notes.push("当前 TasteProfile 为启发式摘要,后续可继续接入持久化反馈回写。".to_string());
notes
.push("当前 TasteProfile 为启发式摘要,可继续通过场景基线与运行反馈沉淀。".to_string());
}
SceneAppContextOverlay {
@@ -353,11 +483,12 @@ pub fn build_sceneapp_context_overlay(
#[cfg(test)]
mod tests {
use super::build_sceneapp_context_overlay;
use super::{build_reference_library_items, build_sceneapp_context_overlay};
use crate::sceneapp::catalog::get_sceneapp_descriptor;
use crate::sceneapp::context::dto::{ContextLayerSourceKind, ReferenceItem, TasteProfile};
use crate::sceneapp::context::store::PersistedSceneAppContext;
use crate::sceneapp::dto::{SceneAppLaunchIntent, SceneAppRuntimeContext};
use lime_memory::UnifiedMemory;
use std::collections::BTreeMap;
#[test]
@@ -379,6 +510,7 @@ mod tests {
workspace_id: Some("workspace-default".to_string()),
project_id: Some("project-export".to_string()),
user_input: Some("请导出这篇文章并保持原始语气".to_string()),
reference_memory_ids: Vec::new(),
slots,
runtime_context: Some(SceneAppRuntimeContext {
browser_session_attached: true,
@@ -386,6 +518,7 @@ mod tests {
}),
},
None,
&[],
);
assert!(overlay
@@ -422,6 +555,9 @@ mod tests {
uri: None,
summary: Some("保留结论前置和对比镜头。".to_string()),
selected: true,
usage_count: Some(2),
last_used_at: Some("2026-04-16T12:00:00.000Z".to_string()),
last_feedback_label: Some("复核阻塞".to_string()),
}],
taste_profile: Some(TasteProfile {
profile_id: "taste-story-video-suite".to_string(),
@@ -430,7 +566,11 @@ mod tests {
avoid_keywords: vec!["冗长铺垫".to_string()],
derived_from_reference_ids: vec!["saved-reference-1".to_string()],
confidence: Some(0.66),
feedback_summary: Some("最近一次运行卡在复核环节。".to_string()),
feedback_signals: vec!["review_blocked".to_string()],
last_feedback_at: Some("2026-04-16T12:00:00.000Z".to_string()),
}),
last_feedback_run_id: Some("sceneapp-run-42".to_string()),
};
let overlay = build_sceneapp_context_overlay(
@@ -441,10 +581,12 @@ mod tests {
workspace_id: Some("workspace-default".to_string()),
project_id: Some("project-video".to_string()),
user_input: Some("做一个 30 秒新品短视频".to_string()),
reference_memory_ids: Vec::new(),
slots,
runtime_context: None,
},
Some(&persisted_context),
&[],
);
assert_eq!(overlay.compiler_plan.reference_count, 3);
@@ -463,11 +605,83 @@ mod tests {
.reference_items
.iter()
.any(|item| item.label == "竞品拆解"
&& matches!(item.source_kind, ContextLayerSourceKind::ReferenceLibrary)));
&& matches!(item.source_kind, ContextLayerSourceKind::ReferenceLibrary)
&& item.usage_count == Some(2)
&& item.last_feedback_label.as_deref() == Some("复核阻塞")));
assert!(overlay
.snapshot
.taste_profile
.as_ref()
.is_some_and(|profile| profile.summary.contains("项目沉淀基础")));
.is_some_and(|profile| profile.summary.contains("项目沉淀基础")
&& profile.feedback_summary.as_deref() == Some("最近一次运行卡在复核环节。")
&& profile
.feedback_signals
.iter()
.any(|signal| signal == "review_blocked")));
}
#[test]
fn should_promote_selected_memory_entries_into_reference_items() {
let descriptor = get_sceneapp_descriptor("story-video-suite")
.expect("story-video-suite descriptor should exist");
let explicit_reference_items = build_reference_library_items(&[UnifiedMemory {
id: "memory-1".to_string(),
session_id: "session-1".to_string(),
memory_type: lime_memory::MemoryType::Conversation,
category: lime_memory::MemoryCategory::Identity,
title: "夏日短视频语气".to_string(),
content: "保留轻盈、结论前置和更强的节奏推进。".to_string(),
summary: "轻盈、结论前置、快节奏。".to_string(),
tags: vec!["小红书".to_string()],
metadata: lime_memory::MemoryMetadata {
confidence: 0.86,
importance: 7,
access_count: 0,
last_accessed_at: None,
source: lime_memory::MemorySource::Manual,
embedding: None,
},
created_at: 1_712_345_678_900,
updated_at: 1_712_345_678_900,
archived: false,
}]);
let overlay = build_sceneapp_context_overlay(
&descriptor,
&SceneAppLaunchIntent {
sceneapp_id: "story-video-suite".to_string(),
entry_source: Some("memory_page".to_string()),
workspace_id: Some("workspace-default".to_string()),
project_id: Some("project-video".to_string()),
user_input: Some("继续整理成 30 秒短视频方案".to_string()),
reference_memory_ids: vec!["memory-1".to_string()],
slots: BTreeMap::new(),
runtime_context: Some(SceneAppRuntimeContext {
cloud_session_ready: true,
..SceneAppRuntimeContext::default()
}),
},
None,
explicit_reference_items.as_slice(),
);
assert!(overlay
.snapshot
.reference_items
.iter()
.any(|item| item.id == "memory:memory-1" && item.label == "夏日短视频语气"));
assert!(overlay
.snapshot
.taste_profile
.as_ref()
.is_some_and(|profile| profile
.keywords
.iter()
.any(|keyword| keyword == "夏日短视频语气")));
assert!(overlay
.compiler_plan
.notes
.iter()
.any(|note| note.contains("显式带入 1 条灵感对象")));
}
}
+12
View File
@@ -22,6 +22,12 @@ pub struct ReferenceItem {
pub uri: Option<String>,
pub summary: Option<String>,
pub selected: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_used_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_feedback_label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -33,6 +39,12 @@ pub struct TasteProfile {
pub avoid_keywords: Vec<String>,
pub derived_from_reference_ids: Vec<String>,
pub confidence: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub feedback_summary: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub feedback_signals: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_feedback_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+9
View File
@@ -17,6 +17,8 @@ pub struct PersistedSceneAppContext {
pub reference_items: Vec<ReferenceItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub taste_profile: Option<TasteProfile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_feedback_run_id: Option<String>,
}
fn normalize_optional_id(value: Option<&str>) -> Option<String> {
@@ -97,6 +99,7 @@ pub fn build_persisted_sceneapp_context(
project_id: snapshot.project_id.clone(),
reference_items: snapshot.reference_items.clone(),
taste_profile: snapshot.taste_profile.clone(),
last_feedback_run_id: None,
}
}
@@ -211,6 +214,9 @@ mod tests {
uri: None,
summary: Some("科技感,快节奏".to_string()),
selected: true,
usage_count: Some(1),
last_used_at: Some("2026-04-17T00:00:00.000Z".to_string()),
last_feedback_label: Some("可继续复用".to_string()),
}],
taste_profile: Some(crate::sceneapp::context::dto::TasteProfile {
profile_id: "taste-story-video-suite".to_string(),
@@ -219,6 +225,9 @@ mod tests {
avoid_keywords: vec!["冗长铺垫".to_string()],
derived_from_reference_ids: vec!["slot-style-a1b2".to_string()],
confidence: Some(0.72),
feedback_summary: Some("最近一次运行已沉淀为正向风格反馈。".to_string()),
feedback_signals: vec!["publish_ready".to_string()],
last_feedback_at: Some("2026-04-17T00:00:00.000Z".to_string()),
}),
};
let persisted = build_persisted_sceneapp_context("story-video-suite", &snapshot);
+2
View File
@@ -183,6 +183,8 @@ pub struct SceneAppLaunchIntent {
pub project_id: Option<String>,
pub user_input: Option<String>,
#[serde(default)]
pub reference_memory_ids: Vec<String>,
#[serde(default)]
pub slots: BTreeMap<String, String>,
pub runtime_context: Option<SceneAppRuntimeContext>,
}
+23 -2
View File
@@ -1,6 +1,6 @@
use super::adapters::build_sceneapp_runtime_adapter_plan;
use super::context::compiler::build_sceneapp_context_overlay;
use super::context::dto::SceneAppContextOverlay;
use super::context::dto::{ReferenceItem, SceneAppContextOverlay};
use super::context::store::PersistedSceneAppContext;
use super::dto::*;
use std::collections::BTreeSet;
@@ -236,8 +236,14 @@ pub fn build_launch_plan(
descriptor: SceneAppDescriptor,
intent: SceneAppLaunchIntent,
persisted_context: Option<&PersistedSceneAppContext>,
explicit_reference_items: &[ReferenceItem],
) -> SceneAppPlanResult {
let context_overlay = build_sceneapp_context_overlay(&descriptor, &intent, persisted_context);
let context_overlay = build_sceneapp_context_overlay(
&descriptor,
&intent,
persisted_context,
explicit_reference_items,
);
let project_pack_plan = build_project_pack_plan(&descriptor, &intent, &context_overlay);
let sceneapp_id = descriptor.id.clone();
let binding_family = descriptor
@@ -306,6 +312,7 @@ mod tests {
workspace_id: Some("workspace-default".to_string()),
project_id: Some("project-video".to_string()),
user_input: Some("根据发布会内容生成 30 秒短视频草稿".to_string()),
reference_memory_ids: Vec::new(),
slots: BTreeMap::new(),
runtime_context: Some(SceneAppRuntimeContext {
cloud_session_ready: true,
@@ -313,6 +320,7 @@ mod tests {
}),
},
None,
&[],
);
assert_eq!(
@@ -369,6 +377,7 @@ mod tests {
workspace_id: Some("workspace-default".to_string()),
project_id: Some("project-research".to_string()),
user_input: Some("请导出这篇文章".to_string()),
reference_memory_ids: Vec::new(),
slots,
runtime_context: Some(SceneAppRuntimeContext {
browser_session_attached: true,
@@ -376,6 +385,7 @@ mod tests {
}),
},
None,
&[],
);
assert_eq!(
@@ -419,6 +429,7 @@ mod tests {
workspace_id: Some("workspace-default".to_string()),
project_id: Some("project-growth".to_string()),
user_input: Some("关注 AI Agent 产品趋势".to_string()),
reference_memory_ids: Vec::new(),
slots: BTreeMap::new(),
runtime_context: Some(SceneAppRuntimeContext {
automation_enabled: true,
@@ -426,6 +437,7 @@ mod tests {
}),
},
None,
&[],
);
assert_eq!(
@@ -466,6 +478,9 @@ mod tests {
uri: None,
summary: Some("保留强开头与多镜头切换。".to_string()),
selected: true,
usage_count: Some(1),
last_used_at: Some("2026-04-16T00:00:00.000Z".to_string()),
last_feedback_label: Some("可继续复用".to_string()),
}],
taste_profile: Some(TasteProfile {
profile_id: "taste-story-video-suite".to_string(),
@@ -474,7 +489,11 @@ mod tests {
avoid_keywords: vec!["铺垫过长".to_string()],
derived_from_reference_ids: vec!["saved-reference-1".to_string()],
confidence: Some(0.74),
feedback_summary: Some("最近一次运行可继续复用。".to_string()),
feedback_signals: vec!["publish_ready".to_string()],
last_feedback_at: Some("2026-04-16T00:00:00.000Z".to_string()),
}),
last_feedback_run_id: Some("sceneapp-run-1".to_string()),
};
let mut slots = BTreeMap::new();
slots.insert("style".to_string(), "科技感".to_string());
@@ -487,6 +506,7 @@ mod tests {
workspace_id: Some("workspace-default".to_string()),
project_id: Some("project-video".to_string()),
user_input: Some("根据这次发布会做 30 秒短视频".to_string()),
reference_memory_ids: Vec::new(),
slots,
runtime_context: Some(SceneAppRuntimeContext {
cloud_session_ready: true,
@@ -494,6 +514,7 @@ mod tests {
}),
},
Some(&persisted_context),
&[],
);
let overlay = plan.context_overlay.expect("context overlay should exist");
+15 -8
View File
@@ -381,10 +381,10 @@ fn open_floating_window_with_url(app: &AppHandle, url: &str) -> Result<(), Windo
if is_voice_mode {
use tauri::Emitter;
// 延迟发送事件,等待页面加载
let window_clone = window.clone();
let app_clone = app.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = window_clone.emit("voice-start-recording", ());
let _ = app_clone.emit("voice-start-recording", ());
info!("[语音输入] 已发送开始录音事件");
});
}
@@ -428,6 +428,16 @@ fn open_floating_window_with_url(app: &AppHandle, url: &str) -> Result<(), Windo
info!("悬浮窗口创建成功: {}", FLOATING_WINDOW_LABEL);
if is_voice_mode {
use tauri::Emitter;
let app_clone = app.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = app_clone.emit("voice-start-recording", ());
info!("[语音输入] 已广播开始录音事件");
});
}
Ok(())
}
@@ -440,12 +450,9 @@ pub fn open_floating_window_with_voice(app: &AppHandle) -> Result<(), WindowErro
pub fn send_voice_stop_event(app: &AppHandle) -> Result<(), WindowError> {
use tauri::Emitter;
if let Some(window) = app.get_webview_window(FLOATING_WINDOW_LABEL) {
window
.emit("voice-stop-recording", ())
.map_err(|e| WindowError::OperationFailed(format!("发送停止录音事件失败: {e}")))?;
info!("[语音输入] 已发送停止录音事件到截图输入框");
}
app.emit("voice-stop-recording", ())
.map_err(|e| WindowError::OperationFailed(format!("发送停止录音事件失败: {e}")))?;
info!("[语音输入] 已广播停止录音事件");
Ok(())
}
@@ -54,6 +54,8 @@ pub async fn execute_job(
prompt,
system_prompt,
web_search,
approval_policy,
sandbox_policy,
request_metadata,
content_id,
} => {
@@ -64,6 +66,8 @@ pub async fn execute_job(
prompt,
system_prompt,
web_search,
approval_policy,
sandbox_policy,
request_metadata,
content_id,
)
@@ -84,6 +88,8 @@ async fn execute_agent_turn(
prompt: String,
system_prompt: Option<String>,
web_search: bool,
approval_policy: Option<String>,
sandbox_policy: Option<String>,
request_metadata: Option<Value>,
content_id: Option<String>,
) -> Result<JobExecutionResult, String> {
@@ -108,6 +114,12 @@ async fn execute_agent_turn(
job.workspace_id.clone(),
Some("auto".to_string()),
)?;
let access_mode = resolve_agent_turn_access_mode_from_payload(
approval_policy.as_deref(),
sandbox_policy.as_deref(),
request_metadata.as_ref(),
)?;
AsterAgentWrapper::persist_session_recent_access_mode(&session_id, access_mode).await?;
let agent_state = app
.try_state::<crate::agent::AsterAgentState>()
@@ -137,8 +149,8 @@ async fn execute_agent_turn(
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
approval_policy: Some(access_mode.approval_policy().to_string()),
sandbox_policy: Some(access_mode.sandbox_policy().to_string()),
project_id: None,
workspace_id: job.workspace_id.clone(),
web_search: Some(web_search),
@@ -198,6 +210,50 @@ fn build_prompt(job: &AutomationJobRecord, prompt: &str, web_search: bool) -> St
sections.join("\n\n")
}
fn extract_access_mode_text(request_metadata: Option<&Value>) -> Option<&str> {
let root = request_metadata?.as_object()?;
let harness = root.get("harness").and_then(Value::as_object);
harness
.and_then(|value| value.get("access_mode").and_then(Value::as_str))
.or_else(|| harness.and_then(|value| value.get("accessMode").and_then(Value::as_str)))
.or_else(|| root.get("access_mode").and_then(Value::as_str))
.or_else(|| root.get("accessMode").and_then(Value::as_str))
}
pub(super) fn resolve_agent_turn_access_mode_from_payload(
approval_policy: Option<&str>,
sandbox_policy: Option<&str>,
request_metadata: Option<&Value>,
) -> Result<lime_agent::SessionExecutionRuntimeAccessMode, String> {
if approval_policy.is_some() || sandbox_policy.is_some() {
return match (approval_policy.map(str::trim), sandbox_policy.map(str::trim)) {
(Some("on-request"), Some("read-only")) | (None, Some("read-only")) => {
Ok(lime_agent::SessionExecutionRuntimeAccessMode::ReadOnly)
}
(Some("on-request"), Some("workspace-write"))
| (None, Some("workspace-write")) => {
Ok(lime_agent::SessionExecutionRuntimeAccessMode::Current)
}
(Some("never"), Some("danger-full-access"))
| (None, Some("danger-full-access")) => {
Ok(lime_agent::SessionExecutionRuntimeAccessMode::FullAccess)
}
_ => Err(
"自动化任务 approval_policy/sandbox_policy 仅支持 read-only/current/full-access 对应的正式策略组合"
.to_string(),
),
};
}
Ok(
lime_agent::SessionExecutionRuntimeAccessMode::from_access_mode_text(
extract_access_mode_text(request_metadata),
)
.unwrap_or_else(lime_agent::SessionExecutionRuntimeAccessMode::default_for_session),
)
}
fn normalize_agent_turn_request_metadata(
request_metadata: Option<Value>,
content_id: Option<String>,
@@ -282,4 +338,63 @@ mod tests {
Some("content-2")
);
}
#[test]
fn resolve_agent_turn_access_mode_prefers_formal_policies_over_legacy_metadata() {
assert_eq!(
resolve_agent_turn_access_mode_from_payload(
Some("never"),
Some("danger-full-access"),
Some(&json!({
"harness": {
"access_mode": "read-only"
}
})),
)
.expect("resolved access mode"),
lime_agent::SessionExecutionRuntimeAccessMode::FullAccess
);
}
#[test]
fn resolve_agent_turn_access_mode_prefers_explicit_harness_value_when_formal_policies_missing()
{
assert_eq!(
resolve_agent_turn_access_mode_from_payload(
None,
None,
Some(&json!({
"harness": {
"access_mode": "read-only"
}
})),
)
.expect("resolved access mode"),
lime_agent::SessionExecutionRuntimeAccessMode::ReadOnly
);
}
#[test]
fn resolve_agent_turn_access_mode_defaults_to_full_access() {
assert_eq!(
resolve_agent_turn_access_mode_from_payload(None, None, None)
.expect("resolved access mode"),
lime_agent::SessionExecutionRuntimeAccessMode::FullAccess
);
}
#[test]
fn resolve_agent_turn_access_mode_rejects_invalid_formal_policy_pair() {
assert_eq!(
resolve_agent_turn_access_mode_from_payload(
Some("never"),
Some("workspace-write"),
None,
),
Err(
"自动化任务 approval_policy/sandbox_policy 仅支持 read-only/current/full-access 对应的正式策略组合"
.to_string()
)
);
}
}
@@ -76,6 +76,10 @@ pub enum AutomationPayload {
#[serde(default)]
web_search: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
approval_policy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sandbox_policy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
request_metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_id: Option<String>,
@@ -794,6 +798,8 @@ fn validate_payload(payload: &AutomationPayload) -> Result<(), String> {
match payload {
AutomationPayload::AgentTurn {
prompt,
approval_policy,
sandbox_policy,
request_metadata,
content_id,
..
@@ -811,6 +817,11 @@ fn validate_payload(payload: &AutomationPayload) -> Result<(), String> {
return Err("自动化任务 request_metadata 必须为对象".to_string());
}
}
executor::resolve_agent_turn_access_mode_from_payload(
approval_policy.as_deref(),
sandbox_policy.as_deref(),
request_metadata.as_ref(),
)?;
}
AutomationPayload::BrowserSession { .. } => {
return Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string());
@@ -1600,6 +1611,8 @@ mod tests {
prompt: "汇总今日异常".to_string(),
system_prompt: None,
web_search: false,
approval_policy: None,
sandbox_policy: None,
request_metadata: Some(json!({
"harness": {
"theme": "general",
@@ -1620,6 +1633,8 @@ mod tests {
prompt: "汇总今日异常".to_string(),
system_prompt: None,
web_search: false,
approval_policy: None,
sandbox_policy: None,
request_metadata: Some(json!(["invalid"])),
content_id: Some("content-1".to_string()),
};
@@ -1647,4 +1662,25 @@ mod tests {
Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string())
);
}
#[test]
fn validate_payload_should_reject_invalid_formal_policy_pair() {
let payload = AutomationPayload::AgentTurn {
prompt: "汇总今日异常".to_string(),
system_prompt: None,
web_search: false,
approval_policy: Some("never".to_string()),
sandbox_policy: Some("workspace-write".to_string()),
request_metadata: None,
content_id: None,
};
assert_eq!(
validate_payload(&payload),
Err(
"自动化任务 approval_policy/sandbox_policy 仅支持 read-only/current/full-access 对应的正式策略组合"
.to_string()
)
);
}
}
-23
View File
@@ -1,23 +0,0 @@
# terminal(重导出层)
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
## 架构说明
本目录是终端模块的**重导出层**,实际实现已迁移至独立 crate `lime-terminal`(位于 `crates/terminal/`)。
本层职责:
- 提供 `TauriEmitter` newtype,桥接 Tauri `AppHandle` 与终端 crate 的 `TerminalEventEmit` trait
- 重导出 `lime-terminal` 的所有公共模块和类型,保持 `crate::terminal::xxx` 路径兼容
## 文件索引
- `mod.rs` - TauriEmitter 定义 + lime-terminal 重导出
## 实际实现
详见 `crates/terminal/` 目录及其 README。
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
-28
View File
@@ -1,28 +0,0 @@
//! 终端模块
//!
//! 实际实现位于 `lime-terminal` crate。
//! 本模块提供 `TauriEmitter` newtype 桥接 Tauri 与终端 crate。
use std::path::PathBuf;
use tauri::Emitter;
use lime_terminal::emitter::TerminalEventEmit;
/// Tauri AppHandle 的 newtype 包装
///
/// 实现 `TerminalEventEmit` trait,桥接 Tauri 框架与终端 crate。
#[derive(Clone)]
pub struct TauriEmitter(pub tauri::AppHandle);
impl TerminalEventEmit for TauriEmitter {
fn emit_event(&self, event: &str, payload: &serde_json::Value) -> Result<(), String> {
self.0
.emit(event, payload.clone())
.map_err(|e| format!("Tauri emit 失败: {e}"))
}
fn app_data_dir(&self) -> Result<PathBuf, String> {
lime_core::app_paths::preferred_data_dir().map_err(|e| format!("获取应用数据目录失败: {e}"))
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.12.3",
"version": "1.13.0",
"identifier": "com.limecloud.lime",
"build": {
"beforeDevCommand": "npm run dev:web-bridge",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.12.3",
"version": "1.13.0",
"identifier": "com.limecloud.lime",
"build": {
"beforeDevCommand": "npm run dev",