feat: Pro 买断门禁、SFTP 精简与批量多机改造

新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-07 11:38:30 +08:00
1 parent 101d2164b4
commit fea6d8db04
72 files changed
+3307 -396

No files matched your search

+57 -6
View File
@@ -43,11 +43,6 @@ fn set_directory_permissions(path: &std::path::Path) -> Result<(), String> {
.map_err(|error| format!("设置密钥目录权限失败: {error}"))
}
#[cfg(not(unix))]
fn set_directory_permissions(_path: &std::path::Path) -> Result<(), String> {
Ok(())
}
#[cfg(unix)]
fn set_file_permissions(path: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
@@ -55,7 +50,63 @@ fn set_file_permissions(path: &std::path::Path) -> Result<(), String> {
.map_err(|error| format!("设置主密钥权限失败: {error}"))
}
#[cfg(not(unix))]
/// Restrict ACL to the current user on Windows (best-effort via icacls).
#[cfg(windows)]
fn current_windows_user() -> Option<String> {
std::env::var("USERNAME")
.ok()
.or_else(|| std::env::var("USER").ok())
.filter(|value| !value.is_empty())
}
#[cfg(windows)]
fn run_icacls(args: &[&str]) -> Result<(), String> {
let status = std::process::Command::new("icacls")
.args(args)
.status()
.map_err(|error| format!("运行 icacls 失败: {error}"))?;
if status.success() {
Ok(())
} else {
Err(format!("icacls 返回失败状态: {status}"))
}
}
#[cfg(windows)]
fn set_directory_permissions(path: &std::path::Path) -> Result<(), String> {
let Some(user) = current_windows_user() else {
return Ok(());
};
let path_str = path.to_string_lossy();
// Remove inherited ACEs, then grant current user modify on the directory tree.
let _ = run_icacls(&[&path_str, "/inheritance:r"]);
run_icacls(&[
&path_str,
"/grant:r",
&format!("{user}:(OI)(CI)F"),
])
.or_else(|_| {
// Fallback without propagation flags if the shell quoting differs.
run_icacls(&[&path_str, "/grant:r", &format!("{user}:F")])
})
}
#[cfg(windows)]
fn set_file_permissions(path: &std::path::Path) -> Result<(), String> {
let Some(user) = current_windows_user() else {
return Ok(());
};
let path_str = path.to_string_lossy();
let _ = run_icacls(&[&path_str, "/inheritance:r"]);
run_icacls(&[&path_str, "/grant:r", &format!("{user}:F")])
}
#[cfg(not(any(unix, windows)))]
fn set_directory_permissions(_path: &std::path::Path) -> Result<(), String> {
Ok(())
}
#[cfg(not(any(unix, windows)))]
fn set_file_permissions(_path: &std::path::Path) -> Result<(), String> {
Ok(())
}
+217
View File
@@ -0,0 +1,217 @@
//! Device-bound Pro license helpers (HMAC signed tokens).
//! Signing secret stays on license-server; desktop embeds the matching
//! verify key for offline activate (rotate for production).
use std::{
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use tauri::{AppHandle, Manager};
use uuid::Uuid;
type HmacSha256 = Hmac<Sha256>;
/// Dev/test HMAC shared with `license-server`. Override via `OPSX_LICENSE_HMAC`.
pub const DEFAULT_LICENSE_HMAC: &str = "opsxshell-dev-license-hmac-v1-change-in-prod";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LicensePayload {
pub plan: String,
pub device_id: String,
pub iat: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<u64>,
#[serde(default)]
pub features: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LicenseStatus {
pub plan: String,
pub device_id: String,
pub activated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
pub features: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
fn hmac_secret() -> String {
std::env::var("OPSX_LICENSE_HMAC").unwrap_or_else(|_| DEFAULT_LICENSE_HMAC.to_string())
}
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map_err(|error| format!("无法确定应用数据目录: {error}"))
}
fn device_id_path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(data_dir(app)?.join("device.id"))
}
fn license_path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(data_dir(app)?.join("license.opsx"))
}
fn ensure_parent(path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| format!("创建数据目录失败: {error}"))?;
}
Ok(())
}
pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
let path = device_id_path(app)?;
if path.exists() {
let id = fs::read_to_string(&path).map_err(|error| format!("读取设备 ID 失败: {error}"))?;
let trimmed = id.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
ensure_parent(&path)?;
let id = Uuid::new_v4().to_string();
fs::write(&path, &id).map_err(|error| format!("写入设备 ID 失败: {error}"))?;
Ok(id)
}
fn sign_payload(payload_b64: &str, secret: &str) -> Result<String, String> {
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| format!("HMAC 初始化失败: {error}"))?;
mac.update(payload_b64.as_bytes());
Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
}
/// Allow helpers used by unit tests / future issue endpoints.
#[allow(dead_code)]
pub fn issue_token(payload: &LicensePayload, secret: &str) -> Result<String, String> {
let json = serde_json::to_vec(payload).map_err(|error| format!("序列化许可证失败: {error}"))?;
let payload_b64 = URL_SAFE_NO_PAD.encode(json);
let sig = sign_payload(&payload_b64, secret)?;
Ok(format!("opsx1.{payload_b64}.{sig}"))
}
pub fn verify_token(token: &str, secret: &str) -> Result<LicensePayload, String> {
let trimmed = token.trim();
let parts: Vec<&str> = trimmed.split('.').collect();
if parts.len() != 3 || parts[0] != "opsx1" {
return Err("许可证格式无效".to_string());
}
let expected = sign_payload(parts[1], secret)?;
if expected != parts[2] {
return Err("许可证签名无效".to_string());
}
let bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|_| "许可证载荷损坏".to_string())?;
let payload: LicensePayload =
serde_json::from_slice(&bytes).map_err(|_| "许可证载荷无法解析".to_string())?;
if payload.plan != "pro" {
return Err("许可证计划无效".to_string());
}
if let Some(exp) = payload.exp {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now > exp {
return Err("许可证已过期".to_string());
}
}
Ok(payload)
}
pub fn read_status(app: &AppHandle) -> Result<LicenseStatus, String> {
let device_id = get_or_create_device_id(app)?;
let path = license_path(app)?;
if !path.exists() {
return Ok(LicenseStatus {
plan: "free".to_string(),
device_id,
activated: false,
expires_at: None,
features: vec![],
token: None,
});
}
let token = fs::read_to_string(&path).map_err(|error| format!("读取许可证失败: {error}"))?;
match verify_token(&token, &hmac_secret()) {
Ok(payload) => {
if payload.device_id != "*" && payload.device_id != device_id {
return Ok(LicenseStatus {
plan: "free".to_string(),
device_id,
activated: false,
expires_at: None,
features: vec![],
token: None,
});
}
Ok(LicenseStatus {
plan: payload.plan,
device_id,
activated: true,
expires_at: payload.exp,
features: payload.features,
token: Some(token.trim().to_string()),
})
}
Err(_) => Ok(LicenseStatus {
plan: "free".to_string(),
device_id,
activated: false,
expires_at: None,
features: vec![],
token: None,
}),
}
}
pub fn activate(app: &AppHandle, token: &str) -> Result<LicenseStatus, String> {
let device_id = get_or_create_device_id(app)?;
let payload = verify_token(token, &hmac_secret())?;
if payload.device_id != "*" && payload.device_id != device_id {
return Err("许可证与当前设备不匹配".to_string());
}
let path = license_path(app)?;
ensure_parent(&path)?;
fs::write(&path, token.trim()).map_err(|error| format!("保存许可证失败: {error}"))?;
read_status(app)
}
pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
let path = license_path(app)?;
if path.exists() {
fs::remove_file(&path).map_err(|error| format!("清除许可证失败: {error}"))?;
}
read_status(app)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_token() {
let payload = LicensePayload {
plan: "pro".to_string(),
device_id: "dev-1".to_string(),
iat: 1,
exp: None,
features: vec!["*".to_string()],
};
let token = issue_token(&payload, DEFAULT_LICENSE_HMAC).unwrap();
let parsed = verify_token(&token, DEFAULT_LICENSE_HMAC).unwrap();
assert_eq!(parsed.device_id, "dev-1");
}
}
+108 -13
View File
@@ -69,14 +69,20 @@ impl LocalTerminalService {
options: LocalShellOptions,
preferred_id: Option<String>,
) -> Result<LocalShellCreateResponse, String> {
let shell = options.shell.unwrap_or_else(default_shell);
let mut shell = options.shell.unwrap_or_else(default_shell);
if !Path::new(&shell).is_file() {
// Prefer the first available shell when the preferred default vanished.
if let Some(fallback) = self.list_shells().into_iter().next() {
shell = fallback.path;
}
}
if !Path::new(&shell).is_file() {
return Err(format!("shell does not exist: {shell}"));
}
let cwd = options
.cwd
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(PathBuf::from))
.or_else(local_home_dir)
.or_else(|| env::current_dir().ok())
.ok_or_else(|| "unable to determine local shell working directory".to_string())?;
let cols = options.cols.unwrap_or(80).max(2);
@@ -93,8 +99,11 @@ impl LocalTerminalService {
.map_err(|error| format!("failed to open local PTY: {error}"))?;
let mut command = CommandBuilder::new(&shell);
command.cwd(cwd);
command.env("TERM", "xterm-256color");
command.env("COLORTERM", "truecolor");
#[cfg(not(target_os = "windows"))]
{
command.env("TERM", "xterm-256color");
command.env("COLORTERM", "truecolor");
}
let child = pair
.slave
.spawn_command(command)
@@ -205,25 +214,94 @@ fn spawn_reader(app: AppHandle, shell_id: String, mut reader: Box<dyn Read + Sen
});
}
fn local_home_dir() -> Option<PathBuf> {
dirs::home_dir()
.or_else(|| env::var_os("HOME").map(PathBuf::from))
.or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
}
fn first_existing(paths: impl IntoIterator<Item = String>) -> Option<String> {
paths
.into_iter()
.find(|path| !path.is_empty() && Path::new(path).is_file())
}
#[cfg(target_os = "windows")]
fn lookup_on_path(binary: &str) -> Option<String> {
let path_var = env::var_os("PATH")?;
for dir in env::split_paths(&path_var) {
let candidate = dir.join(binary);
if candidate.is_file() {
return Some(candidate.to_string_lossy().to_string());
}
let with_exe = dir.join(format!("{binary}.exe"));
if with_exe.is_file() {
return Some(with_exe.to_string_lossy().to_string());
}
}
None
}
fn default_shell() -> String {
#[cfg(target_os = "windows")]
{
env::var("COMSPEC").unwrap_or_else(|_| "C:\\Windows\\System32\\cmd.exe".to_string())
// Prefer modern PowerShell when installed; otherwise Windows PowerShell / COMSPEC.
if let Some(shell) = first_existing([
lookup_on_path("pwsh").unwrap_or_default(),
r"C:\Program Files\PowerShell\7\pwsh.exe".to_string(),
r"C:\Program Files\PowerShell\7-preview\pwsh.exe".to_string(),
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe".to_string(),
env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()),
r"C:\Windows\System32\cmd.exe".to_string(),
]) {
return shell;
}
env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string())
}
#[cfg(not(target_os = "windows"))]
#[cfg(target_os = "macos")]
{
env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string())
env::var("SHELL")
.ok()
.filter(|path| Path::new(path).is_file())
.or_else(|| first_existing(["/bin/zsh".into(), "/bin/bash".into(), "/bin/sh".into()]))
.unwrap_or_else(|| "/bin/zsh".to_string())
}
#[cfg(all(unix, not(target_os = "macos")))]
{
env::var("SHELL")
.ok()
.filter(|path| Path::new(path).is_file())
.or_else(|| {
first_existing([
"/bin/bash".into(),
"/usr/bin/bash".into(),
"/bin/sh".into(),
"/usr/bin/zsh".into(),
"/bin/zsh".into(),
])
})
.unwrap_or_else(|| "/bin/bash".to_string())
}
}
fn shell_candidates() -> Vec<String> {
#[cfg(target_os = "windows")]
{
vec![
"C:\\Windows\\System32\\cmd.exe".to_string(),
"C:\\Program Files\\PowerShell\\7\\pwsh.exe".to_string(),
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe".to_string(),
]
let mut candidates = Vec::new();
if let Ok(comspec) = env::var("COMSPEC") {
candidates.push(comspec);
}
if let Some(pwsh) = lookup_on_path("pwsh") {
candidates.push(pwsh);
}
candidates.extend([
r"C:\Program Files\PowerShell\7\pwsh.exe".to_string(),
r"C:\Program Files\PowerShell\7-preview\pwsh.exe".to_string(),
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe".to_string(),
r"C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe".to_string(),
r"C:\Windows\System32\cmd.exe".to_string(),
]);
candidates
}
#[cfg(not(target_os = "windows"))]
{
@@ -238,6 +316,7 @@ fn shell_candidates() -> Vec<String> {
"/usr/local/bin/fish",
"/opt/homebrew/bin/fish",
"/bin/sh",
"/usr/bin/sh",
]
.into_iter()
.map(str::to_string)
@@ -290,7 +369,10 @@ fn shell_order(kind: &str) -> u8 {
"zsh" => 0,
"bash" => 1,
"fish" => 2,
_ => 3,
"pwsh" => 3,
"cmd" => 4,
"sh" => 5,
_ => 6,
}
}
@@ -303,6 +385,10 @@ mod tests {
assert_eq!(shell_kind("/bin/zsh"), "zsh");
assert_eq!(shell_kind("/usr/local/bin/bash"), "bash");
assert_eq!(shell_kind("C:\\Windows\\System32\\cmd.exe"), "cmd");
assert_eq!(
shell_kind("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
"pwsh"
);
}
#[test]
@@ -313,4 +399,13 @@ mod tests {
assert!(Path::new(&first.path).is_file());
}
}
#[test]
fn default_shell_points_to_existing_binary_when_possible() {
let shell = default_shell();
// On CI/dev hosts the resolved default should exist; skip soft if sandbox has none.
if Path::new(&shell).is_file() {
assert!(!shell.is_empty());
}
}
}
+1
View File
@@ -3,6 +3,7 @@
pub mod ai;
mod credential_store;
pub mod deep_link;
pub mod license;
pub mod local_terminal;
pub mod serial;
pub mod ssh;
+17 -3
View File
@@ -1,14 +1,19 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
pub(super) fn expand_home(value: &str) -> PathBuf {
if value == "~" {
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value));
}
if let Some(relative) = value.strip_prefix("~/") {
let relative = value
.strip_prefix("~/")
.or_else(|| value.strip_prefix("~\\"));
if let Some(relative) = relative {
if let Some(home) = dirs::home_dir() {
return home.join(relative);
return home.join(Path::new(relative));
}
}
PathBuf::from(value)
}
@@ -20,4 +25,13 @@ mod tests {
fn leaves_absolute_paths_unchanged() {
assert_eq!(expand_home("/tmp/key"), PathBuf::from("/tmp/key"));
}
#[test]
fn expands_unix_and_windows_home_prefixes() {
if let Some(home) = dirs::home_dir() {
assert_eq!(expand_home("~/a/b"), home.join("a/b"));
assert_eq!(expand_home("~\\a\\b"), home.join(Path::new("a\\b")));
assert_eq!(expand_home("~"), home);
}
}
}