use std::path::PathBuf;
use anyhow::{bail, Result};
use clap::{Args, Subcommand, ValueEnum};
use serde_json::{json, Value};
use crate::commands::provision::{
client_from_config, developer_session, resolve_app_id, resolve_org_id,
};
use crate::config::CliConfig;
use crate::output;
use crate::setup::{self, detect, native, AppContext};
type NativeEdit = Box<dyn FnOnce(&mut native::Native) -> Result<bool> + Send>;
#[derive(Subcommand)]
pub enum AppsCommand {
List(ListArgs),
Create(CreateArgs),
Status(StatusArgs),
#[command(subcommand)]
Native(NativeCommand),
#[command(subcommand)]
Origins(OriginsCommand),
#[command(subcommand)]
Features(FeaturesCommand),
}
#[derive(Args)]
pub struct ListArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
pub struct CreateArgs {
pub name: String,
#[arg(long)]
pub org_id: Option<String>,
#[arg(long)]
pub description: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Args, Clone)]
pub struct AppArgs {
#[arg(long)]
pub app_id: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
pub struct StatusArgs {
#[command(flatten)]
pub app: AppArgs,
#[arg(long)]
pub detect: bool,
#[arg(long, default_value = ".")]
pub dir: PathBuf,
}
#[derive(Clone, Copy, ValueEnum)]
pub enum Platform {
Ios,
Android,
}
#[derive(Subcommand)]
pub enum NativeCommand {
List(AppArgs),
Add(NativeAddArgs),
Remove(NativeRemoveArgs),
DevBuilds(DevBuildsArgs),
Detect(DetectArgs),
DevMode(DevModeArgs),
Production(ProductionArgs),
Degraded(DegradedArgs),
History(HistoryArgs),
SetupProject(SetupProjectArgs),
}
#[derive(Args)]
pub struct DevModeArgs {
#[arg(value_enum)]
pub state: OnOff,
#[arg(long, default_value_t = 24)]
pub hours: u32,
#[command(flatten)]
pub app: AppArgs,
}
#[derive(Args)]
pub struct ProductionArgs {
#[arg(value_enum)]
pub state: OnOff,
#[command(flatten)]
pub app: AppArgs,
}
#[derive(Clone, Copy, ValueEnum)]
pub enum AllowRefuse {
Allow,
Refuse,
}
#[derive(Args)]
pub struct DegradedArgs {
#[arg(value_enum)]
pub policy: AllowRefuse,
#[command(flatten)]
pub app: AppArgs,
}
#[derive(Args)]
pub struct HistoryArgs {
#[arg(long, default_value_t = 50)]
pub limit: u32,
#[command(flatten)]
pub app: AppArgs,
}
#[derive(Args)]
pub struct SetupProjectArgs {
#[arg(long, default_value = ".")]
pub dir: PathBuf,
#[arg(long)]
pub write: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
pub struct NativeAddArgs {
#[arg(value_enum)]
pub platform: Platform,
#[arg(long)]
pub bundle_id: Option<String>,
#[arg(long)]
pub team_id: Option<String>,
#[arg(long)]
pub package: Option<String>,
#[arg(long = "sha256")]
pub sha256: Vec<String>,
#[command(flatten)]
pub app: AppArgs,
}
#[derive(Args)]
pub struct NativeRemoveArgs {
#[arg(value_enum)]
pub platform: Platform,
#[arg(long)]
pub bundle_id: Option<String>,
#[arg(long)]
pub team_id: Option<String>,
#[arg(long)]
pub package: Option<String>,
#[arg(long = "sha256")]
pub sha256: Option<String>,
#[command(flatten)]
pub app: AppArgs,
}
#[derive(Clone, Copy, ValueEnum)]
pub enum OnOff {
On,
Off,
}
#[derive(Args)]
pub struct DevBuildsArgs {
#[arg(value_enum)]
pub state: OnOff,
#[command(flatten)]
pub app: AppArgs,
}
#[derive(Args)]
pub struct DetectArgs {
#[arg(long, default_value = ".")]
pub dir: PathBuf,
#[arg(long)]
pub keystore: Option<PathBuf>,
#[arg(long)]
pub keystore_alias: Option<String>,
#[arg(long)]
pub home_debug_keystore: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Subcommand)]
pub enum OriginsCommand {
List(AppArgs),
Add(OriginsArgs),
Remove(OriginsArgs),
}
#[derive(Args)]
pub struct OriginsArgs {
#[arg(required = true)]
pub origins: Vec<String>,
#[command(flatten)]
pub app: AppArgs,
}
#[derive(Subcommand)]
pub enum FeaturesCommand {
List(AppArgs),
Enable(FeaturesArgs),
Disable(FeaturesArgs),
}
#[derive(Args)]
pub struct FeaturesArgs {
#[arg(required = true)]
pub features: Vec<String>,
#[command(flatten)]
pub app: AppArgs,
}
pub async fn run(command: AppsCommand) -> Result<()> {
match command {
AppsCommand::List(list) => list_apps(list).await,
AppsCommand::Create(create) => create_app(create).await,
AppsCommand::Status(args) => status(args).await,
AppsCommand::Native(cmd) => native_command(cmd).await,
AppsCommand::Origins(cmd) => origins_command(cmd).await,
AppsCommand::Features(cmd) => features_command(cmd).await,
}
}
fn app_id_for(config: &CliConfig, flag: Option<&str>) -> Result<String> {
if let Some(id) = flag.map(str::trim).filter(|s| !s.is_empty()) {
return Ok(id.to_string());
}
if let Some(id) = project_config_app_id(std::path::Path::new(".")) {
return Ok(id);
}
resolve_app_id(config, None)
}
pub fn project_config_app_id(dir: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(dir.join("offline.config.json")).ok()?;
let v: Value = serde_json::from_str(&text).ok()?;
v.get("appId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
pub async fn app_context(flag: Option<&str>, command: &str) -> Result<AppContext> {
app_context_in(flag, std::path::Path::new("."), command).await
}
pub async fn app_context_in(
flag: Option<&str>,
dir: &std::path::Path,
command: &str,
) -> Result<AppContext> {
let mut config = CliConfig::load()?;
let token = developer_session(&mut config, command).await?;
let client = client_from_config(&config).await?;
let app_id = match flag.map(str::trim).filter(|s| !s.is_empty()) {
Some(id) => id.to_string(),
None => match project_config_app_id(dir) {
Some(id) => id,
None => app_id_for(&config, None)?,
},
};
Ok(AppContext {
client,
token,
app_id,
})
}
async fn list_apps(args: ListArgs) -> Result<()> {
let mut config = CliConfig::load()?;
let token = developer_session(&mut config, "apps").await?;
let client = client_from_config(&config).await?;
let apps = client.list_applications(&token).await?;
if args.json {
output::json(&json!({ "applications": apps }))?;
return Ok(());
}
output::heading("applications");
if apps.is_empty() {
output::note("no applications — run `offline apps create \"My App\"`");
return Ok(());
}
for app in &apps {
let org = app.organization_id.as_deref().unwrap_or("(unknown org)");
println!(" {} {} org={org}", app.id, app.name);
}
if apps.len() == 1 {
config.app_id = Some(apps[0].id.clone());
if let Some(org_id) = &apps[0].organization_id {
config.org_id = Some(org_id.clone());
}
config.save()?;
}
Ok(())
}
async fn create_app(args: CreateArgs) -> Result<()> {
let mut config = CliConfig::load()?;
let token = developer_session(&mut config, "apps").await?;
let org_id = resolve_org_id(&config, args.org_id.as_deref())?;
let client = client_from_config(&config).await?;
let app = client
.create_application(&token, &args.name, &org_id, args.description.as_deref())
.await?;
config.app_id = Some(app.id.clone());
config.org_id = Some(org_id);
config.save()?;
if args.json {
output::json(&json!({ "application": app }))?;
} else {
output::success(&format!("created application `{}` ({})", app.name, app.id));
output::note(&format!(
"next: register your native apps (`offline apps native detect`, then `offline apply --detect`), and check with `offline apps status --app-id {}`",
app.id
));
}
Ok(())
}
async fn status(args: StatusArgs) -> Result<()> {
let ctx = app_context(args.app.app_id.as_deref(), "apps status").await?;
let detected = args
.detect
.then(|| detect::detect(&args.dir, &detect::DetectOptions::default()));
let report = ctx.status(detected.as_ref()).await?;
if args.app.json {
return output::json(&report);
}
print_status(&report);
Ok(())
}
fn yes(v: &Value) -> &'static str {
match v.as_bool() {
Some(true) => "ready",
Some(false) => "not set up",
None => "unknown",
}
}
pub fn print_status(r: &Value) {
output::heading(&format!(
"{} ({})",
r["name"].as_str().unwrap_or("application"),
r["appId"].as_str().unwrap_or("")
));
println!(" iOS {}", yes(&r["ready"]["ios"]));
for a in r["ios"]["registered"].as_array().into_iter().flatten() {
println!(
" {} Team ID {}",
a["bundleId"].as_str().unwrap_or(""),
a["teamId"].as_str().unwrap_or("")
);
}
println!(
" development builds {}",
if r["ios"]["allowDevelopmentBuilds"].as_bool() == Some(true) {
"accepted"
} else {
"refused"
}
);
println!(" Android {}", yes(&r["ready"]["android"]));
for a in r["android"]["registered"].as_array().into_iter().flatten() {
println!(" {}", a["package"].as_str().unwrap_or(""));
for c in a["sha256"].as_array().into_iter().flatten() {
println!(" {}", c.as_str().unwrap_or(""));
}
}
println!(
" devices that cannot fully attest: {}",
if r["androidDegraded"].as_str() == Some("refuse") {
"refused"
} else {
"allowed at a lower, capped trust level"
}
);
println!(" Web {}", yes(&r["ready"]["web"]));
for o in r["web"]["origins"].as_array().into_iter().flatten() {
println!(" {}", o.as_str().unwrap_or(""));
}
println!(" Server {}", yes(&r["ready"]["server"]));
if let Some(features) = r["features"].as_object() {
let on: Vec<&str> = features
.iter()
.filter(|(_, v)| v.as_bool() == Some(true))
.map(|(k, _)| k.as_str())
.collect();
println!(
" Features {}",
if on.is_empty() {
"none on".to_string()
} else {
on.join(", ")
}
);
}
if r["production"].as_bool() == Some(true) {
println!(" Release marked production: development mode is off");
} else {
match r["development"]["until"].as_str() {
Some(until) => println!(" Dev mode on until {until} (simulators, emulators, Expo Go)"),
None => println!(
" Dev mode off; for simulators, emulators and Expo Go: {}",
r["development"]["command"].as_str().unwrap_or("")
),
}
}
let missing: Vec<&str> = ["ios", "android"]
.iter()
.flat_map(|p| r[*p]["missing"].as_array().into_iter().flatten())
.filter_map(Value::as_str)
.collect();
if !missing.is_empty() {
println!();
output::warn("missing:");
for m in missing {
println!(" - {m}");
}
}
println!();
println!(" SDK: {}", r["sdk"]["requires"].as_str().unwrap_or(""));
if let Some(steps) = r["nextSteps"].as_array().filter(|s| !s.is_empty()) {
println!();
output::heading("next");
for s in steps {
println!(" {}", s.as_str().unwrap_or(""));
}
}
}
fn need<'a>(value: &'a Option<String>, flag: &str, help: &str) -> Result<&'a str> {
value
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("pass {flag}: {help}"))
}
fn report_change(
json_out: bool,
changed: bool,
done: &str,
unchanged: &str,
app: &Value,
) -> Result<()> {
if json_out {
let native = native::Native::from_application(app);
return output::json(&json!({
"changed": changed,
"ios": native.ios_apps(),
"android": native.android_apps(),
"allowDevelopmentBuilds": native.allow_development(),
"production": native.production(),
"androidDegraded": native.android_degraded(),
"developmentModeUntil": native.development_until(),
}));
}
if changed {
output::success(done);
output::note("It applies to sign-ins within a minute.");
} else {
output::note(unchanged);
}
Ok(())
}
async fn native_command(cmd: NativeCommand) -> Result<()> {
match cmd {
NativeCommand::List(args) => {
let ctx = app_context(args.app_id.as_deref(), "apps native").await?;
let app = ctx.application().await?;
let n = native::Native::from_application(&app);
if args.json {
return output::json(&json!({
"appId": ctx.app_id,
"ios": n.ios_apps(),
"android": n.android_apps(),
"androidPackagesWithoutCertificate": n.android_packages_without_certificate(),
"allowDevelopmentBuilds": n.allow_development(),
}));
}
output::heading(&format!("native apps of {}", ctx.app_id));
for a in n.ios_apps() {
println!(" iOS {} Team ID {}", a.bundle_id, a.team_id);
}
for a in n.android_apps() {
println!(" Android {} {}", a.package, a.sha256.join(", "));
}
for p in n.android_packages_without_certificate() {
println!(" Android {p} (no signing certificate: cannot attest)");
}
if n.ios_apps().is_empty() && n.android_apps().is_empty() {
output::note(
"none registered — `offline apps native detect` reads them from your project",
);
}
println!(
" development builds {}",
if n.allow_development() {
"accepted"
} else {
"refused"
}
);
Ok(())
}
NativeCommand::Add(args) => {
let (label, edit): (String, NativeEdit) = match args.platform {
Platform::Ios => {
let bundle = native::normalize_bundle_id(need(
&args.bundle_id,
"--bundle-id",
native::BUNDLE_ID_HELP,
)?)
.ok_or_else(|| anyhow::anyhow!("--bundle-id: {}", native::BUNDLE_ID_HELP))?;
let team = native::normalize_team_id(need(
&args.team_id,
"--team-id",
native::TEAM_ID_HELP,
)?)
.ok_or_else(|| anyhow::anyhow!("--team-id: {}", native::TEAM_ID_HELP))?;
(
format!("iOS {bundle} (Team ID {team})"),
Box::new(move |n| Ok(n.add_ios(&bundle, &team))),
)
}
Platform::Android => {
let package = native::normalize_package(need(
&args.package,
"--package",
native::PACKAGE_HELP,
)?)
.ok_or_else(|| anyhow::anyhow!("--package: {}", native::PACKAGE_HELP))?;
if args.sha256.is_empty() {
bail!("pass --sha256: {}", native::SHA256_HELP);
}
let certs = args
.sha256
.iter()
.map(|c| {
native::normalize_sha256(c).ok_or_else(|| {
anyhow::anyhow!("--sha256 `{c}`: {}", native::SHA256_HELP)
})
})
.collect::<Result<Vec<_>>>()?;
(
format!("Android {package}"),
Box::new(move |n| Ok(n.add_android(&package, &certs))),
)
}
};
let ctx = app_context(args.app.app_id.as_deref(), "apps native add").await?;
let (changed, app) = ctx.edit_native(edit).await?;
report_change(
args.app.json,
changed,
&format!("registered {label} for {}", ctx.app_id),
&format!("{label} is already registered for {}", ctx.app_id),
&app,
)
}
NativeCommand::Remove(args) => {
let ctx = app_context(args.app.app_id.as_deref(), "apps native remove").await?;
let (label, edit): (String, NativeEdit) = match args.platform {
Platform::Ios => {
let bundle =
need(&args.bundle_id, "--bundle-id", native::BUNDLE_ID_HELP)?.to_string();
let team = args.team_id.clone();
(
format!("iOS {bundle}"),
Box::new(move |n| Ok(n.remove_ios(&bundle, team.as_deref()))),
)
}
Platform::Android => {
let package =
need(&args.package, "--package", native::PACKAGE_HELP)?.to_string();
let cert =
match &args.sha256 {
Some(c) => Some(native::normalize_sha256(c).ok_or_else(|| {
anyhow::anyhow!("--sha256: {}", native::SHA256_HELP)
})?),
None => None,
};
(
format!("Android {package}"),
Box::new(move |n| Ok(n.remove_android(&package, cert.as_deref()))),
)
}
};
let (changed, app) = ctx.edit_native(edit).await?;
report_change(
args.app.json,
changed,
&format!("removed {label} from {}", ctx.app_id),
&format!("{label} was not registered for {}", ctx.app_id),
&app,
)
}
NativeCommand::DevBuilds(args) => {
let on = matches!(args.state, OnOff::On);
let ctx = app_context(args.app.app_id.as_deref(), "apps native dev-builds").await?;
let (changed, app) = ctx
.edit_native(move |n| Ok(n.set_allow_development(on)))
.await?;
report_change(
args.app.json,
changed,
&format!(
"development builds {} for {}",
if on { "accepted" } else { "refused" },
ctx.app_id
),
&format!(
"development builds were already {} for {}",
if on { "accepted" } else { "refused" },
ctx.app_id
),
&app,
)
}
NativeCommand::DevMode(args) => {
let on = matches!(args.state, OnOff::On);
let ctx = app_context(args.app.app_id.as_deref(), "apps native dev-mode").await?;
let mode = ctx.set_development_mode(on.then_some(args.hours)).await?;
if args.app.json {
return output::json(&json!({ "appId": ctx.app_id, "developmentMode": mode }));
}
match mode.get("until").and_then(Value::as_str) {
Some(until) => {
output::success(&format!(
"development mode on for {} until {until}",
ctx.app_id
));
output::note("Simulators, emulators and Expo Go can sign in without attestation (email codes only). Their sessions stop working when it ends.");
}
None => output::success(&format!("development mode off for {}", ctx.app_id)),
}
Ok(())
}
NativeCommand::Production(args) => {
let on = matches!(args.state, OnOff::On);
let ctx = app_context(args.app.app_id.as_deref(), "apps native production").await?;
let (changed, app) = ctx.edit_native(move |n| Ok(n.set_production(on))).await?;
report_change(
args.app.json,
changed,
&format!(
"{} {} production{}",
ctx.app_id,
if on { "marked" } else { "no longer marked" },
if on {
"; development mode is off for it"
} else {
""
}
),
&format!(
"{} was already {}marked production",
ctx.app_id,
if on { "" } else { "not " }
),
&app,
)
}
NativeCommand::Degraded(args) => {
let allow = matches!(args.policy, AllowRefuse::Allow);
let ctx = app_context(args.app.app_id.as_deref(), "apps native degraded").await?;
let (changed, app) = ctx
.edit_native(move |n| Ok(n.set_android_degraded(allow)))
.await?;
report_change(
args.app.json,
changed,
&if allow {
format!("{}: Android devices that cannot fully attest sign in with a lower, capped trust level", ctx.app_id)
} else {
format!(
"{}: Android devices that cannot fully attest are refused",
ctx.app_id
)
},
&format!(
"{} already {} them",
ctx.app_id,
if allow { "allows" } else { "refuses" }
),
&app,
)
}
NativeCommand::History(args) => {
let ctx = app_context(args.app.app_id.as_deref(), "apps native history").await?;
let events = ctx.attestation_events(args.limit).await?;
if args.app.json {
return output::json(&json!({ "appId": ctx.app_id, "events": events }));
}
output::heading(&format!("attestation history of {}", ctx.app_id));
let list = events.as_array().cloned().unwrap_or_default();
if list.is_empty() {
output::note("no changes recorded");
}
for e in list {
println!(
" {} {:<28} developer {} {}",
e["created_at"].as_str().unwrap_or(""),
e["event"].as_str().unwrap_or(""),
e["developer_id"],
e["details"]
);
}
Ok(())
}
NativeCommand::SetupProject(args) => {
let out = detect::setup_expo_project(&args.dir, args.write);
if args.json {
return output::json(&out);
}
output::heading("Expo app attestation setup");
for c in &out.changes {
println!(
" {} {c}",
if out.written {
"changed"
} else {
"would change"
}
);
}
if out.changes.is_empty() && out.config.is_some() {
output::note("the App Attest entitlement is already set");
}
if let Some(install) = &out.install {
println!(" install: {install}");
}
for n in &out.notes {
output::note(n);
}
if !out.changes.is_empty() && !out.written {
output::note("run again with --write to apply");
}
Ok(())
}
NativeCommand::Detect(args) => {
let found = detect::detect(
&args.dir,
&detect::DetectOptions {
keystore: args.keystore,
keystore_alias: args.keystore_alias,
home_debug_keystore: args.home_debug_keystore,
},
);
if args.json {
return output::json(&found);
}
print_detected(&found);
Ok(())
}
}
}
pub fn print_detected(found: &detect::Detected) {
output::heading("native apps in this project");
for a in &found.ios {
println!(
" iOS {} Team ID {} ({})",
a.bundle_id,
a.team_id.as_deref().unwrap_or("unknown"),
a.source
);
}
for a in &found.android {
println!(" Android {} ({})", a.package, a.source);
for c in &a.certificates {
println!(" {} {} ({})", c.kind, c.sha256, c.source);
}
}
for n in &found.notes {
output::warn(n);
}
if !found.ios.is_empty() || !found.android.is_empty() {
output::note("register them with `offline apply --detect` (add --debug-cert to accept debug builds on Android)");
}
}
async fn origins_command(cmd: OriginsCommand) -> Result<()> {
let (args, add) = match cmd {
OriginsCommand::List(args) => {
let ctx = app_context(args.app_id.as_deref(), "apps origins").await?;
let app = ctx.application().await?;
let origins = app.get("origins").cloned().unwrap_or(json!([]));
if args.json {
return output::json(&json!({ "appId": ctx.app_id, "origins": origins }));
}
output::heading(&format!("allowed origins of {}", ctx.app_id));
for o in origins.as_array().into_iter().flatten() {
println!(" {}", o.as_str().unwrap_or(""));
}
return Ok(());
}
OriginsCommand::Add(args) => (args, true),
OriginsCommand::Remove(args) => (args, false),
};
let ctx = app_context(args.app.app_id.as_deref(), "apps origins").await?;
let (changed, origins) = if add {
ctx.edit_origins(&args.origins, &[]).await?
} else {
ctx.edit_origins(&[], &args.origins).await?
};
if args.app.json {
return output::json(&json!({ "changed": changed, "origins": origins }));
}
if changed {
output::success(&format!(
"allowed origins of {}: {}",
ctx.app_id,
origins.join(", ")
));
} else {
output::note("nothing to change");
}
Ok(())
}
async fn features_command(cmd: FeaturesCommand) -> Result<()> {
let (args, on) = match cmd {
FeaturesCommand::List(args) => {
let ctx = app_context(args.app_id.as_deref(), "apps features").await?;
let features = ctx.features().await?;
if args.json {
let map: serde_json::Map<String, Value> = features
.iter()
.map(|(f, on)| (f.name.to_string(), Value::Bool(*on)))
.collect();
return output::json(&json!({ "appId": ctx.app_id, "features": map }));
}
output::heading(&format!("features of {}", ctx.app_id));
for (f, on) in features {
println!(
" {:<18} {:<4} {}",
f.name,
if on { "on" } else { "off" },
f.about
);
}
return Ok(());
}
FeaturesCommand::Enable(args) => (args, true),
FeaturesCommand::Disable(args) => (args, false),
};
let wanted = args
.features
.iter()
.map(|f| setup::feature(f).map(|f| (f, on)))
.collect::<Result<Vec<_>>>()?;
let ctx = app_context(args.app.app_id.as_deref(), "apps features").await?;
let changed = ctx.set_features(&wanted).await?;
if args.app.json {
return output::json(&json!({ "changed": changed }));
}
if changed.is_empty() {
output::note("nothing to change");
} else {
output::success(&format!("{}: {}", ctx.app_id, changed.join(", ")));
}
Ok(())
}