aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Inicio de sesión con la suscripción (Claude Pro/Max, ChatGPT Plus/Pro) por OAuth con PKCE en el navegador, el
//! mismo recorrido que hace pi. Aura no necesita tener instalado Claude Code ni Codex: abre el navegador, recibe
//! la redirección en un puerto local y guarda los tokens en el llavero del sistema.

pub mod browser;
pub mod callback;
mod flow;
pub mod pkce;

pub use flow::{fresh, login};

use serde_json::{Value, json};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Service {
    Claude,
    ChatGpt,
}

impl Service {
    /// Identificador del proveedor en los ajustes del panel.
    pub fn provider_id(self) -> &'static str {
        match self {
            Service::Claude => "claude-subscription",
            Service::ChatGpt => "chatgpt-subscription",
        }
    }

    pub fn from_provider(provider: &str) -> Option<Self> {
        [Service::Claude, Service::ChatGpt].into_iter().find(|service| service.provider_id() == provider)
    }

    pub fn account(self) -> &'static str {
        match self {
            Service::Claude => "Claude",
            Service::ChatGpt => "ChatGPT",
        }
    }

    /// Entrada del llavero (distinta de las claves de API del mismo proveedor).
    fn slot(self) -> String {
        format!("oauth-{}", self.provider_id())
    }
}

/// Tokens de la sesión. `expires_ms` es la hora (ms desde 1970) en que el acceso deja de valer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Credential {
    pub access: String,
    pub refresh: String,
    pub expires_ms: u64,
    pub account_id: Option<String>,
}

impl Credential {
    fn to_json(&self) -> String {
        json!({ "access": self.access, "refresh": self.refresh, "expires_ms": self.expires_ms, "account_id": self.account_id })
            .to_string()
    }

    fn from_json(text: &str) -> Option<Self> {
        let value: Value = serde_json::from_str(text).ok()?;
        Some(Self {
            access: value["access"].as_str()?.to_owned(),
            refresh: value["refresh"].as_str()?.to_owned(),
            expires_ms: value["expires_ms"].as_u64()?,
            account_id: value["account_id"].as_str().map(str::to_owned),
        })
    }
}

/// Sesión guardada de `service`, si el usuario ya inició sesión.
pub fn stored(service: Service) -> Option<Credential> {
    crate::ai::secrets::get_exact(&service.slot()).and_then(|text| Credential::from_json(&text))
}

fn save(service: Service, credential: &Credential) -> Result<(), String> {
    crate::ai::secrets::set(&service.slot(), &credential.to_json())
}

/// Cierra la sesión: borra los tokens del llavero.
pub fn logout(service: Service) -> Result<(), String> {
    crate::ai::secrets::remove(&service.slot())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn las_credenciales_ida_y_vuelta() {
        let credential =
            Credential { access: "a".into(), refresh: "r".into(), expires_ms: 42, account_id: Some("acc".into()) };
        assert_eq!(Credential::from_json(&credential.to_json()), Some(credential));
        assert!(Credential::from_json("{}").is_none());
    }

    #[test]
    fn los_proveedores_se_reconocen_por_su_id() {
        assert_eq!(Service::from_provider("claude-subscription"), Some(Service::Claude));
        assert_eq!(Service::from_provider("chatgpt-subscription"), Some(Service::ChatGpt));
        assert_eq!(Service::from_provider("anthropic"), None);
    }
}