chore: 保存 Agent 安全优化前基线
This commit is contained in:
1 parent
08696896f9
commit
ff016347cd
116 files changed
+3858
-2485
No files matched your search
@@ -2,41 +2,58 @@ use tauri::AppHandle;
|
||||
|
||||
use crate::services::license::{self, CheckoutResult, LicenseStatus};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||
license::read_status(&app)
|
||||
async fn license_blocking<T: Send + 'static>(
|
||||
work: impl FnOnce() -> Result<T, String> + Send + 'static,
|
||||
) -> Result<T, String> {
|
||||
tauri::async_runtime::spawn_blocking(work)
|
||||
.await
|
||||
.map_err(|error| format!("授权任务失败: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_activate(
|
||||
pub async fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||
license_blocking(move || license::read_status(&app)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn license_activate(
|
||||
app: AppHandle,
|
||||
token: String,
|
||||
email: Option<String>,
|
||||
) -> Result<LicenseStatus, String> {
|
||||
license::activate(&app, &token, email.as_deref())
|
||||
license_blocking(move || license::activate(&app, &token, email.as_deref())).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_clear(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||
license::clear(&app)
|
||||
pub async fn license_clear(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||
license_blocking(move || license::clear(&app)).await
|
||||
}
|
||||
|
||||
/// 周检:联网重验在线授权(验签/时钟/宽限期)并刷新本地缓存 // Logan.Li
|
||||
#[tauri::command]
|
||||
pub async fn license_verify(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||
license_blocking(move || license::verify(&app)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_device_id(app: AppHandle) -> Result<String, String> {
|
||||
license::get_or_create_device_id(&app)
|
||||
pub async fn license_device_id(app: AppHandle) -> Result<String, String> {
|
||||
license_blocking(move || license::get_or_create_device_id(&app)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_set_email(app: AppHandle, email: String) -> Result<LicenseStatus, String> {
|
||||
license::set_email(&app, &email)
|
||||
pub async fn license_set_email(app: AppHandle, email: String) -> Result<LicenseStatus, String> {
|
||||
license_blocking(move || license::set_email(&app, &email)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_checkout(
|
||||
pub async fn license_checkout(
|
||||
app: AppHandle,
|
||||
customer_email: String,
|
||||
plan_type: String,
|
||||
return_url: Option<String>,
|
||||
) -> Result<CheckoutResult, String> {
|
||||
license::checkout(&app, &customer_email, &plan_type, return_url.as_deref())
|
||||
license_blocking(move || {
|
||||
license::checkout(&app, &customer_email, &plan_type, return_url.as_deref())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -22,6 +22,17 @@ pub fn local_create_shell(
|
||||
.create(app, options.unwrap_or_default(), preferred_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_open_output(
|
||||
state: State<'_, AppState>,
|
||||
shell_id: String,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let service = state.local_terminal.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || service.open_output(&shell_id))
|
||||
.await
|
||||
.map_err(|error| format!("读取本地启动输出失败: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_write(
|
||||
state: State<'_, AppState>,
|
||||
@@ -45,3 +56,8 @@ pub fn local_resize(
|
||||
pub fn local_kill(state: State<'_, AppState>, shell_id: String) -> Result<(), String> {
|
||||
state.local_terminal.kill(&shell_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_kill_all(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
Ok(state.local_terminal.kill_all())
|
||||
}
|
||||
@@ -25,17 +25,24 @@ pub async fn ssh_connect(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_disconnect(
|
||||
pub async fn ssh_disconnect(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
retain_forwards: Option<bool>,
|
||||
) -> OperationResponse {
|
||||
operation(
|
||||
state
|
||||
.ssh
|
||||
.disconnect(&app, &connection_id, retain_forwards.unwrap_or(false)),
|
||||
)
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
let retain = retain_forwards.unwrap_or(false);
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
service.disconnect(&app, &connection_id, retain)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(operation(result)),
|
||||
Err(error) => Ok(OperationResponse::error(format!(
|
||||
"断开连接任务失败: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -43,7 +50,18 @@ pub async fn ssh_exec(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
command: String,
|
||||
from_agent: Option<bool>,
|
||||
) -> Result<ExecResponse, String> {
|
||||
if from_agent.unwrap_or(false) {
|
||||
if let Err(error) = crate::services::ai::guardrail::reject_critical(&command) {
|
||||
return Ok(ExecResponse {
|
||||
success: false,
|
||||
result: None,
|
||||
exit_code: None,
|
||||
error: Some(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
@@ -93,8 +111,15 @@ pub async fn ssh_host_metrics(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_keepalive(state: State<'_, AppState>, connection_id: String) -> OperationResponse {
|
||||
operation(state.ssh.keepalive(&connection_id))
|
||||
pub async fn ssh_keepalive(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
match tauri::async_runtime::spawn_blocking(move || service.keepalive(&connection_id)).await {
|
||||
Ok(result) => Ok(operation(result)),
|
||||
Err(error) => Ok(OperationResponse::error(format!("保活任务失败: {error}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -127,10 +152,17 @@ pub async fn ssh_create_shell(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_shell_open_output(state: State<'_, AppState>, shell_id: String) -> ShellOutputResponse {
|
||||
match state.ssh.shell_open_output(&shell_id) {
|
||||
Ok(data) => ShellOutputResponse::ok(data),
|
||||
Err(error) => ShellOutputResponse::error(error),
|
||||
pub async fn ssh_shell_open_output(
|
||||
state: State<'_, AppState>,
|
||||
shell_id: String,
|
||||
) -> Result<ShellOutputResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
match tauri::async_runtime::spawn_blocking(move || service.shell_open_output(&shell_id)).await {
|
||||
Ok(Ok(data)) => Ok(ShellOutputResponse::ok(data)),
|
||||
Ok(Err(error)) => Ok(ShellOutputResponse::error(error)),
|
||||
Err(error) => Ok(ShellOutputResponse::error(format!(
|
||||
"读取启动输出失败: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,18 @@ pub fn session_delete(app: AppHandle, state: State<'_, AppState>, id: String) ->
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_reorder(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
updates: Vec<Value>,
|
||||
) -> OperationResponse {
|
||||
match state.storage.reorder_sessions(&app, updates) {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn settings_get(app: AppHandle, state: State<'_, AppState>, key: String) -> SettingResponse {
|
||||
match state.storage.get_setting(&app, &key) {
|
||||
|
||||
@@ -89,6 +89,9 @@ pub fn clipboard_write_text(text: String) -> Result<(), String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn toggle_devtools(app: AppHandle) -> Result<bool, String> {
|
||||
if !cfg!(debug_assertions) {
|
||||
return Err("devtools are disabled in release builds".to_string());
|
||||
}
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "主窗口不存在".to_string())?;
|
||||
|
||||
@@ -76,9 +76,11 @@ pub fn run() {
|
||||
commands::deep_link::take_pending_ssh_links,
|
||||
commands::local_terminal::local_list_shells,
|
||||
commands::local_terminal::local_create_shell,
|
||||
commands::local_terminal::local_open_output,
|
||||
commands::local_terminal::local_write,
|
||||
commands::local_terminal::local_resize,
|
||||
commands::local_terminal::local_kill,
|
||||
commands::local_terminal::local_kill_all,
|
||||
commands::serial::serial_list_ports,
|
||||
commands::serial::serial_open,
|
||||
commands::serial::serial_write,
|
||||
@@ -131,6 +133,7 @@ pub fn run() {
|
||||
commands::storage::session_create,
|
||||
commands::storage::session_update,
|
||||
commands::storage::session_delete,
|
||||
commands::storage::session_reorder,
|
||||
commands::storage::settings_get,
|
||||
commands::storage::settings_set,
|
||||
commands::files::show_open_dialog,
|
||||
@@ -153,6 +156,7 @@ pub fn run() {
|
||||
commands::license::license_get,
|
||||
commands::license::license_activate,
|
||||
commands::license::license_clear,
|
||||
commands::license::license_verify,
|
||||
commands::license::license_device_id,
|
||||
commands::license::license_set_email,
|
||||
commands::license::license_checkout,
|
||||
@@ -226,6 +230,9 @@ pub fn run() {
|
||||
let _ = app.emit("menu:open-settings", ());
|
||||
}
|
||||
"toggle-devtools" => {
|
||||
if !cfg!(debug_assertions) {
|
||||
return;
|
||||
}
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_devtools_open() {
|
||||
window.close_devtools();
|
||||
|
||||
@@ -355,18 +355,24 @@ pub fn build_menu<R: Runtime>(app: &AppHandle<R>, locale: &str) -> tauri::Result
|
||||
let settings = MenuItemBuilder::with_id("open-settings", t.settings)
|
||||
.accelerator("CmdOrCtrl+,")
|
||||
.build(app)?;
|
||||
let devtools = MenuItemBuilder::with_id("toggle-devtools", t.devtools)
|
||||
.accelerator(if cfg!(target_os = "macos") {
|
||||
"CmdOrCtrl+Alt+I"
|
||||
} else {
|
||||
"F12"
|
||||
})
|
||||
.build(app)?;
|
||||
let view_menu = SubmenuBuilder::new(app, t.view)
|
||||
.item(&settings)
|
||||
.separator()
|
||||
.item(&devtools)
|
||||
.build()?;
|
||||
let view_menu = {
|
||||
let builder = SubmenuBuilder::new(app, t.view).item(&settings);
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let devtools = MenuItemBuilder::with_id("toggle-devtools", t.devtools)
|
||||
.accelerator(if cfg!(target_os = "macos") {
|
||||
"CmdOrCtrl+Alt+I"
|
||||
} else {
|
||||
"F12"
|
||||
})
|
||||
.build(app)?;
|
||||
builder.separator().item(&devtools).build()?
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
builder.build()?
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
let window_menu = SubmenuBuilder::new(app, t.window)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Critical-command denylist for Agent-originated `ssh_exec`.
|
||||
//! Interactive user shells and batch commands are not filtered here.
|
||||
|
||||
pub fn reject_critical(command: &str) -> Result<(), String> {
|
||||
if is_critical(command) {
|
||||
return Err("agent guardrail rejected a critical command".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_critical(command: &str) -> bool {
|
||||
let text = command.trim();
|
||||
if text.is_empty() {
|
||||
return false;
|
||||
}
|
||||
CRITICAL_PATTERNS
|
||||
.iter()
|
||||
.any(|pattern| pattern.is_match(text))
|
||||
}
|
||||
|
||||
static CRITICAL_PATTERNS: std::sync::LazyLock<Vec<regex::Regex>> = std::sync::LazyLock::new(|| {
|
||||
[
|
||||
r"(?i)\brm\s+[^\n;&|]*-[a-z]*r[a-z]*f[a-z]*\s+(?:--\s+)?/(?:\s|$|\*)",
|
||||
r"(?i)\brm\s+[^\n;&|]*-[a-z]*f[a-z]*r[a-z]*\s+(?:--\s+)?/(?:\s|$|\*)",
|
||||
r"(?i)\brm\s+[^\n;&|]*-[a-z]*r[a-z]*f[a-z]*\s+/(?:bin|boot|etc|home|lib|lib64|proc|root|sbin|sys|usr|var)(?:/|\s|$)",
|
||||
r"(?i)\bmkfs(?:\.[a-z0-9]+)?\b",
|
||||
r"(?i)\bdd\s+[^\n;&|]*\bif=/dev/(?:zero|random|urandom)\b[^\n;&|]*\bof=/dev/",
|
||||
r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:",
|
||||
r"(?i)(?:^|[;&|]\s*)>\s*/dev/(?:sd[a-z]|hd[a-z]|nvme\d+n\d+)(?:\s|$)",
|
||||
r"(?i)\bchmod\s+-R\s+(?:000|777)\s+/(?:\s|$)",
|
||||
r"(?i)\bkill\s+-9\s+1(?:\s|$)",
|
||||
r"(?i)\bsystemctl\s+(?:stop|disable)\s+(?:ssh|sshd)(?:\.service)?(?:\s|$)",
|
||||
r"(?i)\biptables\s+(?:-F|--flush)(?:\s|$)",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|pattern| regex::Regex::new(pattern).expect("valid critical pattern"))
|
||||
.collect()
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_recursive_root_delete() {
|
||||
assert!(reject_critical("rm -rf /").is_err());
|
||||
assert!(reject_critical("rm -rf /etc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_ordinary_commands() {
|
||||
assert!(reject_critical("ls /etc").is_ok());
|
||||
assert!(reject_critical("rm -rf ./tmp").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod guardrail;
|
||||
mod redaction;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -15,9 +15,15 @@ use serde_json::Value;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Public opsx-licence API root (must end with `/api`, never include `/v1`). // Logan.Li
|
||||
const DEFAULT_API_BASE: &str = "https://l123.91ai.io/api";
|
||||
/// Embedded opsx-licence API roots (must end with `/api`, never include `/v1`).
|
||||
/// 双节点容灾:香港主节点 + 国内备份,编译进二进制且不在界面展示。
|
||||
/// 运行时按序尝试,网络层失败(连接/超时)才切换,业务响应不切换。 // Logan.Li
|
||||
const DEFAULT_API_BASES: &[&str] = &["https://l123.91ai.io/api", "https://licence.rlwj.cn/api"];
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// 在线证离线容忍上限:连续 30 天联系不上全部节点则要求联网重验 // Logan.Li
|
||||
const OFFLINE_TOLERANCE_SECS: u64 = 30 * 86_400;
|
||||
/// 时钟回拨容忍(秒):本机时间允许比最近一次服务端时间慢 5 分钟 // Logan.Li
|
||||
const CLOCK_SKEW_TOLERANCE_SECS: u64 = 300;
|
||||
|
||||
/// Offline license verifying key (hex). Must match server `OFFLINE_LICENSE_SEED`. // Logan.Li
|
||||
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
|
||||
@@ -45,6 +51,12 @@ struct StoredLicense {
|
||||
plan_type: Option<String>,
|
||||
#[serde(default)]
|
||||
last_verified_at: u64,
|
||||
/// 设备激活令牌:服务端激活时一次性下发,解绑本机必须携带 // Logan.Li
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
activation_token: Option<String>,
|
||||
/// 最近一次见到的服务端时间(unix 秒),用于时钟回拨检测 // Logan.Li
|
||||
#[serde(default)]
|
||||
last_server_time: u64,
|
||||
}
|
||||
|
||||
fn default_plan() -> String {
|
||||
@@ -69,6 +81,13 @@ pub struct LicenseStatus {
|
||||
pub max_devices: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plan_type: Option<String>,
|
||||
/// 最近一次在线验证时间(unix 秒),前端据此触发周检 // Logan.Li
|
||||
#[serde(default, skip_serializing_if = "is_zero_u64")]
|
||||
pub last_verified_at: u64,
|
||||
}
|
||||
|
||||
fn is_zero_u64(v: &u64) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
/// Normalize license API base so paths like `/v1/license/activate` resolve correctly.
|
||||
@@ -76,7 +95,7 @@ pub struct LicenseStatus {
|
||||
fn normalize_api_base(raw: &str) -> String {
|
||||
let mut base = raw.trim().trim_end_matches('/').to_string();
|
||||
if base.is_empty() {
|
||||
return DEFAULT_API_BASE.to_string();
|
||||
return DEFAULT_API_BASES[0].to_string();
|
||||
}
|
||||
// Prefer https for the public licence host (http→301 HTML breaks JSON clients). // Logan.Li
|
||||
if base.starts_with("http://l123.91ai.io") {
|
||||
@@ -93,10 +112,12 @@ fn normalize_api_base(raw: &str) -> String {
|
||||
base
|
||||
}
|
||||
|
||||
fn api_base() -> String {
|
||||
let raw =
|
||||
std::env::var("OPSX_LICENSE_API_BASE").unwrap_or_else(|_| DEFAULT_API_BASE.to_string());
|
||||
normalize_api_base(&raw)
|
||||
/// 全部可用 API 基址:环境变量覆盖时只用覆盖值(本地联调),否则用内置双节点。 // Logan.Li
|
||||
fn api_bases() -> Vec<String> {
|
||||
match std::env::var("OPSX_LICENSE_API_BASE") {
|
||||
Ok(raw) if !raw.trim().is_empty() => vec![normalize_api_base(&raw)],
|
||||
_ => DEFAULT_API_BASES.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -153,6 +174,7 @@ fn free_status(device_id: String, email: Option<String>) -> LicenseStatus {
|
||||
email,
|
||||
max_devices: None,
|
||||
plan_type: None,
|
||||
last_verified_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +187,17 @@ fn status_from_stored(device_id: String, stored: &StoredLicense) -> LicenseStatu
|
||||
return free_status(device_id, stored.email.clone());
|
||||
}
|
||||
}
|
||||
// 在线证离线容忍:连续 30 天未能联网验证则降级,防"屏蔽授权服务器=永久有效"。
|
||||
// 离线证(LICOFF-)本地自验,不受此限制。 // Logan.Li
|
||||
if !is_offline_license_key(&stored.license_key)
|
||||
&& stored.last_verified_at > 0
|
||||
&& now_secs()
|
||||
> stored
|
||||
.last_verified_at
|
||||
.saturating_add(OFFLINE_TOLERANCE_SECS)
|
||||
{
|
||||
return free_status(device_id, stored.email.clone());
|
||||
}
|
||||
LicenseStatus {
|
||||
plan: if stored.plan.is_empty() {
|
||||
"pro".to_string()
|
||||
@@ -183,6 +216,7 @@ fn status_from_stored(device_id: String, stored: &StoredLicense) -> LicenseStatu
|
||||
email: stored.email.clone(),
|
||||
max_devices: stored.max_devices,
|
||||
plan_type: stored.plan_type.clone(),
|
||||
last_verified_at: stored.last_verified_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,12 +403,13 @@ fn expires_from_days_left(days_left: Option<i64>) -> Option<u64> {
|
||||
Some(now_secs().saturating_add((days as u64).saturating_mul(86_400)))
|
||||
}
|
||||
|
||||
fn post_json(path: &str, body: Value) -> Result<Value, String> {
|
||||
let url = format!("{}{path}", api_base());
|
||||
/// 单节点请求:只有网络层失败(连接/超时/DNS)才返回 Err 供上层切换节点;
|
||||
/// 服务器返回了响应(含 4xx/5xx 业务错误)则原样解析返回,不切换。 // Logan.Li
|
||||
fn try_post_json(url: &str, body: &Value) -> Result<Value, String> {
|
||||
let client = http_client()?;
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.post(url)
|
||||
.json(body)
|
||||
.send()
|
||||
.map_err(|error| format!("许可证服务请求失败: {error}"))?;
|
||||
let status = response.status();
|
||||
@@ -392,27 +427,129 @@ fn post_json(path: &str, body: Value) -> Result<Value, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 多节点容灾请求:按内置顺序尝试全部节点。 // Logan.Li
|
||||
fn post_json(path: &str, body: Value) -> Result<Value, String> {
|
||||
let mut last_err = String::from("无可用授权节点");
|
||||
for base in api_bases() {
|
||||
match try_post_json(&format!("{base}{path}"), &body) {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(error) => last_err = error,
|
||||
}
|
||||
}
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
/// 离线证验签公钥(与在线响应签名同源:服务端 OFFLINE_LICENSE_SEED 派生)。 // Logan.Li
|
||||
fn offline_verifying_key() -> Result<VerifyingKey, String> {
|
||||
let pub_bytes = hex::decode(OFFLINE_LICENSE_PUBLIC_KEY_HEX)
|
||||
.map_err(|_| "离线验签公钥配置错误".to_string())?;
|
||||
VerifyingKey::from_bytes(
|
||||
pub_bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| "离线验签公钥长度错误".to_string())?,
|
||||
)
|
||||
.map_err(|_| "离线验签公钥无效".to_string())
|
||||
}
|
||||
|
||||
/// 校验在线响应签名(防 hosts 劫持假服务器 / 响应篡改)。
|
||||
/// 签名原文与服务端 guard.go 一致:
|
||||
/// v1|license_key|valid|status|expires_at_unix|server_time|in_grace|max_devices|plan_type
|
||||
/// 旧版服务器不下发 sig(字段缺失)时放行;sig 存在但校验失败则拒绝。 // Logan.Li
|
||||
fn verify_response_sig(payload: &Value) -> Result<(), String> {
|
||||
let sig_hex = payload
|
||||
.get("sig")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if sig_hex.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let sig_bytes = hex::decode(sig_hex).map_err(|_| "响应签名无法解码".to_string())?;
|
||||
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "响应签名无效".to_string())?;
|
||||
let msg = format!(
|
||||
"v1|{}|{}|{}|{}|{}|{}|{}|{}",
|
||||
json_string_field(payload, "license_key", "licenseKey").unwrap_or_default(),
|
||||
payload
|
||||
.get("valid")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
json_string_field(payload, "status", "status").unwrap_or_default(),
|
||||
json_i64_field(payload, "expires_at_unix", "expiresAtUnix").unwrap_or(0),
|
||||
json_i64_field(payload, "server_time", "serverTime").unwrap_or(0),
|
||||
payload
|
||||
.get("in_grace")
|
||||
.or_else(|| payload.get("inGrace"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
json_i64_field(payload, "max_devices", "maxDevices").unwrap_or(0),
|
||||
json_string_field(payload, "plan_type", "planType").unwrap_or_default(),
|
||||
);
|
||||
offline_verifying_key()?
|
||||
.verify(msg.as_bytes(), &sig)
|
||||
.map_err(|_| "授权服务器响应验签失败(可能被劫持)".to_string())
|
||||
}
|
||||
|
||||
struct RemoteLicenseInfo {
|
||||
expires_at: Option<u64>,
|
||||
features: Vec<String>,
|
||||
max_devices: Option<u32>,
|
||||
plan_type: Option<String>,
|
||||
/// 服务端时间(unix 秒),用于时钟回拨检测 // Logan.Li
|
||||
server_time: u64,
|
||||
/// 激活时一次性下发的设备令牌 // Logan.Li
|
||||
activation_token: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_remote_info(data: &Value) -> RemoteLicenseInfo {
|
||||
let days_left = json_i64_field(data, "days_left", "daysLeft");
|
||||
let expires_at = expires_from_days_left(days_left);
|
||||
let features = json_string_array(data, "features", "features");
|
||||
let max_devices = json_i64_field(data, "max_devices", "maxDevices").map(|n| n.max(0) as u32);
|
||||
let plan_type = json_string_field(data, "plan_type", "planType");
|
||||
let server_time = json_i64_field(data, "server_time", "serverTime")
|
||||
.filter(|t| *t > 0)
|
||||
.unwrap_or_else(|| now_secs() as i64) as u64;
|
||||
let in_grace = data
|
||||
.get("in_grace")
|
||||
.or_else(|| data.get("inGrace"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let grace_days_left = json_i64_field(data, "grace_days_left", "graceDaysLeft")
|
||||
.filter(|d| *d > 0)
|
||||
.unwrap_or(0);
|
||||
|
||||
// 到期时间优先级:宽限期 > expires_at_unix > 按 days_left 估算。
|
||||
// 宽限期内真实到期日已过,本地截止时间改用 服务端时间 + 宽限天数,
|
||||
// 避免缓存被立即判过期(吊销/暂停不会走到这里,服务端直接 valid=false)。 // Logan.Li
|
||||
let expires_at = if in_grace && grace_days_left > 0 {
|
||||
Some(server_time.saturating_add((grace_days_left as u64) * 86_400))
|
||||
} else if let Some(unix) =
|
||||
json_i64_field(data, "expires_at_unix", "expiresAtUnix").filter(|u| *u > 0)
|
||||
{
|
||||
Some(unix as u64)
|
||||
} else {
|
||||
expires_from_days_left(days_left)
|
||||
};
|
||||
|
||||
RemoteLicenseInfo {
|
||||
expires_at,
|
||||
features,
|
||||
max_devices,
|
||||
plan_type,
|
||||
server_time,
|
||||
activation_token: json_string_field(data, "activation_token", "activationToken"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 在线验证失败原因:区分"服务器明确拒绝"与"网络不可达",周检降级策略不同。 // Logan.Li
|
||||
#[derive(Debug)]
|
||||
enum VerifyError {
|
||||
/// 服务器返回 valid=false / 吊销 / 过期超宽限 —— 本地立即降级 free
|
||||
Rejected(String),
|
||||
/// 全部节点网络不可达 —— 离线容忍期内保留缓存
|
||||
Network(String),
|
||||
}
|
||||
|
||||
fn remote_activate(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
|
||||
let body = serde_json::json!({
|
||||
"license_key": license_key,
|
||||
@@ -425,15 +562,18 @@ fn remote_activate(license_key: &str, device_id: &str) -> Result<RemoteLicenseIn
|
||||
return Err(envelope_message(&value).unwrap_or_else(|| "激活失败".to_string()));
|
||||
}
|
||||
}
|
||||
Ok(parse_remote_info(envelope_data(&value)))
|
||||
let data = envelope_data(&value);
|
||||
// 激活响应签名校验(防假服务器在激活环节伪造成功) // Logan.Li
|
||||
verify_response_sig(data)?;
|
||||
Ok(parse_remote_info(data))
|
||||
}
|
||||
|
||||
fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
|
||||
fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, VerifyError> {
|
||||
let body = serde_json::json!({
|
||||
"license_key": license_key,
|
||||
"device_id": device_id,
|
||||
});
|
||||
let value = post_json("/v1/license/verify", body)?;
|
||||
let value = post_json("/v1/license/verify", body).map_err(VerifyError::Network)?;
|
||||
// Bare object or envelope.
|
||||
let payload = if value.get("valid").is_some() {
|
||||
&value
|
||||
@@ -441,7 +581,9 @@ fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo
|
||||
envelope_data(&value)
|
||||
} else if let Some(code) = envelope_code(&value) {
|
||||
if code != 0 {
|
||||
return Err(envelope_message(&value).unwrap_or_else(|| "验证失败".to_string()));
|
||||
return Err(VerifyError::Rejected(
|
||||
envelope_message(&value).unwrap_or_else(|| "验证失败".to_string()),
|
||||
));
|
||||
}
|
||||
envelope_data(&value)
|
||||
} else {
|
||||
@@ -453,19 +595,25 @@ fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
if !valid {
|
||||
return Err(envelope_message(payload)
|
||||
.or_else(|| json_string_field(payload, "message", "message"))
|
||||
.unwrap_or_else(|| "许可证无效".to_string()));
|
||||
return Err(VerifyError::Rejected(
|
||||
envelope_message(payload)
|
||||
.or_else(|| json_string_field(payload, "message", "message"))
|
||||
.unwrap_or_else(|| "许可证无效".to_string()),
|
||||
));
|
||||
}
|
||||
verify_response_sig(payload).map_err(VerifyError::Rejected)?;
|
||||
|
||||
Ok(parse_remote_info(payload))
|
||||
}
|
||||
|
||||
fn remote_deactivate(license_key: &str, device_id: &str) {
|
||||
let body = serde_json::json!({
|
||||
fn remote_deactivate(license_key: &str, device_id: &str, activation_token: Option<&str>) {
|
||||
let mut body = serde_json::json!({
|
||||
"license_key": license_key,
|
||||
"device_id": device_id,
|
||||
});
|
||||
if let Some(token) = activation_token.filter(|t| !t.trim().is_empty()) {
|
||||
body["activation_token"] = Value::String(token.trim().to_string());
|
||||
}
|
||||
let _ = post_json("/v1/license/deactivate", body);
|
||||
}
|
||||
|
||||
@@ -545,6 +693,14 @@ pub fn activate(
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let key = license_key.trim();
|
||||
|
||||
// 时钟回拨检测:本机时间明显早于最近一次服务端时间时拒绝,防改表续命 // Logan.Li
|
||||
let path = license_path(app)?;
|
||||
if let Some(stored) = read_stored(&path)? {
|
||||
if now_secs().saturating_add(CLOCK_SKEW_TOLERANCE_SECS) < stored.last_server_time {
|
||||
return Err("本机时钟异常(早于最近一次服务器时间),请校准系统时间后重试".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if is_offline_license_key(key) {
|
||||
let claims = verify_offline_serial(key, &device_id)?;
|
||||
let email_store = email
|
||||
@@ -561,8 +717,9 @@ pub fn activate(
|
||||
max_devices: Some(claims.max_devices.max(1) as u32),
|
||||
plan_type: Some(claims.plan_type),
|
||||
last_verified_at: now_secs(),
|
||||
activation_token: None,
|
||||
last_server_time: 0,
|
||||
};
|
||||
let path = license_path(app)?;
|
||||
write_stored(&path, &stored)?;
|
||||
return read_status(app);
|
||||
}
|
||||
@@ -581,6 +738,9 @@ pub fn activate(
|
||||
if verify.plan_type.is_some() {
|
||||
info.plan_type = verify.plan_type;
|
||||
}
|
||||
if verify.server_time > 0 {
|
||||
info.server_time = verify.server_time;
|
||||
}
|
||||
}
|
||||
if info.features.is_empty() {
|
||||
info.features = vec!["*".to_string()];
|
||||
@@ -598,12 +758,69 @@ pub fn activate(
|
||||
max_devices: info.max_devices,
|
||||
plan_type: info.plan_type,
|
||||
last_verified_at: now_secs(),
|
||||
// 激活令牌仅此一次下发,落盘供解绑使用 // Logan.Li
|
||||
activation_token: info.activation_token.clone(),
|
||||
last_server_time: info.server_time,
|
||||
};
|
||||
let path = license_path(app)?;
|
||||
write_stored(&path, &stored)?;
|
||||
read_status(app)
|
||||
}
|
||||
|
||||
/// 周检:在线证联网重验(验签 + 时钟 + 宽限期),并刷新本地缓存。
|
||||
/// 服务器明确拒绝(吊销/暂停/过期超宽限)→ 本地立即降级 free 并返回错误;
|
||||
/// 网络不可达 → 离线容忍期内保留缓存照常返回状态。 // Logan.Li
|
||||
pub fn verify(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let path = license_path(app)?;
|
||||
let Some(stored) = read_stored(&path)? else {
|
||||
return Ok(free_status(device_id, None));
|
||||
};
|
||||
if !stored.activated || stored.license_key.trim().is_empty() {
|
||||
return Ok(free_status(device_id, stored.email.clone()));
|
||||
}
|
||||
// 离线证本地自验(到期/设备变更在 read_status 内生效),不联网 // Logan.Li
|
||||
if is_offline_license_key(&stored.license_key) {
|
||||
return read_status(app);
|
||||
}
|
||||
|
||||
match remote_verify(&stored.license_key, &device_id) {
|
||||
Ok(info) => {
|
||||
let mut updated = stored.clone();
|
||||
updated.expires_at = info.expires_at;
|
||||
if !info.features.is_empty() {
|
||||
updated.features = info.features;
|
||||
}
|
||||
if info.max_devices.is_some() {
|
||||
updated.max_devices = info.max_devices;
|
||||
}
|
||||
if info.plan_type.is_some() {
|
||||
updated.plan_type = info.plan_type;
|
||||
}
|
||||
updated.last_verified_at = now_secs();
|
||||
updated.last_server_time = info.server_time;
|
||||
write_stored(&path, &updated)?;
|
||||
read_status(app)
|
||||
}
|
||||
Err(VerifyError::Rejected(message)) => {
|
||||
// 明确拒绝:吊销/暂停/过期超宽限立即生效,本地降级 free // Logan.Li
|
||||
let mut updated = stored.clone();
|
||||
updated.activated = false;
|
||||
updated.plan = "free".to_string();
|
||||
write_stored(&path, &updated)?;
|
||||
Err(message)
|
||||
}
|
||||
Err(VerifyError::Network(message)) => {
|
||||
// 离线容忍期内保留缓存;超时降级由 status_from_stored 的 30 天规则处理 // Logan.Li
|
||||
let status = read_status(app)?;
|
||||
if status.activated {
|
||||
Ok(status)
|
||||
} else {
|
||||
Err(format!("无法连接授权服务器: {message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_email(app: &AppHandle, email: &str) -> Result<LicenseStatus, String> {
|
||||
let path = license_path(app)?;
|
||||
let mut stored = read_stored(&path)?.unwrap_or(StoredLicense {
|
||||
@@ -616,6 +833,8 @@ pub fn set_email(app: &AppHandle, email: &str) -> Result<LicenseStatus, String>
|
||||
max_devices: None,
|
||||
plan_type: None,
|
||||
last_verified_at: 0,
|
||||
activation_token: None,
|
||||
last_server_time: 0,
|
||||
});
|
||||
let trimmed = email.trim();
|
||||
stored.email = if trimmed.is_empty() {
|
||||
@@ -632,7 +851,12 @@ pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let path = license_path(app)?;
|
||||
if let Ok(Some(stored)) = read_stored(&path) {
|
||||
if !stored.license_key.is_empty() && !is_offline_license_key(&stored.license_key) {
|
||||
remote_deactivate(&stored.license_key, &device_id);
|
||||
// 携带设备激活令牌解绑本机(新契约下仅凭序列号无法解绑) // Logan.Li
|
||||
remote_deactivate(
|
||||
&stored.license_key,
|
||||
&device_id,
|
||||
stored.activation_token.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if path.exists() {
|
||||
@@ -695,6 +919,8 @@ mod tests {
|
||||
max_devices: Some(3),
|
||||
plan_type: Some("perpetual".to_string()),
|
||||
last_verified_at: 1,
|
||||
activation_token: Some("tok".to_string()),
|
||||
last_server_time: 42,
|
||||
};
|
||||
let json = serde_json::to_string(&stored).unwrap();
|
||||
let parsed: StoredLicense = serde_json::from_str(&json).unwrap();
|
||||
@@ -703,6 +929,98 @@ mod tests {
|
||||
assert!(parsed.activated);
|
||||
assert_eq!(parsed.max_devices, Some(3));
|
||||
assert_eq!(parsed.plan_type.as_deref(), Some("perpetual"));
|
||||
assert_eq!(parsed.activation_token.as_deref(), Some("tok"));
|
||||
assert_eq!(parsed.last_server_time, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_stored_license_parses_with_defaults() {
|
||||
let legacy =
|
||||
r#"{"license_key":"LIC-OLD","plan":"pro","activated":true,"last_verified_at":5}"#;
|
||||
let parsed: StoredLicense = serde_json::from_str(legacy).unwrap();
|
||||
assert_eq!(parsed.activation_token, None);
|
||||
assert_eq!(parsed.last_server_time, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_sig_missing_passes_and_bad_sig_rejected() {
|
||||
// 旧服务器不下发 sig:放行
|
||||
let no_sig = serde_json::json!({"valid": true, "license_key": "LIC-X"});
|
||||
assert!(verify_response_sig(&no_sig).is_ok());
|
||||
// sig 存在但内容与字段不匹配(伪造):拒绝
|
||||
let forged = serde_json::json!({
|
||||
"valid": true, "license_key": "LIC-X", "status": "active",
|
||||
"expires_at_unix": 4_102_444_800_i64, "server_time": 1_780_000_000_i64,
|
||||
"in_grace": false, "max_devices": 3, "plan_type": "yearly",
|
||||
"sig": "00".repeat(64),
|
||||
});
|
||||
assert!(verify_response_sig(&forged).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_remote_info_prefers_grace_window() {
|
||||
// 宽限期内:本地截止 = server_time + 宽限天数,而非已过期的真实到期日 // Logan.Li
|
||||
let data = serde_json::json!({
|
||||
"valid": true, "in_grace": true, "grace_days_left": 10,
|
||||
"server_time": 1_000_000, "expires_at_unix": 900_000,
|
||||
});
|
||||
let info = parse_remote_info(&data);
|
||||
assert_eq!(info.expires_at, Some(1_000_000 + 10 * 86_400));
|
||||
|
||||
// 正常状态:直接用 expires_at_unix
|
||||
let ok = serde_json::json!({
|
||||
"valid": true, "expires_at_unix": 4_000_000, "server_time": 1_000_000,
|
||||
});
|
||||
assert_eq!(parse_remote_info(&ok).expires_at, Some(4_000_000));
|
||||
}
|
||||
|
||||
/// 真实端到端(默认忽略):需本地授权服务器与测试序列号。
|
||||
/// 运行方式:
|
||||
/// OPSX_LICENSE_API_BASE=http://127.0.0.1:9001/api \
|
||||
/// OPSX_E2E_LICENSE_KEY=LIC-... cargo test --lib -- --ignored online_e2e // Logan.Li
|
||||
#[test]
|
||||
#[ignore = "需要本地 opsx-licence 服务器与 OPSX_E2E_LICENSE_KEY"]
|
||||
fn online_e2e_against_local_server() {
|
||||
let key = std::env::var("OPSX_E2E_LICENSE_KEY").expect("缺少 OPSX_E2E_LICENSE_KEY");
|
||||
let device = format!("e2e-{}", Uuid::new_v4());
|
||||
|
||||
// 1) 激活:真实签名响应 + 一次性令牌下发
|
||||
let info = remote_activate(&key, &device).expect("激活失败");
|
||||
assert!(info.server_time > 0, "响应应携带服务端时间");
|
||||
assert!(
|
||||
info.activation_token.is_some(),
|
||||
"激活应下发 activation_token"
|
||||
);
|
||||
assert!(info.expires_at.is_some(), "激活应返回到期时间");
|
||||
// sig 已在 remote_activate 内部验签(伪造/缺失均已在单测覆盖)
|
||||
|
||||
// 2) 周检验证:真实签名 + valid
|
||||
let verified = match remote_verify(&key, &device) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("验证失败: {e:?}"),
|
||||
};
|
||||
assert!(verified.server_time > 0);
|
||||
|
||||
// 3) 无令牌解绑:新契约下应被服务端拒绝
|
||||
let no_token = post_json(
|
||||
"/v1/license/deactivate",
|
||||
serde_json::json!({"license_key": key, "device_id": device}),
|
||||
)
|
||||
.expect("请求失败");
|
||||
assert_ne!(
|
||||
envelope_code(&no_token),
|
||||
Some(0),
|
||||
"无令牌解绑应被拒绝: {no_token}"
|
||||
);
|
||||
|
||||
// 4) 带令牌解绑:成功释放席位
|
||||
remote_deactivate(&key, &device, info.activation_token.as_deref());
|
||||
// 解绑后授权本身仍有效(席位已还)
|
||||
match remote_verify(&key, &device) {
|
||||
Ok(_) => {}
|
||||
Err(VerifyError::Rejected(m)) => panic!("解绑后验证被拒: {m}"),
|
||||
Err(VerifyError::Network(m)) => panic!("网络错误: {m}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -710,4 +1028,19 @@ mod tests {
|
||||
assert_eq!(expires_from_days_left(Some(40_000)), None);
|
||||
assert!(expires_from_days_left(Some(30)).is_some());
|
||||
}
|
||||
|
||||
/// 离线证端到端(默认忽略):服务端签发的 LICOFF- 用客户端真实函数验。
|
||||
/// 运行方式:OPSX_E2E_OFFLINE_SERIAL=LICOFF-1... OPSX_E2E_OFFLINE_DEVICE=<device_id> \
|
||||
/// cargo test --lib -- --ignored offline_e2e // Logan.Li
|
||||
#[test]
|
||||
#[ignore = "需要 OPSX_E2E_OFFLINE_SERIAL 与 OPSX_E2E_OFFLINE_DEVICE"]
|
||||
fn offline_e2e_server_issued_serial() {
|
||||
let serial = std::env::var("OPSX_E2E_OFFLINE_SERIAL").expect("缺少序列号");
|
||||
let device = std::env::var("OPSX_E2E_OFFLINE_DEVICE").expect("缺少设备 ID");
|
||||
let claims = verify_offline_serial(&serial, &device).expect("离线证验签失败");
|
||||
assert_eq!(claims.product_code, "opsxshell");
|
||||
assert!(claims.exp > claims.iat);
|
||||
// 错误设备必须被拒(设备绑定)
|
||||
assert!(verify_offline_serial(&serial, "other-device").is_err());
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,12 @@ use std::{
|
||||
env,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
||||
@@ -20,6 +24,8 @@ struct LocalShellSession {
|
||||
master: Box<dyn MasterPty + Send>,
|
||||
writer: Box<dyn Write + Send>,
|
||||
child: Box<dyn Child + Send + Sync>,
|
||||
output_live: Arc<AtomicBool>,
|
||||
pending_output: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -115,6 +121,8 @@ impl LocalTerminalService {
|
||||
.master
|
||||
.take_writer()
|
||||
.map_err(|error| format!("failed to open PTY writer: {error}"))?;
|
||||
let output_live = Arc::new(AtomicBool::new(false));
|
||||
let pending_output = Arc::new(Mutex::new(Vec::new()));
|
||||
self.sessions
|
||||
.lock()
|
||||
.map_err(|_| "local terminal session lock poisoned".to_string())?
|
||||
@@ -124,10 +132,12 @@ impl LocalTerminalService {
|
||||
master: pair.master,
|
||||
writer,
|
||||
child,
|
||||
output_live: Arc::clone(&output_live),
|
||||
pending_output: Arc::clone(&pending_output),
|
||||
},
|
||||
);
|
||||
|
||||
spawn_reader(app, shell_id.clone(), reader);
|
||||
spawn_reader(app, shell_id.clone(), reader, output_live, pending_output);
|
||||
Ok(LocalShellCreateResponse {
|
||||
shell_name: shell_basename(&shell),
|
||||
shell_path: shell,
|
||||
@@ -135,6 +145,45 @@ impl LocalTerminalService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Flush PTY bytes buffered before the UI subscribed to `local:data`, then go live.
|
||||
///
|
||||
/// bash/sh print PS1 during `create()`; without this the pane still has
|
||||
/// `shellId === null` and drops that first chunk. zsh is slower and also
|
||||
/// redraws on SIGWINCH, so it looked fine. Enter reprints a new prompt.
|
||||
pub fn open_output(&self, shell_id: &str) -> Result<Vec<u8>, String> {
|
||||
let (output_live, pending_output) = {
|
||||
let sessions = self
|
||||
.sessions
|
||||
.lock()
|
||||
.map_err(|_| "local terminal session lock poisoned".to_string())?;
|
||||
let session = sessions
|
||||
.get(shell_id)
|
||||
.ok_or_else(|| format!("local shell not found: {shell_id}"))?;
|
||||
(
|
||||
Arc::clone(&session.output_live),
|
||||
Arc::clone(&session.pending_output),
|
||||
)
|
||||
};
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_millis(200);
|
||||
while std::time::Instant::now() < deadline {
|
||||
let has_data = pending_output
|
||||
.lock()
|
||||
.map(|guard| !guard.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_data {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
let mut pending = pending_output
|
||||
.lock()
|
||||
.map_err(|_| "local terminal pending output lock poisoned".to_string())?;
|
||||
output_live.store(true, Ordering::Release);
|
||||
Ok(std::mem::take(&mut *pending))
|
||||
}
|
||||
|
||||
pub fn write(&self, shell_id: &str, data: &[u8]) -> Result<(), String> {
|
||||
let mut sessions = self
|
||||
.sessions
|
||||
@@ -186,22 +235,65 @@ impl LocalTerminalService {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kill every tracked local shell and report how many were reaped.
|
||||
///
|
||||
/// Shell ids exist only in renderer memory, so a WebView reload orphans the
|
||||
/// native sessions; the renderer calls this once per boot to reclaim them.
|
||||
pub fn kill_all(&self) -> usize {
|
||||
let mut sessions = match self.sessions.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let mut killed = 0;
|
||||
for (_, mut session) in sessions.drain().collect::<Vec<_>>() {
|
||||
if session.child.kill().is_ok() {
|
||||
killed += 1;
|
||||
}
|
||||
}
|
||||
killed
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_reader(app: AppHandle, shell_id: String, mut reader: Box<dyn Read + Send>) {
|
||||
fn spawn_reader(
|
||||
app: AppHandle,
|
||||
shell_id: String,
|
||||
mut reader: Box<dyn Read + Send>,
|
||||
output_live: Arc<AtomicBool>,
|
||||
pending_output: Arc<Mutex<Vec<u8>>>,
|
||||
) {
|
||||
thread::spawn(move || {
|
||||
let mut buffer = [0_u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(length) => {
|
||||
let _ = app.emit(
|
||||
"local:data",
|
||||
LocalShellDataEvent {
|
||||
shell_id: shell_id.clone(),
|
||||
data: buffer[..length].to_vec(),
|
||||
},
|
||||
);
|
||||
let chunk = &buffer[..length];
|
||||
let should_emit = {
|
||||
let mut pending = match pending_output.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => break,
|
||||
};
|
||||
if output_live.load(Ordering::Acquire) {
|
||||
true
|
||||
} else {
|
||||
const MAX_PENDING: usize = 256 * 1024;
|
||||
if pending.len() < MAX_PENDING {
|
||||
let room = MAX_PENDING - pending.len();
|
||||
pending.extend_from_slice(&chunk[..length.min(room)]);
|
||||
}
|
||||
false
|
||||
}
|
||||
};
|
||||
if should_emit {
|
||||
let _ = app.emit(
|
||||
"local:data",
|
||||
LocalShellDataEvent {
|
||||
shell_id: shell_id.clone(),
|
||||
data: chunk.to_vec(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => break,
|
||||
@@ -227,7 +319,10 @@ fn apply_login_shell_args(command: &mut CommandBuilder, shell: &str) {
|
||||
|
||||
fn login_shell_args(kind: &str) -> &'static [&'static str] {
|
||||
match kind {
|
||||
"zsh" | "bash" | "fish" | "sh" => &["-l"],
|
||||
"zsh" | "fish" => &["-l"],
|
||||
// Force interactive: bash/sh can miss isatty during the first PTY tick
|
||||
// and then wait for a line of stdin with no PS1.
|
||||
"bash" | "sh" => &["-il"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -564,9 +659,9 @@ mod tests {
|
||||
#[test]
|
||||
fn unix_shells_start_as_login_shells() {
|
||||
assert_eq!(login_shell_args("zsh"), &["-l"]);
|
||||
assert_eq!(login_shell_args("bash"), &["-l"]);
|
||||
assert_eq!(login_shell_args("bash"), &["-il"]);
|
||||
assert_eq!(login_shell_args("fish"), &["-l"]);
|
||||
assert_eq!(login_shell_args("sh"), &["-l"]);
|
||||
assert_eq!(login_shell_args("sh"), &["-il"]);
|
||||
assert!(login_shell_args("pwsh").is_empty());
|
||||
assert!(login_shell_args("cmd").is_empty());
|
||||
}
|
||||
|
||||
@@ -90,6 +90,41 @@ impl StorageService {
|
||||
write_data(app, &data)
|
||||
}
|
||||
|
||||
/// Batch sidebar reorder: set `sortOrder` (and optionally move `group`) on
|
||||
/// many sessions in one locked read-modify-write. `group: null` clears the
|
||||
/// group so the session returns to the default bucket.
|
||||
pub fn reorder_sessions(&self, app: &AppHandle, updates: Vec<Value>) -> Result<(), String> {
|
||||
let _guard = self.lock.lock().map_err(lock_error)?;
|
||||
let mut data = read_data(app)?;
|
||||
for update in &updates {
|
||||
let id = update
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "排序条目缺少 id".to_string())?;
|
||||
let session = data
|
||||
.sessions
|
||||
.iter_mut()
|
||||
.find(|session| session.get("id").and_then(Value::as_str) == Some(id))
|
||||
.ok_or_else(|| format!("会话不存在: {id}"))?;
|
||||
let target = session
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "已保存的会话格式无效".to_string())?;
|
||||
if let Some(order) = update.get("sortOrder") {
|
||||
target.insert("sortOrder".to_string(), order.clone());
|
||||
}
|
||||
match update.get("group") {
|
||||
Some(Value::Null) => {
|
||||
target.remove("group");
|
||||
}
|
||||
Some(group) => {
|
||||
target.insert("group".to_string(), group.clone());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
write_data(app, &data)
|
||||
}
|
||||
|
||||
pub fn get_setting(&self, app: &AppHandle, key: &str) -> Result<Option<Value>, String> {
|
||||
let _guard = self.lock.lock().map_err(lock_error)?;
|
||||
Ok(read_data(app)?.settings.get(key).cloned())
|
||||
|
||||
Reference in new issue
Block a user