# ai-cortex-sdk
[](https://crates.io/crates/ai-cortex-sdk)
[](https://docs.rs/ai-cortex-sdk)
[](./LICENSE)
Rust client SDK for the [AI Cortex](https://github.com/Liangdi/ai-station) server — PAT 鉴权、设备绑定、软件商店、**自动更新**(check-update + SSE)、**离线许可证**(Ed25519 签名)。
## Installation
```toml
[dependencies]
ai-cortex-sdk = "0.1"
```
## Quick Start
SDK 用**用户 PAT**(`actx_pat_...`)鉴权,PAT 由用户在服务端 web 控制台调 `POST /api/access-tokens` 创建。
```rust
use ai_cortex_sdk::{CortexClient, CortexConfig};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// server_url, PAT, software_id(SDK 以该软件身份心跳 / 下载 / 检查更新)
let config = CortexConfig::new("http://localhost:40404", "actx_pat_...", "software-uuid");
let client = CortexClient::new(config);
// 软件商店:列出软件及其最新版本
for item in client.list_software().await? {
let latest = item.latest_version.as_ref().map(|v| v.version.as_str()).unwrap_or("-");
println!("{} -> {}", item.software.name, latest);
}
// 检查自动更新(Pull)
let info = client.check_update("linux", "1.0.0", None).await?;
if info.has_update {
println!("有新版本,强制升级: {}", info.force_update);
}
Ok(())
}
```
## Configuration
```rust
let config = CortexConfig::new("http://localhost:40404", "actx_pat_...", "software-uuid")
.with_timeout(60) // 请求超时(秒),默认 30
.with_heartbeat_interval(60) // 心跳间隔(秒);0 = 关闭,默认 60
.without_heartbeat() // 或直接关闭心跳
.with_software_public_key("abcdef...32字节hex公钥"); // 钉扎离线许可证公钥
```
`CortexClient::new` 会自动启动后台心跳(首次即向服务端绑定当前设备,按该软件许可证的 `max_devices` 校验上限);`Drop` 时自动停止心跳。
## API Reference
### Software Store
| `client.list_software()` | 列出所有软件及其最新活动版本 |
| `client.get_latest_version(software_id)` | 取某软件最新版本 |
| `client.download(version_id)` | 取某版本的下载地址(需许可证) |
### Auto Update(自动更新)
提供 **Pull(主动检查)** 与 **Push(SSE 订阅)** 两种检测方式,互补使用:SSE 做实时通知,Pull 做准确判定 + 断线兜底。两者都需对该 software 持有有效许可证。
| `client.check_update(platform, current_version, channel)` | 主动检查更新(Pull),返回 `UpdateInfo` |
| `client.open_update_events(software_id, last_event_id)` | 打开更新事件 SSE 流(Push),返回 `UpdateEventStream` |
**Pull — `check_update`**
```rust
// channel 传 None 默认 "stable"
let info = client.check_update("linux", "1.0.0", None).await?;
// UpdateInfo { has_update, force_update, current_version, target_version: Option<VersionBrief> }
if info.force_update {
// 强制升级:客户端应阻止旧版启动,引导用户升级后再放行
} else if info.has_update {
let t = info.target_version.unwrap();
println!("可升级到 {} (channel={}, {} bytes)", t.version, t.channel, t.file_size);
}
```
**Push — `open_update_events`(SSE 长连接)**
服务端发布新版本时通过 `GET /api/softwares/sdk-update-events`(`text/event-stream`,PAT 鉴权)主动推送,无需轮询。返回的流配合 `futures_util::StreamExt` 使用:
```rust
use futures_util::StreamExt;
// software_id 传 None 订阅全部;last_event_id 用于断线重连补播
let mut stream = client.open_update_events(Some("software-uuid"), None).await?;
while let Some(Ok(ev)) = stream.next().await {
// ev.id 单调递增;ev.force_update 标记是否强制
println!("update #{}: v{} force={}", ev.id, ev.version, ev.force_update);
}
```
说明:
- SSE 是长连接,内部用**无读超时**的 HTTP 客户端(绕过 `CortexConfig.timeout` 的 30s 默认值),靠服务端 15s keepalive 注释帧保活。
- 断线重连时把最后收到的 `event.id` 作为 `last_event_id` 传入,服务端补播 `id > last` 的事件。
- `: keepalive` / `: lagged` 注释帧由 SDK 内部自动忽略。
### Device(设备)
| `device::collect()` | 采集当前设备指纹(`DeviceIdentity { fingerprint, info }`) |
| `client.list_devices()` | 列出当前用户名下绑定的有效设备 |
| `client.unbind_device(device_id)` | 解绑设备(释放名额) |
## Error Handling
```rust
use ai_cortex_sdk::SdkError;
match client.check_update("linux", "1.0.0", None).await {
Ok(info) => { /* ... */ }
Err(SdkError::NotAuthenticated) => eprintln!("未认证 / PAT 失效"),
Err(SdkError::Unauthorized(reason)) => eprintln!("无授权: {reason}"),
Err(SdkError::RequestError(e)) => eprintln!("网络错误: {e}"),
Err(SdkError::ServerError(msg)) => eprintln!("服务端错误: {msg}"),
Err(e) => eprintln!("其它: {e}"),
}
```
## Examples
- [`basic.rs`](examples/basic.rs) — 软件商店浏览 / 下载 / 设备列表
- [`auth_check.rs`](examples/auth_check.rs) — 校验 PAT 是否有效
- [`offline_license.rs`](examples/offline_license.rs) — 拉取并验签离线许可证
- [`auto_update.rs`](examples/auto_update.rs) — 检查更新(Pull)+ 订阅 SSE 事件(Push)
```bash
cargo run --example basic
cargo run --example offline_license -- <PAT> <SOFTWARE_ID> [PINNED_PUBKEY_HEX]
cargo run --example auto_update -- <PAT> <SOFTWARE_ID> [stream]
```
## 离线许可证
服务端可在 PAT 鉴权下通过 `POST /api/offline-licenses/issue` 下发一份**签名许可证文件**,
让客户端在受限网络/断网环境下仍能本地证明自己持有有效授权。文件结构:
```json
{
"payload": "<LicensePayload 的 JSON 字符串>",
"signature": "<hex 64 字节 Ed25519 签名>",
"public_key":"<hex 32 字节 Ed25519 公钥>"
}
```
`payload` 是 `OfflineLicensePayload` 的 snake_case JSON,包含
`license_id / user_id / software_id / fingerprint / max_devices / expire_time / issued_at`。
签名是对 `payload` 字符串 UTF-8 字节的 Ed25519 签名。
### 流程
```rust
use ai_cortex_sdk::{verify_offline_license, CortexClient, CortexConfig};
// 1. 配置(可选钉扎公钥,增强防替换)
let config = CortexConfig::new("http://localhost:40404", "actx_pat_...", "software-uuid")
.with_software_public_key("abcdef...32字节hex公钥");
let client = CortexClient::new(config);
let fp = ai_cortex_sdk::device::collect().fingerprint;
// 2. 在线拉取(需联网 + 有效 PAT + 有效软件授权)
let file = client.fetch_offline_license(&fp).await?;
// 3. 本地验签:Ed25519 验签 → 解析 payload → 校验 software_id/fingerprint → 校验未过期
let payload = verify_offline_license(&file, "software-uuid", &fp, Some("abcdef..."))?;
// 或用 client.verify_offline_license(&file, &fp) 自动带入配置的 software_id 与钉扎公钥
```
### 撤销语义与短 TTL
许可证的 `expire_time = issued_at + 86400`(1 天 TTL)。**撤销依赖在线刷新**:
许可证本身是自包含签名文件,服务端无法主动吊销已下发的离线文件;通过短 TTL 强制客户端
周期性联网 `fetch_offline_license` 重新换取。若服务端撤销了用户的软件授权或解绑了该设备指纹,
下一次 fetch 将失败,客户端即失去有效许可证,从而实现“软撤销”。
### 可选公钥钉扎
默认情况下客户端信任 issue 响应中下发的 `public_key`(依赖 TLS 保证传输不被篡改)。
对安全敏感场景,可通过 `CortexConfig::with_software_public_key(hex)` 钉扎一个预期公钥:
设置后 `verify_offline_license` 会要求下发公钥与之**严格相等**,否则直接判定为
`LicenseFieldMismatch`,可防御 TLS 被绕过/中间人替换公钥的攻击。不设置时则退化为信任下发公钥。
### 校验顺序
`verify_offline_license` 按以下顺序校验,任一失败立即返回对应 `SdkError`:
1. 公钥钉扎(若提供)—— `public_key` 必须与钉扎值相等
2. Ed25519 验签 —— `public_key` 对 `payload` 字节验签
3. 解析 payload JSON
4. 字段匹配 —— `software_id` 与 `fingerprint` 必须与期望值一致
5. 过期校验 —— `expire_time > now`
## License
MIT(见 [LICENSE](./LICENSE))。