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 项),非空转守卫。
152 lines
4.7 KiB
Rust
152 lines
4.7 KiB
Rust
//! 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");
|
||
}
|