pub mod mcp_tools;
pub use offline_mcp::account::{
desired_from_config, detect, feature, merge_detected, native, normalize_origin, status_report,
string_list, Desired, Feature, FEATURES,
};
use anyhow::{bail, Result};
use serde_json::{json, Value};
use crate::api::client::DeveloperClient;
use native::Native;
pub struct AppContext {
pub client: DeveloperClient,
pub token: String,
pub app_id: String,
}
impl AppContext {
pub async fn application(&self) -> Result<Value> {
self.client
.get_application_json(&self.token, &self.app_id)
.await
}
pub async fn edit_native<F>(&self, edit: F) -> Result<(bool, Value)>
where
F: FnOnce(&mut Native) -> Result<bool>,
{
let app = self.application().await?;
let mut native = Native::from_application(&app);
if !edit(&mut native)? {
return Ok((false, app));
}
let updated = self
.client
.patch_application_json(&self.token, &self.app_id, &native.patch_body())
.await?;
Ok((true, updated))
}
pub async fn edit_origins(
&self,
add: &[String],
remove: &[String],
) -> Result<(bool, Vec<String>)> {
let app = self.application().await?;
let current = string_list(&app, "origins");
let mut next = current.clone();
for origin in add {
let origin = normalize_origin(origin)?;
if !next.contains(&origin) {
next.push(origin);
}
}
for origin in remove {
let origin = normalize_origin(origin)?;
next.retain(|o| o != &origin);
}
if next == current {
return Ok((false, current));
}
let updated = self
.client
.patch_application_json(&self.token, &self.app_id, &json!({ "origins": next }))
.await?;
Ok((true, string_list(&updated, "origins")))
}
pub async fn features(&self) -> Result<Vec<(&'static Feature, bool)>> {
let mut products: Vec<(&str, Value)> = Vec::new();
let mut out = Vec::new();
for f in FEATURES {
if !products.iter().any(|(p, _)| *p == f.product) {
let settings = self
.client
.get_product_settings_json(&self.token, &self.app_id, f.product)
.await?;
products.push((f.product, settings));
}
let settings = &products.iter().find(|(p, _)| *p == f.product).unwrap().1;
out.push((
f,
settings
.get(f.setting)
.and_then(Value::as_bool)
.unwrap_or(false),
));
}
Ok(out)
}
pub async fn set_features(&self, wanted: &[(&'static Feature, bool)]) -> Result<Vec<String>> {
let current = self.features().await?;
let mut changed = Vec::new();
for (f, on) in wanted {
let now = current
.iter()
.find(|(c, _)| c.name == f.name)
.map(|(_, v)| *v)
.unwrap_or(false);
if now == *on {
continue;
}
self.client
.patch_product_settings_json(
&self.token,
&self.app_id,
f.product,
&json!({ f.setting: on }),
)
.await?;
changed.push(format!("{} {}", f.name, if *on { "on" } else { "off" }));
}
Ok(changed)
}
pub async fn set_development_mode(&self, hours: Option<u32>) -> Result<Value> {
if let Some(h) = hours {
if !(1..=72).contains(&h) {
bail!("--hours must be between 1 and 72: development mode ends by itself");
}
}
self.client
.set_development_mode(&self.token, &self.app_id, hours)
.await
}
pub async fn attestation_events(&self, limit: u32) -> Result<Value> {
self.client
.attestation_events(&self.token, &self.app_id, limit.clamp(1, 200))
.await
}
pub async fn status(&self, detected: Option<&detect::Detected>) -> Result<Value> {
let app = self.application().await?;
let features = self.features().await.ok();
let keys = self
.client
.list_api_keys(&self.token, &self.app_id)
.await
.map(|k| k.len())
.ok();
Ok(status_report(&app, features.as_deref(), keys, detected))
}
}
pub async fn apply(ctx: &AppContext, desired: &Desired, dry_run: bool) -> Result<Vec<String>> {
if !desired.problems.is_empty() {
bail!("offline.config.json: {}", desired.problems.join("; "));
}
let mut changes: Vec<String> = Vec::new();
for (package, certs) in &desired.android {
if certs.is_empty() {
bail!(
"app.native.android: {package} needs at least one signing certificate SHA-256 ({})",
native::SHA256_HELP
);
}
}
let app = ctx.application().await?;
let mut native = Native::from_application(&app);
for (bundle, team) in &desired.ios {
if native.add_ios(bundle, team) {
changes.push(format!("register iOS {bundle} (Team ID {team})"));
}
}
for (package, certs) in &desired.android {
let before = native.android_apps();
if native.add_android(package, certs) {
let had: Vec<String> = before
.iter()
.find(|a| &a.package == package)
.map(|a| a.sha256.clone())
.unwrap_or_default();
for c in certs.iter().filter(|c| !had.contains(c)) {
changes.push(format!("register Android {package} certificate {c}"));
}
}
}
if let Some(on) = desired.allow_development {
if native.set_allow_development(on) {
changes.push(format!(
"development builds {}",
if on { "on" } else { "off" }
));
}
}
let native_changed = native != Native::from_application(&app);
let current_origins = string_list(&app, "origins");
let new_origins: Vec<String> = desired
.origins
.iter()
.filter(|o| !current_origins.contains(o))
.cloned()
.collect();
for o in &new_origins {
changes.push(format!("allow origin {o}"));
}
let current_features = ctx.features().await?;
let feature_changes: Vec<(&'static Feature, bool)> = desired
.features
.iter()
.filter(|(f, on)| {
current_features
.iter()
.find(|(c, _)| c.name == f.name)
.map(|(_, v)| v != on)
.unwrap_or(true)
})
.cloned()
.collect();
for (f, on) in &feature_changes {
changes.push(format!("{} {}", f.name, if *on { "on" } else { "off" }));
}
if dry_run {
return Ok(changes);
}
if native_changed {
ctx.client
.patch_application_json(&ctx.token, &ctx.app_id, &native.patch_body())
.await?;
}
if !new_origins.is_empty() {
ctx.edit_origins(&new_origins, &[]).await?;
}
if !feature_changes.is_empty() {
ctx.set_features(&feature_changes).await?;
}
Ok(changes)
}
#[cfg(test)]
mod http_tests;