Compare commits
2 commits
b684c88d2b
...
cf31666c35
| Author | SHA1 | Date | |
|---|---|---|---|
| cf31666c35 | |||
|
|
2d7d1e05d2 |
6 changed files with 320 additions and 34 deletions
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -4430,6 +4430,7 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
|
"hmac",
|
||||||
"hostname",
|
"hostname",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ base64 = "0.22"
|
||||||
# on Linux (libdbus-1-3 is present on every desktop Linux at runtime).
|
# on Linux (libdbus-1-3 is present on every desktop Linux at runtime).
|
||||||
keyring = { version = "3.6", default-features = false, features = ["sync-secret-service", "crypto-rust", "windows-native"] }
|
keyring = { version = "3.6", default-features = false, features = ["sync-secret-service", "crypto-rust", "windows-native"] }
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
|
hmac = "0.12"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`
|
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`
|
||||||
|
|
|
||||||
292
src-tauri/src/commands/account_cache.rs
Normal file
292
src-tauri/src/commands/account_cache.rs
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
// Integrity-protected cache for cached account info.
|
||||||
|
//
|
||||||
|
// The user's subscription tier is used by `license_commands::current_edition`
|
||||||
|
// to unlock Premium features. Until this module, the subscription_status
|
||||||
|
// claim was read directly from a plaintext `account.json` on disk, which
|
||||||
|
// meant any local process (malware, nosy user, curl) could write
|
||||||
|
// `{"subscription_status": "active"}` and bypass the paywall without
|
||||||
|
// ever touching the Logto session. This module closes that trap.
|
||||||
|
//
|
||||||
|
// Approach: an HMAC-SHA256 signature is computed over the serialized
|
||||||
|
// AccountInfo bytes using a per-install key stored in the OS keychain
|
||||||
|
// (via the `keyring` crate, same backend as token_store). The signed
|
||||||
|
// payload is wrapped as `{"data": {...}, "sig": "<base64>"}`. On read,
|
||||||
|
// verification requires the same key; tampering with either `data` or
|
||||||
|
// `sig` invalidates the cache.
|
||||||
|
//
|
||||||
|
// Trust chain:
|
||||||
|
// - Key lives in the keychain, scoped to service "com.simpl.resultat",
|
||||||
|
// user "account-hmac-key". Compromising it requires compromising the
|
||||||
|
// keychain, which is the existing trust boundary for OAuth tokens.
|
||||||
|
// - A legacy unsigned `account.json` (from v0.7.x) is still readable
|
||||||
|
// for display purposes (email, name, picture), but the gating path
|
||||||
|
// uses `load_verified` which returns None for legacy payloads —
|
||||||
|
// Premium features stay locked until the next token refresh rewrites
|
||||||
|
// the file with a signature.
|
||||||
|
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use super::auth_commands::AccountInfo;
|
||||||
|
use super::token_store::{auth_dir, write_restricted};
|
||||||
|
|
||||||
|
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
|
||||||
|
const KEYCHAIN_USER_HMAC: &str = "account-hmac-key";
|
||||||
|
const ACCOUNT_FILE: &str = "account.json";
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct SignedAccount {
|
||||||
|
data: AccountInfo,
|
||||||
|
sig: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the HMAC key from the keychain, creating a fresh random key on
|
||||||
|
/// first use. The key is never persisted to disk — if the keychain is
|
||||||
|
/// unreachable, the whole cache signing/verification path falls back
|
||||||
|
/// to "not signed" which means gating stays locked until the keychain
|
||||||
|
/// is available again. This is intentional (fail-closed).
|
||||||
|
fn get_or_create_key() -> Result<[u8; 32], String> {
|
||||||
|
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC)
|
||||||
|
.map_err(|e| format!("Keychain entry init failed: {}", e))?;
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(b64) => decode_key(&b64),
|
||||||
|
Err(keyring::Error::NoEntry) => {
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut key);
|
||||||
|
let encoded = encode_key(&key);
|
||||||
|
entry
|
||||||
|
.set_password(&encoded)
|
||||||
|
.map_err(|e| format!("Keychain HMAC key write failed: {}", e))?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("Keychain HMAC key read failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_key(key: &[u8]) -> String {
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
STANDARD.encode(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_key(raw: &str) -> Result<[u8; 32], String> {
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
let bytes = STANDARD
|
||||||
|
.decode(raw.trim())
|
||||||
|
.map_err(|e| format!("Invalid HMAC key encoding: {}", e))?;
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
return Err(format!(
|
||||||
|
"HMAC key must be 32 bytes, got {}",
|
||||||
|
bytes.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut arr = [0u8; 32];
|
||||||
|
arr.copy_from_slice(&bytes);
|
||||||
|
Ok(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign(key: &[u8; 32], payload: &[u8]) -> String {
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take any key length");
|
||||||
|
mac.update(payload);
|
||||||
|
STANDARD.encode(mac.finalize().into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify(key: &[u8; 32], payload: &[u8], sig_b64: &str) -> bool {
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
let Ok(sig_bytes) = STANDARD.decode(sig_b64) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(mut mac) = HmacSha256::new_from_slice(key) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
mac.update(payload);
|
||||||
|
mac.verify_slice(&sig_bytes).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the account cache as `{"data": {...}, "sig": "..."}`. The key
|
||||||
|
/// is fetched from (or created in) the keychain. Writes fall back to an
|
||||||
|
/// unsigned legacy-shape payload only when the keychain is unreachable
|
||||||
|
/// — this keeps the UI functional on keychain-less systems but means
|
||||||
|
/// the gating path will refuse to grant Premium until the keychain
|
||||||
|
/// comes back.
|
||||||
|
pub fn save(app: &tauri::AppHandle, account: &AccountInfo) -> Result<(), String> {
|
||||||
|
let dir = auth_dir(app)?;
|
||||||
|
let path = dir.join(ACCOUNT_FILE);
|
||||||
|
|
||||||
|
match get_or_create_key() {
|
||||||
|
Ok(key) => {
|
||||||
|
// Serialise the AccountInfo alone (compact, deterministic
|
||||||
|
// for a given struct layout), sign the bytes, then re-wrap
|
||||||
|
// into the signed envelope. We write the signed envelope
|
||||||
|
// as pretty-printed JSON for readability in the audit log.
|
||||||
|
let data_bytes =
|
||||||
|
serde_json::to_vec(account).map_err(|e| format!("Serialize error: {}", e))?;
|
||||||
|
let sig = sign(&key, &data_bytes);
|
||||||
|
let envelope = SignedAccount {
|
||||||
|
data: account.clone(),
|
||||||
|
sig,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string_pretty(&envelope)
|
||||||
|
.map_err(|e| format!("Serialize envelope error: {}", e))?;
|
||||||
|
write_restricted(&path, &json)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"account_cache: keychain HMAC key unavailable, writing unsigned legacy payload ({})",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
// Fallback: unsigned payload. UI still works, but
|
||||||
|
// `load_verified` will reject this file for gating.
|
||||||
|
let json = serde_json::to_string_pretty(account)
|
||||||
|
.map_err(|e| format!("Serialize error: {}", e))?;
|
||||||
|
write_restricted(&path, &json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the cached account for **display purposes**. Accepts both the
|
||||||
|
/// new signed envelope and legacy plaintext AccountInfo files. Does
|
||||||
|
/// NOT verify the signature — suitable for showing the user's name /
|
||||||
|
/// email / picture in the UI, but never for gating decisions.
|
||||||
|
pub fn load_unverified(app: &tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
|
||||||
|
let dir = auth_dir(app)?;
|
||||||
|
let path = dir.join(ACCOUNT_FILE);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
|
||||||
|
|
||||||
|
// Prefer the signed envelope shape; fall back to legacy flat
|
||||||
|
// AccountInfo so upgraded users see their account info immediately
|
||||||
|
// rather than a blank card until the next token refresh.
|
||||||
|
if let Ok(envelope) = serde_json::from_str::<SignedAccount>(&raw) {
|
||||||
|
return Ok(Some(envelope.data));
|
||||||
|
}
|
||||||
|
if let Ok(flat) = serde_json::from_str::<AccountInfo>(&raw) {
|
||||||
|
return Ok(Some(flat));
|
||||||
|
}
|
||||||
|
Err("Invalid account cache payload".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the cached account and verify the HMAC signature. Used by the
|
||||||
|
/// license gating path (`current_edition`). Returns Ok(None) when:
|
||||||
|
/// - no cache exists,
|
||||||
|
/// - the cache is in legacy unsigned shape (pre-v0.8 or post-fallback),
|
||||||
|
/// - the keychain HMAC key is unreachable,
|
||||||
|
/// - the signature does not verify.
|
||||||
|
///
|
||||||
|
/// Any of these states must cause Premium features to stay locked —
|
||||||
|
/// never accept an unverifiable payload for a gating decision.
|
||||||
|
pub fn load_verified(app: &tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
|
||||||
|
let dir = auth_dir(app)?;
|
||||||
|
let path = dir.join(ACCOUNT_FILE);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
|
||||||
|
|
||||||
|
// Only signed envelopes are acceptable here. Legacy flat payloads
|
||||||
|
// are treated as "no verified account".
|
||||||
|
let Ok(envelope) = serde_json::from_str::<SignedAccount>(&raw) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Ok(key) = get_or_create_key() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let data_bytes = match serde_json::to_vec(&envelope.data) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
if !verify(&key, &data_bytes, &envelope.sig) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(envelope.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the cached account file AND the keychain HMAC key. Called on
|
||||||
|
/// logout so the next login generates a fresh key bound to the new
|
||||||
|
/// session.
|
||||||
|
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let dir = auth_dir(app)?;
|
||||||
|
let path = dir.join(ACCOUNT_FILE);
|
||||||
|
if path.exists() {
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC) {
|
||||||
|
let _ = entry.delete_credential();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_account() -> AccountInfo {
|
||||||
|
AccountInfo {
|
||||||
|
email: "user@example.com".into(),
|
||||||
|
name: Some("Test User".into()),
|
||||||
|
picture: None,
|
||||||
|
subscription_status: Some("active".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_then_verify_same_key() {
|
||||||
|
let key = [7u8; 32];
|
||||||
|
let payload = b"hello world";
|
||||||
|
let sig = sign(&key, payload);
|
||||||
|
assert!(verify(&key, payload, &sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_rejects_tampered_payload() {
|
||||||
|
let key = [7u8; 32];
|
||||||
|
let sig = sign(&key, b"original");
|
||||||
|
assert!(!verify(&key, b"tampered", &sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_rejects_wrong_key() {
|
||||||
|
let key_a = [7u8; 32];
|
||||||
|
let key_b = [8u8; 32];
|
||||||
|
let sig = sign(&key_a, b"payload");
|
||||||
|
assert!(!verify(&key_b, b"payload", &sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_roundtrip_serde() {
|
||||||
|
let account = sample_account();
|
||||||
|
let key = [3u8; 32];
|
||||||
|
let data = serde_json::to_vec(&account).unwrap();
|
||||||
|
let sig = sign(&key, &data);
|
||||||
|
let env = SignedAccount {
|
||||||
|
data: account.clone(),
|
||||||
|
sig,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&env).unwrap();
|
||||||
|
let decoded: SignedAccount = serde_json::from_str(&json).unwrap();
|
||||||
|
let decoded_data = serde_json::to_vec(&decoded.data).unwrap();
|
||||||
|
assert!(verify(&key, &decoded_data, &decoded.sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_decode_key_roundtrip() {
|
||||||
|
let key = [42u8; 32];
|
||||||
|
let encoded = encode_key(&key);
|
||||||
|
let decoded = decode_key(&encoded).unwrap();
|
||||||
|
assert_eq!(decoded, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_key_rejects_wrong_length() {
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
let short = STANDARD.encode([1u8; 16]);
|
||||||
|
assert!(decode_key(&short).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ use std::fs;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use super::account_cache;
|
||||||
use super::token_store::{
|
use super::token_store::{
|
||||||
self, auth_dir, chrono_now, write_restricted, StoredTokens,
|
self, auth_dir, chrono_now, write_restricted, StoredTokens,
|
||||||
};
|
};
|
||||||
|
|
@ -35,7 +36,6 @@ fn logto_app_id() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
const REDIRECT_URI: &str = "simpl-resultat://auth/callback";
|
const REDIRECT_URI: &str = "simpl-resultat://auth/callback";
|
||||||
const ACCOUNT_FILE: &str = "account.json";
|
|
||||||
const LAST_CHECK_FILE: &str = "last_check";
|
const LAST_CHECK_FILE: &str = "last_check";
|
||||||
const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours
|
const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours
|
||||||
|
|
||||||
|
|
@ -158,11 +158,10 @@ pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result
|
||||||
// Fetch user info
|
// Fetch user info
|
||||||
let account = fetch_userinfo(&endpoint, &access_token).await?;
|
let account = fetch_userinfo(&endpoint, &access_token).await?;
|
||||||
|
|
||||||
// Store account info (non-secret; stays in the restricted file store).
|
// Store account info with an HMAC signature so the license gating
|
||||||
let dir = auth_dir(&app)?;
|
// path can trust the cached subscription_status without re-calling
|
||||||
let account_json =
|
// Logto on every entitlement check.
|
||||||
serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?;
|
account_cache::save(&app, &account)?;
|
||||||
write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?;
|
|
||||||
|
|
||||||
Ok(account)
|
Ok(account)
|
||||||
}
|
}
|
||||||
|
|
@ -195,8 +194,7 @@ pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, St
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
// Clear stored tokens on refresh failure.
|
// Clear stored tokens on refresh failure.
|
||||||
let _ = token_store::delete(&app);
|
let _ = token_store::delete(&app);
|
||||||
let dir = auth_dir(&app)?;
|
let _ = account_cache::delete(&app);
|
||||||
let _ = fs::remove_file(dir.join(ACCOUNT_FILE));
|
|
||||||
return Err("Session expired, please sign in again".to_string());
|
return Err("Session expired, please sign in again".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,34 +220,27 @@ pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, St
|
||||||
token_store::save(&app, &new_tokens)?;
|
token_store::save(&app, &new_tokens)?;
|
||||||
|
|
||||||
let account = fetch_userinfo(&endpoint, &new_access).await?;
|
let account = fetch_userinfo(&endpoint, &new_access).await?;
|
||||||
let dir = auth_dir(&app)?;
|
account_cache::save(&app, &account)?;
|
||||||
let account_json =
|
|
||||||
serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?;
|
|
||||||
write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?;
|
|
||||||
|
|
||||||
Ok(account)
|
Ok(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read cached account info without network call.
|
/// Read cached account info without network call. Used for UI display
|
||||||
|
/// only — accepts both signed (v0.8+) and legacy (v0.7.x) payloads so
|
||||||
|
/// upgraded users still see their name/email immediately. The license
|
||||||
|
/// gating path uses `account_cache::load_verified` instead.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_account_info(app: tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
|
pub fn get_account_info(app: tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
|
||||||
let dir = auth_dir(&app)?;
|
account_cache::load_unverified(&app)
|
||||||
let path = dir.join(ACCOUNT_FILE);
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
|
|
||||||
let account: AccountInfo =
|
|
||||||
serde_json::from_str(&raw).map_err(|e| format!("Invalid account file: {}", e))?;
|
|
||||||
Ok(Some(account))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log out: clear all stored tokens and account info.
|
/// Log out: clear all stored tokens and account info, including the
|
||||||
|
/// HMAC key so the next session starts with a fresh cryptographic
|
||||||
|
/// anchor.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn logout(app: tauri::AppHandle) -> Result<(), String> {
|
pub fn logout(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
token_store::delete(&app)?;
|
token_store::delete(&app)?;
|
||||||
let dir = auth_dir(&app)?;
|
account_cache::delete(&app)?;
|
||||||
let _ = fs::remove_file(dir.join(ACCOUNT_FILE));
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,16 +267,16 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
||||||
info.edition
|
info.edition
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the cached account.json to check for an active Premium subscription.
|
/// Read the HMAC-verified account cache to check for an active Premium
|
||||||
/// Returns None if no account file exists or the file is invalid.
|
/// subscription. Legacy unsigned caches (from v0.7.x) and tampered
|
||||||
|
/// payloads return None — Premium features stay locked until the user
|
||||||
|
/// re-authenticates or the next token refresh re-signs the cache.
|
||||||
|
///
|
||||||
|
/// This is intentional: before HMAC signing, any local process could
|
||||||
|
/// write `{"subscription_status": "active"}` to account.json and
|
||||||
|
/// bypass the paywall. Fail-closed is the correct posture here.
|
||||||
fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
|
fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
|
||||||
let dir = app_data_dir(app).ok()?.join("auth");
|
let account = super::account_cache::load_verified(app).ok().flatten()?;
|
||||||
let account_path = dir.join("account.json");
|
|
||||||
if !account_path.exists() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let raw = fs::read_to_string(&account_path).ok()?;
|
|
||||||
let account: super::auth_commands::AccountInfo = serde_json::from_str(&raw).ok()?;
|
|
||||||
match account.subscription_status.as_deref() {
|
match account.subscription_status.as_deref() {
|
||||||
Some("active") => Some(EDITION_PREMIUM.to_string()),
|
Some("active") => Some(EDITION_PREMIUM.to_string()),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod account_cache;
|
||||||
pub mod auth_commands;
|
pub mod auth_commands;
|
||||||
pub mod entitlements;
|
pub mod entitlements;
|
||||||
pub mod export_import_commands;
|
pub mod export_import_commands;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue