//! altkeeper: rinnova via Wi-Fi i profili di provisioning delle app installate //! con un Apple ID gratuito, senza computer collegato e senza VPN. //! //! altkeeper phone [--phone ip:porta] legge i profili dal telefono //! altkeeper pair-usb [file] crea il pairing remoto via USB //! altkeeper login accede e salva l'account //! altkeeper apps elenca App ID e scadenze //! altkeeper renew [--phone ip:porta] [--dry-run] [--force] [--min-days N] [--bundle ID] [--installed-only] //! altkeeper profile-remove [--phone ip:porta] toglie un profilo (ne salva prima una copia) //! altkeeper profile-restore [--phone ip:porta] rimette un profilo salvato //! altkeeper serve [--bind ip:porta] [--pin PIN] [--dir cartella] [--phone ip:porta] //! [--altserver] [--altserver-port N] [--dns-domain D --dns-address IP] //! [--anisette-bind ip:porta] [--anisette-libs cartella] //! interfaccia web (stato, rinnovo, accesso Apple ID) //! altkeeper altserver [--port N] [--dir cartella] [--phone ip:porta] //! [--dns-domain D --dns-address IP [--dns-port N]] //! fa da AltServer per AltStore (Refresh All e installazioni) //! altkeeper tray [--dir cartella] [--phone ip:porta] [--console] //! (Windows e macOS) icona nella barra di sistema o //! dei menu: come `serve --altserver` ma senza terminale //! //! Se non indichi il telefono (`--phone` o ALTKEEPER_PHONE) lo cerca in rete via Bonjour. //! //! Con `--dns-domain` pubblica AltServer anche come record DNS normali, così AltStore lo trova //! da fuori casa attraverso una VPN (iOS non manda Bonjour multicast nei tunnel, le query DNS sì). mod altserver; #[cfg(any(target_os = "linux", target_os = "macos", windows))] mod anisette_server; mod apple; mod devices; mod dnssd; mod env; mod fsperm; mod i18n; mod install_archive; mod log; mod phone; mod profile; mod renewal_wait; mod terminal; #[cfg(any(windows, target_os = "macos"))] mod tray; #[cfg(any(windows, target_os = "macos"))] mod update; mod web; use std::net::SocketAddr; use std::path::Path; use phone::dbg; const PAIRING_FILE: &str = "rp-pairing.plist"; /// Yes is opt-in: empty or unrecognized responses must never save a password. fn save_password_confirmed(answer: &str) -> bool { matches!( answer.trim().to_lowercase().as_str(), "s" | "si" | "sì" | "y" | "yes" ) } const USAGE: &str = "uso: altkeeper [opzioni]"; const USAGE_EN: &str = "usage: altkeeper [options]"; /// Dove `profile-remove` tiene la copia dei profili tolti dal telefono. const SAVED_DIR: &str = "profili-salvati"; /// Indirizzo del servizio `_remotepairing._tcp` dell'iPhone (di solito porta 49152): /// da `--phone ip:porta` oppure dalla variabile d'ambiente ALTKEEPER_PHONE. Se manca non è /// un errore: il telefono si cerca in rete. fn phone_addr(opt: Option) -> Result, String> { opt.or_else(|| env::var("PHONE")) .filter(|s| !s.trim().is_empty()) .map(|s| { s.trim().parse().map_err(|e| { crate::tr!( format!("indirizzo del telefono non valido: {e}"), format!("invalid phone address: {e}") ) }) }) .transpose() } /// Porta su cui ascolta AltServer: `--altserver-port`/`--port`, altrimenti 49500. fn altserver_port(opt: Option) -> Result { opt.map(|v| { v.parse::().map_err(|e| { crate::tr!( format!("porta non valida: {e}"), format!("invalid port: {e}") ) }) }) .transpose() .map(|p| p.unwrap_or(49500)) } /// Legge una rete nella forma `10.7.0.0/24`. fn parse_cidr(s: &str) -> Result<(std::net::Ipv4Addr, u8), String> { let (ip, bits) = s.split_once('/').ok_or_else(|| { crate::tr!( format!("--dns-clients {s}: manca la lunghezza, per esempio 10.7.0.0/24"), format!("--dns-clients {s}: prefix length missing, e.g. 10.7.0.0/24") ) })?; let ip = ip.parse().map_err(|e| { crate::tr!( format!("--dns-clients: indirizzo non valido: {e}"), format!("--dns-clients: invalid address: {e}") ) })?; let bits: u8 = bits.parse().map_err(|e| { crate::tr!( format!("--dns-clients: /{bits} non valido: {e}"), format!("--dns-clients: invalid /{bits}: {e}") ) })?; if bits > 32 { return Err(crate::tr!( format!("--dns-clients: /{bits} non esiste, il massimo è /32"), format!("--dns-clients: /{bits} is invalid (maximum /32)") )); } Ok((ip, bits)) } /// Avvia, se richiesto, il server DNS che pubblica AltServer come record normali (DNS-SD /// unicast). Serve per farsi trovare da AltStore quando il telefono non è sulla rete di casa: /// iOS non manda le richieste Bonjour multicast dentro le VPN, ma le query DNS sì. fn start_dnssd(opt: impl Fn(&str) -> Option, altserver_port: u16) -> Result<(), String> { let Some(domain) = opt("--dns-domain") else { return Ok(()); }; let address: std::net::Ipv4Addr = opt("--dns-address") .ok_or(crate::tr!( "--dns-domain vuole anche --dns-address : l'indirizzo a cui il telefono si collega, di solito quello della VPN di questa macchina", "--dns-domain requires --dns-address : the address the phone connects to, usually this machine's VPN address" ))? .parse() .map_err(|e| crate::tr!(format!("--dns-address non valido: {e}"), format!("invalid --dns-address: {e}")))?; let port: u16 = match opt("--dns-port") { Some(p) => p.parse().map_err(|e| { crate::tr!( format!("--dns-port non valido: {e}"), format!("invalid --dns-port: {e}") ) })?, None => 5533, }; // Da dove arrivano i client: serve perché iOS scopre il dominio chiedendo del "rovescio" // del proprio indirizzo. Se non si indica, si assume la /24 dell'indirizzo pubblicato. let clients = match opt("--dns-clients") { Some(cidr) => parse_cidr(&cidr)?, None => (address, 24), }; let zone = dnssd::Zone::new( &domain, &altserver::instance_name(), &altserver::id()?, altserver_port, address, clients, ); dnssd::start(zone, SocketAddr::from(([0, 0, 0, 0], port)))?; Ok(()) } /// Si collega al telefono, all'indirizzo indicato o a quello trovato in rete. async fn open_phone( dir: &Path, addr: Option, say: &mut dyn FnMut(&str), ) -> Result { let (_, link) = open_phone_with_route(dir, addr, say).await?; Ok(link) } async fn open_phone_with_route( dir: &Path, addr: Option, say: &mut dyn FnMut(&str), ) -> Result<(phone::Via, phone::PhoneLink), String> { let (found, link) = phone::connect_auto_in(dir, addr, PAIRING_FILE, &mut *say).await?; if found == phone::Via::Usb { say(crate::tr!("Collegato via USB", "Connected via USB")); } else if addr.map(phone::Via::Wifi) != Some(found) { say(&crate::tr!( format!("Telefono trovato a {found}"), format!("Found phone at {found}") )); } Ok((found, link)) } /// Legge e interpreta tutti i profili presenti sul telefono. async fn phone_profiles(link: &mut phone::PhoneLink) -> Result, String> { let raw = link.profiles().await?; Ok(raw.iter().filter_map(|r| profile::parse(r)).collect()) } fn profile_line(p: &profile::PhoneProfile) -> String { let expiry = p.expires_text(); let days = p.days_left().map(|d| d.to_string()).unwrap_or("?".into()); crate::tr!( format!( " {} [{}] scade {expiry} ({days} giorni)", p.name, p.uuid ), format!( " {} [{}] expires {expiry} ({days} days)", p.name, p.uuid ), ) } fn print_profile(p: &profile::PhoneProfile) { println!("{}", profile_line(p)); } async fn cmd_phone(addr: Option) -> Result<(), String> { let mut link = open_phone(Path::new("."), addr, &mut |s| println!("{s}")).await?; let profiles = phone_profiles(&mut link).await?; println!( "{}", crate::tr!( format!("{} profili installati sul telefono:", profiles.len()), format!("{} profiles installed on the phone:", profiles.len()) ) ); profiles.iter().for_each(print_profile); Ok(()) } async fn cmd_login(apple_id: Option) -> Result<(), String> { let apple_id = apple_id.ok_or(crate::tr!( "uso: altkeeper login ", "usage: altkeeper login " ))?; let password = rpassword::prompt_password(crate::tr!( format!("Password di {apple_id}: "), format!("Password for {apple_id}: ") )) .map_err(dbg)?; let password = password.trim().to_string(); if password.is_empty() { return Err(crate::tr!("password vuota", "empty password").into()); } apple::set_interactive(true); let mut sess = apple::open_session(Path::new("."), &apple_id, &password).await?; let team = apple::pick_team(&mut sess, None, true).await?; println!( "{}", crate::tr!( format!( "Accesso riuscito. Team: {} ({})", team.name.as_deref().unwrap_or(""), team.team_id ), format!( "Signed in successfully. Team: {} ({})", team.name.as_deref().unwrap_or(""), team.team_id ) ) ); let answer = match terminal::prompt_line(crate::tr!( "Salvare la password su questo server per i rinnovi automatici (file riservato a root)? [s/N] ", "Save the password on this server for automatic renewals (restricted file)? [y/N] ", )) { Ok(answer) => answer, // A noninteractive session may still save the account without its password. // EOF must never be interpreted as approval to store credentials. Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => String::new(), Err(e) => return Err(dbg(e)), }; let save = save_password_confirmed(&answer); apple::save_account( Path::new("."), &apple::Account { apple_id, password: if save { password } else { String::new() }, team_id: Some(team.team_id), }, )?; println!( "{}", crate::tr!( "Account salvato in account.json (permessi 600).", "Account saved to account.json (restricted permissions)." ) ); Ok(()) } async fn cmd_apps() -> Result<(), String> { let acc = apple::load_account(Path::new("."))?; apple::set_interactive(false); let mut sess = apple::open_session(Path::new("."), &acc.apple_id, &acc.password).await?; let team = apple::pick_team(&mut sess, acc.team_id.as_deref(), false).await?; let ids = apple::app_ids(&mut sess, &team).await?; println!("Team {}: {} App ID", team.team_id, ids.len()); for a in &ids { let exp = a .expiration_date .map(|d| d.to_xml_format()) .unwrap_or_else(|| "-".into()); println!( "{}", crate::tr!( format!(" {} ({}) scade {}", a.identifier, a.name, exp), format!(" {} ({}) expires {}", a.identifier, a.name, exp) ) ); } Ok(()) } async fn cmd_renew( dir: &Path, addr: Option, dry_run: bool, force: bool, min_days: i64, bundle_filter: Option<&str>, installed_only: bool, wait_control: Option<&renewal_wait::Control>, say: &mut dyn FnMut(&str), ) -> Result<(), String> { let _auto_pair_pause = phone::pause_auto_pairing(); let standalone_control = renewal_wait::Control::default(); let wait_control = wait_control.unwrap_or(&standalone_control); wait_control.reset(); let acc = apple::load_account(dir)?; let team_id = acc.team_id.clone().ok_or("team not saved: sign in again")?; say("Connecting to the phone..."); let (route, mut link) = open_phone_with_route(dir, addr, &mut *say).await?; let profiles = phone_profiles(&mut link).await?; let mine: Vec<&profile::PhoneProfile> = profiles .iter() .filter(|p| p.team_id.as_deref() == Some(team_id.as_str())) .collect(); say(&format!( "{} profiles on the phone, {} for team {team_id}", profiles.len(), mine.len() )); if mine.is_empty() { return Ok(()); } // Dopo un rinnovo i profili vecchi restano sul telefono: per ogni bundle vale quello che // scade più tardi, e si rinnova una volta sola (altrimenti ogni giro rifarebbe il rinnovo // finché il profilo vecchio non scade). let latest = profile::latest_by_bundle(mine); let bundles: Vec<&str> = latest .iter() .filter_map(|p| p.bundle_id.as_deref()) .collect(); // Use the shortest remaining lifetime in each app/extension group, so an // expiring extension triggers renewal for all related profiles. let group_days: std::collections::HashMap<&str, i64> = { let mut result = std::collections::HashMap::new(); for p in &latest { if let Some(bundle) = p.bundle_id.as_deref() { let root = profile::group_root(bundle, &bundles, &team_id); result .entry(root) .and_modify(|days: &mut i64| { *days = (*days).min(p.days_left().unwrap_or(-1)); }) .or_insert_with(|| p.days_left().unwrap_or(-1)); } } result }; // When the installed-app lookup fails, explicitly fall back to renewing ALL // known team profiles. This is intentional and must be visible in logs. let mut fallback_renew_all = false; let installed = if installed_only { match phone::with_timeout( "Installed app lookup", std::time::Duration::from_secs(30), link.installed_app_ids(), ) .await { Ok(apps) => { say(&format!( "Detected {} installed user apps on the iPhone.", apps.len() )); Some(apps) } Err(err) => { fallback_renew_all = true; say(&format!("WARNING: installed-app lookup failed: {err}")); say("FALLBACK: renewing ALL known profiles for this Apple team, regardless of expiry."); None } } } else { None }; // Si decide cosa rinnovare prima di parlare con Apple: se niente è in scadenza non si // accede affatto (meno login, meno rischio che Apple limiti le richieste). let mut due: Vec<(&str, i64)> = Vec::new(); let mut requested_found = false; for p in &latest { let Some(bundle) = p.bundle_id.as_deref() else { say(&format!( " {}: bundle ID cannot be read, skipping", p.name )); continue; }; let root = profile::group_root(bundle, &bundles, &team_id); if let Some(selected) = bundle_filter { if selected != root { continue; } requested_found = true; } if let Some(apps) = &installed { // InstallationProxy may report either the signed (raw) Bundle ID // or the unsuffixed app Bundle ID. Profiles, unlike installed // apps, may carry a trailing ".{team}" in their Apple App ID. let host_id = profile::profile_app_id(root, &team_id); let member_id = profile::profile_app_id(bundle, &team_id); if !apps.contains(root) && !apps.contains(host_id) && !apps.contains(bundle) && !apps.contains(member_id) { say(&format!(" {bundle}: host app not installed, skipping")); continue; } } let days = p.days_left().unwrap_or(-1); let group_left = *group_days.get(root).unwrap_or(&days); if !force && !fallback_renew_all && group_left > min_days { say(&format!( " {bundle}: expires in {days} days, no renewal needed" )); continue; } due.push((bundle, days)); } if bundle_filter.is_some() && !requested_found { return Err( "Selected app has no provisioning profile for the configured Apple team.".into(), ); } if due.is_empty() { say("Nothing to renew: skipping Apple sign-in."); return Ok(()); } say("Signing in to Apple..."); apple::set_interactive(false); let mut sess = apple::open_session(Path::new("."), &acc.apple_id, &acc.password).await?; let team = apple::pick_team(&mut sess, Some(&team_id), false).await?; let ids = apple::app_ids(&mut sess, &team).await?; let mut done = 0; let mut missing_app_ids = Vec::new(); for (bundle, days) in due { let Some(app) = ids.iter().find(|a| a.identifier == bundle) else { say(&format!( " {bundle}: no matching App ID on Apple, skipping" )); say(&format!("MISSING_APP_ID {bundle}")); missing_app_ids.push(bundle); continue; }; if dry_run { say(&format!( " {bundle}: expires in {days} days, WOULD RENEW (dry run)" )); continue; } say(&format!( " {bundle}: expires in {days} days, downloading a new profile..." )); let new = apple::download_profile(&mut sess, &team, app).await?; say(&crate::tr!( format!( " nuovo profilo valido fino al {}", new.date_expire.to_xml_format() ), format!( " new profile valid until {}", new.date_expire.to_xml_format() ) )); let bytes: Vec = new.encoded_profile.into(); let expected = profile::parse(&bytes).ok_or("Downloaded provisioning profile cannot be verified.")?; if expected.team_id.as_deref() != Some(team_id.as_str()) || expected.bundle_id.as_deref() != Some(bundle) { return Err( "Downloaded provisioning profile does not match the requested team and app.".into(), ); } let mut first_link = Some(link); let completion = renewal_wait::await_install( || { let existing = first_link.take(); let profile_bytes = bytes.clone(); async move { let mut request_link = match existing { Some(link) => link, None => phone::reconnect(route, PAIRING_FILE) .await .map_err(renewal_wait::AttemptError::Retryable)?, }; request_link.install_renewal_profile(profile_bytes).await?; Ok(request_link) } }, || phone::probe_profile(route, PAIRING_FILE, &expected.uuid, &team_id), wait_control, renewal_wait::Policy::default(), |reason| match reason { renewal_wait::WaitReason::NoResponse => say("WAITING_FOR_PHONE No install response after 60 seconds. Keeping the request pending; unlock the iPhone and keep it awake. Waiting stops if the phone disconnects."), renewal_wait::WaitReason::SocketTimeout(error) => say(&format!("WAITING_FOR_PHONE Install socket timed out ({error}). Reconnecting to verify the downloaded profile; waiting while the iPhone remains reachable. Unlock it and keep it awake.")), }, ) .await .map_err(|e| format!("Profile install: {e}"))?; say(" installed on the phone"); say(&format!("RENEWED {bundle}")); done += 1; link = match completion { renewal_wait::Completion::Acknowledged(link) => link, renewal_wait::Completion::Verified => { say(" New profile verified on the iPhone despite a missing install response."); phone::reconnect(route, PAIRING_FILE).await? } }; } if !dry_run && done > 0 { say("Final check on the phone:"); let after = phone_profiles(&mut link).await?; for p in after .iter() .filter(|p| p.team_id.as_deref() == Some(team_id.as_str())) { say(&profile_line(p)); } } renewal_completion(&missing_app_ids, done) } /// A skipped due profile is still an incomplete renewal, even if other apps /// succeeded. Keep processing known IDs, then propagate failure to CLI/web /// callers so scheduled retries and the final failure notification still run. fn renewal_completion(missing_app_ids: &[&str], renewed: usize) -> Result<(), String> { if missing_app_ids.is_empty() { return Ok(()); } Err(format!( "Renewal incomplete: {} due profile(s) have no matching App ID on Apple; {} renewed. Missing: {}", missing_app_ids.len(), renewed, missing_app_ids.join(", ") )) } /// Toglie dal telefono il profilo con questo UUID, dopo averne salvato e riletto una copia in /// `profili-salvati/` (si rimette con `profile-restore`). Serve a capire se iOS accetta i /// profili nuovi per un'app installata con quello originale. /// Validate an exact UUID, back up its original bytes, then remove only that profile. /// The web endpoint also restricts deletion to the configured Apple team. async fn remove_profile_with_backup( dir: &Path, link: &mut phone::PhoneLink, uuid: &str, required_team: Option<&str>, ) -> Result { if uuid.is_empty() || uuid.len() > 64 || !uuid.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') { return Err("Invalid profile UUID.".into()); } let raw = link.profiles().await?; let (found, parsed) = raw .iter() .find_map(|r| { profile::parse(r) .filter(|p| p.uuid.eq_ignore_ascii_case(uuid)) .map(|p| (r, p)) }) .ok_or("Profile not found on the iPhone.")?; if let Some(team) = required_team { if parsed.team_id.as_deref() != Some(team) || parsed.bundle_id.is_none() { return Err("Refusing to remove a profile outside your Apple team.".into()); } } let saved_dir = dir.join(SAVED_DIR); std::fs::create_dir_all(&saved_dir).map_err(dbg)?; fsperm::restrict_dir(&saved_dir).map_err(dbg)?; let path = saved_dir.join(format!("{}.mobileprovision", parsed.uuid)); fsperm::write_private(&path, found).map_err(dbg)?; if std::fs::read(&path).map_err(dbg)? != *found { return Err("Profile backup verification failed; nothing was removed.".into()); } link.remove_profile(&parsed.uuid).await?; Ok(parsed) } async fn cmd_profile_remove(addr: Option, uuid: Option) -> Result<(), String> { let uuid = uuid.ok_or(crate::tr!( "uso: altkeeper profile-remove ", "usage: altkeeper profile-remove " ))?; let mut link = open_phone(Path::new("."), addr, &mut |s| println!("{s}")).await?; let parsed = remove_profile_with_backup(Path::new("."), &mut link, &uuid, None).await?; println!( "{}", crate::tr!( format!( "Tolto dal telefono: {} [{}] (copia in {})", parsed.name, parsed.uuid, SAVED_DIR ), format!( "Removed from the phone: {} [{}] (backup in {})", parsed.name, parsed.uuid, SAVED_DIR ) ) ); Ok(()) } /// Rimette sul telefono un profilo salvato da `profile-remove`. async fn cmd_profile_restore(addr: Option, file: Option) -> Result<(), String> { let file = file.ok_or(crate::tr!( "uso: altkeeper profile-restore ", "usage: altkeeper profile-restore " ))?; let bytes = std::fs::read(&file).map_err(dbg)?; let parsed = profile::parse(&bytes).ok_or(crate::tr!( "il file non è un profilo valido", "the file is not a valid provisioning profile" ))?; let mut link = open_phone(Path::new("."), addr, &mut |s| println!("{s}")).await?; link.install_profile(bytes).await?; println!( "{}", crate::tr!( format!("Rimesso sul telefono: {} [{}]", parsed.name, parsed.uuid), format!("Restored on the phone: {} [{}]", parsed.name, parsed.uuid) ) ); Ok(()) } /// Aspetta la richiesta di chiusura del sistema: SIGTERM su Unix (`docker stop`, systemd), /// chiusura della finestra o spegnimento su Windows. Ctrl-C è gestito a parte. #[cfg(unix)] async fn terminated() { use tokio::signal::unix::{signal, SignalKind}; match signal(SignalKind::terminate()) { Ok(mut term) => { term.recv().await; } Err(_) => std::future::pending::<()>().await, } } #[cfg(windows)] async fn terminated() { use tokio::signal::windows::{ctrl_close, ctrl_shutdown}; let (Ok(mut close), Ok(mut shutdown)) = (ctrl_close(), ctrl_shutdown()) else { return std::future::pending::<()>().await; }; tokio::select! { _ = close.recv() => {} _ = shutdown.recv() => {} } } #[cfg(not(any(unix, windows)))] async fn terminated() { std::future::pending::<()>().await } /// Su macOS la barra dei menu e le finestre funzionano solo dal thread principale del processo, /// che però qui aspetta il thread `altkeeper-main`: il comando `tray` gli manda il ciclo degli /// eventi da eseguire attraverso questo canale. #[cfg(target_os = "macos")] type MainJob = Box; #[cfg(target_os = "macos")] static MAIN_JOB: std::sync::Mutex>> = std::sync::Mutex::new(None); /// Chiude il canale quando il thread `altkeeper-main` finisce (anche in panico), così il thread /// principale smette di aspettare. #[cfg(target_os = "macos")] struct ReleaseMainThread; #[cfg(target_os = "macos")] impl Drop for ReleaseMainThread { fn drop(&mut self) { MAIN_JOB.lock().unwrap_or_else(|e| e.into_inner()).take(); } } fn main() -> Result<(), String> { #[cfg(target_os = "macos")] let jobs = { let (tx, rx) = std::sync::mpsc::channel::(); *MAIN_JOB.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx); rx }; // Il future di `run()` è grande in debug: su Windows lo stack di main è di 1 MB e // andrebbe in overflow, quindi il runtime gira su un thread con 16 MiB di stack. let worker = std::thread::Builder::new() .name("altkeeper-main".into()) .stack_size(16 * 1024 * 1024) .spawn(|| -> Result<(), String> { #[cfg(target_os = "macos")] let _release = ReleaseMainThread; tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(|e| e.to_string())? .block_on(run()) }) .map_err(|e| e.to_string())?; // Il ciclo degli eventi non ritorna: "Quit" chiude il processo. #[cfg(target_os = "macos")] if let Ok(job) = jobs.recv() { job(); } worker .join() .unwrap_or_else(|_| Err("main thread panicked".to_string())) } /// Su macOS, aperto con un doppio clic su AltKeeper.app: l'eseguibile sta in `*.app/Contents/MacOS`. #[cfg(target_os = "macos")] fn in_app_bundle() -> bool { std::env::current_exe() .map(|p| p.to_string_lossy().contains(".app/Contents/MacOS/")) .unwrap_or(false) } async fn run() -> Result<(), String> { rustls::crypto::ring::default_provider() .install_default() .ok(); isideload::init().map_err(dbg)?; // RUST_LOG=debug mostra i messaggi delle librerie. Attenzione: a livello debug // idevice stampa anche le chiavi del pairing: non usarlo su log condivisi. tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let mut args: Vec = std::env::args().skip(1).collect(); // Le versioni vecchie di macOS passano `-psn_...` alle app aperte dal Finder. args.retain(|a| !a.starts_with("-psn_")); // Su Windows un doppio clic sull'exe (nessun argomento) apre la versione con l'icona, e su // macOS lo stesso fa AltKeeper.app. #[cfg(target_os = "macos")] let double_clicked = in_app_bundle(); #[cfg(not(target_os = "macos"))] let double_clicked = cfg!(windows); if args.is_empty() && double_clicked { args.push("tray".into()); } if args.is_empty() { return Err(crate::tr!(USAGE, USAGE_EN).into()); } let cmd = args.remove(0); let flag = |name: &str| args.iter().any(|a| a == name); let opt = |name: &str| { args.iter() .position(|a| a == name) .and_then(|i| args.get(i + 1)) .cloned() }; // A command-level --help must not try to authenticate or access a device. if matches!(cmd.as_str(), "help" | "--help" | "-h") || flag("--help") || flag("-h") { println!("{}", crate::tr!(USAGE, USAGE_EN)); return Ok(()); } match cmd.as_str() { "version" | "--version" | "-V" => { println!("altkeeper {}", env!("CARGO_PKG_VERSION")); Ok(()) } "phone" => cmd_phone(phone_addr(opt("--phone"))?).await, "pair-usb" => { let out = args.first().cloned().unwrap_or_else(|| PAIRING_FILE.into()); phone::pair_usb(&out).await?; println!( "{}", crate::tr!( format!("pairing remoto creato e salvato in {out}"), format!("Remote pairing created and saved to {out}") ) ); Ok(()) } "login" => cmd_login(args.first().cloned()).await, "apps" => cmd_apps().await, "renew" => { let min_days = opt("--min-days") .map(|v| { v.parse::().map_err(|e| { crate::tr!( format!("--min-days non valido: {e}"), format!("invalid --min-days: {e}") ) }) }) .transpose()? .unwrap_or(3); let bundle = opt("--bundle"); let force = flag("--force"); // Default CLI renewal (e.g. cron) and web "due" use installed-only. // Explicit --force without --installed-only retains manual override behavior. let installed_only = flag("--installed-only") || (!force && bundle.is_none()); cmd_renew( Path::new("."), phone_addr(opt("--phone"))?, flag("--dry-run"), force, min_days, bundle.as_deref(), installed_only, None, &mut |s| println!("{s}"), ) .await } "serve" => { let bind: SocketAddr = opt("--bind") .unwrap_or_else(|| "127.0.0.1:8787".into()) .parse() .map_err(|e| { crate::tr!( format!("--bind non valido (serve ip:porta): {e}"), format!("invalid --bind (expected ip:port): {e}") ) })?; let anisette_bind = opt("--anisette-bind") .map(|value| { value.parse::().map_err(|error| { crate::tr!( format!("--anisette-bind non valido (serve ip:porta): {error}"), format!("invalid --anisette-bind (expected ip:port): {error}") ) }) }) .transpose()?; let anisette_libraries = opt("--anisette-libs"); #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] if anisette_bind.is_some() || anisette_libraries.is_some() { return Err(crate::tr!( "il server anisette locale non è disponibile su questo sistema", "the local Anisette server is not available on this operating system" ) .into()); } #[cfg(any(target_os = "linux", target_os = "macos", windows))] if anisette_libraries.is_some() && anisette_bind.is_none() { return Err(crate::tr!( "--anisette-libs richiede --anisette-bind", "--anisette-libs requires --anisette-bind" ) .into()); } // Meglio la variabile d'ambiente: un PIN sulla riga di comando si vede con `ps`. let pin = opt("--pin") .or_else(|| env::var("WEB_PIN")) .filter(|p| !p.is_empty()); if let Some(dir) = opt("--dir") { std::env::set_current_dir(&dir).map_err(|e| { crate::tr!(format!("--dir {dir}: {e}"), format!("--dir {dir}: {e}")) })?; } log::init(Path::new(".")); let phone = phone_addr(opt("--phone"))?; web::check_options(bind, &pin)?; apple::anisette_choice(Path::new("."))?; altserver::set_profile_dir(std::env::current_dir().map_err(dbg)?); if flag("--altserver") { let port = altserver::start(altserver_port(opt("--altserver-port"))?, phone)?; start_dnssd(&opt, port)?; } else if opt("--dns-domain").is_some() { return Err(crate::tr!( "--dns-domain serve solo insieme a --altserver", "--dns-domain requires --altserver" ) .into()); } let dir = std::env::current_dir().map_err(dbg)?; #[cfg(any(target_os = "linux", target_os = "macos", windows))] if let Some(anisette_bind) = anisette_bind { let libraries = anisette_libraries .map(std::path::PathBuf::from) .unwrap_or_else(|| dir.join("anisette-libraries")); return tokio::try_join!( web::serve(bind, pin, phone, dir.clone()), anisette_server::serve(anisette_bind, libraries, dir), ) .map(|_| ()); } web::serve(bind, pin, phone, dir).await } #[cfg(any(windows, target_os = "macos"))] "tray" => { // Senza --dir: su Windows la cartella dell'exe (all'avvio con Windows la cartella di // lavoro è un'altra), su macOS ~/Library/Application Support/AltKeeper. let dir = match opt("--dir") { Some(d) => std::path::PathBuf::from(d), #[cfg(windows)] None => std::env::current_exe() .map_err(dbg)? .parent() .ok_or(crate::tr!( "cartella dell'exe non trovata", "cannot find the exe folder" ))? .to_path_buf(), #[cfg(target_os = "macos")] None => { let d = tray::default_dir()?; std::fs::create_dir_all(&d).map_err(|e| format!("{}: {e}", d.display()))?; d } }; std::env::set_current_dir(&dir).map_err(|e| format!("--dir {}: {e}", dir.display()))?; let dir = std::env::current_dir().map_err(dbg)?; log::init(&dir); #[cfg(windows)] if !flag("--console") { tray::hide_console(); } // Avviato da un aggiornamento: prima si aspetta che la versione vecchia liberi le porte. let replacing = opt("--replacing").and_then(|p| p.parse::().ok()); #[cfg(windows)] if let Some(pid) = replacing { tray::wait_for_exit(pid); } let exe = std::env::current_exe().map_err(dbg)?; let bind: SocketAddr = "127.0.0.1:8787".parse().map_err(dbg)?; let phone = phone_addr(opt("--phone"))?; let start = || -> Result<(), String> { apple::anisette_choice(Path::new("."))?; altserver::set_profile_dir(dir.clone()); let port = altserver::start(altserver_port(opt("--altserver-port"))?, phone)?; start_dnssd(&opt, port) }; if let Err(e) = start() { log::err(&e); // La versione nuova non parte: si rimette quella vecchia e si riavvia quella. if replacing.is_some() && update::rollback(&exe) { log::err("[update] the new version did not start, going back to the old one"); let _ = std::process::Command::new(&exe) .arg("tray") .arg("--dir") .arg(&dir) .spawn(); return Err(e); } tray::message_box(&format!("AltKeeper cannot start:\n\n{e}")); return Err(e); } update::cleanup(&exe); let rt = tokio::runtime::Handle::current(); let web_dir = dir.clone(); let start_web = move |proxy: tao::event_loop::EventLoopProxy| { rt.spawn(async move { if let Err(e) = web::serve(bind, None, phone, web_dir).await { let _ = proxy.send_event(tray::UserEvent::ServerFailed(e)); } }); }; #[cfg(windows)] let done: Result<(), String> = tokio::task::block_in_place(|| tray::run(bind, &dir, start_web)); // Il ciclo degli eventi passa al thread principale; questo thread tiene vivo il // runtime di tokio, su cui girano la pagina web e il resto. #[cfg(target_os = "macos")] let done: Result<(), String> = { let job: MainJob = Box::new(move || tray::run(bind, &dir, start_web)); MAIN_JOB .lock() .unwrap_or_else(|e| e.into_inner()) .as_ref() .ok_or("main thread not available")? .send(job) .map_err(dbg)?; std::future::pending().await }; done } "altserver" => { if let Some(dir) = opt("--dir") { std::env::set_current_dir(&dir).map_err(|e| { crate::tr!(format!("--dir {dir}: {e}"), format!("--dir {dir}: {e}")) })?; } log::init(Path::new(".")); apple::anisette_choice(Path::new("."))?; altserver::set_profile_dir(std::env::current_dir().map_err(dbg)?); let port = altserver::start(altserver_port(opt("--port"))?, phone_addr(opt("--phone"))?)?; start_dnssd(&opt, port)?; log::line( &crate::tr!( "AltServer attivo. Ctrl-C per fermarlo.", "AltServer running. Press Ctrl-C to stop." ) .to_string(), ); tokio::select! { r = tokio::signal::ctrl_c() => r.map_err(dbg)?, _ = terminated() => {} } altserver::stop(); Ok(()) } "profile-remove" => { cmd_profile_remove(phone_addr(opt("--phone"))?, args.first().cloned()).await } "profile-restore" => { cmd_profile_restore(phone_addr(opt("--phone"))?, args.first().cloned()).await } _ => Err(crate::tr!(USAGE, USAGE_EN).into()), } } #[cfg(test)] mod cli_tests { use super::save_password_confirmed; #[test] fn missing_due_app_ids_cannot_report_success() { let missing = ["com.example.app.TEAM", "com.example.app.TEAM.extension"]; let error = super::renewal_completion(&missing, 0).unwrap_err(); assert!(error.contains("2 due profile(s)")); assert!(error.contains("0 renewed")); for bundle in missing { assert!(error.contains(bundle)); } let partial = super::renewal_completion(&missing[..1], 2).unwrap_err(); assert!(partial.contains("2 renewed")); } #[test] fn successful_and_no_op_renewals_remain_successful() { assert!(super::renewal_completion(&[], 0).is_ok()); assert!(super::renewal_completion(&[], 3).is_ok()); } #[test] fn confirmation_accepts_italian_and_english_yes() { for yes in ["s", "S", "si", "SÌ", "y", "Y", "yes", "YES", " yes\r\n"] { assert!(save_password_confirmed(yes), "{yes:?}"); } } #[test] fn confirmation_never_saves_on_negative_or_missing_input() { for no in ["", "\r\n", " ", "n", "N", "no", "NO", "unknown"] { assert!(!save_password_confirmed(no), "{no:?}"); } } }