leyline-http 0.1.1

HTTP client that mimics browsers on the wire
Documentation
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use url::Url;

use crate::cookie::Jar;
use crate::core::error::{Error, Kind, Result};
use crate::core::{Identity, ProxyConfig, ProxyUrl, Session, SessionBuilder, Tab};
use crate::profile::{BrowserProfile, ChromiumBrand, Platform};
use crate::util::atomic::{FileMode, write_atomic};

mod autosave;
mod check;
mod secret;
mod state;
mod version;
mod wire;

pub use autosave::{DeviceAutosave, DeviceAutosaveOptions};
pub use state::SessionState;
pub(crate) use state::{StateParts, unix_secs};
pub(crate) use version::FileVersion;

#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Device {
    #[serde(default)]
    pub identity: Option<Identity>,
    pub platform: Platform,
    #[serde(default)]
    pub brand: Option<ChromiumBrand>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub profile_toml: Option<String>,
    #[serde(default)]
    pub profile_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub user_agent: Option<String>,
    #[serde(default)]
    pub proxy: Option<ProxyUrl>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub proxy_password_env: Option<String>,
    #[serde(default)]
    pub languages: Option<Vec<String>>,
    #[serde(default)]
    pub env_proxy: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub jar_path: Option<PathBuf>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub jar: Option<Jar>,
    #[serde(default)]
    pub state: SessionState,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub app: BTreeMap<String, serde_json::Value>,
    #[serde(default)]
    pub strict: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub page: Option<Url>,
}

impl Device {
    #[must_use]
    pub fn capture(session: &Session, proxy: Option<ProxyUrl>) -> Device {
        let id = session.identity();
        Device {
            identity: id.to_identity(),
            platform: id.platform(),
            brand: id.brand(),
            profile_toml: None,
            profile_id: id.profile_id().map(str::to_owned),
            user_agent: id.identity().map(|_| id.user_agent().to_owned()),
            proxy,
            proxy_password_env: None,
            languages: id.languages().map(<[String]>::to_vec),
            env_proxy: false,
            jar_path: None,
            jar: None,
            state: session.state(),
            app: BTreeMap::new(),
            strict: false,
            page: None,
        }
    }

    pub fn pin_profile(&mut self, session: &Session) -> Result<()> {
        let id = session.identity();
        if id.to_identity().is_some_and(|i| i.http() != i.tls()) {
            return Err(config(
                "a session with a rotated TLS hello cannot pin one profile; keep the identity",
            ));
        }
        let toml = id
            .export_profile()
            .ok_or_else(|| config("the session has no profile source to pin"))?;
        self.profile_toml = Some(toml);
        self.profile_id = id.profile_id().map(str::to_owned);
        Ok(())
    }

    pub fn session_builder(&self) -> Result<SessionBuilder> {
        let mut builder = match (&self.profile_toml, self.identity) {
            (Some(toml), _) => Session::builder()
                .profile(BrowserProfile::from_toml(toml).map_err(|e| config(e.to_string()))?)
                .platform(self.platform),
            (None, Some(identity)) => Session::builder().identity(identity),
            (None, None) => Session::builder().platform(self.platform),
        };
        if let Some(brand) = self.brand {
            builder = builder.brand(brand);
        }
        builder = builder.proxy(self.proxy_config()?);
        if let Some(languages) = &self.languages {
            builder = builder.languages(languages);
        }
        Ok(builder.cookie_jar(self.load_jar()?))
    }

    pub fn open(&self) -> Result<Session> {
        let session = self.session_builder()?.build()?;
        self.check(&session)?;
        self.state.restore_into(&session);
        Ok(session)
    }

    #[must_use]
    pub fn tab(&self, session: &Session) -> Tab {
        let tab = session.tab();
        tab.set_current(self.page.clone());
        tab
    }

    pub fn save_to(&self, path: impl AsRef<Path>) -> Result<()> {
        let file = DeviceFileOut {
            version: FileVersion::default(),
            device: self,
        };
        let bytes = serde_json::to_vec_pretty(&file).map_err(Error::from_json)?;
        write_atomic(path.as_ref(), &bytes, FileMode::Private)
    }

    pub fn load_from(path: impl AsRef<Path>) -> Result<Device> {
        let bytes = std::fs::read(path)?;
        let file: DeviceFileIn = serde_json::from_slice(&bytes).map_err(Error::from_json)?;
        match file.version {
            FileVersion::V1 => Ok(file.device),
        }
    }

    fn proxy_config(&self) -> Result<ProxyConfig> {
        Ok(self
            .resolved_proxy()?
            .map_or_else(ProxyConfig::new, ProxyConfig::from)
            .env(self.env_proxy))
    }

    fn load_jar(&self) -> Result<Jar> {
        if let Some(jar) = &self.jar {
            return Ok(jar.clone());
        }
        match &self.jar_path {
            Some(path) if path.exists() => Jar::load_from(path),
            _ => Ok(Jar::new()),
        }
    }
}

#[derive(Serialize)]
struct DeviceFileOut<'a> {
    version: FileVersion,
    #[serde(flatten)]
    device: &'a Device,
}

#[derive(Deserialize)]
struct DeviceFileIn {
    version: FileVersion,
    #[serde(flatten)]
    device: Device,
}

fn config(message: impl Into<String>) -> Error {
    Error::new(Kind::Config).with_message(message.into())
}