feat: harden local shell, SFTP, and desktop session reliability
Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
188f3556ed
commit
08696896f9
223 files changed
+13493
-3423
No files matched your search
+608
-112
@@ -1,35 +1,54 @@
|
||||
//! 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).
|
||||
//! Pro license persistence + thin-client activate against opsx-licence.
|
||||
//! Runtime entitlement cache lives in `license.opsx`; online verify is remote,
|
||||
//! offline `LICOFF-` serials are Ed25519 self-contained.
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use hmac::{Hmac, Mac};
|
||||
use base64::Engine;
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
/// Public opsx-licence API root (must end with `/api`, never include `/v1`). // Logan.Li
|
||||
const DEFAULT_API_BASE: &str = "https://l123.91ai.io/api";
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// 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";
|
||||
/// Offline license verifying key (hex). Must match server `OFFLINE_LICENSE_SEED`. // Logan.Li
|
||||
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
|
||||
"108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d";
|
||||
|
||||
#[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>,
|
||||
struct StoredLicense {
|
||||
#[serde(default)]
|
||||
pub features: Vec<String>,
|
||||
license_key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
email: Option<String>,
|
||||
#[serde(default = "default_plan")]
|
||||
plan: String,
|
||||
#[serde(default)]
|
||||
activated: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
expires_at: Option<u64>,
|
||||
#[serde(default)]
|
||||
features: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
max_devices: Option<u32>,
|
||||
/// yearly | perpetual | trial | …
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
plan_type: Option<String>,
|
||||
#[serde(default)]
|
||||
last_verified_at: u64,
|
||||
}
|
||||
|
||||
fn default_plan() -> String {
|
||||
"pro".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -41,12 +60,43 @@ pub struct LicenseStatus {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<u64>,
|
||||
pub features: Vec<String>,
|
||||
/// License key string (compat field name for existing frontend).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_devices: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plan_type: Option<String>,
|
||||
}
|
||||
|
||||
fn hmac_secret() -> String {
|
||||
std::env::var("OPSX_LICENSE_HMAC").unwrap_or_else(|_| DEFAULT_LICENSE_HMAC.to_string())
|
||||
/// Normalize license API base so paths like `/v1/license/activate` resolve correctly.
|
||||
/// Accepts `https://host`, `https://host/api`, or mistaken `…/api/v1`. // Logan.Li
|
||||
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();
|
||||
}
|
||||
// Prefer https for the public licence host (http→301 HTML breaks JSON clients). // Logan.Li
|
||||
if base.starts_with("http://l123.91ai.io") {
|
||||
base = format!("https{}", &base["http".len()..]);
|
||||
}
|
||||
if let Some(stripped) = base.strip_suffix("/api/v1") {
|
||||
base = format!("{stripped}/api");
|
||||
} else if base.ends_with("/v1") && !base.ends_with("/api/v1") {
|
||||
// e.g. https://l123.91ai.io/v1 → https://l123.91ai.io/api
|
||||
base = format!("{}/api", base.trim_end_matches("/v1").trim_end_matches('/'));
|
||||
} else if !base.ends_with("/api") {
|
||||
base = format!("{base}/api");
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -70,6 +120,13 @@ fn ensure_parent(path: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
|
||||
let path = device_id_path(app)?;
|
||||
if path.exists() {
|
||||
@@ -85,112 +142,499 @@ pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
|
||||
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()))
|
||||
fn free_status(device_id: String, email: Option<String>) -> LicenseStatus {
|
||||
LicenseStatus {
|
||||
plan: "free".to_string(),
|
||||
device_id,
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
token: None,
|
||||
email,
|
||||
max_devices: None,
|
||||
plan_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
fn status_from_stored(device_id: String, stored: &StoredLicense) -> LicenseStatus {
|
||||
if !stored.activated || stored.license_key.trim().is_empty() {
|
||||
return free_status(device_id, stored.email.clone());
|
||||
}
|
||||
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());
|
||||
if let Some(exp) = stored.expires_at {
|
||||
if exp > 0 && exp < now_secs() {
|
||||
return free_status(device_id, stored.email.clone());
|
||||
}
|
||||
}
|
||||
Ok(payload)
|
||||
LicenseStatus {
|
||||
plan: if stored.plan.is_empty() {
|
||||
"pro".to_string()
|
||||
} else {
|
||||
stored.plan.clone()
|
||||
},
|
||||
device_id,
|
||||
activated: true,
|
||||
expires_at: stored.expires_at,
|
||||
features: if stored.features.is_empty() {
|
||||
vec!["*".to_string()]
|
||||
} else {
|
||||
stored.features.clone()
|
||||
},
|
||||
token: Some(stored.license_key.clone()),
|
||||
email: stored.email.clone(),
|
||||
max_devices: stored.max_devices,
|
||||
plan_type: stored.plan_type.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stored(path: &Path) -> Result<Option<StoredLicense>, String> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = fs::read_to_string(path).map_err(|error| format!("读取许可证失败: {error}"))?;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Legacy: plain opsx1 token file — treat as absent (must re-activate with LIC-).
|
||||
if trimmed.starts_with("opsx1.") || !trimmed.starts_with('{') {
|
||||
return Ok(None);
|
||||
}
|
||||
let stored: StoredLicense =
|
||||
serde_json::from_str(trimmed).map_err(|error| format!("许可证文件损坏: {error}"))?;
|
||||
Ok(Some(stored))
|
||||
}
|
||||
|
||||
fn write_stored(path: &Path, stored: &StoredLicense) -> Result<(), String> {
|
||||
ensure_parent(path)?;
|
||||
let json = serde_json::to_string_pretty(stored)
|
||||
.map_err(|error| format!("序列化许可证失败: {error}"))?;
|
||||
fs::write(path, json).map_err(|error| format!("保存许可证失败: {error}"))
|
||||
}
|
||||
|
||||
fn reject_legacy_key(license_key: &str) -> Result<(), String> {
|
||||
let trimmed = license_key.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("请输入许可证密钥".to_string());
|
||||
}
|
||||
if trimmed.starts_with("opsx1.") || trimmed.contains(".opsx1.") {
|
||||
return Err("不支持旧版 opsx1 许可证,请使用 LIC- / LICOFF- 授权码".to_string());
|
||||
}
|
||||
if trimmed.starts_with("MII") {
|
||||
return Err("看起来像私钥,请粘贴 LIC- / LICOFF- 许可证密钥".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_offline_license_key(license_key: &str) -> bool {
|
||||
license_key.trim().starts_with("LICOFF-")
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OfflineClaims {
|
||||
#[serde(default)]
|
||||
v: i32,
|
||||
#[serde(rename = "pid")]
|
||||
product_code: String,
|
||||
#[serde(rename = "did")]
|
||||
device_id: String,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(rename = "pt")]
|
||||
plan_type: String,
|
||||
iat: i64,
|
||||
exp: i64,
|
||||
#[serde(rename = "md", default)]
|
||||
max_devices: i32,
|
||||
}
|
||||
|
||||
fn verify_offline_serial(serial: &str, device_id: &str) -> Result<OfflineClaims, String> {
|
||||
let trimmed = serial.trim();
|
||||
let body = trimmed
|
||||
.strip_prefix("LICOFF-1.")
|
||||
.ok_or_else(|| "不是合法的离线序列号(需 LICOFF-1. 前缀)".to_string())?;
|
||||
let (payload_b64, sig_b64) = body
|
||||
.rsplit_once('.')
|
||||
.ok_or_else(|| "离线序列号格式错误".to_string())?;
|
||||
|
||||
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
let sig_bytes = engine
|
||||
.decode(sig_b64)
|
||||
.map_err(|_| "离线序列号签名无法解码".to_string())?;
|
||||
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "离线序列号签名无效".to_string())?;
|
||||
|
||||
let pub_bytes = hex::decode(OFFLINE_LICENSE_PUBLIC_KEY_HEX)
|
||||
.map_err(|_| "离线验签公钥配置错误".to_string())?;
|
||||
let verifying_key = VerifyingKey::from_bytes(
|
||||
pub_bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| "离线验签公钥长度错误".to_string())?,
|
||||
)
|
||||
.map_err(|_| "离线验签公钥无效".to_string())?;
|
||||
verifying_key
|
||||
.verify(payload_b64.as_bytes(), &sig)
|
||||
.map_err(|_| "离线序列号签名校验失败".to_string())?;
|
||||
|
||||
let payload = engine
|
||||
.decode(payload_b64)
|
||||
.map_err(|_| "离线序列号载荷无法解码".to_string())?;
|
||||
let claims: OfflineClaims =
|
||||
serde_json::from_slice(&payload).map_err(|_| "离线序列号载荷无法解析".to_string())?;
|
||||
|
||||
if claims.v != 0 && claims.v != 1 {
|
||||
return Err("不支持的离线证版本".to_string());
|
||||
}
|
||||
if claims.product_code != "opsxshell" {
|
||||
return Err("离线证产品不匹配".to_string());
|
||||
}
|
||||
if claims.device_id != device_id {
|
||||
return Err("离线证与当前设备不匹配".to_string());
|
||||
}
|
||||
let plan = claims.plan_type.as_str();
|
||||
if plan != "trial" && plan != "yearly" {
|
||||
return Err("离线证计划无效(仅体验/包年)".to_string());
|
||||
}
|
||||
let now = now_secs() as i64;
|
||||
if claims.exp <= now {
|
||||
return Err("离线许可证已过期".to_string());
|
||||
}
|
||||
if claims.exp - claims.iat > 365 * 86400 + 3600 {
|
||||
return Err("离线许可证有效期非法".to_string());
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::blocking::Client, String> {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.user_agent("OpsXShell/2")
|
||||
.build()
|
||||
.map_err(|error| format!("创建 HTTP 客户端失败: {error}"))
|
||||
}
|
||||
|
||||
fn envelope_message(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn envelope_code(value: &Value) -> Option<i64> {
|
||||
value.get("code").and_then(|v| v.as_i64())
|
||||
}
|
||||
|
||||
fn envelope_data(value: &Value) -> &Value {
|
||||
value.get("data").unwrap_or(value)
|
||||
}
|
||||
|
||||
fn json_string_field(value: &Value, snake: &str, camel: &str) -> Option<String> {
|
||||
value
|
||||
.get(snake)
|
||||
.or_else(|| value.get(camel))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn json_i64_field(value: &Value, snake: &str, camel: &str) -> Option<i64> {
|
||||
value
|
||||
.get(snake)
|
||||
.or_else(|| value.get(camel))
|
||||
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))
|
||||
}
|
||||
|
||||
fn json_string_array(value: &Value, snake: &str, camel: &str) -> Vec<String> {
|
||||
value
|
||||
.get(snake)
|
||||
.or_else(|| value.get(camel))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| item.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn expires_from_days_left(days_left: Option<i64>) -> Option<u64> {
|
||||
let days = days_left?;
|
||||
if days < 0 {
|
||||
return Some(0);
|
||||
}
|
||||
// Treat very long remaining windows as perpetual.
|
||||
if days > 36_500 {
|
||||
return None;
|
||||
}
|
||||
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());
|
||||
let client = http_client()?;
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.map_err(|error| format!("许可证服务请求失败: {error}"))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.map_err(|error| format!("读取许可证服务响应失败: {error}"))?;
|
||||
if text.trim().is_empty() {
|
||||
return Err(format!("许可证服务返回空响应 ({status})"));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|error| {
|
||||
format!(
|
||||
"许可证服务响应无法解析 ({status}): {error}; body={}",
|
||||
&text[..text.len().min(200)]
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
struct RemoteLicenseInfo {
|
||||
expires_at: Option<u64>,
|
||||
features: Vec<String>,
|
||||
max_devices: Option<u32>,
|
||||
plan_type: 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");
|
||||
RemoteLicenseInfo {
|
||||
expires_at,
|
||||
features,
|
||||
max_devices,
|
||||
plan_type,
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_activate(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
|
||||
let body = serde_json::json!({
|
||||
"license_key": license_key,
|
||||
"device_id": device_id,
|
||||
"device_name": "OpsXShell",
|
||||
});
|
||||
let value = post_json("/v1/license/activate", body)?;
|
||||
if let Some(code) = envelope_code(&value) {
|
||||
if code != 0 {
|
||||
return Err(envelope_message(&value).unwrap_or_else(|| "激活失败".to_string()));
|
||||
}
|
||||
}
|
||||
Ok(parse_remote_info(envelope_data(&value)))
|
||||
}
|
||||
|
||||
fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
|
||||
let body = serde_json::json!({
|
||||
"license_key": license_key,
|
||||
"device_id": device_id,
|
||||
});
|
||||
let value = post_json("/v1/license/verify", body)?;
|
||||
// Bare object or envelope.
|
||||
let payload = if value.get("valid").is_some() {
|
||||
&value
|
||||
} else if envelope_code(&value) == Some(0) {
|
||||
envelope_data(&value)
|
||||
} else if let Some(code) = envelope_code(&value) {
|
||||
if code != 0 {
|
||||
return Err(envelope_message(&value).unwrap_or_else(|| "验证失败".to_string()));
|
||||
}
|
||||
envelope_data(&value)
|
||||
} else {
|
||||
&value
|
||||
};
|
||||
|
||||
let valid = payload
|
||||
.get("valid")
|
||||
.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()));
|
||||
}
|
||||
|
||||
Ok(parse_remote_info(payload))
|
||||
}
|
||||
|
||||
fn remote_deactivate(license_key: &str, device_id: &str) {
|
||||
let body = serde_json::json!({
|
||||
"license_key": license_key,
|
||||
"device_id": device_id,
|
||||
});
|
||||
let _ = post_json("/v1/license/deactivate", body);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CheckoutResult {
|
||||
pub order_no: String,
|
||||
pub checkout_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub amount: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub currency: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// Create a pending order via opsx-licence and return the Waffo hosted checkout URL. // Logan.Li
|
||||
pub fn checkout(
|
||||
app: &AppHandle,
|
||||
customer_email: &str,
|
||||
plan_type: &str,
|
||||
return_url: Option<&str>,
|
||||
) -> Result<CheckoutResult, String> {
|
||||
let email = customer_email.trim();
|
||||
if email.is_empty() || !email.contains('@') {
|
||||
return Err("请先填写有效邮箱".to_string());
|
||||
}
|
||||
let plan = match plan_type.trim() {
|
||||
"yearly" | "perpetual" => plan_type.trim(),
|
||||
other if !other.is_empty() => other,
|
||||
_ => "perpetual",
|
||||
};
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let body = serde_json::json!({
|
||||
"customer_email": email,
|
||||
"product_code": "opsxshell",
|
||||
"plan_type": plan,
|
||||
"device_id": device_id,
|
||||
"return_url": return_url.unwrap_or("https://l123.91ai.io/api/v1/purchase/success"),
|
||||
});
|
||||
let value = post_json("/v1/checkout", body)?;
|
||||
if let Some(code) = envelope_code(&value) {
|
||||
if code != 0 {
|
||||
return Err(envelope_message(&value).unwrap_or_else(|| "创建结账订单失败".to_string()));
|
||||
}
|
||||
}
|
||||
let data = envelope_data(&value);
|
||||
let checkout_url = json_string_field(data, "checkout_url", "checkoutUrl")
|
||||
.ok_or_else(|| "结账链接缺失".to_string())?;
|
||||
let order_no = json_string_field(data, "order_no", "orderNo").unwrap_or_default();
|
||||
Ok(CheckoutResult {
|
||||
order_no,
|
||||
checkout_url,
|
||||
amount: data
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|n| n as f64))),
|
||||
currency: json_string_field(data, "currency", "currency"),
|
||||
status: json_string_field(data, "status", "status"),
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
match read_stored(&path)? {
|
||||
None => Ok(free_status(device_id, None)),
|
||||
Some(stored) => Ok(status_from_stored(device_id, &stored)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate(app: &AppHandle, token: &str) -> Result<LicenseStatus, String> {
|
||||
pub fn activate(
|
||||
app: &AppHandle,
|
||||
license_key: &str,
|
||||
email: Option<&str>,
|
||||
) -> Result<LicenseStatus, String> {
|
||||
reject_legacy_key(license_key)?;
|
||||
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 key = license_key.trim();
|
||||
|
||||
if is_offline_license_key(key) {
|
||||
let claims = verify_offline_serial(key, &device_id)?;
|
||||
let email_store = email
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.or(claims.email.clone());
|
||||
let stored = StoredLicense {
|
||||
license_key: key.to_string(),
|
||||
email: email_store,
|
||||
plan: "pro".to_string(),
|
||||
activated: true,
|
||||
expires_at: Some(claims.exp as u64),
|
||||
features: vec!["*".to_string()],
|
||||
max_devices: Some(claims.max_devices.max(1) as u32),
|
||||
plan_type: Some(claims.plan_type),
|
||||
last_verified_at: now_secs(),
|
||||
};
|
||||
let path = license_path(app)?;
|
||||
write_stored(&path, &stored)?;
|
||||
return read_status(app);
|
||||
}
|
||||
|
||||
let mut info = remote_activate(key, &device_id)?;
|
||||
if let Ok(verify) = remote_verify(key, &device_id) {
|
||||
if verify.expires_at.is_some() {
|
||||
info.expires_at = verify.expires_at;
|
||||
}
|
||||
if !verify.features.is_empty() {
|
||||
info.features = verify.features;
|
||||
}
|
||||
if verify.max_devices.is_some() {
|
||||
info.max_devices = verify.max_devices;
|
||||
}
|
||||
if verify.plan_type.is_some() {
|
||||
info.plan_type = verify.plan_type;
|
||||
}
|
||||
}
|
||||
if info.features.is_empty() {
|
||||
info.features = vec!["*".to_string()];
|
||||
}
|
||||
|
||||
let stored = StoredLicense {
|
||||
license_key: key.to_string(),
|
||||
email: email
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
plan: "pro".to_string(),
|
||||
activated: true,
|
||||
expires_at: info.expires_at,
|
||||
features: info.features,
|
||||
max_devices: info.max_devices,
|
||||
plan_type: info.plan_type,
|
||||
last_verified_at: now_secs(),
|
||||
};
|
||||
let path = license_path(app)?;
|
||||
ensure_parent(&path)?;
|
||||
fs::write(&path, token.trim()).map_err(|error| format!("保存许可证失败: {error}"))?;
|
||||
write_stored(&path, &stored)?;
|
||||
read_status(app)
|
||||
}
|
||||
|
||||
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 {
|
||||
license_key: String::new(),
|
||||
email: None,
|
||||
plan: "free".to_string(),
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
max_devices: None,
|
||||
plan_type: None,
|
||||
last_verified_at: 0,
|
||||
});
|
||||
let trimmed = email.trim();
|
||||
stored.email = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
write_stored(&path, &stored)?;
|
||||
read_status(app)
|
||||
}
|
||||
|
||||
pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
if path.exists() {
|
||||
fs::remove_file(&path).map_err(|error| format!("清除许可证失败: {error}"))?;
|
||||
}
|
||||
@@ -202,16 +646,68 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_token() {
|
||||
let payload = LicensePayload {
|
||||
fn normalize_api_base_accepts_host_and_strips_v1() {
|
||||
assert_eq!(
|
||||
normalize_api_base("https://l123.91ai.io"),
|
||||
"https://l123.91ai.io/api"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_base("https://l123.91ai.io/api/"),
|
||||
"https://l123.91ai.io/api"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_base("https://l123.91ai.io/api/v1"),
|
||||
"https://l123.91ai.io/api"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_base("http://l123.91ai.io/api"),
|
||||
"https://l123.91ai.io/api"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_base("http://127.0.0.1:9001/api"),
|
||||
"http://127.0.0.1:9001/api"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_opsx1_and_private_key() {
|
||||
assert!(reject_legacy_key("opsx1.abc.def").is_err());
|
||||
assert!(reject_legacy_key("MIIEowIBAAK...").is_err());
|
||||
assert!(reject_legacy_key("LIC-TEST-KEY").is_ok());
|
||||
assert!(reject_legacy_key("LICOFF-1.abc.def").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_offline_prefix() {
|
||||
assert!(is_offline_license_key("LICOFF-1.x.y"));
|
||||
assert!(!is_offline_license_key("LIC-2026-AAAA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_license_roundtrip_json() {
|
||||
let stored = StoredLicense {
|
||||
license_key: "LIC-ABC".to_string(),
|
||||
email: Some("a@b.com".to_string()),
|
||||
plan: "pro".to_string(),
|
||||
device_id: "dev-1".to_string(),
|
||||
iat: 1,
|
||||
exp: None,
|
||||
activated: true,
|
||||
expires_at: None,
|
||||
features: vec!["*".to_string()],
|
||||
max_devices: Some(3),
|
||||
plan_type: Some("perpetual".to_string()),
|
||||
last_verified_at: 1,
|
||||
};
|
||||
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");
|
||||
let json = serde_json::to_string(&stored).unwrap();
|
||||
let parsed: StoredLicense = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.license_key, "LIC-ABC");
|
||||
assert_eq!(parsed.email.as_deref(), Some("a@b.com"));
|
||||
assert!(parsed.activated);
|
||||
assert_eq!(parsed.max_devices, Some(3));
|
||||
assert_eq!(parsed.plan_type.as_deref(), Some("perpetual"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expires_from_days_left_perpetual() {
|
||||
assert_eq!(expires_from_days_left(Some(40_000)), None);
|
||||
assert!(expires_from_days_left(Some(30)).is_some());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user