授权体系落地:30 天试用 + LIC- 在线 / LICOFF- 离线激活 + /pricing 定价页

借鉴 Yaak 授权模型 + 复用自家 OpsXShell 生产级授权协议(opsx-licence 服务端):
- Rust services/license:30 天试用自首次启动自动开始;LIC- 在线激活/周检/解绑
  (双节点容灾 + 响应 Ed25519 签名校验 + 时钟回拨检测 + 30 天离线容忍);
  LICOFF- 离线证本地自验(产品码 opsxapi + 设备绑定 + ≤1 年期限)
- 前端 src/license.ts + 活动栏授权徽章(试用 N 天/已授权/未授权)+
  设置→授权页(状态横幅/激活表单/解绑/定价链接带 ?intent= 追踪)
- /pricing 独立定价页(三站同结构):30 天试用条款、在线/离线激活说明三卡、
  年付↔终身切换、授权 FAQ、独立开发者声明;导航全部接入
- opsxDB 个人 ¥69/年·¥199 终身 / 团队 ¥69 / 私有化 ¥6000(与 API 定位错开)
- GUI 实测:试用 30 天徽章渲染正常;浏览器态授权页正确显示仅桌面端提示
This commit is contained in:
logan committed 2026-10-16 03:27:45 +08:00
1 parent d7a9503042
commit c304b9f251
19 files changed
+888 -7

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@
<a href="#features">功能</a> <a href="#features">功能</a>
<a href="#protocols">协议</a> <a href="#protocols">协议</a>
<a href="#exchange">导入导出</a> <a href="#exchange">导入导出</a>
<a href="#pricing">定价</a> <a href="/pricing">定价</a>
<a href="/docs" target="_blank" rel="noopener">文档</a> <a href="/docs" target="_blank" rel="noopener">文档</a>
</nav> </nav>
<div class="nav-cta"> <div class="nav-cta">
+114
View File
@@ -231,6 +231,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -508,6 +514,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.2" version = "0.18.2"
@@ -639,6 +651,33 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.24.1" version = "0.24.1"
@@ -721,6 +760,16 @@ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.20",
] ]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -879,6 +928,30 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.11" version = "3.0.11"
@@ -988,6 +1061,12 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@@ -1070,6 +1149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -2423,7 +2503,10 @@ dependencies = [
name = "opsxapi" name = "opsxapi"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1",
"ed25519-dalek",
"futures-util", "futures-util",
"hex",
"http", "http",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
@@ -2583,6 +2666,16 @@ dependencies = [
"futures-io", "futures-io",
] ]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
@@ -2973,7 +3066,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -3430,6 +3525,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -3512,6 +3616,16 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
+4 -1
View File
@@ -18,11 +18,14 @@ path = "src/main.rs"
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
hex = "0.4"
ed25519-dalek = { version = "2.1", default-features = false, features = ["std"] }
base64 = "0.22"
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" # External link jump (About page/documentation/website) tauri-plugin-opener = "2" # External link jump (About page/documentation/website)
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net"] }
url = "2" url = "2"
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
+1
View File
@@ -7,6 +7,7 @@
"core:default", "core:default",
"core:event:default", "core:event:default",
"transport", "transport",
"license",
"opener:default" "opener:default"
] ]
} }
+1 -1
View File
@@ -1 +1 @@
{"__app-acl__":{"default_permission":null,"permissions":{"transport":{"identifier":"transport","description":"Allow HTTP/WebSocket native transport","commands":{"allow":["http_send","ws_connect","ws_send","ws_close"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-fetch-data-store-identifiers":{"iLine truncated {"__app-acl__":{"default_permission":null,"permissions":{"license":{"identifier":"license","description":"License trial status, activate (online/offline), verify and deactivate","commands":{"allow":["license_status","license_activate","license_verify","license_deactivate"],"deny":[]}},"transport":{"identifier":"transport","description":"Allow HTTP/WebSocket native transport","commands":{"allow":["http_send","ws_connect","ws_send","ws_close"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow"Line truncated
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for the OpsxAPI main window","local":true,"windows":["main"],"permissions":["core:default","core:event:default","transport","opener:default"]}} {"default":{"identifier":"default","description":"Default capabilities for the OpsxAPI main window","local":true,"windows":["main"],"permissions":["core:default","core:event:default","transport","license","opener:default"]}}
@@ -344,6 +344,12 @@
"Identifier": { "Identifier": {
"description": "Permission identifier", "description": "Permission identifier",
"oneOf": [ "oneOf": [
{
"description": "License trial status, activate (online/offline), verify and deactivate",
"type": "string",
"const": "license",
"markdownDescription": "License trial status, activate (online/offline), verify and deactivate"
},
{ {
"description": "Allow HTTP/WebSocket native transport", "description": "Allow HTTP/WebSocket native transport",
"type": "string", "type": "string",
+6
View File
@@ -344,6 +344,12 @@
"Identifier": { "Identifier": {
"description": "Permission identifier", "description": "Permission identifier",
"oneOf": [ "oneOf": [
{
"description": "License trial status, activate (online/offline), verify and deactivate",
"type": "string",
"const": "license",
"markdownDescription": "License trial status, activate (online/offline), verify and deactivate"
},
{ {
"description": "Allow HTTP/WebSocket native transport", "description": "Allow HTTP/WebSocket native transport",
"type": "string", "type": "string",
+4
View File
@@ -0,0 +1,4 @@
[[permission]]
identifier = "license"
description = "License trial status, activate (online/offline), verify and deactivate"
commands.allow = ["license_status", "license_activate", "license_verify", "license_deactivate"]
+28
View File
@@ -1,5 +1,6 @@
use crate::{ use crate::{
error::AppError, error::AppError,
services::license,
models::{HttpResult, HttpSpec, WsSpec}, models::{HttpResult, HttpSpec, WsSpec},
services::{ services::{
http, http,
@@ -42,3 +43,30 @@ pub async fn ws_send(
pub async fn ws_close(state: State<'_, WsState>, session_id: String) -> Result<(), AppError> { pub async fn ws_close(state: State<'_, WsState>, session_id: String) -> Result<(), AppError> {
websocket::close(&state, session_id).await websocket::close(&state, session_id).await
} }
/* ---------------- 授权(试用 / 在线 LIC- / 离线 LICOFF-) ---------------- */
#[tauri::command]
pub fn license_status(app: AppHandle) -> Result<license::LicenseStatus, String> {
license::read_status(&app)
}
#[tauri::command]
pub fn license_activate(
app: AppHandle,
license_key: String,
email: Option<String>,
) -> Result<license::LicenseStatus, String> {
license::activate(&app, &license_key, email.as_deref())
}
#[tauri::command]
pub fn license_verify(app: AppHandle) -> Result<license::LicenseStatus, String> {
license::verify(&app)
}
#[tauri::command]
pub fn license_deactivate(app: AppHandle) -> Result<license::LicenseStatus, String> {
license::deactivate(&app)
}
+2 -1
View File
@@ -11,7 +11,8 @@ pub fn run() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.manage(WsState::default()) .manage(WsState::default())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
http_send, ws_connect, ws_send, ws_close http_send, ws_connect, ws_send, ws_close,
license_status, license_activate, license_verify, license_deactivate
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running OpsxAPI desktop"); .expect("error while running OpsxAPI desktop");
+434
View File
@@ -0,0 +1,434 @@
//! OpsxAPI 授权:30 天全功能试用 + LIC- 在线授权 / LICOFF- 离线许可证。
//! 协议与 opsx-licence 服务端、OpsXShell 客户端保持同源:
//! - 在线激活 POST /v1/license/activate(双节点容灾)
//! - 在线验证 POST /v1/license/verify(响应带 Ed25519 sig,防假服务器)
//! - 离线证 LICOFF-1.{payload}.{sig} 自包含验签(绑定 product=opsxapi 与 device_id)
//! - 在线证连续 30 天无法联网验证则降级;离线证不受此限制
//! 试用策略(借鉴 Yaak):首次启动自动开始 30 天;到期后个人非商业可继续用,
//! 商用需授权——v1 不做硬功能锁,仅状态提示。
use base64::Engine;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{fs, path::PathBuf, time::{Duration, SystemTime, UNIX_EPOCH}};
use tauri::{AppHandle, Manager};
use uuid::Uuid;
/// opsx-licence API 根(须以 /api 结尾)。双节点容灾:主 + 备。
const DEFAULT_API_BASES: &[&str] = &["https://l123.91ai.io/api", "https://licence.rlwj.cn/api"];
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
/// 在线证离线容忍上限:连续 30 天联系不上全部节点则要求联网重验
const OFFLINE_TOLERANCE_SECS: u64 = 30 * 86_400;
/// 时钟回拨容忍
const CLOCK_SKEW_TOLERANCE_SECS: u64 = 300;
/// 离线证验签公钥(与服务端 OFFLINE_LICENSE_SEED 同源;与 OpsXShell 客户端一致)
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
"108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d";
/// 本产品码:离线证 claims.pid 必须匹配
const PRODUCT_CODE: &str = "opsxapi";
/// 试用时长:30 天
const TRIAL_SECS: u64 = 30 * 86_400;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StoredLicense {
#[serde(default)]
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, skip_serializing_if = "Option::is_none")]
plan_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_devices: Option<u32>,
#[serde(default)]
last_verified_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
activation_token: Option<String>,
#[serde(default)]
last_server_time: u64,
}
fn default_plan() -> String {
"licensed".to_string()
}
/// 前端可见状态:phase 驱动 UI(试用倒计时 / 已授权 / 试用结束)
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LicenseStatus {
pub phase: String, // trialing | trial_ended | active
pub days_left: i64,
pub device_id: String,
pub activated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_devices: Option<u32>,
pub trial_end: u64,
}
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
app.path().app_data_dir().map_err(|e| format!("无法确定应用数据目录: {e}"))
}
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 trial_path(app: &AppHandle) -> Result<PathBuf, String> { Ok(data_dir(app)?.join("trial.opsx")) }
fn ensure_parent(path: &PathBuf) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建数据目录失败: {e}"))?;
}
Ok(())
}
pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
let path = device_id_path(app)?;
if path.exists() {
if let Ok(id) = fs::read_to_string(&path) {
let t = id.trim().to_string();
if !t.is_empty() { return Ok(t); }
}
}
ensure_parent(&path)?;
let id = Uuid::new_v4().to_string();
fs::write(&path, &id).map_err(|e| format!("写入设备 ID 失败: {e}"))?;
Ok(id)
}
/// 首次启动时间(试用起点);文件缺失则写入当前时间——即「首次启动自动开始试用」。
fn trial_start(app: &AppHandle) -> Result<u64, String> {
let path = trial_path(app)?;
if path.exists() {
if let Ok(raw) = fs::read_to_string(&path) {
if let Ok(n) = raw.trim().parse::<u64>() {
if n > 0 { return Ok(n); }
}
}
}
ensure_parent(&path)?;
let now = now_secs();
fs::write(&path, now.to_string()).map_err(|e| format!("写入试用起点失败: {e}"))?;
Ok(now)
}
fn read_stored(path: &PathBuf) -> Result<Option<StoredLicense>, String> {
if !path.exists() { return Ok(None); }
let raw = fs::read_to_string(path).map_err(|e| format!("读取许可证失败: {e}"))?;
let t = raw.trim();
if t.is_empty() || !t.starts_with('{') { return Ok(None); }
serde_json::from_str(t).map_err(|e| format!("许可证文件损坏: {e}")).map(Some)
}
fn write_stored(path: &PathBuf, stored: &StoredLicense) -> Result<(), String> {
ensure_parent(path)?;
let json = serde_json::to_string_pretty(stored).map_err(|e| format!("序列化许可证失败: {e}"))?;
fs::write(path, json).map_err(|e| format!("保存许可证失败: {e}"))
}
fn is_offline_license_key(k: &str) -> bool { k.trim().starts_with("LICOFF-") }
fn reject_legacy_key(k: &str) -> Result<(), String> {
let t = k.trim();
if t.is_empty() { return Err("请输入许可证密钥".into()); }
if t.starts_with("opsx1.") || t.contains(".opsx1.") {
return Err("不支持旧版 opsx1 许可证,请使用 LIC- / LICOFF- 授权码".into());
}
if t.starts_with("MII") {
return Err("看起来像私钥,请粘贴 LIC- / LICOFF- 许可证密钥".into());
}
Ok(())
}
#[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 offline_verifying_key() -> Result<VerifyingKey, String> {
let bytes = hex::decode(OFFLINE_LICENSE_PUBLIC_KEY_HEX).map_err(|_| "离线验签公钥配置错误".to_string())?;
VerifyingKey::from_bytes(bytes.as_slice().try_into().map_err(|_| "公钥长度错误".to_string())?)
.map_err(|_| "离线验签公钥无效".to_string())
}
/// 离线证本地自验:Ed25519 签名 + 产品码 + 设备绑定 + 有效期上限(≤1 年,长期授权走在线)。
fn verify_offline_serial(serial: &str, device_id: &str) -> Result<OfflineClaims, String> {
let body = serial.trim().strip_prefix("LICOFF-1.")
.ok_or("不是合法的离线序列号(需 LICOFF-1. 前缀)")?;
let (payload_b64, sig_b64) = body.rsplit_once('.').ok_or("离线序列号格式错误")?;
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let sig = Signature::from_slice(&engine.decode(sig_b64).map_err(|_| "签名无法解码".to_string())?)
.map_err(|_| "签名无效".to_string())?;
offline_verifying_key()?
.verify(payload_b64.as_bytes(), &sig)
.map_err(|_| "离线序列号签名校验失败".to_string())?;
let claims: OfflineClaims = serde_json::from_slice(
&engine.decode(payload_b64).map_err(|_| "载荷无法解码".to_string())?,
).map_err(|_| "载荷无法解析".to_string())?;
if claims.v != 0 && claims.v != 1 { return Err("不支持的离线证版本".into()); }
if claims.product_code != PRODUCT_CODE { return Err("离线证产品不匹配".into()); }
if claims.device_id != device_id { return Err("离线证与当前设备不匹配".into()); }
let plan = claims.plan_type.as_str();
if plan != "trial" && plan != "yearly" { return Err("离线证计划无效(仅体验/包年)".into()); }
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 api_bases() -> Vec<String> {
match std::env::var("OPSX_LICENSE_API_BASE") {
Ok(raw) if !raw.trim().is_empty() => {
let mut b = raw.trim().trim_end_matches('/').to_string();
if !b.ends_with("/api") { b = format!("{b}/api"); }
vec![b]
}
_ => DEFAULT_API_BASES.iter().map(|s| s.to_string()).collect(),
}
}
fn http_client() -> Result<reqwest::blocking::Client, String> {
reqwest::blocking::Client::builder()
.timeout(HTTP_TIMEOUT)
.user_agent("OpsxAPI/1")
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))
}
fn try_post_json(url: &str, body: &Value) -> Result<Value, String> {
let resp = http_client()?.post(url).json(body).send().map_err(|_| "许可证服务请求失败".to_string())?;
let status = resp.status();
let text = resp.text().map_err(|_| "读取响应失败".to_string())?;
if text.trim().is_empty() { return Err(format!("许可证服务返回空响应 ({status})")); }
serde_json::from_str(&text).map_err(|_| format!("许可证服务响应无法解析 ({status})"))
}
fn post_json(path: &str, body: Value) -> Result<Value, String> {
let mut last = String::from("无可用授权节点");
for base in api_bases() {
match try_post_json(&format!("{base}{path}"), &body) {
Ok(v) => return Ok(v),
Err(e) => last = e,
}
}
Err(last)
}
fn env_code(v: &Value) -> Option<i64> { v.get("code").and_then(|x| x.as_i64()) }
fn env_msg(v: &Value) -> Option<String> {
v.get("message").and_then(|x| x.as_str()).map(|s| s.to_string()).filter(|s| !s.is_empty())
}
fn env_data<'a>(v: &'a Value) -> &'a Value { v.get("data").unwrap_or(v) }
fn j_str(v: &Value, k: &str) -> Option<String> {
v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string()).filter(|s| !s.is_empty())
}
fn j_i64(v: &Value, k: &str) -> Option<i64> {
v.get(k).and_then(|x| x.as_i64().or_else(|| x.as_f64().map(|f| f as i64)))
}
/// 在线响应签名校验(与 opsx-licence guard.go 同源签名串;旧版无 sig 字段时放行)。
fn verify_response_sig(payload: &Value) -> Result<(), String> {
let sig_hex = payload.get("sig").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
if sig_hex.is_empty() { return Ok(()); }
let sig = Signature::from_slice(&hex::decode(&sig_hex).map_err(|_| "响应签名无法解码".to_string())?)
.map_err(|_| "响应签名无效".to_string())?;
let msg = format!(
"v1|{}|{}|{}|{}|{}|{}|{}|{}",
j_str(payload, "license_key").unwrap_or_default(),
payload.get("valid").and_then(|v| v.as_bool()).unwrap_or(false),
j_str(payload, "status").unwrap_or_default(),
j_i64(payload, "expires_at_unix").unwrap_or(0),
j_i64(payload, "server_time").unwrap_or(0),
payload.get("in_grace").and_then(|v| v.as_bool()).unwrap_or(false),
j_i64(payload, "max_devices").unwrap_or(0),
j_str(payload, "plan_type").unwrap_or_default(),
);
offline_verifying_key()?.verify(msg.as_bytes(), &sig).map_err(|_| "响应签名校验失败".to_string())
}
struct RemoteInfo {
expires_at: Option<u64>,
plan_type: Option<String>,
max_devices: Option<u32>,
server_time: u64,
activation_token: Option<String>,
}
fn parse_remote(data: &Value) -> RemoteInfo {
let server_time = j_i64(data, "server_time").filter(|t| *t > 0).unwrap_or_else(|| now_secs() as i64) as u64;
let expires_at = j_i64(data, "expires_at_unix").filter(|u| *u > 0).map(|u| u as u64)
.or_else(|| j_i64(data, "days_left").filter(|d| *d >= 0).map(|d| now_secs() + d as u64 * 86_400));
RemoteInfo {
expires_at,
plan_type: j_str(data, "plan_type"),
max_devices: j_i64(data, "max_devices").map(|n| n.max(0) as u32),
server_time,
activation_token: j_str(data, "activation_token"),
}
}
fn build_status(app: &AppHandle, stored: Option<&StoredLicense>) -> Result<LicenseStatus, String> {
let device_id = get_or_create_device_id(app)?;
let trial_end = trial_start(app)? + TRIAL_SECS;
let now = now_secs();
if let Some(s) = stored {
if s.activated && !s.license_key.trim().is_empty() {
let expired = s.expires_at.map(|e| e > 0 && e < now).unwrap_or(false);
let offline_overdue = !is_offline_license_key(&s.license_key)
&& s.last_verified_at > 0
&& now > s.last_verified_at + OFFLINE_TOLERANCE_SECS;
if !expired && !offline_overdue {
return Ok(LicenseStatus {
phase: "active".into(),
days_left: s.expires_at.map(|e| ((e as i64 - now as i64) / 86_400).max(0)).unwrap_or(i64::MAX / 4),
device_id, activated: true,
expires_at: s.expires_at,
email: s.email.clone(),
plan_type: s.plan_type.clone(),
max_devices: s.max_devices,
trial_end,
});
}
}
}
// 未激活:试用状态(借鉴 Yaak——到期后个人非商业可继续,仅提示商用需授权)
let trialing = now < trial_end;
Ok(LicenseStatus {
phase: if trialing { "trialing".into() } else { "trial_ended".into() },
days_left: ((trial_end as i64 - now as i64) / 86_400).max(0),
device_id, activated: false,
expires_at: None, email: None, plan_type: None, max_devices: None,
trial_end,
})
}
pub fn read_status(app: &AppHandle) -> Result<LicenseStatus, String> {
let stored = read_stored(&license_path(app)?)?;
build_status(app, stored.as_ref())
}
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 key = license_key.trim().to_string();
let path = license_path(app)?;
// 时钟回拨检测
if let Some(stored) = read_stored(&path)? {
if now_secs() + CLOCK_SKEW_TOLERANCE_SECS < stored.last_server_time {
return Err("本机时钟异常(早于最近一次服务器时间),请校准系统时间后重试".into());
}
}
let stored = if is_offline_license_key(&key) {
// 离线激活:本地验签,完全无网络
let claims = verify_offline_serial(&key, &device_id)?;
StoredLicense {
license_key: key.clone(),
email: email.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).or(claims.email.clone()),
plan: default_plan(), activated: true,
expires_at: Some(claims.exp as u64),
plan_type: Some(claims.plan_type),
max_devices: Some(claims.max_devices.max(1) as u32),
last_verified_at: now_secs(),
activation_token: None,
last_server_time: 0,
}
} else {
// 在线授权:激活 + 立即验证一次(拿准 expires/features)
let body = serde_json::json!({
"license_key": key, "device_id": device_id, "device_name": "OpsxAPI",
});
let value = post_json("/v1/license/activate", body)?;
if let Some(code) = env_code(&value) {
if code != 0 { return Err(env_msg(&value).unwrap_or_else(|| "激活失败".into())); }
}
let data = env_data(&value).clone();
verify_response_sig(&data)?;
let info = parse_remote(&data);
StoredLicense {
license_key: key.clone(),
email: email.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
plan: default_plan(), activated: true,
expires_at: info.expires_at,
plan_type: info.plan_type,
max_devices: info.max_devices,
last_verified_at: now_secs(),
activation_token: info.activation_token,
last_server_time: info.server_time,
}
};
write_stored(&path, &stored)?;
read_status(app)
}
/// 周期性在线验证(前端每周调用一次);网络不可达时在容忍期内保留缓存。
pub fn verify(app: &AppHandle) -> Result<LicenseStatus, String> {
let path = license_path(app)?;
let stored = match read_stored(&path)? { Some(s) => s, None => return read_status(app) };
if !stored.activated || is_offline_license_key(&stored.license_key) { return read_status(app); }
let device_id = get_or_create_device_id(app)?;
let body = serde_json::json!({ "license_key": stored.license_key, "device_id": device_id });
match post_json("/v1/license/verify", body) {
Ok(value) => {
let payload = if value.get("valid").is_some() { value.clone() }
else if env_code(&value) == Some(0) { env_data(&value).clone() }
else { return read_status(app); };
let valid = payload.get("valid").and_then(|v| v.as_bool()).unwrap_or(true);
if !valid {
let _ = fs::remove_file(&path); // 服务器明确拒绝:吊销/无效 → 回到未激活
return read_status(app);
}
verify_response_sig(&payload)?;
let info = parse_remote(&payload);
let mut next = stored.clone();
if info.expires_at.is_some() { next.expires_at = info.expires_at; }
if info.plan_type.is_some() { next.plan_type = info.plan_type; }
if info.max_devices.is_some() { next.max_devices = info.max_devices; }
next.last_verified_at = now_secs();
next.last_server_time = info.server_time;
write_stored(&path, &next)?;
}
Err(_) => { /* 网络不可达:容忍期内保留 */ }
}
read_status(app)
}
pub fn deactivate(app: &AppHandle) -> Result<LicenseStatus, String> {
let path = license_path(app)?;
if let Some(stored) = read_stored(&path)? {
if stored.activated && !is_offline_license_key(&stored.license_key) {
let device_id = get_or_create_device_id(app)?;
let mut body = serde_json::json!({
"license_key": stored.license_key, "device_id": device_id,
});
if let Some(t) = stored.activation_token.as_ref().filter(|t| !t.trim().is_empty()) {
body["activation_token"] = Value::String(t.trim().to_string());
}
let _ = post_json("/v1/license/deactivate", body);
}
}
let _ = fs::remove_file(&path);
read_status(app)
}
+1
View File
@@ -1,4 +1,5 @@
pub mod http; pub mod http;
pub mod license;
pub mod proxy; pub mod proxy;
mod validation; mod validation;
pub mod websocket; pub mod websocket;
+4
View File
@@ -6,6 +6,8 @@ import { useWorkspaceLifecycle } from "./features/workspace/useWorkspaceLifecycl
*/ */
import { findNode } from "./core/index"; import { findNode } from "./core/index";
import { useWorkspace } from "./workspaceStore"; import { useWorkspace } from "./workspaceStore";
import { useLicense } from "./license";
import { useEffect } from "react";
import { ActivityRail } from "./components/ActivityRail"; import { ActivityRail } from "./components/ActivityRail";
import { Sidebar } from "./components/Sidebar"; import { Sidebar } from "./components/Sidebar";
import { TestingView } from "./features/testing/TestingView"; import { TestingView } from "./features/testing/TestingView";
@@ -22,6 +24,8 @@ import { EmptyState } from "./features/workspace/EmptyState";
export function OpsxWorkspace() { export function OpsxWorkspace() {
useWorkspaceLifecycle(); useWorkspaceLifecycle();
// 授权状态(桌面端):30 天试用倒计时 / 激活状态;浏览器环境自动跳过
useEffect(() => { void useLicense.getState().load(); }, []);
const inited = useWorkspace((s) => s.collections.length > 0 || s.environments.length > 0); const inited = useWorkspace((s) => s.collections.length > 0 || s.environments.length > 0);
const collections = useWorkspace((s) => s.collections); const collections = useWorkspace((s) => s.collections);
const activeTabId = useWorkspace((s) => s.activeTabId); const activeTabId = useWorkspace((s) => s.activeTabId);
+47
View File
@@ -4,6 +4,7 @@
*/ */
import { useState } from "react"; import { useState } from "react";
import { useWorkspace } from "../workspaceStore"; import { useWorkspace } from "../workspaceStore";
import { useLicense } from "../license";
import { openExternal } from "../openExternal"; import { openExternal } from "../openExternal";
import { ContextMenu, cx, type MenuItem } from "./common"; import { ContextMenu, cx, type MenuItem } from "./common";
import { Icon, type IconName } from "./icons"; import { Icon, type IconName } from "./icons";
@@ -197,6 +198,8 @@ export function ActivityRail() {
))} ))}
<div className="flex-1" /> <div className="flex-1" />
<LicenseBadge />
<button className="rail-item" title="设置" onClick={() => setDialog({ type: "settings" })}> <button className="rail-item" title="设置" onClick={() => setDialog({ type: "settings" })}>
<Icon name="settings" size={16} /> <Icon name="settings" size={16} />
<span className="text-[11px]">设置</span> <span className="text-[11px]">设置</span>
@@ -245,3 +248,47 @@ export function ActivityRail() {
</nav> </nav>
); );
} }
/** 授权徽章:试用倒计时 / 已授权 / 试用结束(仅桌面端显示;点击进设置→授权) */
function LicenseBadge() {
const status = useLicense((s) => s.status);
const setDialog = useWorkspace((s) => s.setDialog);
if (!status) return null; // 浏览器环境或未加载
if (status.phase === "active") {
const until = status.expiresAt
? new Date(status.expiresAt * 1000).toLocaleDateString("zh-CN")
: "永久";
return (
<button
className="rail-item rail-license active"
title={`已授权${status.planType ? ` · ${status.planType}` : ""} · 到期 ${until}(点击查看)`}
onClick={() => setDialog({ type: "settings", tab: "license" })}
>
<Icon name="shield-check" size={16} />
<span className="text-[10px]">已授权</span>
</button>
);
}
if (status.phase === "trialing") {
return (
<button
className="rail-item rail-license trial"
title={`30 天全功能试用剩 ${status.daysLeft} 天 · 商用需授权(点击查看)`}
onClick={() => setDialog({ type: "settings", tab: "license" })}
>
<Icon name="clock" size={16} />
<span className="text-[10px]">试用 {status.daysLeft}天</span>
</button>
);
}
return (
<button
className="rail-item rail-license ended"
title="试用已结束 · 个人非商业可继续使用,商用请授权(点击查看)"
onClick={() => setDialog({ type: "settings", tab: "license" })}
>
<Icon name="shield-check" size={16} />
<span className="text-[10px]">未授权</span>
</button>
);
}
+126 -1
View File
@@ -14,6 +14,7 @@ import { Icon, type IconName } from "../icons";
import { KindIcon } from "../KindIcon"; import { KindIcon } from "../KindIcon";
import { Logo } from "../Logo"; import { Logo } from "../Logo";
import { WECHAT_GROUP_URL } from "../../links"; import { WECHAT_GROUP_URL } from "../../links";
import { useLicense } from "../../license";
import { openExternal } from "../../openExternal"; import { openExternal } from "../../openExternal";
const KIND_MENU: { kind: RequestKind | "folder"; icon: IconName; desc: string }[] = [ const KIND_MENU: { kind: RequestKind | "folder"; icon: IconName; desc: string }[] = [
@@ -380,7 +381,7 @@ export function EnvironmentsDialog() {
/* ---------------- 设置弹窗(左侧导航 + 分区) ---------------- */ /* ---------------- 设置弹窗(左侧导航 + 分区) ---------------- */
type SettingsTab = "appearance" | "network" | "mock" | "mockdata" | "ai" | "data" | "about"; type SettingsTab = "appearance" | "network" | "mock" | "mockdata" | "ai" | "data" | "license" | "about";
const SETTINGS_TABS: { id: SettingsTab; label: string; desc: string; icon: IconName }[] = [ const SETTINGS_TABS: { id: SettingsTab; label: string; desc: string; icon: IconName }[] = [
{ id: "appearance", label: "外观", desc: "主题与界面", icon: "palette" }, { id: "appearance", label: "外观", desc: "主题与界面", icon: "palette" },
@@ -388,6 +389,7 @@ const SETTINGS_TABS: { id: SettingsTab; label: string; desc: string; icon: IconN
{ id: "mock", label: "Mock 服务", desc: "服务开关与访问地址", icon: "flask-conical" }, { id: "mock", label: "Mock 服务", desc: "服务开关与访问地址", icon: "flask-conical" },
{ id: "ai", label: "大模型", desc: "供应商与默认模型", icon: "bot" }, { id: "ai", label: "大模型", desc: "供应商与默认模型", icon: "bot" },
{ id: "data", label: "数据管理", desc: "工作区与备份", icon: "database" }, { id: "data", label: "数据管理", desc: "工作区与备份", icon: "database" },
{ id: "license", label: "授权", desc: "试用与许可证", icon: "shield-check" },
{ id: "about", label: "关于", desc: "版本与链接", icon: "info" }, { id: "about", label: "关于", desc: "版本与链接", icon: "info" },
]; ];
@@ -782,6 +784,7 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
</> </>
)} )}
{active === "license" && <LicensePane />}
{active === "about" && ( {active === "about" && (
<> <>
<p className="ops-faint px-1 text-[11px]"> <p className="ops-faint px-1 text-[11px]">
@@ -825,3 +828,125 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
</Modal> </Modal>
); );
} }
/* ---------------- 授权:试用状态 + 在线/离线激活 ---------------- */
function LicensePane() {
const status = useLicense((s) => s.status);
const busy = useLicense((s) => s.busy);
const error = useLicense((s) => s.error);
const activate = useLicense((s) => s.activate);
const deactivate = useLicense((s) => s.deactivate);
const load = useLicense((s) => s.load);
const [key, setKey] = useState("");
const [email, setEmail] = useState("");
const [ok, setOk] = useState(false);
const doActivate = async () => {
if (!key.trim()) return;
setOk(false);
const done = await activate(key, email);
if (done) {
setKey("");
setOk(true);
}
};
if (!status) {
return (
<div className="ops-hint">
<Icon name="info" size={14} />
<span>授权状态仅在桌面端可用;网页预览环境不涉及许可证。</span>
</div>
);
}
const until = status.expiresAt ? new Date(status.expiresAt * 1000).toLocaleDateString("zh-CN") : "永久";
return (
<div className="space-y-4">
{status.phase === "active" ? (
<div className="set-banner" style={{ borderColor: "var(--success)" }}>
<div className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--success)" }}>
<Icon name="shield-check" size={15} /> 已授权
</div>
<div className="ops-faint mt-1 text-[12px] leading-6">
{status.planType ? `计划:${status.planType} · ` : ""}
{status.maxDevices ? `设备数:${status.maxDevices} · ` : ""}
到期:{until}
{status.email ? ` · ${status.email}` : ""}
</div>
<div className="ops-faint mt-1 text-[11px]">设备 ID:{status.deviceId}</div>
<div className="mt-2 flex gap-2">
<button className="ops-btn ops-btn-secondary !py-1 text-[12px]" disabled={busy} onClick={() => void deactivate()}>
解绑本机
</button>
<button className="ops-btn ops-btn-ghost !py-1 text-[12px]" onClick={() => void load()}>
刷新状态
</button>
</div>
</div>
) : status.phase === "trialing" ? (
<div className="set-banner" style={{ borderColor: "var(--accent)" }}>
<div className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--accent-strong)" }}>
<Icon name="clock" size={15} /> 试用中 · 剩 {status.daysLeft} 天
</div>
<div className="ops-faint mt-1 text-[12px] leading-6">
30 天全功能试用自首次启动自动开始。试用期结束后个人非商业用途可继续使用;工作中商用需购买授权。
</div>
</div>
) : (
<div className="set-banner" style={{ borderColor: "var(--danger)" }}>
<div className="flex items-center gap-2 text-[13px] font-semibold ops-danger">
<Icon name="shield-check" size={15} /> 试用已结束
</div>
<div className="ops-faint mt-1 text-[12px] leading-6">
个人非商业用途可继续使用;在工作中商用请购买授权。
</div>
</div>
)}
{status.phase !== "active" && (
<div className="set-group">
<div className="set-group-title">激活许可证</div>
<div className="space-y-2">
<input
className="ops-input font-mono"
placeholder="LIC- 在线授权码 或 LICOFF- 离线许可证"
value={key}
onChange={(e) => { setKey(e.target.value); setOk(false); }}
onKeyDown={(e) => e.key === "Enter" && void doActivate()}
/>
<input
className="ops-input"
placeholder="邮箱(可选,用于授权关联)"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<div className="flex items-center gap-2">
<button className="ops-btn ops-btn-primary" disabled={busy || !key.trim()} onClick={() => void doActivate()}>
{busy ? "激活中…" : "激活"}
</button>
<span className="ops-faint text-[11px]">
LIC- 联网激活、可自助解绑;LICOFF- 完全离线激活(绑定本机)
</span>
</div>
</div>
</div>
)}
{ok && <div className="ops-success text-[12px]">激活成功,已授权 ✓</div>}
{error && <div className="ops-danger text-[12px]">{error}</div>}
<div className="flex items-center justify-between">
<span className="ops-faint text-[11px]">定价与购买</span>
<button
className="ops-btn ops-btn-secondary !py-1 text-[12px]"
onClick={() => void openExternal(`/pricing?intent=app.license.purchase.${status.phase}`)}
>
<Icon name="external-link" size={13} /> 查看定价
</button>
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
/**
* 授权状态(桌面端):30 天全功能试用 + LIC- 在线授权 / LICOFF- 离线许可证。
* 状态来源为 Rust 侧 license 服务(device.id / trial.opsx / license.opsx),
* 浏览器(无 Tauri)环境下保持 unknown,界面自动隐藏授权徽章。
*/
import { create } from "zustand";
export type LicensePhase = "unknown" | "trialing" | "trial_ended" | "active";
export interface LicenseStatus {
phase: Exclude<LicensePhase, "unknown">;
daysLeft: number;
deviceId: string;
activated: boolean;
expiresAt?: number | null;
email?: string | null;
planType?: string | null;
maxDevices?: number | null;
trialEnd: number;
}
const LAST_VERIFY_KEY = "opsxapi.license.lastVerifyAt";
const WEEK_MS = 7 * 24 * 3600 * 1000;
function isDesktop(): boolean {
return Boolean((window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__);
}
async function invokeLicense<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<T>(command, args);
}
interface LicenseStore {
status: LicenseStatus | null;
busy: boolean;
error: string | null;
load: () => Promise<void>;
activate: (key: string, email?: string) => Promise<boolean>;
deactivate: () => Promise<void>;
}
export const useLicense = create<LicenseStore>((set, get) => ({
status: null,
busy: false,
error: null,
load: async () => {
if (!isDesktop()) return;
try {
const status = await invokeLicense<LicenseStatus>("license_status");
set({ status, error: null });
// 已激活:每周静默联网验证一次(离线容忍 30 天由 Rust 侧判定)
const last = Number(localStorage.getItem(LAST_VERIFY_KEY) ?? 0);
if (status.phase === "active" && Date.now() - last > WEEK_MS) {
try {
const fresh = await invokeLicense<LicenseStatus>("license_verify");
localStorage.setItem(LAST_VERIFY_KEY, String(Date.now()));
set({ status: fresh });
} catch {
/* 网络不可达:容忍期内保留缓存 */
}
}
} catch (e) {
set({ error: e instanceof Error ? e.message : String(e) });
}
},
activate: async (key, email) => {
set({ busy: true, error: null });
try {
const status = await invokeLicense<LicenseStatus>("license_activate", {
licenseKey: key.trim(),
email: email?.trim() || null,
});
set({ status, busy: false });
return true;
} catch (e) {
set({ busy: false, error: e instanceof Error ? e.message : String(e) });
return false;
}
},
deactivate: async () => {
set({ busy: true, error: null });
try {
const status = await invokeLicense<LicenseStatus>("license_deactivate");
set({ status, busy: false });
} catch (e) {
set({ busy: false, error: e instanceof Error ? e.message : String(e) });
}
},
}));
export function licensePhase(): LicensePhase {
return useLicense.getState().status?.phase ?? "unknown";
}
+10
View File
@@ -521,6 +521,13 @@
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--panel-2); background: var(--panel-2);
} }
.set-banner {
border: 1px solid var(--border);
border-left-width: 3px;
border-radius: 10px;
padding: 12px 14px;
background: var(--panel-2);
}
.set-group-title { .set-group-title {
@apply px-4 pt-3.5 pb-1.5 text-[12px] font-semibold tracking-[0.02em]; @apply px-4 pt-3.5 pb-1.5 text-[12px] font-semibold tracking-[0.02em];
color: var(--text-dim); color: var(--text-dim);
@@ -706,6 +713,9 @@
border-radius: 999px; border-radius: 999px;
border: 1.5px solid var(--panel); border: 1.5px solid var(--panel);
} }
.rail-license.trial { color: var(--accent-strong); }
.rail-license.active { color: var(--success); }
.rail-license.ended { color: var(--danger); }
.rail-ws-badge.local { background: #22c55e; } .rail-ws-badge.local { background: #22c55e; }
.rail-ws-badge.team { background: #3b82f6; } .rail-ws-badge.team { background: #3b82f6; }
.rail-ws-badge.cloud { background: #60a5fa; } .rail-ws-badge.cloud { background: #60a5fa; }
+1 -1
View File
@@ -97,7 +97,7 @@ export type Dialog =
| { type: "import" } | { type: "import" }
| { type: "export"; collectionId: string } | { type: "export"; collectionId: string }
| { type: "environments" } | { type: "environments" }
| { type: "settings"; tab?: "appearance" | "network" | "mock" | "mockdata" | "ai" | "data" | "about" } | { type: "settings"; tab?: "appearance" | "network" | "mock" | "mockdata" | "ai" | "data" | "license" | "about" }
| { type: "rename"; id: string } | { type: "rename"; id: string }
| { type: "confirm"; title: string; message: string; action: () => void } | { type: "confirm"; title: string; message: string; action: () => void }
| { type: "auth" } | { type: "auth" }