mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: prepare v1.13.0 release
This commit is contained in:
Generated
+18
-46
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) => (¶ms[..pos], ¶ms[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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"), "");
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。
|
||||
- 更新检查支持提醒频控(同版本 24h 一次、关闭退避)与埋点计数
|
||||
- `streaming/` - 流式响应处理
|
||||
- `telemetry/` - 遥测和统计
|
||||
- `terminal/` - 终端核心模块(PTY 管理、会话管理)
|
||||
- `terminal/` - 已移除的终端历史模块(仅保留治理语义,不再参与编译)
|
||||
- `tray/` - 系统托盘
|
||||
- `websocket/` - WebSocket 支持
|
||||
- `workspace/` - Workspace 工作目录管理
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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 条灵感对象")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -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,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,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",
|
||||
|
||||
Reference in New Issue
Block a user