Files
nodeagent/apps/console/src-tauri/src/lib.rs
T
jkooe dac8a0a2a7 feat(console): 入库 Tauri 2 桌面控制台(主控/被控一体)
apps/console —— 跨端(macOS/Windows)桌面控制台,复用 ops 侧的 Node agent 作本地被控端,
不做第二套 agent。技术栈:Tauri 2(Rust 壳 + 系统 WebView,包体小)+
Vue 3 + TS + Vite + Pinia + Naive UI(暗色)+ Vue Router(hash)。

- command 层(src-tauri/src/lib.rs):connect / disconnect / invoke_capability /
  get_capabilities / get_state;client 事件经 Emitter 桥接为 na-log / na-state / na-event 推前端。
  握手后**先 await 成功再落 state**(不持锁 await)。
- 7 页面:设备管理 / 状态总览 / 终端 / 进程与服务 / 软件 / 文件 / 审计。
- 实测:macOS .app 6.75 MiB(方案指标 <30MB;Electron 同级约 100MB+),
  直接执行二进制进程存活、无崩溃 —— `codesign --verify --deep --strict` 那条
  "code has no resources but signature indicates they must be present" 是 adhoc
  链接签名的固有噪声,实测无害(勿被误导去「修签名」)。

⚠️ 本轮用 examples/probe.rs 对真实被控端核对字段契约,抓出 9 处**静默空列**
(不报错、类型系统也抓不到,界面只显示 "-"),全部按契约修掉:
- 进程表读 r.cpu / r.memory → 真源 cpu_pct / memory_bytes(CPU、内存两列恒为 "-")
- 服务表读 r.status → 真源 state
- 磁盘读 d.mount||d.name / d.used → 真源只有 drive + total/free,
  **没有 used**(须自算 total-free)
- 网卡读 {name,rx_bytes,tx_bytes} → 真源 {adapter,ip,up}
- 软件表读 a.id → app.list 结果**没有 id**,应为 publisher
- 审计 broken_at 当数字渲染 → 真源是 {file,line,reason} 对象
另清掉幻影字段 SystemInfo.node(契约里只有 node_path)。

配套两点设计:
- **平台门控**:连接后探测 system.info.os → isWindows/platformReady;app.* 与
  system.service.list 仅 Windows 被控端可用,故软件页与服务 Tab **事前禁用并说明原因**,
  而不是发出注定失败的请求再解释错误。
- **结构化错误**:协议真源 InvokeResult.error 只有 {name,message,data?}(**没有 code**),
  故分支一律按 name;前端 CapabilityFailure 暴露 errorName,并提供
  isUnsupportedPlatform / isAclDenied / isRateLimited。未改协议(改动会扩散 4 端)。

tests/unit/console-contract.test.mjs(20 例):把「前端声明 ⊆ 契约」与「契约 ⊆ 前端声明」
双向钉死,另校验平台门控清单与 agent 侧 requireWindows 集合一致、AuditType 覆盖
apps/agent/src/audit.ts 全部取值。**已注入漂移实测会红**(把 cpu_pct 改回 cpu →
正/反向各挂 1 项),非空转守卫。
2026-10-07 10:14:12 +08:00

152 lines
4.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! nodeagent 桌面控制台(主控 + 被控一体)——Tauri 后端。
//!
//! command 层把 `nodeagent-client`(Rust 控制端)接到前端:
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
//! - client 的日志/状态/被控端事件经 Tauri `emit` 推给前端(`na-log`/`na-state`/`na-event`)。
use std::sync::{Arc, Mutex};
use nodeagent_client::{
CapabilityDescriptor, Client, ClientEvent, ClientOptions, ConnState, InvokeResult,
};
use serde::Serialize;
use serde_json::Value;
use tauri::{AppHandle, Emitter, State};
/// 全局状态:持有当前连接(`None` = 未连接)。
struct AppState {
client: Mutex<Option<Client>>,
}
/// 连接成功后回传给前端的元信息。
#[derive(Serialize)]
struct ConnectInfo {
capabilities: Vec<CapabilityDescriptor>,
agent_version: Option<String>,
authorized: Option<Vec<String>>,
peer_cert_fp: Option<String>,
auth_mode: String,
client_id: String,
}
fn conn_state_str(s: ConnState) -> &'static str {
match s {
ConnState::Connected => "connected",
ConnState::Reconnecting => "reconnecting",
ConnState::Closed => "closed",
}
}
/// 建立到被控端的连接并完成握手(默认 psk 模式、接受自签证书、开启自动重连)。
#[tauri::command]
async fn connect(
app: AppHandle,
state: State<'_, AppState>,
url: String,
key: String,
client_id: Option<String>,
insecure: Option<bool>,
) -> Result<ConnectInfo, String> {
let client_id = client_id.unwrap_or_else(|| "console".to_string());
let client = Client::new(ClientOptions {
url,
key,
client_id: client_id.clone(),
insecure: insecure.unwrap_or(true),
auto_reconnect: true,
..Default::default()
});
// 把 client 的事件桥接成 Tauri 事件,推给前端。
let app_events = app.clone();
client.set_handler(Arc::new(move |evt| match evt {
ClientEvent::State(s) => {
let _ = app_events.emit("na-state", conn_state_str(s));
}
ClientEvent::Log(msg) => {
let _ = app_events.emit("na-log", msg);
}
ClientEvent::Event(params) => {
let _ = app_events.emit("na-event", params);
}
}));
let caps = client.connect().await.map_err(|e| e.to_string())?;
let info = ConnectInfo {
agent_version: client.agent_version(),
authorized: client.list_authorized(),
peer_cert_fp: client.peer_cert_fingerprint(),
auth_mode: "psk".to_string(),
capabilities: caps,
client_id,
};
*state.client.lock().unwrap() = Some(client);
Ok(info)
}
/// 断开连接(停止自动重连)。
#[tauri::command]
fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
if let Some(client) = state.client.lock().unwrap().take() {
client.close();
}
Ok(())
}
/// 调用一项被控端能力。`timeout_ms` 可选:长任务(装软件 / 长命令)需显式放大。
#[tauri::command]
async fn invoke_capability(
state: State<'_, AppState>,
capability: String,
args: Option<Value>,
timeout_ms: Option<u64>,
) -> Result<InvokeResult, String> {
let client = {
let guard = state.client.lock().unwrap();
guard.as_ref().cloned().ok_or_else(|| "未连接".to_string())?
};
let args = args.unwrap_or(Value::Object(Default::default()));
match timeout_ms {
Some(ms) if ms > 0 => client.invoke_with_timeout(&capability, args, ms).await,
_ => client.invoke(&capability, args).await,
}
.map_err(|e| e.to_string())
}
/// 当前连接的能力清单。
#[tauri::command]
fn get_capabilities(state: State<'_, AppState>) -> Result<Vec<CapabilityDescriptor>, String> {
let guard = state.client.lock().unwrap();
let client = guard.as_ref().ok_or_else(|| "未连接".to_string())?;
Ok(client.list_capabilities())
}
/// 当前连接状态字符串(connected / reconnecting / closed / 未连接)。
#[tauri::command]
fn get_state(state: State<'_, AppState>) -> String {
match state.client.lock().unwrap().as_ref() {
Some(client) => conn_state_str(client.state()).to_string(),
None => "未连接".to_string(),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(AppState {
client: Mutex::new(None),
})
.invoke_handler(tauri::generate_handler![
connect,
disconnect,
invoke_capability,
get_capabilities,
get_state
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}