feat: Pro 买断门禁、SFTP 精简与批量多机改造
新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
101d2164b4
commit
fea6d8db04
72 files changed
+3307
-396
No files matched your search
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user