Compare commits
No commits in common. "cf31666c3581a7dda25866d977767707fa9b170a" and "b684c88d2bdb78d58c6525fbe388e8537d6dbab1" have entirely different histories.
cf31666c35
...
b684c88d2b
6 changed files with 34 additions and 320 deletions
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -4430,7 +4430,6 @@ 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,7 +51,6 @@ 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`
|
||||||
|
|
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
// 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,7 +19,6 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
@ -36,6 +35,7 @@ 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,10 +158,11 @@ 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 with an HMAC signature so the license gating
|
// Store account info (non-secret; stays in the restricted file store).
|
||||||
// path can trust the cached subscription_status without re-calling
|
let dir = auth_dir(&app)?;
|
||||||
// Logto on every entitlement check.
|
let account_json =
|
||||||
account_cache::save(&app, &account)?;
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +195,8 @@ 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 _ = account_cache::delete(&app);
|
let dir = auth_dir(&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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,27 +222,34 @@ 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?;
|
||||||
account_cache::save(&app, &account)?;
|
let dir = auth_dir(&app)?;
|
||||||
|
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. Used for UI display
|
/// Read cached account info without network call.
|
||||||
/// 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> {
|
||||||
account_cache::load_unverified(&app)
|
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))?;
|
||||||
|
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, including the
|
/// Log out: clear all stored tokens and account info.
|
||||||
/// 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)?;
|
||||||
account_cache::delete(&app)?;
|
let dir = auth_dir(&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 HMAC-verified account cache to check for an active Premium
|
/// Read the cached account.json to check for an active Premium subscription.
|
||||||
/// subscription. Legacy unsigned caches (from v0.7.x) and tampered
|
/// Returns None if no account file exists or the file is invalid.
|
||||||
/// 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 account = super::account_cache::load_verified(app).ok().flatten()?;
|
let dir = app_data_dir(app).ok()?.join("auth");
|
||||||
|
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,4 +1,3 @@
|
||||||
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