# Desktop helpers

Feature-gated module (`features = ["desktop"]`) with OS keychain, AES-256-GCM token cipher, device fingerprint, session store, refresh helper, and license snapshot store. Pairs with [40-loopback](40-loopback.md) for the full sign-in flow.

```rust
use akira_billing::desktop::{
    open_browser,
    TokenCipher, generate_key, CipherError,
    KeyStore, KeyStoreError,
    TokenKeyring,
    device_fingerprint, DeviceFingerprint,
    SessionStore,
    refresh_auth, AuthSnapshot, RefreshOptions,
    LicenseSnapshotStore, SnapshotEntry,
};
```

Pulled crates (optional, only with the `desktop` feature): `keyring`, `machine-uid`, `aes-gcm`.

## open_browser

```rust
pub fn open_browser(url: &str) -> Result<(), String>;
```

Shells out via `std::process::Command::new(cmd)`:

- macOS → `open`
- Windows → `explorer`
- Linux / other → `xdg-open`

Returns `Err(String)` when `spawn` fails. Used by `loopback_login` — pass anything implementing `Fn(&str) -> Result<(), String>` for Tauri/Electron-flavored wrappers.

## TokenCipher

AES-256-GCM string encryption keyed by a 32-byte secret.

```rust
pub struct TokenCipher { /* … */ }

impl TokenCipher {
    pub fn new(key: [u8; 32]) -> Self;
    pub fn encrypt(&self, plaintext: &str) -> Result<String, CipherError>;
    pub fn decrypt(&self, encoded: &str) -> Result<String, CipherError>;
}

pub fn generate_key() -> [u8; 32];

pub enum CipherError {
    KeyLength,
    EncryptFailed,
    DecryptFailed,
    Base64(base64::DecodeError),
}
```

Encryption format: `base64( nonce[12] ‖ ciphertext )`. A fresh nonce is generated per call (12 bytes from `rand::thread_rng()`).

`Cipher: Clone` because the key is a `[u8; 32]`. Wrap in `Arc<TokenCipher>` when sharing across tasks.

## KeyStore

```rust
pub struct KeyStore { /* … */ }

impl KeyStore {
    pub fn new(keyring: TokenKeyring) -> Self;
    pub fn with_debug_file(self, path: PathBuf) -> Self;

    pub fn load_or_create(&self) -> Result<[u8; 32], KeyStoreError>;
}

pub enum KeyStoreError {
    Keyring(String),
    Io(std::io::Error),
    Base64(base64::DecodeError),
    KeyLength,
    Cipher(CipherError),
}
```

Loads a 32-byte AES key from the OS keychain. On first run, generates and stores a fresh key. The optional `with_debug_file(path)` mirrors the key to disk in debug builds (`#[cfg(debug_assertions)]`) so unsigned dev rebuilds can decrypt previously persisted data — the macOS keychain ACL rejects reads from ad-hoc signatures and would otherwise effectively rotate the key on every rebuild.

The debug file path is **only consulted in debug builds**. Production binaries never read or write it.

```rust
let keyring = TokenKeyring::new("io.akira.unified-dev", "default");
let key = KeyStore::new(keyring)
    .with_debug_file(dirs::data_dir().unwrap().join("unified-dev/key.b64"))
    .load_or_create()?;

let cipher = TokenCipher::new(key);
```

## TokenKeyring

```rust
pub struct TokenKeyring { /* … */ }

impl TokenKeyring {
    pub fn new(service: impl Into<String>, account: impl Into<String>) -> Self;

    pub fn get(&self) -> Result<Option<String>, String>;
    pub fn set(&self, value: &str) -> Result<(), String>;
    pub fn delete(&self) -> Result<(), String>;
}
```

Thin wrapper over the `keyring` crate. `service` is conventionally the bundle id (`io.akira.unified-dev`); `account` is the customer id or a fixed `"default"` for single-account apps.

`get` returns `Ok(None)` (not `Err`) when the entry is missing. `delete` also treats `NoEntry` as success — idempotent.

## device_fingerprint

```rust
pub fn device_fingerprint(app_version: &str) -> DeviceFingerprint;

pub struct DeviceFingerprint {
    pub fingerprint: String,         // sha256(machine_id :: platform :: app_version), hex
    pub platform: String,            // std::env::consts::OS
    pub app_version: String,
}
```

Uses `machine_uid::get()` for the machine identifier; falls back to `"unknown"` when the call fails. The raw machine id never leaves the function — only the SHA-256 digest is exposed.

```rust
let fp = device_fingerprint(env!("CARGO_PKG_VERSION"));
client.license_activate(LicenseActivatePayload {
    product: "unified-dev",
    device_type: "desktop",
    platform: Some(&fp.platform),
    fingerprint: &fp.fingerprint,
    /* … */
}).await?;
```

## SessionStore

```rust
pub struct SessionStore { /* … */ }

impl SessionStore {
    pub fn new(keyring: TokenKeyring) -> Self;

    pub fn hydrate(&self, client: &mut Client) -> Result<bool, String>;
    pub fn persist(&self, client: &mut Client, token: &str) -> Result<(), String>;
    pub fn clear(&self, client: &mut Client) -> Result<(), String>;
    pub fn has_token(&self) -> bool;
}
```

| Method | Behaviour |
|--------|-----------|
| `hydrate` | Read the saved token, push it onto the client. Returns `Ok(false)` when no token exists. |
| `persist` | Write to keyring AND `client.set_customer_token`. |
| `clear` | Delete the keyring entry AND `client.clear_customer_token`. |
| `has_token` | Best-effort check; returns `false` on any error. |

`SessionStore` is `Clone`. Wrap in `Arc<Mutex<SessionStore>>` if you need shared mutable state across tasks — the methods themselves take `&self` but mutate the client.

## refresh_auth

```rust
pub async fn refresh_auth(
    client: &Client,
    opts: RefreshOptions<'_>,
) -> Result<AuthSnapshot, Error>;

pub struct RefreshOptions<'a> {
    pub product: &'a str,
    pub fallback_feature: &'a str,
}

pub struct AuthSnapshot {
    pub authenticated: bool,
    pub licensed: bool,
    pub customer: Option<Customer>,
    pub features: Vec<String>,
}

impl AuthSnapshot {
    pub fn guest() -> Self;
    pub fn has_feature(&self, key: &str) -> bool;
}
```

Sequence:

1. No bearer → `AuthSnapshot::guest()`.
2. `customer_me()` — 401 → return guest (token expired); other errors propagate.
3. `customer_features(product)` — failure falls through to empty features (license check decides).
4. `licensed = features.is_empty() ? (license_check(fallback_feature).allowed) : true`.

The function never mutates the client — caller handles `clear` on guest fallback if desired.

```rust
let snapshot = refresh_auth(&client, RefreshOptions {
    product: "unified-dev",
    fallback_feature: "general",
}).await?;

if snapshot.authenticated && snapshot.licensed {
    render_home(&snapshot);
} else if snapshot.authenticated {
    render_paywall();
} else {
    render_login();
}
```

## LicenseSnapshotStore

```rust
pub struct LicenseSnapshotStore;

pub struct SnapshotEntry {
    pub signed: SignedLicense,
    pub fetched_at_unix: i64,
}

impl LicenseSnapshotStore {
    pub fn serialize(entry: &SnapshotEntry) -> Result<String, serde_json::Error>;
    pub fn deserialize(raw: &str) -> Result<SnapshotEntry, serde_json::Error>;

    pub fn verify_and_decode(
        entry: &SnapshotEntry,
        public_key_b64: &str,
    ) -> Result<DecodedLicense, String>;
}
```

Storage-agnostic — serialise/deserialise the `SnapshotEntry` to whatever durable store the app uses (sqlite, app data file, …). `verify_and_decode` is sugar over `verify_license` + `decode_license`.

```rust
// Boot
if let Some(raw) = fs::read_to_string("license.json").ok() {
    let entry = LicenseSnapshotStore::deserialize(&raw)?;
    let decoded = LicenseSnapshotStore::verify_and_decode(&entry, &active_pub_key)?;
    apply_snapshot(decoded);
}

// After refresh
let res = client.license_refresh(payload).await?;
let entry = SnapshotEntry {
    signed: res.license,
    fetched_at_unix: chrono::Utc::now().timestamp(),
};
fs::write("license.json", LicenseSnapshotStore::serialize(&entry)?)?;
```

## Worked example — desktop boot

```rust
use std::sync::Arc;
use akira_billing::{Client, desktop::*};

let keyring = TokenKeyring::new("io.akira.unified-dev", "default");

let mut client = Client::new(base_url, "unified-dev", secret);
let session = SessionStore::new(keyring.clone());
session.hydrate(&mut client)?;

let snapshot = refresh_auth(&client, RefreshOptions {
    product: "unified-dev",
    fallback_feature: "general",
}).await?;

if !snapshot.authenticated {
    let outcome = loopback_login(&mut client, "unified-dev", "github", open_browser, 300).await?;
    session.persist(&mut client, &outcome.exchange.access_token)?;
}

let fp = device_fingerprint(env!("CARGO_PKG_VERSION"));
let activated = client.license_activate(LicenseActivatePayload {
    product: "unified-dev",
    device_type: "desktop",
    platform: Some(&fp.platform),
    fingerprint: &fp.fingerprint,
    app_version: Some(env!("CARGO_PKG_VERSION")),
    device_name: None,
}).await?;

let entry = SnapshotEntry { signed: activated.license, fetched_at_unix: chrono::Utc::now().timestamp() };
fs::write("license.json", LicenseSnapshotStore::serialize(&entry)?)?;
```

---

Navigation: [← Loopback](40-loopback.md) · **Desktop** · [Protocol →](60-protocol.md)
