From b85291def64135fe6222f79ac7826c82a8c8e579 Mon Sep 17 00:00:00 2001 From: blankll Date: Wed, 16 Sep 2026 00:57:46 +0800 Subject: [PATCH 1/3] feat(entitlement): Ultimate entitlements & version-lock subscription (geekfun#56) Client-side implementation of the Ultimate entitlement contract: - Rust: new entitlement module (persisted cache, offline tolerance, failure degradation, 5-min refresh throttle, release-date vs versionLockHorizon unlock check), device activation/identity, and keyring-backed session lease. Command gates return ENTITLEMENT_REQUIRED for AI, Transfer, SSH tunnels and MCP bridge. - Frontend: entitlement/device stores, Geekfun login, upgrade guidance on gated features, Account & Plan settings tab; logout clears the local entitlement cache. - Tests: entitlement contract and deviceStore suites (531 passing). --- CHANGELOG.md | 4 + package-lock.json | 11 + package.json | 1 + src-tauri/Cargo.lock | 12 + src-tauri/Cargo.toml | 2 + src-tauri/src/agent_adapters.rs | 23 + src-tauri/src/commands/helpers.rs | 5 +- src-tauri/src/commands/transfer.rs | 8 + src-tauri/src/device_activation.rs | 309 +++++++++++ src-tauri/src/device_identity.rs | 423 +++++++++++++++ src-tauri/src/entitlement.rs | 507 ++++++++++++++++++ src-tauri/src/lib.rs | 25 +- src-tauri/src/mcp_bridge.rs | 10 + src-tauri/src/session.rs | 203 +++++++ src/App.vue | 26 + src/common/entitlement.ts | 34 ++ src/common/index.ts | 1 + src/components/DeviceReplaceDialog.vue | 237 ++++++++ .../connections/ServerFormDialog.vue | 7 + src/components/layout/AppLayout.vue | 8 + src/components/upgrade/PaidGate.vue | 58 ++ src/components/upgrade/UpgradeDialog.vue | 103 ++++ src/components/upgrade/index.ts | 3 + .../upgrade/upgradeDialogService.ts | 15 + src/lang/enUS.ts | 48 ++ src/lang/zhCN.ts | 48 ++ src/pages/DataStudioPage.vue | 444 +++++++-------- src/pages/QueriesPage.vue | 11 + src/pages/SettingsPage.vue | 26 +- src/pages/TransferPage.vue | 85 +-- src/store/accountStore.ts | 8 + src/store/deviceStore.ts | 149 +++++ src/store/entitlementStore.ts | 62 +++ src/store/index.ts | 4 + src/utils/authService.ts | 19 + src/views/setting/plan-section.vue | 98 ++++ tests/common/entitlement.test.ts | 58 ++ tests/store/deviceStore.test.ts | 178 ++++++ 38 files changed, 3007 insertions(+), 266 deletions(-) create mode 100644 src-tauri/src/device_activation.rs create mode 100644 src-tauri/src/device_identity.rs create mode 100644 src-tauri/src/entitlement.rs create mode 100644 src-tauri/src/session.rs create mode 100644 src/common/entitlement.ts create mode 100644 src/components/DeviceReplaceDialog.vue create mode 100644 src/components/upgrade/PaidGate.vue create mode 100644 src/components/upgrade/UpgradeDialog.vue create mode 100644 src/components/upgrade/index.ts create mode 100644 src/components/upgrade/upgradeDialogService.ts create mode 100644 src/store/deviceStore.ts create mode 100644 src/store/entitlementStore.ts create mode 100644 src/utils/authService.ts create mode 100644 src/views/setting/plan-section.vue create mode 100644 tests/common/entitlement.test.ts create mode 100644 tests/store/deviceStore.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21e641b7..f75a3ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Entitlements & version-lock subscription model (geekfun#56)** — client-side implementation of the Ultimate entitlement contract: the two server-computed fields `ultimateExpiresAt` + `versionLockHorizon` are consumed via a new Rust entitlement module (persisted cache, offline tolerance, failure degradation, 5-minute refresh throttle, `app.releaseDate <= versionLockHorizon` unlock check). Rust command gates return `ENTITLEMENT_REQUIRED` for AI (agent loop/step, compaction, LLM validation), the whole Transfer module (import/export/migration/structure execution), SSH tunnel establishment, and the MCP bridge (config/policy save + auto-start). The frontend adds an entitlement store, Geekfun login entry, paid-feature gates with upgrade guidance on Data Studio, Transfer, AI assistant sidebar, ER diagram actions, AI/MCP settings, SSH tunnel option in the connection form, plus an Account & Plan settings tab showing the version-lock state with Geekfun login and logout (logout clears the local entitlement cache so entitlements never outlive the account session). + ## [0.8.7] - 2026-08-17 ### Added diff --git a/package-lock.json b/package-lock.json index 1404e530..717c3db5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "json-with-bigint": "^3.5.8", "json5": "^2.2.3", "lodash": "^4.18.1", + "lucide-vue-next": "^1.0.0", "markdown-it": "^14.2.0", "markdown-it-task-lists": "^2.1.1", "monaco-editor": "^0.55.1", @@ -8229,6 +8230,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-vue-next": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", + "integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==", + "deprecated": "Package deprecated. Please use @lucide/vue instead.", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index 5046952f..b194b47a 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "json-with-bigint": "^3.5.8", "json5": "^2.2.3", "lodash": "^4.18.1", + "lucide-vue-next": "^1.0.0", "markdown-it": "^14.2.0", "markdown-it-task-lists": "^2.1.1", "monaco-editor": "^0.55.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 012e0b2e..c191925d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3510,6 +3510,16 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -6521,7 +6531,9 @@ dependencies = [ "flate2", "futures", "hex", + "hmac 0.12.1", "http", + "keyring", "log", "mockall", "mysql_async", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5543bc0f..00d7d87f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -98,6 +98,8 @@ zip = { version = "2", features = [ "deflate" ] } # TOML parsing (drivers.toml) toml = "0.8" sha2 = "0.10" +hmac = "0.12" +keyring = "3" sqlparser = "0.62" russh = "0.60" axum = "0.8" diff --git a/src-tauri/src/agent_adapters.rs b/src-tauri/src/agent_adapters.rs index e42d2d5d..739e483f 100644 --- a/src-tauri/src/agent_adapters.rs +++ b/src-tauri/src/agent_adapters.rs @@ -37,6 +37,10 @@ pub async fn run_agent_loop( settings: Value, app: AppHandle, ) -> Result<(), String> { + crate::entitlement::ensure_local_ultimate( + &app.state::(), + "AI", + )?; let db_state: State = app.state::(); let store = storage::session_store::SqliteSessionStore::new(db_state.inner().clone()); let emitter = TauriEmitter(app.clone()); @@ -151,6 +155,10 @@ pub async fn compact_agent_session( settings: Value, app: AppHandle, ) -> Result { + crate::entitlement::ensure_local_ultimate( + &app.state::(), + "AI", + )?; let db_state: State = app.state::(); let store = storage::session_store::SqliteSessionStore::new(db_state.inner().clone()); let emitter = TauriEmitter(app.clone()); @@ -185,6 +193,11 @@ pub async fn run_agent_step( api_key: String, base_url: Option, ) -> Result { + use tauri::Manager; + crate::entitlement::ensure_local_ultimate( + &window.state::(), + "AI", + )?; let result = lib::harness::run_agent_step( provider, model, messages, tools, http_proxy, proxy_mode, api_key, base_url, ) @@ -198,6 +211,7 @@ pub async fn run_agent_step( #[tauri::command] pub async fn validate_llm_config( + app: AppHandle, provider: String, api_key: String, model: String, @@ -205,18 +219,27 @@ pub async fn validate_llm_config( proxy_mode: Option, base_url: Option, ) -> Result { + crate::entitlement::ensure_local_ultimate( + &app.state::(), + "AI", + )?; lib::harness::validate_llm_config(provider, api_key, model, http_proxy, proxy_mode, base_url) .await } #[tauri::command] pub async fn list_llm_models( + app: AppHandle, provider: String, api_key: String, http_proxy: Option, proxy_mode: Option, base_url: Option, ) -> Result, String> { + crate::entitlement::ensure_local_ultimate( + &app.state::(), + "AI", + )?; lib::harness::list_llm_models(provider, api_key, http_proxy, proxy_mode, base_url).await } diff --git a/src-tauri/src/commands/helpers.rs b/src-tauri/src/commands/helpers.rs index 56ddca36..7196aa79 100644 --- a/src-tauri/src/commands/helpers.rs +++ b/src-tauri/src/commands/helpers.rs @@ -253,7 +253,10 @@ pub async fn connection_host_port( match start_transport_layers(connection_id, &layers, &config.host, config.port, tunnels).await? { - Some(local_port) => Ok(("127.0.0.1".to_string(), local_port)), + Some(local_port) => { + crate::entitlement::ensure_local_ultimate_global("SSH tunnel")?; + Ok(("127.0.0.1".to_string(), local_port)) + } None => Ok((config.host.clone(), config.port)), } } diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index e98c9525..d1d381bb 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -15,6 +15,7 @@ pub async fn preview_export_data( preview_rows: u32, state: State<'_, AppState>, ) -> Result { + crate::entitlement::ensure_local_ultimate_global("Transfer")?; let connection = state.ensure_connection(&request.connection_id).await?; match connection { @@ -44,6 +45,7 @@ pub async fn execute_export_data( app_handle: AppHandle, state: State<'_, AppState>, ) -> Result { + crate::entitlement::ensure_local_ultimate_global("Transfer")?; let connection = state.ensure_connection(&request.connection_id).await?; match connection { @@ -87,6 +89,7 @@ pub async fn execute_import_data( app_handle: AppHandle, state: State<'_, AppState>, ) -> Result { + crate::entitlement::ensure_local_ultimate_global("Transfer")?; let connection = state.ensure_connection(&request.connection_id).await?; match connection { @@ -115,6 +118,7 @@ pub async fn preview_migration_data( request: MigrationRequest, state: State<'_, AppState>, ) -> Result { + crate::entitlement::ensure_local_ultimate_global("Transfer")?; let source_connection = state .ensure_connection(&request.source_connection_id) .await?; @@ -146,6 +150,7 @@ pub async fn execute_migration_data( app_handle: AppHandle, state: State<'_, AppState>, ) -> Result { + crate::entitlement::ensure_local_ultimate_global("Transfer")?; let source_connection = state .ensure_connection(&request.source_connection_id) .await?; @@ -254,6 +259,7 @@ pub async fn auto_map_migration_columns( target_engine: String, state: State<'_, AppState>, ) -> Result, String> { + crate::entitlement::ensure_local_ultimate_global("Transfer")?; use crate::database::DatabaseAdapter; let connection = state.ensure_connection(&connection_id).await?; @@ -291,6 +297,7 @@ pub async fn generate_ddl_for_objects( request: DdlRequest, state: State<'_, AppState>, ) -> Result { + crate::entitlement::ensure_local_ultimate_global("Transfer")?; let connection = state.ensure_connection(&request.connection_id).await?; let engine = match connection { @@ -422,6 +429,7 @@ pub async fn execute_sql_content( on_error: Option, state: State<'_, AppState>, ) -> Result { + crate::entitlement::ensure_local_ultimate_global("Transfer")?; let connection = state.ensure_connection(&connection_id).await?; let strategy = on_error.as_deref().unwrap_or("stop"); diff --git a/src-tauri/src/device_activation.rs b/src-tauri/src/device_activation.rs new file mode 100644 index 00000000..9755b19a --- /dev/null +++ b/src-tauri/src/device_activation.rs @@ -0,0 +1,309 @@ +//! Device activation (geekfun#59): registers this machine against the +//! account's device ledger at the entitlement-activation point and surfaces +//! the 5030 "limit reached" payload for the replace flow. +//! +//! The command returns `Ok(ActivatedResult)` on success and a structured +//! JSON error string with `error_type: DEVICE_LIMIT_REACHED` when the server +//! answers 5030 — the frontend renders the replace picker from the attached +//! device list. Other failures degrade to plain error strings (never +//! silently swallowed by the caller). + +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::path::PathBuf; +use tauri::{AppHandle, Emitter, State}; + +use crate::device_identity; +use crate::session::{self, SessionState}; + +const CONSOLE_PROD_URL: &str = "https://console-geekfun.wentsen.com"; +const CONSOLE_DEV_URL: &str = "http://localhost:5174"; +const HTTP_TIMEOUT_SECS: u64 = 10; + +pub const DEVICE_LIMIT_ERROR_TYPE: &str = "DEVICE_LIMIT_REACHED"; + +fn api_base_url() -> &'static str { + if cfg!(debug_assertions) { + CONSOLE_DEV_URL + } else { + CONSOLE_PROD_URL + } +} + +pub struct DeviceIdentityState { + app_data_dir: PathBuf, +} + +impl DeviceIdentityState { + pub fn load(app_data_dir: PathBuf) -> Self { + DeviceIdentityState { app_data_dir } + } + + pub fn payload(&self) -> device_identity::DevicePayload { + device_identity::build_payload(&self.app_data_dir) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceDto { + pub id: String, + pub name: String, + pub platform: String, + #[serde(default)] + pub activated_at: Option, + #[serde(default)] + pub last_seen_at: Option, + #[serde(default)] + pub is_current: bool, + #[serde(default)] + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceLimitInfo { + pub limit: u32, + pub used: u32, + #[serde(default)] + pub devices: Vec, + #[serde(default)] + pub manage_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivatedResult { + pub device_id: String, + pub limit: u32, + pub used: u32, + /// Device-bound 30-day lease — persisted into the session store. + /// The backend keeps `access_token`-style snake_case for token fields. + #[serde(rename = "refresh_token", default)] + pub refresh_token: Option, +} + +fn device_limit_error(info: &DeviceLimitInfo) -> String { + json!({ + "error_type": DEVICE_LIMIT_ERROR_TYPE, + "code": 5030, + "limit_reached": info, + }) + .to_string() +} + +/// Parse the `{code, messages, data}` envelope; `Ok(None)` means the body +/// could not be interpreted as an envelope at all. +fn parse_envelope(payload: serde_json::Value) -> Option<(u32, Vec, serde_json::Value)> { + let code = payload.get("code").and_then(|v| v.as_u64())? as u32; + let messages = payload + .get("messages") + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + Some((code, messages, payload.get("data").cloned().unwrap_or_default())) +} + +/// Outcome of a single activation HTTP attempt. +enum ActivateAttempt { + Ok(ActivatedResult), + LimitReached(DeviceLimitInfo), + /// 401 — the access token is stale; a session refresh may recover. + Unauthorized, + Failed(String), +} + +async fn post_activate( + token: &str, + payload: &device_identity::DevicePayload, + replace_device_id: Option<&str>, +) -> ActivateAttempt { + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) + .build() + { + Ok(client) => client, + Err(e) => return ActivateAttempt::Failed(format!("failed to build http client: {e}")), + }; + let url = format!("{}/api/v1/devices/activate", api_base_url()); + + let mut body = json!({ "device": payload }); + if let Some(replace) = replace_device_id { + body["replaceDeviceId"] = json!(replace); + } + + let response = match client.post(url).bearer_auth(token).json(&body).send().await { + Ok(response) => response, + Err(e) => return ActivateAttempt::Failed(format!("network error: {e}")), + }; + + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return ActivateAttempt::Unauthorized; + } + + let raw: serde_json::Value = match response.json().await { + Ok(raw) => raw, + Err(e) => return ActivateAttempt::Failed(format!("invalid activation payload: {e}")), + }; + + let Some((code, messages, data)) = parse_envelope(raw) else { + return ActivateAttempt::Failed("activation endpoint returned an invalid envelope".into()); + }; + + if code == 2000 { + return match serde_json::from_value::(data) { + Ok(parsed) => ActivateAttempt::Ok(parsed), + Err(e) => ActivateAttempt::Failed(format!("invalid activation result: {e}")), + }; + } + if code == 5030 { + return match serde_json::from_value::(data) { + Ok(info) => ActivateAttempt::LimitReached(info), + Err(e) => ActivateAttempt::Failed(format!("invalid 5030 payload: {e}")), + }; + } + ActivateAttempt::Failed( + messages + .first() + .cloned() + .unwrap_or_else(|| format!("activation failed (code {code})")), + ) +} + +/// Activate this device for the account. Idempotent on the server — safe to +/// call at every entitlement-activation point. A stale access token (401) +/// is transparently recovered via the stored refresh lease, and the new +/// access token is broadcast so the frontend session stays in sync. +#[tauri::command] +pub async fn activate_device( + token: String, + replace_device_id: Option, + state: State<'_, DeviceIdentityState>, + session_state: State<'_, SessionState>, + app: AppHandle, +) -> Result { + let token = token.trim().to_string(); + if token.is_empty() { + return Err("not logged in".to_string()); + } + let payload = state.payload(); + + let mut attempt = post_activate(&token, &payload, replace_device_id.as_deref()).await; + if let ActivateAttempt::Unauthorized = attempt { + if let Ok(refreshed) = session::rotate_session(&session_state, &payload).await { + let _ = app.emit("session-refreshed", refreshed.access_token.clone()); + attempt = post_activate( + &refreshed.access_token, + &payload, + replace_device_id.as_deref(), + ) + .await; + } + } + + match attempt { + ActivateAttempt::Ok(result) => { + if let Some(refresh_token) = &result.refresh_token { + session::persist_token(&session_state, refresh_token); + } + Ok(result) + } + ActivateAttempt::LimitReached(info) => Err(device_limit_error(&info)), + ActivateAttempt::Unauthorized => Err("session expired — please sign in again".to_string()), + ActivateAttempt::Failed(message) => Err(message), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn envelope_parsing_handles_code_messages_data() { + let raw = json!({ + "code": 2000, + "messages": ["操作成功"], + "data": { "deviceId": "dev_1", "limit": 3, "used": 1 }, + }); + let (code, messages, data) = parse_envelope(raw).expect("envelope"); + assert_eq!(code, 2000); + assert_eq!(messages, vec!["操作成功".to_string()]); + assert_eq!(data["deviceId"], "dev_1"); + } + + #[test] + fn limit_reached_error_carries_the_device_list() { + let info = DeviceLimitInfo { + limit: 3, + used: 3, + devices: vec![DeviceDto { + id: "dev_1".to_string(), + name: "Old Mac".to_string(), + platform: "macos".to_string(), + activated_at: None, + last_seen_at: None, + is_current: false, + status: "active".to_string(), + }], + manage_url: Some("https://console/home/devices".to_string()), + }; + let raw = device_limit_error(&info); + assert!(raw.contains(DEVICE_LIMIT_ERROR_TYPE)); + assert!(raw.contains("Old Mac")); + } + + #[test] + fn device_dto_deserializes_camel_case_dates() { + let raw = serde_json::json!({ + "id": "dev_1", + "name": "MacBook Pro", + "platform": "macos", + "activatedAt": "2026-09-01T00:00:00.000Z", + "lastSeenAt": null, + "isCurrent": true, + "status": "active", + }); + let dto: DeviceDto = serde_json::from_value(raw).expect("dto"); + assert!(dto.is_current); + assert_eq!(dto.activated_at.as_deref(), Some("2026-09-01T00:00:00.000Z")); + assert!(dto.last_seen_at.is_none()); + } + + #[test] + fn activated_result_carries_the_optional_refresh_lease() { + let raw = json!({ + "deviceId": "dev_9", + "limit": 3, + "used": 3, + "refresh_token": "opaque-lease", + }); + let result: ActivatedResult = serde_json::from_value(raw).expect("result"); + assert_eq!(result.refresh_token.as_deref(), Some("opaque-lease")); + + // tolerate responses without the lease (older backends) + let bare: ActivatedResult = + serde_json::from_value(json!({ "deviceId": "d", "limit": 3, "used": 1 })) + .expect("bare"); + assert!(bare.refresh_token.is_none()); + } + + #[test] + fn payload_bodies_carry_the_device_and_optional_replace_target() { + let identity = device_identity::RawIdentity { + primary: Some("PLATFORM".to_string()), + secondary: vec![], + is_virtual: false, + }; + let composed = device_identity::compose_payload(&identity, "install", "n", "linux"); + + let mut without_replace = json!({ "device": composed }); + assert!(without_replace.get("replaceDeviceId").is_none()); + without_replace["replaceDeviceId"] = json!("dev_9"); + assert_eq!(without_replace["replaceDeviceId"], "dev_9"); + } +} diff --git a/src-tauri/src/device_identity.rs b/src-tauri/src/device_identity.rs new file mode 100644 index 00000000..798ff607 --- /dev/null +++ b/src-tauri/src/device_identity.rs @@ -0,0 +1,423 @@ +//! Device identity & fingerprint (geekfun#59). +//! +//! Multi-source hardware identifiers are HMAC'd with an app-specific key +//! before leaving the device — the server treats fingerprints and component +//! hashes as opaque strings and never sees raw identifiers. Sources follow +//! the design matrix: macOS `IOPlatformUUID` (survives reinstall), Windows +//! SMBIOS UUID (survives) + `MachineGuid` (resets on reinstall), Linux +//! `machine-id` (resets) + DMI `product_uuid`. Virtual machines derive their +//! identity from the platform instance UUID + a per-install random ID so +//! clones diverge into separate slots (design Q5-b). + +use std::collections::BTreeMap; +use std::path::Path; + +use hmac::{Hmac, Mac}; +use serde::Serialize; +use sha2::Sha256; +use uuid::Uuid; + +type HmacSha256 = Hmac; + +/// App-specific fingerprint key (SqlKit). Rotating it forks device identity +/// for every install — only change together with a coordinated release. +pub const DEVICE_HMAC_KEY: &str = "geekfun/sqlkit/device-identity/v1"; + +const INSTALL_ID_FILE: &str = "device-install-id"; + +pub fn hmac_hex(value: &str) -> String { + let mut mac = HmacSha256::new_from_slice(DEVICE_HMAC_KEY.as_bytes()) + .expect("HMAC accepts any key length"); + mac.update(value.as_bytes()); + hex_encode(&mac.finalize().into_bytes()) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +// ─── raw sources (per OS) ──────────────────────────────────────────────────── + +#[cfg(target_os = "macos")] +pub fn platform_uuid() -> Option { + let output = std::process::Command::new("ioreg") + .args(["-rd1", "-c", "IOPlatformExpertDevice"]) + .output() + .ok()?; + parse_ioreg_value(&String::from_utf8_lossy(&output.stdout), "IOPlatformUUID") +} + +#[cfg(target_os = "macos")] +pub fn hardware_serial() -> Option { + let output = std::process::Command::new("ioreg") + .args(["-rd1", "-c", "IOPlatformExpertDevice"]) + .output() + .ok()?; + parse_ioreg_value(&String::from_utf8_lossy(&output.stdout), "IOPlatformSerialNumber") +} + +#[cfg(target_os = "macos")] +fn parse_ioreg_value(text: &str, key: &str) -> Option { + let quoted = format!("\"{key}\""); + text.lines() + .find(|line| line.contains("ed)) + .and_then(|line| line.rsplit('=').next()) + .map(|value| { + value + .trim() + .trim_end_matches(';') + .trim() + .trim_matches('"') + .to_string() + }) + .filter(|value| !value.is_empty()) +} + +#[cfg(target_os = "windows")] +pub fn platform_uuid() -> Option { + command_line("csproduct get UUID").or_else(|| { + powershell("(Get-CimInstance Win32_ComputerSystemProduct).UUID.Value") + }) +} + +#[cfg(target_os = "windows")] +pub fn machine_guid() -> Option { + let output = std::process::Command::new("reg") + .args([ + "query", + r"HKLM\SOFTWARE\Microsoft\Cryptography", + "/v", + "MachineGuid", + ]) + .output() + .ok()?; + String::from_utf8_lossy(&output.stdout) + .lines() + .find(|line| line.contains("MachineGuid")) + .and_then(|line| line.rsplit(|c| c == ' ').find(|part| !part.is_empty())) + .map(str::to_string) + .filter(|value| !value.is_empty()) +} + +#[cfg(target_os = "windows")] +pub fn system_manufacturer() -> Option { + command_line("computersystem get Manufacturer").or_else(|| { + powershell("(Get-CimInstance Win32_ComputerSystem).Manufacturer.Value") + }) +} + +#[cfg(target_os = "windows")] +fn command_line(wmic_args: &str) -> Option { + let mut args = vec!["/value:off", "/header:off"]; + args.extend(wmic_args.split_whitespace()); + let output = std::process::Command::new("wmic").args(args).output().ok()?; + parse_value_output(&String::from_utf8_lossy(&output.stdout)) +} + +#[cfg(target_os = "windows")] +fn powershell(script: &str) -> Option { + let output = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", script]) + .output() + .ok()?; + parse_value_output(&String::from_utf8_lossy(&output.stdout)) +} + +#[cfg(target_os = "windows")] +fn parse_value_output(stdout: &str) -> Option { + stdout + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_string) +} + +#[cfg(target_os = "linux")] +pub fn platform_uuid() -> Option { + read_trimmed("/sys/class/dmi/id/product_uuid") + .or_else(|| read_trimmed("/etc/machine-id")) + .or_else(|| read_trimmed("/var/lib/dbus/machine-id")) +} + +#[cfg(target_os = "linux")] +pub fn machine_id() -> Option { + read_trimmed("/etc/machine-id").or_else(|| read_trimmed("/var/lib/dbus/machine-id")) +} + +#[cfg(target_os = "linux")] +pub fn system_vendor() -> Option { + read_trimmed("/sys/class/dmi/id/sys_vendor") +} + +#[cfg(target_os = "linux")] +fn read_trimmed(path: &str) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|raw| raw.trim().to_string()) + .filter(|raw| !raw.is_empty()) +} + +fn hostname() -> String { + let detected = detect_hostname(); + detected.unwrap_or_else(|| "Unknown".to_string()) +} + +#[cfg(target_os = "macos")] +fn detect_hostname() -> Option { + std::process::Command::new("sysctl") + .args(["-n", "kern.hostname"]) + .output() + .ok() + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|name| !name.is_empty()) +} + +#[cfg(target_os = "linux")] +fn detect_hostname() -> Option { + read_trimmed("/proc/sys/kernel/hostname").or_else(|| { + std::process::Command::new("hostname") + .output() + .ok() + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|name| !name.is_empty()) + }) +} + +#[cfg(target_os = "windows")] +fn detect_hostname() -> Option { + std::env::var("COMPUTERNAME").ok().filter(|name| !name.is_empty()) +} + +pub const CURRENT_PLATFORM: &str = if cfg!(target_os = "macos") { + "macos" +} else if cfg!(target_os = "windows") { + "windows" +} else { + "linux" +}; + +#[cfg(target_os = "macos")] +fn detect_virtual() -> bool { + std::process::Command::new("sysctl") + .args(["-n", "hw.model"]) + .output() + .ok() + .map(|output| String::from_utf8_lossy(&output.stdout).to_lowercase()) + .map(|model| { + ["virtualmac", "vmware", "parallels", "kvm", "qemu"] + .iter() + .any(|marker| model.contains(marker)) + }) + .unwrap_or(false) +} + +#[cfg(any(target_os = "linux", target_os = "windows"))] +fn detect_virtual() -> bool { + let vendor = if cfg!(target_os = "linux") { + system_vendor() + } else { + system_manufacturer() + }; + vendor + .map(|vendor| vendor.to_lowercase()) + .map(|vendor| { + ["qemu", "kvm", "vmware", "virtualbox", "xen", "microsoft", "parallels"] + .iter() + .any(|marker| vendor.contains(marker)) + }) + .unwrap_or(false) +} + +// ─── identity composition ──────────────────────────────────────────────────── + +/// Install-scoped random ID: makes clones that preserve every hardware +/// identifier (full VM clones) diverge into separate device slots. +pub fn load_or_create_install_id(app_data_dir: &Path) -> String { + let path = app_data_dir.join(INSTALL_ID_FILE); + if let Ok(existing) = std::fs::read_to_string(&path) { + let trimmed = existing.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + let fresh = Uuid::new_v4().to_string(); + if let Err(err) = std::fs::write(&path, &fresh) { + log::warn!("failed to persist device install id: {err}"); + } + fresh +} + +pub struct RawIdentity { + /// Most stable identifier for this machine (survives reinstall). + pub primary: Option, + /// Weaker sources — used as primary fallback and as fuzzy-match drift. + pub secondary: Vec<(String, String)>, + pub is_virtual: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DevicePayload { + pub fingerprint: String, + pub components: BTreeMap, + pub name: String, + pub platform: String, + pub is_virtual: bool, +} + +/// Pure composition (unit-testable): primary survives reinstall; VMs mix in +/// the install id so clones diverge; missing sources never cause a failure — +/// the install id alone still yields a stable identity (design F5). +pub fn compose_payload( + identity: &RawIdentity, + install_id: &str, + name: &str, + platform: &str, +) -> DevicePayload { + let mut components: BTreeMap = identity + .secondary + .iter() + .map(|(source, value)| (source.clone(), hmac_hex(value))) + .collect(); + components.insert("install".to_string(), hmac_hex(install_id)); + + let primary = match identity.primary.as_deref() { + Some(primary) if identity.is_virtual => format!("vm|{primary}|{install_id}"), + Some(primary) => primary.to_string(), + None => format!("fallback|{install_id}"), + }; + + DevicePayload { + fingerprint: hmac_hex(&primary), + components, + // Console-replaceable label; hostnames are already short but stay safe. + name: name.chars().take(100).collect(), + platform: platform.to_string(), + is_virtual: identity.is_virtual, + } +} + +pub fn build_payload(app_data_dir: &Path) -> DevicePayload { + let install_id = load_or_create_install_id(app_data_dir); + let identity = collect_identity(); + compose_payload(&identity, &install_id, &hostname(), CURRENT_PLATFORM) +} + +#[cfg(target_os = "macos")] +fn collect_identity() -> RawIdentity { + let mut secondary: Vec<(String, String)> = Vec::new(); + if let Some(serial) = hardware_serial() { + secondary.push(("serial".to_string(), serial)); + } + RawIdentity { + primary: platform_uuid(), + secondary, + is_virtual: detect_virtual(), + } +} + +#[cfg(target_os = "windows")] +fn collect_identity() -> RawIdentity { + let mut secondary: Vec<(String, String)> = Vec::new(); + if let Some(guid) = machine_guid() { + secondary.push(("machineGuid".to_string(), guid)); + } + RawIdentity { + primary: platform_uuid(), + secondary, + is_virtual: detect_virtual(), + } +} + +#[cfg(target_os = "linux")] +fn collect_identity() -> RawIdentity { + let machine_id = machine_id(); + let mut secondary: Vec<(String, String)> = Vec::new(); + if let Some(id) = machine_id.as_ref() { + secondary.push(("machineId".to_string(), id.clone())); + } + // product_uuid is root-readable only on many distros — machine-id is the + // primary when DMI is unavailable. + let primary = read_trimmed("/sys/class/dmi/id/product_uuid").or(machine_id); + RawIdentity { + primary, + secondary, + is_virtual: detect_virtual(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hmac_is_deterministic_hex() { + let first = hmac_hex("stable-identifier"); + let second = hmac_hex("stable-identifier"); + assert_eq!(first, second); + assert_eq!(first.len(), 64); + assert!(first.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(hmac_hex("a"), hmac_hex("b")); + } + + #[test] + fn components_are_hashed_and_install_always_present() { + let identity = RawIdentity { + primary: Some("PLATFORM-UUID".to_string()), + secondary: vec![("serial".to_string(), "C02X1234".to_string())], + is_virtual: false, + }; + let payload = compose_payload(&identity, "install-id-1", "MacBook Pro", "macos"); + + assert_eq!(payload.components.len(), 2); + assert_eq!( + payload.components.get("serial"), + Some(&hmac_hex("C02X1234")) + ); + assert_eq!( + payload.components.get("install"), + Some(&hmac_hex("install-id-1")) + ); + // The fingerprint never exposes the raw identifier. + assert_eq!(payload.fingerprint, hmac_hex("PLATFORM-UUID")); + assert!(!payload.fingerprint.contains("PLATFORM-UUID")); + } + + #[test] + fn virtual_machines_diverge_from_the_host_and_from_clones() { + let host = RawIdentity { + primary: Some("PLATFORM-UUID".to_string()), + secondary: vec![], + is_virtual: false, + }; + let vm = RawIdentity { + primary: Some("PLATFORM-UUID".to_string()), + secondary: vec![], + is_virtual: true, + }; + + let host_payload = compose_payload(&host, "install-1", "MacBook Pro", "macos"); + let vm_payload = compose_payload(&vm, "install-1", "VM", "macos"); + let vm_clone = compose_payload(&vm, "install-2", "VM", "macos"); + + assert_ne!(host_payload.fingerprint, vm_payload.fingerprint); + // A full clone that preserves the platform UUID still diverges via the + // install-scoped random id. + assert_ne!(vm_payload.fingerprint, vm_clone.fingerprint); + assert!(vm_payload.is_virtual); + } + + #[test] + fn missing_sources_still_yield_a_stable_identity() { + let bare = RawIdentity { + primary: None, + secondary: vec![], + is_virtual: false, + }; + let payload = compose_payload(&bare, "install-only", "Linux box", "linux"); + + assert_eq!(payload.fingerprint, hmac_hex("fallback|install-only")); + assert_eq!(payload.components.len(), 1); + assert_eq!(payload.name, "Linux box"); + } +} diff --git a/src-tauri/src/entitlement.rs b/src-tauri/src/entitlement.rs new file mode 100644 index 00000000..a8ac54b0 --- /dev/null +++ b/src-tauri/src/entitlement.rs @@ -0,0 +1,507 @@ +//! Entitlement & version-lock resolution (geekfun#56 contract). +//! +//! The server owns all judgement semantics; the client only reads two +//! server-computed fields from `GET /api/v1/subscriptions`: +//! `ultimateExpiresAt` (active benefit, covers trials) and +//! `versionLockHorizon` (monotonic high-water mark of paid periods, +//! trials excluded). Local Ultimate features unlock while +//! `ultimateActive || versionLocked`; cloud services require +//! `ultimateActive` regardless of the version lock. + +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tauri::{AppHandle, Emitter, State}; + +/// Release date of the running app version (UTC, `YYYY-MM-DD`). Bump on +/// every release; a version stays unlocked forever when +/// `APP_RELEASE_DATE <= versionLockHorizon`. +pub const APP_RELEASE_DATE: &str = "2026-09-12"; + +/// Minimum interval between two network refreshes (contract rule 2). +pub const REFRESH_MIN_INTERVAL_MS: i64 = 5 * 60 * 1000; + +const CONSOLE_PROD_URL: &str = "https://console-geekfun.wentsen.com"; +const CONSOLE_DEV_URL: &str = "http://localhost:5174"; +const HTTP_TIMEOUT_SECS: u64 = 10; + +pub const ENTITLEMENT_ERROR_TYPE: &str = "ENTITLEMENT_REQUIRED"; + +enum SubscriptionsError { + /// 401 — the access token expired; a session refresh may recover. + Unauthorized, + Other(String), +} + +fn subscriptions_base_url() -> &'static str { + if cfg!(debug_assertions) { + CONSOLE_DEV_URL + } else { + CONSOLE_PROD_URL + } +} + +/// Days since 1970-01-01 for a civil UTC date (Howard Hinnant's algorithm). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// Parse an RFC3339 timestamp (`Z` or `±HH:MM` offset, optional fractional +/// seconds) into unix milliseconds. +pub fn parse_rfc3339_ms(value: &str) -> Option { + let value = value.trim(); + let bytes = value.as_bytes(); + if bytes.len() < 19 || bytes[4] != b'-' || bytes[7] != b'-' { + return None; + } + let year: i64 = value.get(0..4)?.parse().ok()?; + let month: i64 = value.get(5..7)?.parse().ok()?; + let day: i64 = value.get(8..10)?.parse().ok()?; + let hour: i64 = value.get(11..13)?.parse().ok()?; + let minute: i64 = value.get(14..16)?.parse().ok()?; + let second: i64 = value.get(17..19)?.parse().ok()?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + + let mut rest = value.get(19..)?; + let mut millis = 0i64; + if rest.starts_with('.') { + let frac_end = rest[1..] + .find(|c: char| !c.is_ascii_digit()) + .map(|i| i + 1) + .unwrap_or(rest.len()); + let digits = &rest[1..frac_end]; + if digits.is_empty() { + return None; + } + let scaled = format!("{:0<3}", &digits[..digits.len().min(3)]); + millis = scaled.parse().ok()?; + rest = &rest[frac_end..]; + } + + let offset_ms = match rest { + "" | "Z" | "z" => 0, + _ => { + let sign = match rest.as_bytes()[0] { + b'+' => 1, + b'-' => -1, + _ => return None, + }; + let offset = &rest[1..]; + if offset.len() != 5 || offset.as_bytes()[2] != b':' { + return None; + } + let oh: i64 = offset.get(0..2)?.parse().ok()?; + let om: i64 = offset.get(3..5)?.parse().ok()?; + sign * (oh * 3600 + om * 60) * 1000 + } + }; + + Some( + (days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second) * 1000 + + millis + - offset_ms, + ) +} + +/// Parse a `YYYY-MM-DD` UTC date into unix milliseconds (start of day). +pub fn parse_date_utc_ms(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.len() != 10 { + return parse_rfc3339_ms(trimmed); + } + let year: i64 = trimmed.get(0..4)?.parse().ok()?; + let month: i64 = trimmed.get(5..7)?.parse().ok()?; + let day: i64 = trimmed.get(8..10)?.parse().ok()?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + Some(days_from_civil(year, month, day) * 86_400 * 1000) +} + +pub fn now_unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SubscriptionCache { + pub fetched_at_ms: i64, + #[serde(rename = "ultimateExpiresAt")] + pub ultimate_expires_at: Option, + #[serde(rename = "versionLockHorizon")] + pub version_lock_horizon: Option, + #[serde(rename = "cancelScheduledAt")] + pub cancel_scheduled_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EntitlementView { + /// Active benefit (paid period or trial) — gates cloud services and, + /// while active, local features too. + pub ultimate_active: bool, + /// This app release falls under the version lock — permanent offline + /// access to local Ultimate features. + pub version_locked: bool, + /// `ultimateActive || versionLocked` — the gate for local features. + pub local_ultimate: bool, + pub app_release_date: &'static str, + pub ultimate_expires_at: Option, + pub version_lock_horizon: Option, + pub cancel_scheduled_at: Option, + /// True when the view was derived from the cached last success instead + /// of a fresh network response (offline / degraded / throttled). + pub cached: bool, + pub fetched_at_ms: Option, + pub last_error: Option, +} + +/// Pure entitlement decision. Fail-closed: an unparseable release date or +/// missing horizon locks the release out of version-locked features. +pub fn compute_entitlement(cache: Option<&SubscriptionCache>, now_ms: i64) -> EntitlementView { + let release_ms = parse_date_utc_ms(APP_RELEASE_DATE); + let ultimate_active = cache + .and_then(|c| c.ultimate_expires_at.as_deref()) + .and_then(parse_rfc3339_ms) + .is_some_and(|expires_at| expires_at > now_ms); + let version_locked = match (release_ms, cache) { + (Some(release_ms), Some(cache)) => cache + .version_lock_horizon + .as_deref() + .and_then(parse_rfc3339_ms) + .is_some_and(|horizon| release_ms <= horizon), + _ => false, + }; + EntitlementView { + ultimate_active, + version_locked, + local_ultimate: ultimate_active || version_locked, + app_release_date: APP_RELEASE_DATE, + ultimate_expires_at: cache.and_then(|c| c.ultimate_expires_at.clone()), + version_lock_horizon: cache.and_then(|c| c.version_lock_horizon.clone()), + cancel_scheduled_at: cache.and_then(|c| c.cancel_scheduled_at.clone()), + cached: true, + fetched_at_ms: cache.map(|c| c.fetched_at_ms), + last_error: None, + } +} + +pub struct EntitlementState { + cache: Mutex>, + last_success_ms: Mutex, + cache_path: Mutex>, +} + +impl EntitlementState { + pub fn load(cache_path: Option) -> Self { + let cache = cache_path.as_ref().and_then(|path| { + std::fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + }); + EntitlementState { + cache: Mutex::new(cache), + last_success_ms: Mutex::new(0), + cache_path: Mutex::new(cache_path), + } + } + + fn set_cache(&self, cache: SubscriptionCache) { + if let Ok(raw) = serde_json::to_string(&cache) { + if let Ok(path) = self.cache_path.lock() { + if let Some(path) = path.as_ref() { + let _ = std::fs::write(path, raw); + } + } + } + *self.cache.lock().unwrap_or_else(|e| e.into_inner()) = Some(cache); + *self + .last_success_ms + .lock() + .unwrap_or_else(|e| e.into_inner()) = now_unix_ms(); + } + + pub fn view(&self, cached: bool, last_error: Option) -> EntitlementView { + let binding = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + let mut view = compute_entitlement(binding.as_ref(), now_unix_ms()); + view.cached = cached; + view.last_error = last_error; + view + } + + pub fn local_entitled(&self) -> bool { + self.view(true, None).local_ultimate + } + + /// Wipe the cached entitlement (logout): memory + persisted cache file. + pub fn clear(&self) { + *self.cache.lock().unwrap_or_else(|e| e.into_inner()) = None; + *self + .last_success_ms + .lock() + .unwrap_or_else(|e| e.into_inner()) = 0; + if let Ok(path) = self.cache_path.lock() { + if let Some(path) = path.as_ref() { + let _ = std::fs::remove_file(path); + } + } + } + + fn last_success_ms(&self) -> i64 { + *self + .last_success_ms + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + fn refresh_throttled(&self) -> bool { + now_unix_ms() - self.last_success_ms() < REFRESH_MIN_INTERVAL_MS + } +} + +pub fn entitlement_required_error(feature: &str) -> String { + json!({ + "status": 403, + "error_type": ENTITLEMENT_ERROR_TYPE, + "message": format!("'{feature}' requires an Ultimate subscription"), + }) + .to_string() +} + +/// Rust-side gate for local Ultimate features. Commands return the +/// `ENTITLEMENT_REQUIRED` error and the frontend only guides. +pub fn ensure_local_ultimate(state: &EntitlementState, feature: &str) -> Result<(), String> { + if state.local_entitled() { + Ok(()) + } else { + Err(entitlement_required_error(feature)) + } +} + +/// Gate for commands without direct access to managed state — resolves the +/// entitlement state through the global app handle. Fails closed. +pub fn ensure_local_ultimate_global(feature: &str) -> Result<(), String> { + use tauri::Manager; + match crate::APP_HANDLE + .get() + .and_then(|handle| handle.try_state::()) + { + Some(state) => ensure_local_ultimate(&state, feature), + None => Err(entitlement_required_error(feature)), + } +} + +async fn fetch_subscriptions(token: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) + .build() + .map_err(|e| SubscriptionsError::Other(format!("failed to build http client: {e}")))?; + let url = format!("{}/api/v1/subscriptions", subscriptions_base_url()); + let response = client + .get(url) + .bearer_auth(token) + .send() + .await + .map_err(|e| SubscriptionsError::Other(format!("network error: {e}")))?; + let status = response.status(); + if !status.is_success() { + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(SubscriptionsError::Unauthorized); + } + return Err(SubscriptionsError::Other(format!( + "subscriptions endpoint returned HTTP {status}" + ))); + } + let payload: serde_json::Value = response + .json() + .await + .map_err(|e| SubscriptionsError::Other(format!("invalid subscriptions payload: {e}")))?; + let field = |name: &str| { + payload + .get(name) + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + Ok(SubscriptionCache { + fetched_at_ms: now_unix_ms(), + ultimate_expires_at: field("ultimateExpiresAt"), + version_lock_horizon: field("versionLockHorizon"), + cancel_scheduled_at: payload + .get("subscription") + .and_then(|s| s.get("cancelScheduledAt")) + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + .map(str::to_string), + }) +} + +/// Refresh entitlements from the server. Network/5xx failures degrade to the +/// cached view (contract rule 5) — only an invalid input is a hard error. +#[tauri::command] +pub async fn refresh_entitlement( + token: String, + force: bool, + state: State<'_, EntitlementState>, + session_state: State<'_, crate::session::SessionState>, + identity: State<'_, crate::device_activation::DeviceIdentityState>, + app: AppHandle, +) -> Result { + if token.trim().is_empty() { + return Ok(state.view(true, Some("not logged in".to_string()))); + } + if !force && state.refresh_throttled() { + return Ok(state.view(true, None)); + } + match fetch_subscriptions(token.trim()).await { + Ok(cache) => { + state.set_cache(cache); + Ok(state.view(false, None)) + } + Err(SubscriptionsError::Unauthorized) => { + match crate::session::rotate_session(&session_state, &identity.payload()).await { + Ok(refreshed) => { + let _ = app.emit("session-refreshed", refreshed.access_token.clone()); + match fetch_subscriptions(&refreshed.access_token).await { + Ok(cache) => { + state.set_cache(cache); + Ok(state.view(false, None)) + } + Err(_) => Ok(state.view(true, Some("session refresh failed".to_string()))), + } + } + Err(err) => Ok(state.view(true, Some(err))), + } + } + Err(SubscriptionsError::Other(err)) => Ok(state.view(true, Some(err))), + } +} + +#[tauri::command] +pub fn get_entitlement(state: State<'_, EntitlementState>) -> EntitlementView { + state.view(true, None) +} + +/// Called on logout — entitlements are account-scoped, so the cached state +/// must not outlive the session (a different account on the same machine +/// must not inherit the previous account's version lock). +#[tauri::command] +pub fn clear_entitlement(state: State<'_, EntitlementState>) -> EntitlementView { + state.clear(); + state.view(true, None) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: i64 = 1_760_000_000_000; // 2025-10-09T06:13:20Z + + fn cache(ultimate: Option<&str>, horizon: Option<&str>) -> Option { + Some(SubscriptionCache { + fetched_at_ms: NOW, + ultimate_expires_at: ultimate.map(str::to_string), + version_lock_horizon: horizon.map(str::to_string), + cancel_scheduled_at: None, + }) + } + + #[test] + fn parse_rfc3339_handles_utc_millis_and_offsets() { + assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00Z"), Some(0)); + assert_eq!( + parse_rfc3339_ms("2026-10-09T00:00:00.000Z"), + Some(1_791_504_000_000) + ); + assert_eq!( + parse_rfc3339_ms("2026-10-09T00:00:00Z"), + parse_rfc3339_ms("2026-10-09T08:00:00+08:00") + ); + assert_eq!( + parse_rfc3339_ms("2026-10-09T00:00:00.5Z"), + Some(1_791_504_000_500) + ); + assert_eq!(parse_rfc3339_ms("not-a-date"), None); + assert_eq!(parse_rfc3339_ms("2026-13-09T00:00:00Z"), None); + } + + #[test] + fn trial_is_active_but_never_version_locked() { + let view = compute_entitlement( + cache(Some("2026-10-09T00:00:00.000Z"), None).as_ref(), + NOW, + ); + assert!(view.ultimate_active); + assert!(!view.version_locked); + assert!(view.local_ultimate); + } + + #[test] + fn expired_trial_without_horizon_falls_back_to_free() { + let view = compute_entitlement( + cache(Some("2020-01-01T00:00:00.000Z"), None).as_ref(), + NOW, + ); + assert!(!view.ultimate_active); + assert!(!view.version_locked); + assert!(!view.local_ultimate); + } + + #[test] + fn expired_subscription_stays_version_locked_for_older_releases() { + let view = compute_entitlement( + cache( + Some("2020-01-01T00:00:00.000Z"), + Some("2027-01-01T00:00:00.000Z"), + ) + .as_ref(), + NOW, + ); + assert!(!view.ultimate_active); + assert!(view.version_locked); + assert!(view.local_ultimate); + } + + #[test] + fn releases_after_the_horizon_need_renewal() { + let view = compute_entitlement( + cache(None, Some("2020-01-01T00:00:00.000Z")).as_ref(), + NOW, + ); + assert!(view.version_locked == (parse_date_utc_ms(APP_RELEASE_DATE).unwrap() <= 0)); + let fresh = compute_entitlement( + cache(None, Some("2999-01-01T00:00:00.000Z")).as_ref(), + NOW, + ); + assert!(fresh.version_locked); + assert!(fresh.local_ultimate); + } + + #[test] + fn no_cache_means_community_mode() { + let view = compute_entitlement(None, NOW); + assert!(!view.ultimate_active); + assert!(!view.version_locked); + assert!(!view.local_ultimate); + } + + #[test] + fn entitlement_error_is_structured() { + let raw = entitlement_required_error("AI"); + assert!(raw.contains(ENTITLEMENT_ERROR_TYPE)); + assert!(raw.contains("AI")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f111122e..5e278179 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,6 +22,10 @@ pub mod agent_adapters; pub mod capabilities; pub mod common; pub mod db; +pub mod device_activation; +pub mod device_identity; +pub mod entitlement; +pub mod session; pub mod mcp_bridge; use std::sync::Arc; @@ -151,8 +155,21 @@ pub fn run() { .app_data_dir() .map_err(|e| format!("{}", e))? .to_path_buf(); + let entitlement_state = crate::entitlement::EntitlementState::load(Some( + app_data_dir.join("entitlement-cache.json"), + )); + app.manage(entitlement_state); + app.manage(crate::device_activation::DeviceIdentityState::load( + app_data_dir.clone(), + )); + app.manage(crate::session::SessionState { + app_data_dir: app_data_dir.clone(), + }); let config = crate::mcp_bridge::McpConfig::load(&app_data_dir); - if config.auto_start { + let mcp_entitled = app + .state::() + .local_entitled(); + if config.auto_start && mcp_entitled { let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let server_handle: tauri::State<'_, crate::mcp_bridge::McpServerHandle> = app.state(); @@ -330,6 +347,12 @@ pub fn run() { commands::generate_ddl_for_objects, commands::execute_sql_content, commands::get_app_version, + crate::entitlement::refresh_entitlement, + crate::entitlement::get_entitlement, + crate::entitlement::clear_entitlement, + crate::device_activation::activate_device, + crate::session::refresh_session, + crate::session::clear_session, crate::mcp_bridge::get_mcp_status, crate::mcp_bridge::save_mcp_config, crate::mcp_bridge::save_mcp_policy, diff --git a/src-tauri/src/mcp_bridge.rs b/src-tauri/src/mcp_bridge.rs index f13bb411..d6a095de 100644 --- a/src-tauri/src/mcp_bridge.rs +++ b/src-tauri/src/mcp_bridge.rs @@ -561,6 +561,11 @@ pub async fn save_mcp_config( policy: Option, app: AppHandle, ) -> Result { + use tauri::Manager; + crate::entitlement::ensure_local_ultimate( + &app.state::(), + "MCP Server", + )?; let app_data_dir = app .path() .app_data_dir() @@ -605,6 +610,11 @@ pub async fn save_mcp_config( /// restart, so permission changes never interrupt in-flight LLM requests. #[tauri::command] pub async fn save_mcp_policy(policy: McpPolicy, app: AppHandle) -> Result { + use tauri::Manager; + crate::entitlement::ensure_local_ultimate( + &app.state::(), + "MCP Server", + )?; let app_data_dir = app .path() .app_data_dir() diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs new file mode 100644 index 00000000..5a9f05dc --- /dev/null +++ b/src-tauri/src/session.rs @@ -0,0 +1,203 @@ +//! Refresh-token session (geekfun#59 §2.2 / cloud-sync P0). +//! +//! The refresh token is an opaque 30-day bearer credential rotated on every +//! use; the raw value lives only on this machine — macOS Keychain / +//! Windows Credential Manager / Linux Secret Service via `keyring`, with a +//! 0600-style app-data file fallback for machines without a keyring +//! service. Presenting a rotated token server-side is treated as a leak and +//! revokes the device scope, so rotation failures must never keep the old +//! token: `rotate_session` persists the successor before returning. + +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::path::PathBuf; +use tauri::{AppHandle, Emitter, State}; + +use crate::device_activation::DeviceIdentityState; +use crate::device_identity; + +const CONSOLE_PROD_URL: &str = "https://console-geekfun.wentsen.com"; +const CONSOLE_DEV_URL: &str = "http://localhost:5174"; +const HTTP_TIMEOUT_SECS: u64 = 10; + +const KEYCHAIN_SERVICE: &str = "geekfun/sqlkit/refresh-token"; +const KEYCHAIN_USER: &str = "default"; +const FALLBACK_FILE: &str = "device-refresh-token"; + +pub fn api_base_url() -> &'static str { + if cfg!(debug_assertions) { + CONSOLE_DEV_URL + } else { + CONSOLE_PROD_URL + } +} + +pub struct SessionState { + pub app_data_dir: PathBuf, +} + +fn keyring_entry() -> Result { + keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER) + .map_err(|e| format!("keyring entry unavailable: {e}")) +} + +pub fn persist_token(state: &SessionState, token: &str) { + let stored = keyring_entry().and_then(|entry| { + entry + .set_password(token) + .map_err(|e| format!("keychain write failed: {e}")) + }); + if let Err(err) = stored { + log::warn!("refresh token keychain storage unavailable ({err}) — using file fallback"); + if let Err(e) = std::fs::write(state.app_data_dir.join(FALLBACK_FILE), token) { + log::error!("failed to persist refresh token: {e}"); + } + } +} + +pub fn load_token(state: &SessionState) -> Option { + if let Ok(entry) = keyring_entry() { + if let Ok(token) = entry.get_password() { + if !token.is_empty() { + return Some(token); + } + } + } + std::fs::read_to_string(state.app_data_dir.join(FALLBACK_FILE)) + .ok() + .map(|raw| raw.trim().to_string()) + .filter(|raw| !raw.is_empty()) +} + +pub fn clear_token(state: &SessionState) { + if let Ok(entry) = keyring_entry() { + let _ = entry.delete_credential(); + } + let _ = std::fs::remove_file(state.app_data_dir.join(FALLBACK_FILE)); +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshedSession { + #[serde(rename = "access_token")] + pub access_token: String, + #[serde(rename = "refresh_token")] + pub refresh_token: String, +} + +/// Exchange the stored lease for a fresh pair. The persisted refresh token +/// is replaced with the successor (single-use semantics) before returning. +pub async fn rotate_session( + state: &SessionState, + device_payload: &device_identity::DevicePayload, +) -> Result { + let Some(refresh_token) = load_token(state) else { + return Err("no stored refresh token".to_string()); + }; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) + .build() + .map_err(|e| format!("failed to build http client: {e}"))?; + let response = client + .post(format!("{}/api/v1/auth/refresh", api_base_url())) + .json(&json!({ "refresh_token": refresh_token, "device": device_payload })) + .send() + .await + .map_err(|e| format!("network error: {e}"))?; + + let raw: serde_json::Value = response + .json() + .await + .map_err(|e| format!("invalid refresh payload: {e}"))?; + let code = raw.get("code").and_then(|v| v.as_u64()).unwrap_or(0); + if code != 2000 { + // Rejected (expired / revoked / reuse) — the stored lease is dead. + clear_token(state); + let message = raw + .get("messages") + .and_then(|m| m.get(0)) + .and_then(|m| m.as_str()) + .unwrap_or("session refresh rejected") + .to_string(); + return Err(message); + } + + let data = raw.get("data").cloned().unwrap_or(json!({})); + let session: RefreshedSession = serde_json::from_value(data) + .map_err(|e| format!("invalid refresh result: {e}"))?; + persist_token(state, &session.refresh_token); + Ok(session) +} + +/// Fire the refresh loop explicitly; broadcasts the new access token so the +/// frontend can update its stored session. +#[tauri::command] +pub async fn refresh_session( + app: AppHandle, + session: State<'_, SessionState>, + identity: State<'_, DeviceIdentityState>, +) -> Result { + let result = rotate_session(&session, &identity.payload()).await; + if let Ok(refreshed) = &result { + let _ = app.emit("session-refreshed", refreshed.access_token.clone()); + } + result +} + +/// Logout: the lease must not outlive the account on this machine. +#[tauri::command] +pub fn clear_session(session: State<'_, SessionState>) { + clear_token(&session); +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestState(PathBuf); + + impl TestState { + fn new() -> Self { + let dir = std::env::temp_dir().join(format!( + "dockit-session-test-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + TestState(dir) + } + } + + impl Drop for TestState { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn file_fallback_roundtrips_and_clears() { + let state = TestState::new(); + let session_state = SessionState { + app_data_dir: state.0.clone(), + }; + + assert_eq!(load_token(&session_state), None); + + std::fs::write(state.0.join(FALLBACK_FILE), "raw-lease-token\n").unwrap(); + assert_eq!(load_token(&session_state).as_deref(), Some("raw-lease-token")); + + clear_token(&session_state); + assert_eq!(load_token(&session_state), None); + } + + #[test] + fn refreshed_session_deserializes_the_envelope_shape() { + // the backend keeps access_token-style snake_case for token fields + let raw = json!({ + "access_token": "jwt-value", + "refresh_token": "opaque-lease", + }); + let session: RefreshedSession = serde_json::from_value(raw).expect("session"); + assert_eq!(session.access_token, "jwt-value"); + assert_eq!(session.refresh_token, "opaque-lease"); + } +} diff --git a/src/App.vue b/src/App.vue index eb101911..5cd4016c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,15 +4,21 @@ import { listen } from '@tauri-apps/api/event' import { storeToRefs } from 'pinia' import { onMounted, onUnmounted, watch } from 'vue' import { RouterView } from 'vue-router' +import DeviceReplaceDialog from '@/components/DeviceReplaceDialog.vue' import AppNotifications from '@/components/ui/notification/AppNotifications.vue' import UpdateNotification from '@/components/UpdateNotification.vue' +import UpgradeDialog from '@/components/upgrade/UpgradeDialog.vue' import { useAppUpdater } from '@/composables/useAppUpdater' import { useAccountStore } from '@/store/accountStore' import { useAppStore } from '@/store/appStore' +import { useDeviceStore } from '@/store/deviceStore' +import { useEntitlementStore } from '@/store/entitlementStore' const appStore = useAppStore() const { themeType } = storeToRefs(appStore) const accountStore = useAccountStore() +const entitlementStore = useEntitlementStore() +const deviceStore = useDeviceStore() const { checkForUpdates } = useAppUpdater() // Apply theme immediately on store hydration (before first render) and whenever it changes @@ -21,21 +27,39 @@ watch(themeType, (newTheme) => { }, { immediate: true }) let unlistenAuth: UnlistenFn | null = null +let unlistenSessionRefresh: UnlistenFn | null = null onMounted(async () => { checkForUpdates(false) + if (accountStore.isLoggedIn) { + entitlementStore.refreshEntitlement(true) + // geekfun#59: entitlement-activation point — register/verify this device. + deviceStore.ensureActivated() + } + unlistenAuth = await listen<{ token: string username: string email: string }>('sqlkit://auth', ({ payload }) => { accountStore.setAuth(payload.token, payload.username, payload.email) + entitlementStore.refreshEntitlement(true) + // The deep-linked token comes from a web login with no device attached — + // register/verify this machine right away. + deviceStore.ensureActivated(true) + }) + + // Transparent session refresh (Rust rotates the lease): keep the frontend + // copy of the access token in sync. + unlistenSessionRefresh = await listen('session-refreshed', ({ payload }) => { + accountStore.setToken(payload) }) }) onUnmounted(() => { unlistenAuth?.() + unlistenSessionRefresh?.() }) @@ -43,4 +67,6 @@ onUnmounted(() => { + + diff --git a/src/common/entitlement.ts b/src/common/entitlement.ts new file mode 100644 index 00000000..62690758 --- /dev/null +++ b/src/common/entitlement.ts @@ -0,0 +1,34 @@ +export const ENTITLEMENT_ERROR_TYPE = 'ENTITLEMENT_REQUIRED' + +export const UPGRADE_URL = 'https://www.geekfun.club/pricing' + +export type PaidFeature = 'ai' | 'er_diagram' | 'transfer' | 'ssh_tunnel' | 'mcp_bridge' + +export type EntitlementView = { + ultimateActive: boolean + versionLocked: boolean + localUltimate: boolean + appReleaseDate: string + ultimateExpiresAt: string | null + versionLockHorizon: string | null + cancelScheduledAt: string | null + cached: boolean + fetchedAtMs: number | null + lastError: string | null +} + +export function isEntitlementError(error: unknown): boolean { + if (!error) + return false + if (typeof error === 'object' && 'errorType' in error) { + return (error as { errorType?: string }).errorType === ENTITLEMENT_ERROR_TYPE + } + const raw = typeof error === 'string' ? error : String(error) + try { + const parsed = JSON.parse(raw) as { error_type?: string } + return parsed.error_type === ENTITLEMENT_ERROR_TYPE + } + catch { + return raw.includes(ENTITLEMENT_ERROR_TYPE) + } +} diff --git a/src/common/index.ts b/src/common/index.ts index 558531f0..bec3f53e 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -3,4 +3,5 @@ */ export const pureObject = (obj: T): T => JSON.parse(JSON.stringify(obj)) as T +export * from './entitlement' export * from './sqlParser' diff --git a/src/components/DeviceReplaceDialog.vue b/src/components/DeviceReplaceDialog.vue new file mode 100644 index 00000000..f1af8f59 --- /dev/null +++ b/src/components/DeviceReplaceDialog.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/src/components/connections/ServerFormDialog.vue b/src/components/connections/ServerFormDialog.vue index cf6874e7..c67a622f 100644 --- a/src/components/connections/ServerFormDialog.vue +++ b/src/components/connections/ServerFormDialog.vue @@ -18,11 +18,13 @@ import { SelectValue, } from '@/components/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { openUpgradeDialog } from '@/components/upgrade' import { useDatabaseIcon } from '@/composables/useDatabaseIcon' import { useDownloadEvents } from '@/composables/useDownloadEvents' import { toast } from '@/composables/useNotifications' import { jdbcApi } from '@/datasources/jdbcApi' import { buildOracleOptions, buildTransportLayers, databasePlaceholderFor, DatabaseType, dbTypeToBackend, isDatabaseRequired, isJdbcDatabase, resolveDatabase } from '@/store' +import { useEntitlementStore } from '@/store/entitlementStore' import { DEFAULT_SSL_MODE, sslModeToBackend, validateSslConfig } from '@/types/connection' import SslConfigSection from './ssl/SslConfigSection.vue' @@ -37,6 +39,7 @@ const emit = defineEmits<{ }>() const { t } = useI18n() +const entitlementStore = useEntitlementStore() const { getDatabaseIcon } = useDatabaseIcon() const dl = useDownloadEvents() @@ -182,6 +185,10 @@ async function runStep(step: StepDef): Promise { } function toggleSsh(checked: boolean) { + if (checked && !entitlementStore.isLocalUltimate) { + openUpgradeDialog('ssh_tunnel') + return + } if (!formData.value.sshTunnel) { formData.value.sshTunnel = { enabled: checked, diff --git a/src/components/layout/AppLayout.vue b/src/components/layout/AppLayout.vue index 537e5e7b..c7a1473a 100644 --- a/src/components/layout/AppLayout.vue +++ b/src/components/layout/AppLayout.vue @@ -1,5 +1,7 @@ + + diff --git a/src/components/upgrade/UpgradeDialog.vue b/src/components/upgrade/UpgradeDialog.vue new file mode 100644 index 00000000..d1b37d66 --- /dev/null +++ b/src/components/upgrade/UpgradeDialog.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/components/upgrade/index.ts b/src/components/upgrade/index.ts new file mode 100644 index 00000000..83d9f00f --- /dev/null +++ b/src/components/upgrade/index.ts @@ -0,0 +1,3 @@ +export { default as PaidGate } from './PaidGate.vue' +export { default as UpgradeDialog } from './UpgradeDialog.vue' +export * from './upgradeDialogService' diff --git a/src/components/upgrade/upgradeDialogService.ts b/src/components/upgrade/upgradeDialogService.ts new file mode 100644 index 00000000..f06d41c0 --- /dev/null +++ b/src/components/upgrade/upgradeDialogService.ts @@ -0,0 +1,15 @@ +import type { PaidFeature } from '../../common' + +type OpenUpgradeDialogFn = ((feature?: PaidFeature) => void) | null + +let openUpgradeDialogFn: OpenUpgradeDialogFn = null + +export function registerUpgradeDialog(fn: OpenUpgradeDialogFn): void { + openUpgradeDialogFn = fn +} + +export function openUpgradeDialog(feature?: PaidFeature): void { + if (openUpgradeDialogFn) { + openUpgradeDialogFn(feature) + } +} diff --git a/src/lang/enUS.ts b/src/lang/enUS.ts index b78545ff..7da8baf9 100644 --- a/src/lang/enUS.ts +++ b/src/lang/enUS.ts @@ -1,4 +1,52 @@ export const enUS = { + device: { + replaceTitle: 'Device limit reached', + replaceDescription: + 'Your subscription can stay activated on up to {limit} devices. Pick a device below to replace it with this one — the replaced device loses access immediately.', + slotUsage: '{used}/{limit} devices in use', + thisDevice: 'This device', + lastActive: 'Last active', + neverActive: 'Never', + replaceConfirm: 'Replace & Activate', + replaceFailed: 'Failed to replace the device. Pick another one and try again.', + }, + plan: { + tab: 'Account & Plan', + pricing: 'Ultimate $9.9/mo · $99/yr · 7-day free trial', + state: { + ultimate: 'Ultimate', + community: 'Community', + }, + features: { + ai: 'AI — SQL generation, optimization, explanation and fix. Bring your own LLM key.', + er_diagram: 'ER diagrams — visualize table relationships across schemas.', + transfer: 'Bulk import/export, DDL structure sync and cross-engine migration.', + ssh_tunnel: 'SSH tunnel for remote connections.', + mcp_bridge: 'Built-in MCP Server bridge.', + }, + upgrade: { + title: 'Upgrade to Ultimate', + description: 'Unlock AI, ER diagrams, bulk import/export, cross-engine migration, SSH tunnels and the MCP bridge.', + cta: 'Upgrade', + refresh: 'Refresh entitlements', + versionPermanent: 'Subscribed versions are permanently usable, even offline.', + versionLockedOut: 'This release requires an active subscription to unlock. Renew to unlock it permanently.', + }, + section: { + title: 'Account & Plan', + desc: 'Subscription entitlements and version-lock state for this installation.', + versionPermanent: 'Current version is permanently usable (covered by your version lock).', + subscriptionActive: 'Ultimate is active — all features unlocked while subscribed.', + versionLockedOut: 'This version ({date}) is not covered by a version lock — showing Community mode.', + expiresAt: 'Active until {time}', + cancelScheduled: 'Subscription is scheduled to end at the close of the current billing period.', + checkFailed: 'Could not verify entitlements — showing cached state. Check your network and refresh.', + notLoggedIn: 'You are not logged in.', + loginLink: 'Log in with Geekfun', + refresh: 'Refresh', + logout: 'Log out', + }, + }, aside: { aiAssistant: 'AI Assistant', tasks: 'Tasks', diff --git a/src/lang/zhCN.ts b/src/lang/zhCN.ts index 3c20cb3a..b42dfd8b 100644 --- a/src/lang/zhCN.ts +++ b/src/lang/zhCN.ts @@ -1,4 +1,52 @@ export const zhCN = { + device: { + replaceTitle: '设备数量已达上限', + replaceDescription: + '订阅最多可在 {limit} 台设备上激活。请从下方选择一台设备替换为本机——被替换的设备将立即失去访问权限。', + slotUsage: '已使用 {used}/{limit} 台', + thisDevice: '本机', + lastActive: '最后活跃', + neverActive: '从未活跃', + replaceConfirm: '替换并激活', + replaceFailed: '替换设备失败,请重新选择后再试。', + }, + plan: { + tab: '账户与订阅', + pricing: '旗舰版 $9.9/月 · $99/年 · 7 天免费试用', + state: { + ultimate: '旗舰版', + community: '社区版', + }, + features: { + ai: 'AI — SQL 生成 / 优化 / 解释 / 修复(自带 LLM Key)。', + er_diagram: 'ER 图 — 可视化表关系。', + transfer: '批量导入导出、DDL 结构同步与跨引擎迁移。', + ssh_tunnel: 'SSH 隧道远程连接。', + mcp_bridge: '内置 MCP Server bridge。', + }, + upgrade: { + title: '升级到旗舰版', + description: '解锁 AI、ER 图、批量导入导出、跨引擎迁移、SSH 隧道与 MCP bridge。', + cta: '升级', + refresh: '刷新权益', + versionPermanent: '订阅期内发布的版本永久可用,支持离线。', + versionLockedOut: '当前版本需要有效订阅解锁,续订后可永久解锁。', + }, + section: { + title: '账户与订阅', + desc: '当前安装的订阅权益与版本锁状态。', + versionPermanent: '当前版本已被版本锁覆盖,永久可用。', + subscriptionActive: '旗舰版订阅生效中,全部功能可用。', + versionLockedOut: '当前版本({date})未被版本锁覆盖,当前为社区版模式。', + expiresAt: '有效期至 {time}', + cancelScheduled: '订阅已计划于本期期末结束。', + checkFailed: '权益校验失败,当前展示缓存状态。请检查网络后刷新。', + notLoggedIn: '尚未登录。', + loginLink: '通过 Geekfun 登录', + refresh: '刷新', + logout: '退出登录', + }, + }, aside: { aiAssistant: 'AI 助手', tasks: '任务', diff --git a/src/pages/DataStudioPage.vue b/src/pages/DataStudioPage.vue index 774913ff..0064dd26 100644 --- a/src/pages/DataStudioPage.vue +++ b/src/pages/DataStudioPage.vue @@ -263,259 +263,261 @@ function syncAllProviderModels() { diff --git a/src/pages/QueriesPage.vue b/src/pages/QueriesPage.vue index 050f52c2..6d900da1 100644 --- a/src/pages/QueriesPage.vue +++ b/src/pages/QueriesPage.vue @@ -16,6 +16,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { Button } from '@/components/ui/button' import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { openUpgradeDialog } from '@/components/upgrade' import { resolveMonacoDialect } from '@/composables/sqlCompletion/dialects' import { getMetadataService } from '@/composables/sqlCompletion/metadata' import { toast } from '@/composables/useNotifications' @@ -24,6 +25,7 @@ import { useSqlFormatter } from '@/composables/useSqlFormatter' import { browseApi, loadQueryFile, saveQueryFile, saveQueryFileAs, saveQueryMetadata } from '@/datasources' import { ConnectionStatus, useAppStore, useConnectionStore, useDatabaseStore, useTabStore } from '@/store' import { DatabaseType } from '@/store/connectionStore' +import { useEntitlementStore } from '@/store/entitlementStore' import { isApiSuccess } from '@/types/api' const { t } = useI18n() @@ -33,6 +35,7 @@ const appStore = useAppStore() const connectionStore = useConnectionStore() const databaseStore = useDatabaseStore() const tabStore = useTabStore() +const entitlementStore = useEntitlementStore() const { modifierKey } = usePlatform() const showResultPanel = ref(false) @@ -590,6 +593,10 @@ function handleExportData(_table: TableInfo, _database: string, _schema?: string } function handleShowErDiagram(database: string, schema?: string) { + if (!entitlementStore.isLocalUltimate) { + openUpgradeDialog('er_diagram') + return + } const connId = getActiveConnectionId() if (connId) tabStore.openErDiagramTab(connId, database, schema) @@ -865,6 +872,10 @@ function handleDatabaseAction(kind: string) { router.push('/transfer') break case 'showErDiagram': { + if (!entitlementStore.isLocalUltimate) { + openUpgradeDialog('er_diagram') + break + } const connId = getActiveConnectionId() const db = selectedDatabase.value // Database-level ER diagram: omit schema to show all tables across all schemas diff --git a/src/pages/SettingsPage.vue b/src/pages/SettingsPage.vue index c32686e2..441b1a50 100644 --- a/src/pages/SettingsPage.vue +++ b/src/pages/SettingsPage.vue @@ -9,11 +9,13 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' // Separator is rendered inline as a styled div import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { PaidGate } from '@/components/upgrade' import { useAppUpdater } from '@/composables/useAppUpdater' import { ThemeType, useAppStore } from '@/store/appStore' import AiSettings from '@/views/setting/ai-settings.vue' import JreDriverSection from '@/views/setting/jre-driver-section.vue' import McpBridge from '@/views/setting/mcp-bridge.vue' +import PlanSection from '@/views/setting/plan-section.vue' const appStore = useAppStore() const { t, locale: _locale } = useI18n() @@ -197,6 +199,9 @@ async function handleCheckUpdates() { {{ t('pages.settings.jre.title') }} + + {{ t('plan.tab') }} + {{ t('pages.settings.about.title') }} @@ -588,12 +593,16 @@ async function handleCheckUpdates() { - + + + - + + + @@ -602,6 +611,19 @@ async function handleCheckUpdates() { + + + + + {{ t('plan.section.title') }} + {{ t('plan.section.desc') }} + + + + + + + diff --git a/src/pages/TransferPage.vue b/src/pages/TransferPage.vue index 86b807e2..f77d42fc 100644 --- a/src/pages/TransferPage.vue +++ b/src/pages/TransferPage.vue @@ -8,6 +8,7 @@ import ImportWizard from '@/components/transfer/import/ImportWizard.vue' import TaskManagerButton from '@/components/transfer/tasks/TaskManagerButton.vue' import TaskManagerPanel from '@/components/transfer/tasks/TaskManagerPanel.vue' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { PaidGate } from '@/components/upgrade' import { useTransferStore } from '@/store/transferStore' const { t } = useI18n() @@ -28,52 +29,54 @@ const tabConfig = computed(() => [ diff --git a/src/store/accountStore.ts b/src/store/accountStore.ts index e3676ac8..aeb29511 100644 --- a/src/store/accountStore.ts +++ b/src/store/accountStore.ts @@ -1,3 +1,4 @@ +import { invoke } from '@tauri-apps/api/core' import { defineStore } from 'pinia' type AccountState = { @@ -22,10 +23,17 @@ export const useAccountStore = defineStore('account', { this.username = username this.email = email }, + setToken(token: string) { + this.token = token + }, clearAuth() { this.token = '' this.username = '' this.email = '' + // the refresh lease must not outlive the account on this machine + invoke('clear_session').catch(() => { + // best effort — local-only logout still applies + }) }, }, }) diff --git a/src/store/deviceStore.ts b/src/store/deviceStore.ts new file mode 100644 index 00000000..c60ac030 --- /dev/null +++ b/src/store/deviceStore.ts @@ -0,0 +1,149 @@ +import { invoke } from '@tauri-apps/api/core' +import { defineStore } from 'pinia' +import { useAccountStore } from './accountStore' + +export const DEVICE_LIMIT_ERROR_TYPE = 'DEVICE_LIMIT_REACHED' + +export type DeviceDto = { + id: string + name: string + platform: string + activatedAt?: string | null + lastSeenAt?: string | null + isCurrent?: boolean + status: string +} + +export type DeviceLimitInfo = { + limit: number + used: number + devices: Array + manageUrl?: string | null +} + +export type ActivatedResult = { + deviceId: string + limit: number + used: number +} + +/** One activation attempt per app run, plus re-activation after each login. */ +const ACTIVATION_THROTTLE_MS = 24 * 60 * 60 * 1000 +const LAST_ACTIVATED_KEY = 'device_last_activated_at' + +export const useDeviceStore = defineStore('device', { + state: (): { + deviceId: string + limit: number + used: number + limitInfo: DeviceLimitInfo | null + showReplaceDialog: boolean + activating: boolean + } => ({ + deviceId: '', + limit: 0, + used: 0, + limitInfo: null, + showReplaceDialog: false, + activating: false, + }), + getters: { + isActivated: state => state.deviceId.length > 0, + limitReached: state => state.limitInfo !== null, + }, + actions: { + shouldAttempt(force: boolean): boolean { + if (force) { + return true + } + const last = Number(localStorage.getItem(LAST_ACTIVATED_KEY) ?? 0) + return !this.isActivated && Date.now() - last > ACTIVATION_THROTTLE_MS + }, + /** + * 权益激活执行点:login 成功与应用启动时调用。失败静默降级为提示—— + * 服务器不会因为设备超限锁账号,5030 弹出替换选择器由用户决定。 + */ + async ensureActivated(force = false): Promise { + const accountStore = useAccountStore() + if (!accountStore.isLoggedIn || this.activating || !this.shouldAttempt(force)) { + return + } + this.activating = true + try { + const result = await invoke('activate_device', { + token: accountStore.token, + replaceDeviceId: null, + }) + this.deviceId = result.deviceId + this.limit = result.limit + this.used = result.used + this.limitInfo = null + this.showReplaceDialog = false + localStorage.setItem(LAST_ACTIVATED_KEY, String(Date.now())) + } + catch (err) { + const limitInfo = parseLimitReached(err) + if (limitInfo) { + this.limitInfo = limitInfo + this.showReplaceDialog = true + } + } + finally { + this.activating = false + } + }, + /** Replace the picked device with this machine (geekfun#59 F2). */ + async replaceDevice(deviceId: string): Promise { + const accountStore = useAccountStore() + if (!accountStore.isLoggedIn || !deviceId || this.activating) { + return false + } + this.activating = true + try { + const result = await invoke('activate_device', { + token: accountStore.token, + replaceDeviceId: deviceId, + }) + this.deviceId = result.deviceId + this.limit = result.limit + this.used = result.used + this.limitInfo = null + this.showReplaceDialog = false + localStorage.setItem(LAST_ACTIVATED_KEY, String(Date.now())) + return true + } + catch (err) { + const limitInfo = parseLimitReached(err) + if (limitInfo) { + // The picked device may have been replaced concurrently — refresh + // the list so the user can pick again. + this.limitInfo = limitInfo + } + return false + } + finally { + this.activating = false + } + }, + dismissReplaceDialog(): void { + this.showReplaceDialog = false + }, + }, +}) + +function parseLimitReached(err: unknown): DeviceLimitInfo | null { + try { + const raw = typeof err === 'string' ? err : JSON.stringify(err) + const parsed = JSON.parse(raw) as { + error_type?: string + limit_reached?: DeviceLimitInfo + } + if (parsed?.error_type === DEVICE_LIMIT_ERROR_TYPE && parsed.limit_reached) { + return parsed.limit_reached + } + } + catch { + // plain network / HTTP errors carry no limit payload + } + return null +} diff --git a/src/store/entitlementStore.ts b/src/store/entitlementStore.ts new file mode 100644 index 00000000..9f18ff4e --- /dev/null +++ b/src/store/entitlementStore.ts @@ -0,0 +1,62 @@ +import type { EntitlementView, PaidFeature } from '../common' +import { invoke } from '@tauri-apps/api/core' +import { defineStore } from 'pinia' +import { + ENTITLEMENT_ERROR_TYPE, + + isEntitlementError, + +} from '../common' +import { useAccountStore } from './accountStore' + +export type PlanState = 'ultimate' | 'community' + +export const useEntitlementStore = defineStore('entitlement', { + state: (): { view: EntitlementView | null } => ({ + view: null, + }), + getters: { + isLocalUltimate: (state): boolean => state.view?.localUltimate ?? false, + isCloudUltimate: (state): boolean => state.view?.ultimateActive ?? false, + planState: (state): PlanState => (state.view?.localUltimate ? 'ultimate' : 'community'), + cancelScheduled: (state): boolean => Boolean(state.view?.cancelScheduledAt), + hasEntitlementError: (state): boolean => Boolean(state.view?.lastError), + }, + actions: { + async refreshEntitlement(force = false): Promise { + const accountStore = useAccountStore() + try { + this.view = await invoke('refresh_entitlement', { + token: accountStore.token, + force, + }) + } + catch (e) { + if (!isEntitlementError(e)) { + throw e + } + this.view = null + } + }, + async ensureLocalUltimate(feature: PaidFeature): Promise { + await this.refreshEntitlement(false) + if (this.isLocalUltimate) { + return true + } + throw Object.assign(new Error(`'${feature}' requires an Ultimate subscription`), { + status: 403, + details: feature, + errorType: ENTITLEMENT_ERROR_TYPE, + }) + }, + async clearCachedEntitlement(): Promise { + try { + await invoke('clear_entitlement') + } + catch { + // best effort — the local view reset below always applies + } + this.view = null + }, + }, +}) diff --git a/src/store/index.ts b/src/store/index.ts index 520c7e98..3f9a6822 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -38,6 +38,10 @@ export type { SessionSource, SourcePermissionsMode, } from './dataStudioStore' +export { useDeviceStore } from './deviceStore' +export type { ActivatedResult, DeviceDto, DeviceLimitInfo } from './deviceStore' +export { useEntitlementStore } from './entitlementStore' +export type { PlanState } from './entitlementStore' export { useHistoryStore } from './historyStore' export type { HistoryEntry, HistoryEntryStatus } from './historyStore' export { useTabStore } from './tabStore' diff --git a/src/utils/authService.ts b/src/utils/authService.ts new file mode 100644 index 00000000..499ab710 --- /dev/null +++ b/src/utils/authService.ts @@ -0,0 +1,19 @@ +import { openUrl } from '@tauri-apps/plugin-opener' + +const GEEKFUN_BASE_URL = 'https://console-geekfun.wentsen.com' +const GEEKFUN_LOCAL_URL = 'http://localhost:5174' + +function getGeekfunUrl(): string { + const isDev = import.meta.env.DEV + return isDev ? GEEKFUN_LOCAL_URL : GEEKFUN_BASE_URL +} + +export async function openLoginUrl(): Promise { + const loginUrl = `${getGeekfunUrl()}/login?source=sqlkit` + await openUrl(loginUrl) +} + +export async function openRegisterUrl(): Promise { + const registerUrl = `${getGeekfunUrl()}/register?source=sqlkit` + await openUrl(registerUrl) +} diff --git a/src/views/setting/plan-section.vue b/src/views/setting/plan-section.vue new file mode 100644 index 00000000..86dfda35 --- /dev/null +++ b/src/views/setting/plan-section.vue @@ -0,0 +1,98 @@ + + + diff --git a/tests/common/entitlement.test.ts b/tests/common/entitlement.test.ts new file mode 100644 index 00000000..24bd136f --- /dev/null +++ b/tests/common/entitlement.test.ts @@ -0,0 +1,58 @@ +import type { EntitlementView } from '@/common' +import { ENTITLEMENT_ERROR_TYPE, isEntitlementError } from '@/common' + +function view(overrides: Partial = {}): EntitlementView { + return { + ultimateActive: false, + versionLocked: false, + localUltimate: false, + appReleaseDate: '2026-09-12', + ultimateExpiresAt: null, + versionLockHorizon: null, + cancelScheduledAt: null, + cached: false, + fetchedAtMs: null, + lastError: null, + ...overrides, + } +} + +describe('isEntitlementError', () => { + it('detects the structured Rust error payload', () => { + const raw = JSON.stringify({ + status: 403, + error_type: ENTITLEMENT_ERROR_TYPE, + message: '\'AI\' requires an Ultimate subscription', + }) + expect(isEntitlementError(raw)).toBe(true) + }) + + it('detects CustomError-like objects carrying the error type', () => { + expect(isEntitlementError({ errorType: ENTITLEMENT_ERROR_TYPE })).toBe(true) + }) + + it('detects plain errors mentioning the type', () => { + expect(isEntitlementError(new Error(ENTITLEMENT_ERROR_TYPE))).toBe(true) + }) + + it('rejects unrelated errors', () => { + expect(isEntitlementError('DNS_ERROR: cannot resolve')).toBe(false) + expect(isEntitlementError(null)).toBe(false) + expect(isEntitlementError(undefined)).toBe(false) + }) +}) + +describe('entitlement view defaults', () => { + it('community mode has no entitlements', () => { + const community = view() + expect(community.localUltimate).toBe(false) + expect(community.ultimateActive).toBe(false) + expect(community.versionLocked).toBe(false) + }) + + it('version lock keeps local features without an active subscription', () => { + const locked = view({ versionLocked: true, localUltimate: true }) + expect(locked.localUltimate).toBe(true) + expect(locked.ultimateActive).toBe(false) + }) +}) diff --git a/tests/store/deviceStore.test.ts b/tests/store/deviceStore.test.ts new file mode 100644 index 00000000..cde14fc2 --- /dev/null +++ b/tests/store/deviceStore.test.ts @@ -0,0 +1,178 @@ +import { createPinia, setActivePinia } from 'pinia' + +import { DEVICE_LIMIT_ERROR_TYPE, useDeviceStore } from '@/store/deviceStore' + +let mockInvoke = jest.fn() + +jest.mock('@tauri-apps/api/core', () => ({ + invoke: (...args: unknown[]) => mockInvoke(...args), +})) + +jest.mock('@/store/accountStore', () => ({ + useAccountStore: () => ({ + isLoggedIn: true, + token: 'token-1', + }), +})) + +function mockLocalStorage(): Storage { + const store = new Map() + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + clear: () => store.clear(), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size + }, + } as Storage +} + +let originalLocalStorage: Storage + +const limitPayload = { + limit: 3, + used: 3, + devices: [ + { + id: 'dev_old', + name: 'Old Mac', + platform: 'macos', + activatedAt: '2026-09-01T00:00:00.000Z', + lastSeenAt: '2026-09-10T00:00:00.000Z', + isCurrent: false, + status: 'active', + }, + { + id: 'dev_this', + name: 'This Mac', + platform: 'macos', + isCurrent: true, + status: 'active', + }, + ], + manageUrl: 'https://console/home/devices', +} + +const activatedPayload = { deviceId: 'dev_new', limit: 3, used: 3 } + +describe('deviceStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockInvoke = jest.fn() + originalLocalStorage = globalThis.localStorage + Object.defineProperty(globalThis, 'localStorage', { + value: mockLocalStorage(), + writable: true, + configurable: true, + }) + }) + + afterEach(() => { + globalThis.localStorage = originalLocalStorage + }) + + it('should store the activated device on success', async () => { + mockInvoke.mockResolvedValue(activatedPayload) + const store = useDeviceStore() + + await store.ensureActivated(true) + + expect(mockInvoke).toHaveBeenCalledWith('activate_device', { + token: 'token-1', + replaceDeviceId: null, + }) + expect(store.isActivated).toBe(true) + expect(store.deviceId).toBe('dev_new') + expect(store.showReplaceDialog).toBe(false) + }) + + it('should surface the 5030 payload and open the replace dialog', async () => { + mockInvoke.mockRejectedValue( + JSON.stringify({ + error_type: DEVICE_LIMIT_ERROR_TYPE, + code: 5030, + limit_reached: limitPayload, + }), + ) + const store = useDeviceStore() + + await store.ensureActivated(true) + + expect(store.isActivated).toBe(false) + expect(store.limitReached).toBe(true) + expect(store.limitInfo?.devices).toHaveLength(2) + expect(store.showReplaceDialog).toBe(true) + }) + + it('should stay silent on plain network errors', async () => { + mockInvoke.mockRejectedValue('network error: timeout') + const store = useDeviceStore() + + await store.ensureActivated(true) + + expect(store.limitReached).toBe(false) + expect(store.showReplaceDialog).toBe(false) + }) + + it('should replace the picked device and close the dialog (F2)', async () => { + const store = useDeviceStore() + mockInvoke.mockRejectedValueOnce( + JSON.stringify({ + error_type: DEVICE_LIMIT_ERROR_TYPE, + code: 5030, + limit_reached: limitPayload, + }), + ) + await store.ensureActivated(true) + expect(store.showReplaceDialog).toBe(true) + + mockInvoke.mockResolvedValueOnce(activatedPayload) + const ok = await store.replaceDevice('dev_old') + + expect(ok).toBe(true) + expect(mockInvoke).toHaveBeenLastCalledWith('activate_device', { + token: 'token-1', + replaceDeviceId: 'dev_old', + }) + expect(store.deviceId).toBe('dev_new') + expect(store.showReplaceDialog).toBe(false) + }) + + it('should refresh the list when a replace attempt hits 5030 again', async () => { + const store = useDeviceStore() + mockInvoke.mockRejectedValue( + JSON.stringify({ + error_type: DEVICE_LIMIT_ERROR_TYPE, + code: 5030, + limit_reached: limitPayload, + }), + ) + const ok = await store.replaceDevice('dev_gone') + + expect(ok).toBe(false) + expect(store.limitInfo?.devices).toHaveLength(2) + expect(store.deviceId).toBe('') + }) + + it('should throttle non-forced activation within 24h of a success', async () => { + mockInvoke.mockResolvedValue(activatedPayload) + const store = useDeviceStore() + + // fresh install: the startup attempt goes through + await store.ensureActivated(false) + expect(mockInvoke).toHaveBeenCalledTimes(1) + + // already activated this run — non-forced attempts are skipped + await store.ensureActivated(false) + expect(mockInvoke).toHaveBeenCalledTimes(1) + + // a new app run (fresh store) whose last success is older than 24h retries + setActivePinia(createPinia()) + localStorage.setItem('device_last_activated_at', String(Date.now() - 25 * 60 * 60 * 1000)) + const fresh = useDeviceStore() + await fresh.ensureActivated(false) + expect(mockInvoke).toHaveBeenCalledTimes(2) + }) +}) From 1414736b36609231605272ca627b2cce34c14f69 Mon Sep 17 00:00:00 2001 From: blankll Date: Wed, 16 Sep 2026 01:32:27 +0800 Subject: [PATCH 2/3] fix(device-identity): use per-OS cfg for detect_virtual to unbreak Linux build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cfg!() is a runtime boolean — both branches must compile — so calling the Windows-only system_manufacturer() inside the any(linux, windows) detect_virtual() failed name resolution on Linux (E0425). Split into per-OS functions sharing a detect_virtual_from helper, matching the file's existing per-platform style. Verified via cross-target cargo check for x86_64-unknown-linux-gnu and x86_64-pc-windows-msvc (device_identity.rs in an isolated crate), plus host check/test/clippy/fmt. --- src-tauri/src/device_activation.rs | 11 ++++-- src-tauri/src/device_identity.rs | 55 ++++++++++++++++++++---------- src-tauri/src/entitlement.rs | 21 +++--------- src-tauri/src/lib.rs | 2 +- src-tauri/src/session.rs | 9 +++-- 5 files changed, 58 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/device_activation.rs b/src-tauri/src/device_activation.rs index 9755b19a..f742406b 100644 --- a/src-tauri/src/device_activation.rs +++ b/src-tauri/src/device_activation.rs @@ -106,7 +106,11 @@ fn parse_envelope(payload: serde_json::Value) -> Option<(u32, Vec, serde .collect() }) .unwrap_or_default(); - Some((code, messages, payload.get("data").cloned().unwrap_or_default())) + Some(( + code, + messages, + payload.get("data").cloned().unwrap_or_default(), + )) } /// Outcome of a single activation HTTP attempt. @@ -270,7 +274,10 @@ mod tests { }); let dto: DeviceDto = serde_json::from_value(raw).expect("dto"); assert!(dto.is_current); - assert_eq!(dto.activated_at.as_deref(), Some("2026-09-01T00:00:00.000Z")); + assert_eq!( + dto.activated_at.as_deref(), + Some("2026-09-01T00:00:00.000Z") + ); assert!(dto.last_seen_at.is_none()); } diff --git a/src-tauri/src/device_identity.rs b/src-tauri/src/device_identity.rs index 798ff607..cb687d98 100644 --- a/src-tauri/src/device_identity.rs +++ b/src-tauri/src/device_identity.rs @@ -53,7 +53,10 @@ pub fn hardware_serial() -> Option { .args(["-rd1", "-c", "IOPlatformExpertDevice"]) .output() .ok()?; - parse_ioreg_value(&String::from_utf8_lossy(&output.stdout), "IOPlatformSerialNumber") + parse_ioreg_value( + &String::from_utf8_lossy(&output.stdout), + "IOPlatformSerialNumber", + ) } #[cfg(target_os = "macos")] @@ -75,9 +78,8 @@ fn parse_ioreg_value(text: &str, key: &str) -> Option { #[cfg(target_os = "windows")] pub fn platform_uuid() -> Option { - command_line("csproduct get UUID").or_else(|| { - powershell("(Get-CimInstance Win32_ComputerSystemProduct).UUID.Value") - }) + command_line("csproduct get UUID") + .or_else(|| powershell("(Get-CimInstance Win32_ComputerSystemProduct).UUID.Value")) } #[cfg(target_os = "windows")] @@ -101,16 +103,18 @@ pub fn machine_guid() -> Option { #[cfg(target_os = "windows")] pub fn system_manufacturer() -> Option { - command_line("computersystem get Manufacturer").or_else(|| { - powershell("(Get-CimInstance Win32_ComputerSystem).Manufacturer.Value") - }) + command_line("computersystem get Manufacturer") + .or_else(|| powershell("(Get-CimInstance Win32_ComputerSystem).Manufacturer.Value")) } #[cfg(target_os = "windows")] fn command_line(wmic_args: &str) -> Option { let mut args = vec!["/value:off", "/header:off"]; args.extend(wmic_args.split_whitespace()); - let output = std::process::Command::new("wmic").args(args).output().ok()?; + let output = std::process::Command::new("wmic") + .args(args) + .output() + .ok()?; parse_value_output(&String::from_utf8_lossy(&output.stdout)) } @@ -185,7 +189,9 @@ fn detect_hostname() -> Option { #[cfg(target_os = "windows")] fn detect_hostname() -> Option { - std::env::var("COMPUTERNAME").ok().filter(|name| !name.is_empty()) + std::env::var("COMPUTERNAME") + .ok() + .filter(|name| !name.is_empty()) } pub const CURRENT_PLATFORM: &str = if cfg!(target_os = "macos") { @@ -211,19 +217,32 @@ fn detect_virtual() -> bool { .unwrap_or(false) } -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(target_os = "linux")] fn detect_virtual() -> bool { - let vendor = if cfg!(target_os = "linux") { - system_vendor() - } else { - system_manufacturer() - }; + detect_virtual_from(system_vendor()) +} + +#[cfg(target_os = "windows")] +fn detect_virtual() -> bool { + detect_virtual_from(system_manufacturer()) +} + +#[cfg(any(target_os = "linux", target_os = "windows"))] +fn detect_virtual_from(vendor: Option) -> bool { vendor .map(|vendor| vendor.to_lowercase()) .map(|vendor| { - ["qemu", "kvm", "vmware", "virtualbox", "xen", "microsoft", "parallels"] - .iter() - .any(|marker| vendor.contains(marker)) + [ + "qemu", + "kvm", + "vmware", + "virtualbox", + "xen", + "microsoft", + "parallels", + ] + .iter() + .any(|marker| vendor.contains(marker)) }) .unwrap_or(false) } diff --git a/src-tauri/src/entitlement.rs b/src-tauri/src/entitlement.rs index a8ac54b0..8d63cd49 100644 --- a/src-tauri/src/entitlement.rs +++ b/src-tauri/src/entitlement.rs @@ -440,10 +440,7 @@ mod tests { #[test] fn trial_is_active_but_never_version_locked() { - let view = compute_entitlement( - cache(Some("2026-10-09T00:00:00.000Z"), None).as_ref(), - NOW, - ); + let view = compute_entitlement(cache(Some("2026-10-09T00:00:00.000Z"), None).as_ref(), NOW); assert!(view.ultimate_active); assert!(!view.version_locked); assert!(view.local_ultimate); @@ -451,10 +448,7 @@ mod tests { #[test] fn expired_trial_without_horizon_falls_back_to_free() { - let view = compute_entitlement( - cache(Some("2020-01-01T00:00:00.000Z"), None).as_ref(), - NOW, - ); + let view = compute_entitlement(cache(Some("2020-01-01T00:00:00.000Z"), None).as_ref(), NOW); assert!(!view.ultimate_active); assert!(!view.version_locked); assert!(!view.local_ultimate); @@ -477,15 +471,10 @@ mod tests { #[test] fn releases_after_the_horizon_need_renewal() { - let view = compute_entitlement( - cache(None, Some("2020-01-01T00:00:00.000Z")).as_ref(), - NOW, - ); + let view = compute_entitlement(cache(None, Some("2020-01-01T00:00:00.000Z")).as_ref(), NOW); assert!(view.version_locked == (parse_date_utc_ms(APP_RELEASE_DATE).unwrap() <= 0)); - let fresh = compute_entitlement( - cache(None, Some("2999-01-01T00:00:00.000Z")).as_ref(), - NOW, - ); + let fresh = + compute_entitlement(cache(None, Some("2999-01-01T00:00:00.000Z")).as_ref(), NOW); assert!(fresh.version_locked); assert!(fresh.local_ultimate); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e278179..76b6e04b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,8 +25,8 @@ pub mod db; pub mod device_activation; pub mod device_identity; pub mod entitlement; -pub mod session; pub mod mcp_bridge; +pub mod session; use std::sync::Arc; use std::sync::OnceLock; diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 5a9f05dc..96ece534 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -123,8 +123,8 @@ pub async fn rotate_session( } let data = raw.get("data").cloned().unwrap_or(json!({})); - let session: RefreshedSession = serde_json::from_value(data) - .map_err(|e| format!("invalid refresh result: {e}"))?; + let session: RefreshedSession = + serde_json::from_value(data).map_err(|e| format!("invalid refresh result: {e}"))?; persist_token(state, &session.refresh_token); Ok(session) } @@ -183,7 +183,10 @@ mod tests { assert_eq!(load_token(&session_state), None); std::fs::write(state.0.join(FALLBACK_FILE), "raw-lease-token\n").unwrap(); - assert_eq!(load_token(&session_state).as_deref(), Some("raw-lease-token")); + assert_eq!( + load_token(&session_state).as_deref(), + Some("raw-lease-token") + ); clear_token(&session_state); assert_eq!(load_token(&session_state), None); From aaa1313fc9df1e68e584c2eefc0beba8389a9c50 Mon Sep 17 00:00:00 2001 From: blankll Date: Wed, 16 Sep 2026 02:47:35 +0800 Subject: [PATCH 3/3] refactor(entitlement): unify session storage in the frontend; address review findings Store the device-bound refresh lease in the persisted account session (localStorage) instead of a Rust-side keyring/file shadow store, and resolve every issue from the PR review: - drop keyring: v3 requires per-platform feature flags, so Entry::new() silently failed everywhere and every login degraded to the plaintext file fallback; the lease now lives with the rest of the session state - session.rs keeps only the rotation protocol; rotations serialize through a tokio::sync::Mutex (single-use lease presented twice reads as a leak and revokes the device), and deliberate rejections surface the structured SESSION_REJECTED error so the frontend drops dead leases while transient failures keep them - wire logout for real: the Account & Plan tab gains a logout button clearing the entitlement cache, the account session and device state - gate SSH tunnels before any transport is established, not after - stamp APP_RELEASE_DATE at build time in the release workflow (option_env fallback covers local builds) so a forgotten manual bump cannot fail a release open - session-refreshed broadcasts both tokens; compute_entitlement returns a pure decision (cached/last_error belong to the view); consolidate the duplicated console base URLs and reqwest clients in common::console; test activation_body directly --- .github/workflows/release.yml | 4 + src-tauri/Cargo.lock | 11 -- src-tauri/Cargo.toml | 1 - src-tauri/src/commands/helpers.rs | 9 +- src-tauri/src/common/console.rs | 27 +++++ src-tauri/src/common/mod.rs | 1 + src-tauri/src/device_activation.rs | 95 ++++++++------- src-tauri/src/entitlement.rs | 145 +++++++++++++--------- src-tauri/src/lib.rs | 6 +- src-tauri/src/session.rs | 186 ++++++++--------------------- src/App.vue | 7 +- src/common/entitlement.ts | 25 +++- src/store/accountStore.ts | 17 ++- src/store/deviceStore.ts | 34 ++++-- src/store/entitlementStore.ts | 28 ++--- src/views/setting/plan-section.vue | 25 +++- tests/common/entitlement.test.ts | 23 +++- tests/store/deviceStore.test.ts | 10 +- 18 files changed, 348 insertions(+), 306 deletions(-) create mode 100644 src-tauri/src/common/console.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45dd7fd6..11bb98a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,10 +155,14 @@ jobs: echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV echo "Signing identity: $CERT_ID" + - name: Stamp release date + run: echo "APP_RELEASE_DATE=$(date -u +%F)" >> $GITHUB_ENV + - name: Build and Upload Artifacts uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APP_RELEASE_DATE: ${{ env.APP_RELEASE_DATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c191925d..b814be1b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3510,16 +3510,6 @@ dependencies = [ "indexmap 2.14.0", ] -[[package]] -name = "keyring" -version = "3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" -dependencies = [ - "log", - "zeroize", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -6533,7 +6523,6 @@ dependencies = [ "hex", "hmac 0.12.1", "http", - "keyring", "log", "mockall", "mysql_async", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 00d7d87f..998d8b96 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -99,7 +99,6 @@ zip = { version = "2", features = [ "deflate" ] } toml = "0.8" sha2 = "0.10" hmac = "0.12" -keyring = "3" sqlparser = "0.62" russh = "0.60" axum = "0.8" diff --git a/src-tauri/src/commands/helpers.rs b/src-tauri/src/commands/helpers.rs index 7196aa79..efa5ab7a 100644 --- a/src-tauri/src/commands/helpers.rs +++ b/src-tauri/src/commands/helpers.rs @@ -251,12 +251,13 @@ pub async fn connection_host_port( return Ok((config.host.clone(), config.port)); } + // Gate before any transport is established — an unentitled user must + // not spin up tunnels at all. + crate::entitlement::ensure_local_ultimate_global("SSH tunnel")?; + match start_transport_layers(connection_id, &layers, &config.host, config.port, tunnels).await? { - Some(local_port) => { - crate::entitlement::ensure_local_ultimate_global("SSH tunnel")?; - Ok(("127.0.0.1".to_string(), local_port)) - } + Some(local_port) => Ok(("127.0.0.1".to_string(), local_port)), None => Ok((config.host.clone(), config.port)), } } diff --git a/src-tauri/src/common/console.rs b/src-tauri/src/common/console.rs new file mode 100644 index 00000000..45cb8ce1 --- /dev/null +++ b/src-tauri/src/common/console.rs @@ -0,0 +1,27 @@ +//! Shared Geekfun console API plumbing: base URL resolution and a process +//! wide HTTP client (the console endpoints are low-frequency, so one client +//! is built once and reused). + +use std::sync::OnceLock; +use std::time::Duration; + +const CONSOLE_PROD_URL: &str = "https://console-geekfun.wentsen.com"; +const CONSOLE_DEV_URL: &str = "http://localhost:5174"; + +pub fn api_base_url() -> &'static str { + if cfg!(debug_assertions) { + CONSOLE_DEV_URL + } else { + CONSOLE_PROD_URL + } +} + +pub fn client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("reqwest client with static configuration") + }) +} diff --git a/src-tauri/src/common/mod.rs b/src-tauri/src/common/mod.rs index 23884aaf..6319464a 100644 --- a/src-tauri/src/common/mod.rs +++ b/src-tauri/src/common/mod.rs @@ -1,2 +1,3 @@ +pub mod console; pub mod format; pub mod http_client; diff --git a/src-tauri/src/device_activation.rs b/src-tauri/src/device_activation.rs index f742406b..d1bbb07a 100644 --- a/src-tauri/src/device_activation.rs +++ b/src-tauri/src/device_activation.rs @@ -13,23 +13,12 @@ use serde_json::json; use std::path::PathBuf; use tauri::{AppHandle, Emitter, State}; +use crate::common::console; use crate::device_identity; use crate::session::{self, SessionState}; -const CONSOLE_PROD_URL: &str = "https://console-geekfun.wentsen.com"; -const CONSOLE_DEV_URL: &str = "http://localhost:5174"; -const HTTP_TIMEOUT_SECS: u64 = 10; - pub const DEVICE_LIMIT_ERROR_TYPE: &str = "DEVICE_LIMIT_REACHED"; -fn api_base_url() -> &'static str { - if cfg!(debug_assertions) { - CONSOLE_DEV_URL - } else { - CONSOLE_PROD_URL - } -} - pub struct DeviceIdentityState { app_data_dir: PathBuf, } @@ -122,26 +111,33 @@ enum ActivateAttempt { Failed(String), } -async fn post_activate( - token: &str, +/// Request body for one activation attempt — pure so the replace flow stays +/// testable without HTTP. +fn activation_body( payload: &device_identity::DevicePayload, replace_device_id: Option<&str>, -) -> ActivateAttempt { - let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) - .build() - { - Ok(client) => client, - Err(e) => return ActivateAttempt::Failed(format!("failed to build http client: {e}")), - }; - let url = format!("{}/api/v1/devices/activate", api_base_url()); - +) -> serde_json::Value { let mut body = json!({ "device": payload }); if let Some(replace) = replace_device_id { body["replaceDeviceId"] = json!(replace); } + body +} - let response = match client.post(url).bearer_auth(token).json(&body).send().await { +async fn post_activate( + token: &str, + payload: &device_identity::DevicePayload, + replace_device_id: Option<&str>, +) -> ActivateAttempt { + let url = format!("{}/api/v1/devices/activate", console::api_base_url()); + + let response = match console::client() + .post(url) + .bearer_auth(token) + .json(&activation_body(payload, replace_device_id)) + .send() + .await + { Ok(response) => response, Err(e) => return ActivateAttempt::Failed(format!("network error: {e}")), }; @@ -181,11 +177,12 @@ async fn post_activate( /// Activate this device for the account. Idempotent on the server — safe to /// call at every entitlement-activation point. A stale access token (401) -/// is transparently recovered via the stored refresh lease, and the new -/// access token is broadcast so the frontend session stays in sync. +/// is transparently recovered via the frontend-supplied refresh lease (the +/// successor pair is broadcast so the persisted session stays in sync). #[tauri::command] pub async fn activate_device( token: String, + refresh_token: Option, replace_device_id: Option, state: State<'_, DeviceIdentityState>, session_state: State<'_, SessionState>, @@ -199,24 +196,30 @@ pub async fn activate_device( let mut attempt = post_activate(&token, &payload, replace_device_id.as_deref()).await; if let ActivateAttempt::Unauthorized = attempt { - if let Ok(refreshed) = session::rotate_session(&session_state, &payload).await { - let _ = app.emit("session-refreshed", refreshed.access_token.clone()); - attempt = post_activate( - &refreshed.access_token, - &payload, - replace_device_id.as_deref(), - ) - .await; + let lease = refresh_token.as_deref().unwrap_or("").trim(); + if !lease.is_empty() { + if let Ok(refreshed) = session::rotate_session(&session_state, lease, &payload).await { + let _ = app.emit( + "session-refreshed", + json!({ + "accessToken": refreshed.access_token, + "refreshToken": refreshed.refresh_token, + }), + ); + attempt = post_activate( + &refreshed.access_token, + &payload, + replace_device_id.as_deref(), + ) + .await; + } } } match attempt { - ActivateAttempt::Ok(result) => { - if let Some(refresh_token) = &result.refresh_token { - session::persist_token(&session_state, refresh_token); - } - Ok(result) - } + // The device-bound lease rides back to the frontend, which persists + // it alongside the rest of the account session. + ActivateAttempt::Ok(result) => Ok(result), ActivateAttempt::LimitReached(info) => Err(device_limit_error(&info)), ActivateAttempt::Unauthorized => Err("session expired — please sign in again".to_string()), ActivateAttempt::Failed(message) => Err(message), @@ -300,7 +303,7 @@ mod tests { } #[test] - fn payload_bodies_carry_the_device_and_optional_replace_target() { + fn activation_bodies_carry_the_device_and_optional_replace_target() { let identity = device_identity::RawIdentity { primary: Some("PLATFORM".to_string()), secondary: vec![], @@ -308,9 +311,11 @@ mod tests { }; let composed = device_identity::compose_payload(&identity, "install", "n", "linux"); - let mut without_replace = json!({ "device": composed }); + let without_replace = activation_body(&composed, None); assert!(without_replace.get("replaceDeviceId").is_none()); - without_replace["replaceDeviceId"] = json!("dev_9"); - assert_eq!(without_replace["replaceDeviceId"], "dev_9"); + assert_eq!(without_replace["device"]["platform"], "linux"); + + let with_replace = activation_body(&composed, Some("dev_9")); + assert_eq!(with_replace["replaceDeviceId"], "dev_9"); } } diff --git a/src-tauri/src/entitlement.rs b/src-tauri/src/entitlement.rs index 8d63cd49..bf5a3777 100644 --- a/src-tauri/src/entitlement.rs +++ b/src-tauri/src/entitlement.rs @@ -16,18 +16,20 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tauri::{AppHandle, Emitter, State}; -/// Release date of the running app version (UTC, `YYYY-MM-DD`). Bump on -/// every release; a version stays unlocked forever when -/// `APP_RELEASE_DATE <= versionLockHorizon`. -pub const APP_RELEASE_DATE: &str = "2026-09-12"; +use crate::common::console; + +/// Release date of the running app version (UTC, `YYYY-MM-DD`). The release +/// workflow stamps the build date via the `APP_RELEASE_DATE` env var; the +/// fallback only covers local/dev builds — bump it on release. A version +/// stays unlocked forever when `APP_RELEASE_DATE <= versionLockHorizon`. +pub const APP_RELEASE_DATE: &str = match option_env!("APP_RELEASE_DATE") { + Some(date) => date, + None => "2026-09-12", +}; /// Minimum interval between two network refreshes (contract rule 2). pub const REFRESH_MIN_INTERVAL_MS: i64 = 5 * 60 * 1000; -const CONSOLE_PROD_URL: &str = "https://console-geekfun.wentsen.com"; -const CONSOLE_DEV_URL: &str = "http://localhost:5174"; -const HTTP_TIMEOUT_SECS: u64 = 10; - pub const ENTITLEMENT_ERROR_TYPE: &str = "ENTITLEMENT_REQUIRED"; enum SubscriptionsError { @@ -36,14 +38,6 @@ enum SubscriptionsError { Other(String), } -fn subscriptions_base_url() -> &'static str { - if cfg!(debug_assertions) { - CONSOLE_DEV_URL - } else { - CONSOLE_PROD_URL - } -} - /// Days since 1970-01-01 for a civil UTC date (Howard Hinnant's algorithm). fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { let y = if m <= 2 { y - 1 } else { y }; @@ -147,6 +141,18 @@ pub struct SubscriptionCache { pub cancel_scheduled_at: Option, } +/// Pure entitlement decision — no transport or caching semantics. +pub struct EntitlementDecision { + /// Active benefit (paid period or trial) — gates cloud services and, + /// while active, local features too. + pub ultimate_active: bool, + /// This app release falls under the version lock — permanent offline + /// access to local Ultimate features. + pub version_locked: bool, + /// `ultimateActive || versionLocked` — the gate for local features. + pub local_ultimate: bool, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct EntitlementView { @@ -171,7 +177,7 @@ pub struct EntitlementView { /// Pure entitlement decision. Fail-closed: an unparseable release date or /// missing horizon locks the release out of version-locked features. -pub fn compute_entitlement(cache: Option<&SubscriptionCache>, now_ms: i64) -> EntitlementView { +pub fn compute_entitlement(cache: Option<&SubscriptionCache>, now_ms: i64) -> EntitlementDecision { let release_ms = parse_date_utc_ms(APP_RELEASE_DATE); let ultimate_active = cache .and_then(|c| c.ultimate_expires_at.as_deref()) @@ -185,17 +191,10 @@ pub fn compute_entitlement(cache: Option<&SubscriptionCache>, now_ms: i64) -> En .is_some_and(|horizon| release_ms <= horizon), _ => false, }; - EntitlementView { + EntitlementDecision { ultimate_active, version_locked, local_ultimate: ultimate_active || version_locked, - app_release_date: APP_RELEASE_DATE, - ultimate_expires_at: cache.and_then(|c| c.ultimate_expires_at.clone()), - version_lock_horizon: cache.and_then(|c| c.version_lock_horizon.clone()), - cancel_scheduled_at: cache.and_then(|c| c.cancel_scheduled_at.clone()), - cached: true, - fetched_at_ms: cache.map(|c| c.fetched_at_ms), - last_error: None, } } @@ -236,10 +235,21 @@ impl EntitlementState { pub fn view(&self, cached: bool, last_error: Option) -> EntitlementView { let binding = self.cache.lock().unwrap_or_else(|e| e.into_inner()); - let mut view = compute_entitlement(binding.as_ref(), now_unix_ms()); - view.cached = cached; - view.last_error = last_error; - view + let decision = compute_entitlement(binding.as_ref(), now_unix_ms()); + EntitlementView { + ultimate_active: decision.ultimate_active, + version_locked: decision.version_locked, + local_ultimate: decision.local_ultimate, + app_release_date: APP_RELEASE_DATE, + ultimate_expires_at: binding.as_ref().and_then(|c| c.ultimate_expires_at.clone()), + version_lock_horizon: binding + .as_ref() + .and_then(|c| c.version_lock_horizon.clone()), + cancel_scheduled_at: binding.as_ref().and_then(|c| c.cancel_scheduled_at.clone()), + cached, + fetched_at_ms: binding.as_ref().map(|c| c.fetched_at_ms), + last_error, + } } pub fn local_entitled(&self) -> bool { @@ -305,12 +315,8 @@ pub fn ensure_local_ultimate_global(feature: &str) -> Result<(), String> { } async fn fetch_subscriptions(token: &str) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) - .build() - .map_err(|e| SubscriptionsError::Other(format!("failed to build http client: {e}")))?; - let url = format!("{}/api/v1/subscriptions", subscriptions_base_url()); - let response = client + let url = format!("{}/api/v1/subscriptions", console::api_base_url()); + let response = console::client() .get(url) .bearer_auth(token) .send() @@ -350,10 +356,13 @@ async fn fetch_subscriptions(token: &str) -> Result, force: bool, state: State<'_, EntitlementState>, session_state: State<'_, crate::session::SessionState>, @@ -372,9 +381,19 @@ pub async fn refresh_entitlement( Ok(state.view(false, None)) } Err(SubscriptionsError::Unauthorized) => { - match crate::session::rotate_session(&session_state, &identity.payload()).await { + let lease = refresh_token.as_deref().unwrap_or("").trim(); + if lease.is_empty() { + return Ok(state.view(true, Some("session expired".to_string()))); + } + match crate::session::rotate_session(&session_state, lease, &identity.payload()).await { Ok(refreshed) => { - let _ = app.emit("session-refreshed", refreshed.access_token.clone()); + let _ = app.emit( + "session-refreshed", + json!({ + "accessToken": refreshed.access_token, + "refreshToken": refreshed.refresh_token, + }), + ); match fetch_subscriptions(&refreshed.access_token).await { Ok(cache) => { state.set_cache(cache); @@ -383,7 +402,13 @@ pub async fn refresh_entitlement( Err(_) => Ok(state.view(true, Some("session refresh failed".to_string()))), } } - Err(err) => Ok(state.view(true, Some(err))), + Err(err) => { + if err.contains(crate::session::SESSION_REJECTED_ERROR_TYPE) { + Err(err) + } else { + Ok(state.view(true, Some(err))) + } + } } } Err(SubscriptionsError::Other(err)) => Ok(state.view(true, Some(err))), @@ -440,23 +465,25 @@ mod tests { #[test] fn trial_is_active_but_never_version_locked() { - let view = compute_entitlement(cache(Some("2026-10-09T00:00:00.000Z"), None).as_ref(), NOW); - assert!(view.ultimate_active); - assert!(!view.version_locked); - assert!(view.local_ultimate); + let decision = + compute_entitlement(cache(Some("2026-10-09T00:00:00.000Z"), None).as_ref(), NOW); + assert!(decision.ultimate_active); + assert!(!decision.version_locked); + assert!(decision.local_ultimate); } #[test] fn expired_trial_without_horizon_falls_back_to_free() { - let view = compute_entitlement(cache(Some("2020-01-01T00:00:00.000Z"), None).as_ref(), NOW); - assert!(!view.ultimate_active); - assert!(!view.version_locked); - assert!(!view.local_ultimate); + let decision = + compute_entitlement(cache(Some("2020-01-01T00:00:00.000Z"), None).as_ref(), NOW); + assert!(!decision.ultimate_active); + assert!(!decision.version_locked); + assert!(!decision.local_ultimate); } #[test] fn expired_subscription_stays_version_locked_for_older_releases() { - let view = compute_entitlement( + let decision = compute_entitlement( cache( Some("2020-01-01T00:00:00.000Z"), Some("2027-01-01T00:00:00.000Z"), @@ -464,15 +491,19 @@ mod tests { .as_ref(), NOW, ); - assert!(!view.ultimate_active); - assert!(view.version_locked); - assert!(view.local_ultimate); + assert!(!decision.ultimate_active); + assert!(decision.version_locked); + assert!(decision.local_ultimate); } #[test] fn releases_after_the_horizon_need_renewal() { - let view = compute_entitlement(cache(None, Some("2020-01-01T00:00:00.000Z")).as_ref(), NOW); - assert!(view.version_locked == (parse_date_utc_ms(APP_RELEASE_DATE).unwrap() <= 0)); + let decision = + compute_entitlement(cache(None, Some("2020-01-01T00:00:00.000Z")).as_ref(), NOW); + assert!( + decision.version_locked == (parse_date_utc_ms(APP_RELEASE_DATE).unwrap() <= 0), + "release date must stay a plain YYYY-MM-DD for the version-lock check" + ); let fresh = compute_entitlement(cache(None, Some("2999-01-01T00:00:00.000Z")).as_ref(), NOW); assert!(fresh.version_locked); @@ -481,10 +512,10 @@ mod tests { #[test] fn no_cache_means_community_mode() { - let view = compute_entitlement(None, NOW); - assert!(!view.ultimate_active); - assert!(!view.version_locked); - assert!(!view.local_ultimate); + let decision = compute_entitlement(None, NOW); + assert!(!decision.ultimate_active); + assert!(!decision.version_locked); + assert!(!decision.local_ultimate); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 76b6e04b..304eb0b1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -162,9 +162,7 @@ pub fn run() { app.manage(crate::device_activation::DeviceIdentityState::load( app_data_dir.clone(), )); - app.manage(crate::session::SessionState { - app_data_dir: app_data_dir.clone(), - }); + app.manage(crate::session::SessionState::default()); let config = crate::mcp_bridge::McpConfig::load(&app_data_dir); let mcp_entitled = app .state::() @@ -351,8 +349,6 @@ pub fn run() { crate::entitlement::get_entitlement, crate::entitlement::clear_entitlement, crate::device_activation::activate_device, - crate::session::refresh_session, - crate::session::clear_session, crate::mcp_bridge::get_mcp_status, crate::mcp_bridge::save_mcp_config, crate::mcp_bridge::save_mcp_policy, diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 96ece534..225427c0 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -1,79 +1,36 @@ //! Refresh-token session (geekfun#59 §2.2 / cloud-sync P0). //! //! The refresh token is an opaque 30-day bearer credential rotated on every -//! use; the raw value lives only on this machine — macOS Keychain / -//! Windows Credential Manager / Linux Secret Service via `keyring`, with a -//! 0600-style app-data file fallback for machines without a keyring -//! service. Presenting a rotated token server-side is treated as a leak and -//! revokes the device scope, so rotation failures must never keep the old -//! token: `rotate_session` persists the successor before returning. +//! use. Storage lives with the frontend session (the persisted account +//! store); this module owns only the rotation protocol: present the current +//! lease + device identity, receive the successor pair, and classify +//! rejections so the frontend can drop dead leases. +//! +//! Rotations serialize through `SessionState::rotate_lock`: the lease is +//! single-use and the server treats presenting an already-rotated token as a +//! leak that revokes the device scope, so two concurrent rotations must +//! never race. use serde::{Deserialize, Serialize}; use serde_json::json; -use std::path::PathBuf; -use tauri::{AppHandle, Emitter, State}; -use crate::device_activation::DeviceIdentityState; +use crate::common::console; use crate::device_identity; -const CONSOLE_PROD_URL: &str = "https://console-geekfun.wentsen.com"; -const CONSOLE_DEV_URL: &str = "http://localhost:5174"; -const HTTP_TIMEOUT_SECS: u64 = 10; - -const KEYCHAIN_SERVICE: &str = "geekfun/sqlkit/refresh-token"; -const KEYCHAIN_USER: &str = "default"; -const FALLBACK_FILE: &str = "device-refresh-token"; - -pub fn api_base_url() -> &'static str { - if cfg!(debug_assertions) { - CONSOLE_DEV_URL - } else { - CONSOLE_PROD_URL - } -} +pub const SESSION_REJECTED_ERROR_TYPE: &str = "SESSION_REJECTED"; +#[derive(Default)] pub struct SessionState { - pub app_data_dir: PathBuf, -} - -fn keyring_entry() -> Result { - keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER) - .map_err(|e| format!("keyring entry unavailable: {e}")) -} - -pub fn persist_token(state: &SessionState, token: &str) { - let stored = keyring_entry().and_then(|entry| { - entry - .set_password(token) - .map_err(|e| format!("keychain write failed: {e}")) - }); - if let Err(err) = stored { - log::warn!("refresh token keychain storage unavailable ({err}) — using file fallback"); - if let Err(e) = std::fs::write(state.app_data_dir.join(FALLBACK_FILE), token) { - log::error!("failed to persist refresh token: {e}"); - } - } + /// See module docs — guards the present-then-replace window. + pub rotate_lock: tokio::sync::Mutex<()>, } -pub fn load_token(state: &SessionState) -> Option { - if let Ok(entry) = keyring_entry() { - if let Ok(token) = entry.get_password() { - if !token.is_empty() { - return Some(token); - } - } - } - std::fs::read_to_string(state.app_data_dir.join(FALLBACK_FILE)) - .ok() - .map(|raw| raw.trim().to_string()) - .filter(|raw| !raw.is_empty()) -} - -pub fn clear_token(state: &SessionState) { - if let Ok(entry) = keyring_entry() { - let _ = entry.delete_credential(); - } - let _ = std::fs::remove_file(state.app_data_dir.join(FALLBACK_FILE)); +pub fn session_rejected_error(message: &str) -> String { + json!({ + "error_type": SESSION_REJECTED_ERROR_TYPE, + "message": message, + }) + .to_string() } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -84,114 +41,62 @@ pub struct RefreshedSession { pub refresh_token: String, } -/// Exchange the stored lease for a fresh pair. The persisted refresh token -/// is replaced with the successor (single-use semantics) before returning. +/// Exchange a lease for the successor pair. The caller must surface the +/// returned refresh token to the frontend (which persists it) before +/// rotating again. +/// +/// A deliberate rejection (HTTP 401/403, or a success-status envelope whose +/// code differs from 2000) returns the structured `SESSION_REJECTED` error +/// so the frontend drops the dead lease; transient failures return plain +/// errors and the lease is kept. pub async fn rotate_session( state: &SessionState, + refresh_token: &str, device_payload: &device_identity::DevicePayload, ) -> Result { - let Some(refresh_token) = load_token(state) else { + let refresh_token = refresh_token.trim(); + if refresh_token.is_empty() { return Err("no stored refresh token".to_string()); - }; + } - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) - .build() - .map_err(|e| format!("failed to build http client: {e}"))?; - let response = client - .post(format!("{}/api/v1/auth/refresh", api_base_url())) + let _guard = state.rotate_lock.lock().await; + let response = console::client() + .post(format!("{}/api/v1/auth/refresh", console::api_base_url())) .json(&json!({ "refresh_token": refresh_token, "device": device_payload })) .send() .await .map_err(|e| format!("network error: {e}"))?; + let status = response.status(); let raw: serde_json::Value = response .json() .await .map_err(|e| format!("invalid refresh payload: {e}"))?; + let code = raw.get("code").and_then(|v| v.as_u64()).unwrap_or(0); - if code != 2000 { - // Rejected (expired / revoked / reuse) — the stored lease is dead. - clear_token(state); + let server_rejected = status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + || (status.is_success() && code != 2000); + if server_rejected { let message = raw .get("messages") .and_then(|m| m.get(0)) .and_then(|m| m.as_str()) .unwrap_or("session refresh rejected") .to_string(); - return Err(message); + return Err(session_rejected_error(&message)); } let data = raw.get("data").cloned().unwrap_or(json!({})); let session: RefreshedSession = serde_json::from_value(data).map_err(|e| format!("invalid refresh result: {e}"))?; - persist_token(state, &session.refresh_token); Ok(session) } -/// Fire the refresh loop explicitly; broadcasts the new access token so the -/// frontend can update its stored session. -#[tauri::command] -pub async fn refresh_session( - app: AppHandle, - session: State<'_, SessionState>, - identity: State<'_, DeviceIdentityState>, -) -> Result { - let result = rotate_session(&session, &identity.payload()).await; - if let Ok(refreshed) = &result { - let _ = app.emit("session-refreshed", refreshed.access_token.clone()); - } - result -} - -/// Logout: the lease must not outlive the account on this machine. -#[tauri::command] -pub fn clear_session(session: State<'_, SessionState>) { - clear_token(&session); -} - #[cfg(test)] mod tests { use super::*; - struct TestState(PathBuf); - - impl TestState { - fn new() -> Self { - let dir = std::env::temp_dir().join(format!( - "dockit-session-test-{}", - uuid::Uuid::new_v4().simple() - )); - std::fs::create_dir_all(&dir).expect("temp dir"); - TestState(dir) - } - } - - impl Drop for TestState { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - - #[test] - fn file_fallback_roundtrips_and_clears() { - let state = TestState::new(); - let session_state = SessionState { - app_data_dir: state.0.clone(), - }; - - assert_eq!(load_token(&session_state), None); - - std::fs::write(state.0.join(FALLBACK_FILE), "raw-lease-token\n").unwrap(); - assert_eq!( - load_token(&session_state).as_deref(), - Some("raw-lease-token") - ); - - clear_token(&session_state); - assert_eq!(load_token(&session_state), None); - } - #[test] fn refreshed_session_deserializes_the_envelope_shape() { // the backend keeps access_token-style snake_case for token fields @@ -203,4 +108,11 @@ mod tests { assert_eq!(session.access_token, "jwt-value"); assert_eq!(session.refresh_token, "opaque-lease"); } + + #[test] + fn rejection_error_is_structured() { + let raw = session_rejected_error("lease expired"); + assert!(raw.contains(SESSION_REJECTED_ERROR_TYPE)); + assert!(raw.contains("lease expired")); + } } diff --git a/src/App.vue b/src/App.vue index 5cd4016c..763b137f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -51,9 +51,10 @@ onMounted(async () => { }) // Transparent session refresh (Rust rotates the lease): keep the frontend - // copy of the access token in sync. - unlistenSessionRefresh = await listen('session-refreshed', ({ payload }) => { - accountStore.setToken(payload) + // copy of both tokens in sync. + unlistenSessionRefresh = await listen<{ accessToken: string, refreshToken: string }>('session-refreshed', ({ payload }) => { + accountStore.setToken(payload.accessToken) + accountStore.setRefreshToken(payload.refreshToken) }) }) diff --git a/src/common/entitlement.ts b/src/common/entitlement.ts index 62690758..db6d38df 100644 --- a/src/common/entitlement.ts +++ b/src/common/entitlement.ts @@ -1,4 +1,5 @@ export const ENTITLEMENT_ERROR_TYPE = 'ENTITLEMENT_REQUIRED' +export const SESSION_REJECTED_ERROR_TYPE = 'SESSION_REJECTED' export const UPGRADE_URL = 'https://www.geekfun.club/pricing' @@ -17,18 +18,34 @@ export type EntitlementView = { lastError: string | null } -export function isEntitlementError(error: unknown): boolean { +/** + * Rust commands signal structured outcomes via JSON error strings carrying + * an `error_type` field. + */ +export function errorCarriesType(error: unknown, type: string): boolean { if (!error) return false if (typeof error === 'object' && 'errorType' in error) { - return (error as { errorType?: string }).errorType === ENTITLEMENT_ERROR_TYPE + return (error as { errorType?: string }).errorType === type } const raw = typeof error === 'string' ? error : String(error) try { const parsed = JSON.parse(raw) as { error_type?: string } - return parsed.error_type === ENTITLEMENT_ERROR_TYPE + return parsed.error_type === type } catch { - return raw.includes(ENTITLEMENT_ERROR_TYPE) + return raw.includes(type) } } + +export function isEntitlementError(error: unknown): boolean { + return errorCarriesType(error, ENTITLEMENT_ERROR_TYPE) +} + +/** + * The refresh lease was deliberately rejected server-side (expired / + * revoked / reuse) — the stored lease is dead and must be dropped. + */ +export function isSessionRejected(error: unknown): boolean { + return errorCarriesType(error, SESSION_REJECTED_ERROR_TYPE) +} diff --git a/src/store/accountStore.ts b/src/store/accountStore.ts index aeb29511..5fcf0ccf 100644 --- a/src/store/accountStore.ts +++ b/src/store/accountStore.ts @@ -1,8 +1,12 @@ -import { invoke } from '@tauri-apps/api/core' import { defineStore } from 'pinia' type AccountState = { token: string + /** + * Device-bound 30-day lease (geekfun#59) — lives with the session so a + * logout (or a web re-login) invalidates it with the rest. + */ + refreshToken: string username: string email: string } @@ -10,6 +14,7 @@ type AccountState = { export const useAccountStore = defineStore('account', { state: (): AccountState => ({ token: '', + refreshToken: '', username: '', email: '', }), @@ -22,18 +27,20 @@ export const useAccountStore = defineStore('account', { this.token = token this.username = username this.email = email + // A web login has no device lease yet — never carry one over. + this.refreshToken = '' }, setToken(token: string) { this.token = token }, + setRefreshToken(token: string) { + this.refreshToken = token + }, clearAuth() { this.token = '' + this.refreshToken = '' this.username = '' this.email = '' - // the refresh lease must not outlive the account on this machine - invoke('clear_session').catch(() => { - // best effort — local-only logout still applies - }) }, }, }) diff --git a/src/store/deviceStore.ts b/src/store/deviceStore.ts index c60ac030..c72f4fe4 100644 --- a/src/store/deviceStore.ts +++ b/src/store/deviceStore.ts @@ -25,6 +25,11 @@ export type ActivatedResult = { deviceId: string limit: number used: number + /** + * Device-bound lease issued/renewed by this activation — persisted with + * the account session. Optional: older backends may omit it. + */ + refreshToken?: string | null } /** One activation attempt per app run, plus re-activation after each login. */ @@ -72,13 +77,10 @@ export const useDeviceStore = defineStore('device', { try { const result = await invoke('activate_device', { token: accountStore.token, + refreshToken: accountStore.refreshToken || null, replaceDeviceId: null, }) - this.deviceId = result.deviceId - this.limit = result.limit - this.used = result.used - this.limitInfo = null - this.showReplaceDialog = false + this.applyActivated(result) localStorage.setItem(LAST_ACTIVATED_KEY, String(Date.now())) } catch (err) { @@ -102,13 +104,10 @@ export const useDeviceStore = defineStore('device', { try { const result = await invoke('activate_device', { token: accountStore.token, + refreshToken: accountStore.refreshToken || null, replaceDeviceId: deviceId, }) - this.deviceId = result.deviceId - this.limit = result.limit - this.used = result.used - this.limitInfo = null - this.showReplaceDialog = false + this.applyActivated(result) localStorage.setItem(LAST_ACTIVATED_KEY, String(Date.now())) return true } @@ -125,6 +124,21 @@ export const useDeviceStore = defineStore('device', { this.activating = false } }, + /** + * Persist an activation outcome: device slots plus the (possibly + * renewed) device-bound lease, which rides back to the account session. + */ + applyActivated(result: ActivatedResult): void { + const accountStore = useAccountStore() + this.deviceId = result.deviceId + this.limit = result.limit + this.used = result.used + this.limitInfo = null + this.showReplaceDialog = false + if (result.refreshToken) { + accountStore.setRefreshToken(result.refreshToken) + } + }, dismissReplaceDialog(): void { this.showReplaceDialog = false }, diff --git a/src/store/entitlementStore.ts b/src/store/entitlementStore.ts index 9f18ff4e..234a79b4 100644 --- a/src/store/entitlementStore.ts +++ b/src/store/entitlementStore.ts @@ -1,12 +1,7 @@ -import type { EntitlementView, PaidFeature } from '../common' +import type { EntitlementView } from '../common' import { invoke } from '@tauri-apps/api/core' import { defineStore } from 'pinia' -import { - ENTITLEMENT_ERROR_TYPE, - - isEntitlementError, - -} from '../common' +import { isEntitlementError, isSessionRejected } from '../common' import { useAccountStore } from './accountStore' export type PlanState = 'ultimate' | 'community' @@ -28,27 +23,22 @@ export const useEntitlementStore = defineStore('entitlement', { try { this.view = await invoke('refresh_entitlement', { token: accountStore.token, + refreshToken: accountStore.refreshToken || null, force, }) } catch (e) { - if (!isEntitlementError(e)) { + if (isSessionRejected(e)) { + // The lease is dead server-side — drop it so the next login starts + // clean instead of presenting a revoked token. + accountStore.setRefreshToken('') + } + if (!isEntitlementError(e) && !isSessionRejected(e)) { throw e } this.view = null } }, - async ensureLocalUltimate(feature: PaidFeature): Promise { - await this.refreshEntitlement(false) - if (this.isLocalUltimate) { - return true - } - throw Object.assign(new Error(`'${feature}' requires an Ultimate subscription`), { - status: 403, - details: feature, - errorType: ENTITLEMENT_ERROR_TYPE, - }) - }, async clearCachedEntitlement(): Promise { try { await invoke('clear_entitlement') diff --git a/src/views/setting/plan-section.vue b/src/views/setting/plan-section.vue index 86dfda35..9353dbab 100644 --- a/src/views/setting/plan-section.vue +++ b/src/views/setting/plan-section.vue @@ -1,5 +1,5 @@