From 8cb2901df985266b172ee3b41d6d0eb11d637aa3 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sat, 24 Oct 2026 11:38:40 +0900 Subject: [PATCH 001/117] ci: publish AltKeeper Docker image for Raspberry Pi arm64 --- .github/workflows/docker.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4bb731a..6cf3e0b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,7 +1,7 @@ # Prova il codice e pubblica l'immagine Docker su GitHub Container Registry (ghcr.io). # # - Pull request: prova i test e la compilazione dell'immagine (amd64), senza pubblicare. -# - Push su main: pubblica `latest` e `sha-` (solo amd64: è la compilazione veloce). +# - Push su main: pubblica `latest` e `sha-` per amd64 e arm64. # - Tag `vX.Y.Z` (per esempio `git tag v0.2.0 && git push --tags`): pubblica `X.Y.Z`, `X.Y` e # `latest`, per amd64 e arm64 (l'arm64 gira sotto emulazione: richiede parecchi minuti). # @@ -65,8 +65,8 @@ jobs: - uses: docker/build-push-action@v7 with: context: . - # I tag di versione pubblicano anche arm64; il resto solo amd64 (più rapido). - platforms: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} + # Il fork pubblica amd64 e arm64 anche su main, per Raspberry Pi. + platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From f712a14eb51ad486474b83e08decb47eb25b0bfd Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 08:48:32 +0900 Subject: [PATCH 002/117] feat: query installed user apps through installation_proxy --- src/phone.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/phone.rs b/src/phone.rs index 5e3e6d1..4fa6d9d 100644 --- a/src/phone.rs +++ b/src/phone.rs @@ -2,6 +2,7 @@ //! TLS-PSK, RSD e client misagent per i profili di provisioning. use std::any::Any; +use std::collections::HashSet; use std::net::{IpAddr, SocketAddr}; use std::path::Path; use std::time::{Duration, Instant}; @@ -137,6 +138,31 @@ impl PhoneLink { misagent.copy_all().await.map_err(dbg) } + /// Installed user-app bundle IDs, not Apple Developer App IDs or provisioning profiles. + /// Fail closed on a lookup error: unattended renewal must never guess. + pub async fn installed_app_ids(&mut self) -> Result, String> { + let apps = match self { + Self::Legacy { provider, .. } + | Self::Rsd { + usb: Some(provider), + .. + } => { + let mut client = InstallationProxyClient::connect(provider).await.map_err(dbg)?; + client.get_apps(Some("User"), None).await.map_err(dbg)? + } + Self::Rsd { + adapter, + hs, + usb: None, + .. + } => { + let mut client = InstallationProxyClient::connect_rsd(adapter, hs).await.map_err(dbg)?; + client.get_apps(Some("User"), None).await.map_err(dbg)? + } + }; + Ok(apps.into_keys().collect()) + } + pub async fn install_profile(&mut self, profile: Vec) -> Result<(), String> { let misagent = match self { Self::Rsd { misagent, .. } | Self::Legacy { misagent, .. } => misagent, From 151cc09916e619e5b12a0a78d37b6d07493f8213 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 08:48:57 +0900 Subject: [PATCH 003/117] feat: filter unattended renewals by installed apps and share verified profile removal --- src/main.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index 7943bef..0ec2571 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ //! 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] +//! 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] @@ -229,6 +229,8 @@ async fn cmd_renew( dry_run: bool, force: bool, min_days: i64, + bundle_filter: Option<&str>, + installed_only: bool, say: &mut dyn FnMut(&str), ) -> Result<(), String> { let acc = apple::load_account(Path::new("."))?; @@ -255,14 +257,40 @@ async fn cmd_renew( // finché il profilo vecchio non scade). let latest = profile::latest_by_bundle(mine); + // Only unattended/expiring-app renewal requires proof of actual installation. + // A failed listing aborts the operation rather than silently renewing stale profiles. + let installed = if installed_only { + Some(phone::with_timeout( + "Installed app lookup", + std::time::Duration::from_secs(30), + link.installed_app_ids(), + ).await?) + } else { + None + }; + if let Some(apps) = &installed { + say(&format!("Detected {} installed user apps on the iPhone.", apps.len())); + } + // 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; }; + if let Some(selected) = bundle_filter { + if selected != bundle { continue; } + requested_found = true; + } + if let Some(apps) = &installed { + if !apps.contains(bundle) { + say(&format!(" {bundle}: no installed app found, skipping")); + continue; + } + } let days = p.days_left().unwrap_or(-1); if !force && days > min_days { say(&format!(" {bundle}: expires in {days} days, no renewal needed")); @@ -270,6 +298,9 @@ async fn cmd_renew( } 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(()); @@ -318,30 +349,50 @@ async fn cmd_renew( /// 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. -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(addr, &mut |s| println!("{s}")).await?; +/// 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( + 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)) + .filter(|p| p.uuid.eq_ignore_ascii_case(uuid)) .map(|p| (r, p)) }) - .ok_or(crate::tr!("profilo non trovato sul telefono", "profile not found on the phone"))?; + .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()); + } + } std::fs::create_dir_all(SAVED_DIR).map_err(dbg)?; fsperm::restrict_dir(Path::new(SAVED_DIR)).map_err(dbg)?; let path = Path::new(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(crate::tr!(format!("la copia in {} non coincide: non tolgo niente", path.display()), format!("saved copy at {} does not match; refusing to remove the profile", path.display()))); + return Err("Profile backup verification failed; nothing was removed.".into()); } - println!("{}", crate::tr!(format!("Copia salvata in {}", path.display()), format!("Copy saved to {}", path.display()))); - link.remove_profile(&parsed.uuid).await?; - println!("{}", crate::tr!(format!("Tolto dal telefono: {} [{}]", parsed.name, parsed.uuid), format!("Removed from the phone: {} [{}]", parsed.name, parsed.uuid))); + 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(addr, &mut |s| println!("{s}")).await?; + let parsed = remove_profile_with_backup(&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(()) } @@ -453,11 +504,18 @@ async fn run() -> Result<(), String> { .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( phone_addr(opt("--phone"))?, flag("--dry-run"), - flag("--force"), + force, min_days, + bundle.as_deref(), + installed_only, &mut |s| println!("{s}"), ) .await From 491b05cc70c5783f00e737eb8f8be54758524140 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 08:49:33 +0900 Subject: [PATCH 004/117] feat(web): per-app renew and safe team-scoped profile deletion with backups --- src/web.rs | 116 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 8 deletions(-) diff --git a/src/web.rs b/src/web.rs index ff6e532..fe1fb63 100644 --- a/src/web.rs +++ b/src/web.rs @@ -64,6 +64,7 @@ struct Snapshot { detail: Option, profiles_total: usize, apps: Vec, + profiles: Vec, } #[derive(Clone, PartialEq, Debug)] @@ -75,6 +76,15 @@ struct AppView { level: &'static str, } +#[derive(Clone, PartialEq, Debug)] +struct ProfileView { + uuid: String, + bundle: String, + label: String, + days: i64, + expires: String, +} + #[derive(Default, Clone)] struct JobView { kind: String, @@ -195,6 +205,7 @@ pub async fn serve( .route("/api/state", get(state)) .route("/api/check", post(check)) .route("/api/renew", post(renew)) + .route("/api/profile/remove", post(remove_profile)) .route( "/api/install", post(install).layer(DefaultBodyLimit::max(MAX_IPA_BYTES + 1024 * 1024)), @@ -392,6 +403,10 @@ async fn state(State(app): State>) -> Json { "bundle": a.bundle, "label": a.label, "days": a.days, "expires": a.expires, "level": a.level, })).collect::>(), + "profiles": g.snapshot.profiles.iter().map(|p| json!({ + "uuid": p.uuid, "bundle": p.bundle, "label": p.label, + "days": p.days, "expires": p.expires, + })).collect::>(), "job": { "kind": g.job.kind, "running": g.job.running, "lines": g.job.lines, "ok": g.job.ok, @@ -416,17 +431,27 @@ async fn check(State(app): State>) -> (StatusCode, Json) { #[derive(Deserialize)] struct RenewReq { - /// "due" rinnova solo le app in scadenza, "now" le rinnova tutte. + /// "due" renews expiring installed apps; "now" all profiles; "single" one bundle. mode: String, + bundle: Option, } async fn renew( State(app): State>, Json(req): Json, ) -> (StatusCode, Json) { - let force = match req.mode.as_str() { - "now" => true, - "due" => false, + let (force, installed_only, bundle) = match req.mode.as_str() { + "now" => (true, false, None), + "due" => (false, true, None), + "single" => { + let Some(b) = req.bundle.filter(|b| !b.is_empty() && b.len() <= 255) else { + return json_err(StatusCode::BAD_REQUEST, "Select an app to renew."); + }; + if !app.lock().snapshot.apps.iter().any(|a| a.bundle == b) { + return json_err(StatusCode::NOT_FOUND, "App is not in the current profile list."); + } + (true, false, Some(b)) + } _ => return json_err(StatusCode::BAD_REQUEST, "Invalid mode"), }; match apple::read_account(Path::new(".")) { @@ -444,13 +469,42 @@ async fn renew( { let mut g = app.lock(); g.job = JobView { - kind: if force { "renew_all".into() } else { "renew_due".into() }, + kind: if bundle.is_some() { "renew_one".into() } + else if force { "renew_all".into() } else { "renew_due".into() }, running: true, lines: Vec::new(), ok: None, }; } - spawn_job(guard, move |app| run_renew(app, force)); + spawn_job(guard, move |app| run_renew(app, force, bundle, installed_only)); + json_ok() +} + +#[derive(Deserialize)] +struct RemoveProfileReq { + uuid: String, +} + +async fn remove_profile( + State(app): State>, + Json(req): Json, +) -> (StatusCode, Json) { + if !app.lock().snapshot.profiles.iter().any(|p| p.uuid.eq_ignore_ascii_case(&req.uuid)) { + return json_err(StatusCode::NOT_FOUND, "Profile is not in your team's current list. Check again."); + } + let Some(guard) = try_busy(&app) else { + return json_err(StatusCode::CONFLICT, BUSY_MSG); + }; + { + let mut g = app.lock(); + g.job = JobView { + kind: "remove_profile".into(), + running: true, + lines: Vec::new(), + ok: None, + }; + } + spawn_job(guard, move |app| run_remove_profile(app, req.uuid)); json_ok() } @@ -686,13 +740,18 @@ async fn run_check(app: Arc) { g.snapshot.detail = None; g.snapshot.profiles_total = profiles.len(); g.snapshot.apps = team.as_deref().map(|t| app_views(&profiles, t)).unwrap_or_default(); + g.snapshot.profiles = team.as_deref().map(|t| profile_views(&profiles, t)).unwrap_or_default(); } Ok(Err(e)) => { + g.snapshot.apps.clear(); + g.snapshot.profiles.clear(); g.snapshot.reachable = Some(false); g.snapshot.error = Some(friendly_error(&e)); g.snapshot.detail = Some(e.chars().take(400).collect()); } Err(_) => { + g.snapshot.apps.clear(); + g.snapshot.profiles.clear(); g.snapshot.reachable = Some(false); g.snapshot.error = Some(friendly_error("non risponde")); g.snapshot.detail = Some("nessuna risposta entro 45 secondi".into()); @@ -700,9 +759,9 @@ async fn run_check(app: Arc) { } } -async fn run_renew(app: Arc, force: bool) { +async fn run_renew(app: Arc, force: bool, bundle: Option, installed_only: bool) { let mut say = |s: &str| app.push_line(&english_job_line(s)); - let result = crate::cmd_renew(app.phone, false, force, 3, &mut say).await; + let result = crate::cmd_renew(app.phone, false, force, 3, bundle.as_deref(), installed_only, &mut say).await; let ok = result.is_ok(); if let Err(e) = &result { @@ -719,6 +778,29 @@ async fn run_renew(app: Arc, force: bool) { run_check(app).await; } +async fn run_remove_profile(app: Arc, uuid: String) { + let result: Result<(), String> = async { + let team = apple::read_account(Path::new(".")) + .and_then(|a| a.team_id) + .ok_or("Sign in to select a team before removing profiles.")?; + let (_, mut link) = phone::connect_auto(app.phone, PAIRING_FILE, |_: &str| {}).await?; + let p = crate::remove_profile_with_backup(&mut link, &uuid, Some(&team)).await?; + app.push_line(&format!("Removed profile for {} [{}]. Backup saved in {}.", + p.bundle_id.as_deref().unwrap_or("?"), p.uuid, crate::SAVED_DIR)); + Ok(()) + }.await; + let ok = result.is_ok(); + if let Err(e) = result { + app.push_line(&format!("Error: {}", short_error(&e))); + } + { + let mut g = app.lock(); + g.job.running = false; + g.job.ok = Some(ok); + } + run_check(app).await; +} + async fn run_install(app: Arc, upload: UploadedIpa) { let result = install_ipa(&app, &upload).await; let ok = result.is_ok(); @@ -879,6 +961,24 @@ fn app_views(profiles: &[profile::PhoneProfile], team: &str) -> Vec { views } +fn profile_views(profiles: &[profile::PhoneProfile], team: &str) -> Vec { + let mut views: Vec<_> = profiles.iter() + .filter(|p| p.team_id.as_deref() == Some(team)) + .filter_map(|p| { + let bundle = p.bundle_id.clone()?; + Some(ProfileView { + uuid: p.uuid.clone(), + label: label_of(&bundle, team), + bundle, + days: p.days_left().unwrap_or(-1), + expires: p.expires_text(), + }) + }) + .collect(); + views.sort_by(|a, b| a.label.cmp(&b.label).then_with(|| b.expires.cmp(&a.expires))); + views +} + /// Traduce gli errori tecnici in una frase comprensibile. fn friendly_error(err: &str) -> String { let e = err.to_lowercase(); From 14f2a6cf6b36e19684d9876e7ac92a5bab43ed3b Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 08:50:40 +0900 Subject: [PATCH 005/117] feat: fall back to all profile renewals when installed-app lookup fails --- src/main.rs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0ec2571..74e049b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -257,20 +257,29 @@ async fn cmd_renew( // finché il profilo vecchio non scade). let latest = profile::latest_by_bundle(mine); - // Only unattended/expiring-app renewal requires proof of actual installation. - // A failed listing aborts the operation rather than silently renewing stale profiles. + // 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 { - Some(phone::with_timeout( + match phone::with_timeout( "Installed app lookup", std::time::Duration::from_secs(30), link.installed_app_ids(), - ).await?) + ).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 }; - if let Some(apps) = &installed { - say(&format!("Detected {} installed user apps on the iPhone.", apps.len())); - } // 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). @@ -292,7 +301,7 @@ async fn cmd_renew( } } let days = p.days_left().unwrap_or(-1); - if !force && days > min_days { + if !force && !fallback_renew_all && days > min_days { say(&format!(" {bundle}: expires in {days} days, no renewal needed")); continue; } From 773b5c7b5af1c71e7238d7e50f570af95c038fe2 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 08:51:03 +0900 Subject: [PATCH 006/117] feat(web): individual renewal and confirmation-backed profile deletion UI --- src/web/index.html | 52 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/web/index.html b/src/web/index.html index fe9a6c9..f3f1aab 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -116,6 +116,13 @@

Your apps

+
+

Provisioning profiles on iPhone

+

Old profiles may remain after renewal. Deleting a profile still needed by an installed app can stop it opening. A verified server-side backup is saved before removal.

+ +
    +
    +

    Install an app from this PC

    Choose an IPA. AltKeeper signs it with the configured Apple ID and installs it over Wi-Fi. The Apple ID password must be saved on the server.

    @@ -239,16 +246,26 @@

    Recent renewals

    $("phone-error").title = p.detail || ""; // App + const canRenew = s.account.present && s.account.password_saved && s.account.team && !s.busy; const ul = $("apps"); ul.replaceChildren(); s.apps.forEach((a) => { const pct = Math.max(4, Math.min(100, Math.round((Math.max(a.days, 0) / 7) * 100))); const bar = div("bar", span("")); bar.firstChild.style.width = pct + "%"; - ul.append(li(a.level, + const details = div("grow", div("app-name", document.createTextNode(a.label)), - div("muted", span(days(a.days), "when"), document.createTextNode(" · " + a.expires.slice(0, 10))), - bar)); + div("muted", span(days(a.days), "when"), document.createTextNode(" · " + a.expires.slice(0, 10)))); + const renewBtn = document.createElement("button"); + renewBtn.type = "button"; + renewBtn.textContent = "Renew"; + renewBtn.disabled = !canRenew; + renewBtn.onclick = () => { + if (confirm("Renew only " + a.label + " now?")) { + act(() => api("/api/renew", { mode: "single", bundle: a.bundle })); + } + }; + ul.append(li(a.level, div("row", details, renewBtn), bar)); }); const empty = $("apps-empty"); if (!s.account.present) { empty.textContent = "Sign in with your Apple ID to see your apps."; empty.hidden = false; } @@ -257,7 +274,6 @@

    Recent renewals

    else { empty.hidden = true; } // Renewal buttons - const canRenew = s.account.present && s.account.password_saved && s.account.team && !s.busy; $("btn-due").disabled = !canRenew; $("btn-all").disabled = !canRenew; $("btn-check").disabled = s.busy; @@ -265,6 +281,30 @@

    Recent renewals

    : !s.account.password_saved ? "The password must be saved to renew: sign in again and leave the checkbox selected." : s.busy ? "Working…" : ""; + // Manual, individually backed-up profile removal. Never deletes apps or Apple App IDs. + const pu = $("profiles"); + pu.replaceChildren(); + (s.profiles || []).forEach((p) => { + const details = div("grow", + div("app-name", document.createTextNode(p.label)), + div("muted", document.createTextNode(days(p.days) + " · " + p.expires.slice(0, 10))), + div("muted", document.createTextNode("Profile " + p.uuid))); + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.textContent = "Delete profile"; + removeBtn.disabled = s.busy || s.phone.reachable !== true; + removeBtn.onclick = () => { + if (confirm("Remove this provisioning profile for " + p.label + + "? This may prevent the app from opening. A backup will be saved first.\n\n" + p.uuid)) { + act(() => api("/api/profile/remove", { uuid: p.uuid })); + } + }; + pu.append(li("", div("row", details, removeBtn))); + }); + const pempty = $("profiles-empty"); + pempty.textContent = "No provisioning profiles found for the current Apple team."; + pempty.hidden = (s.profiles || []).length > 0 || s.phone.reachable !== true; + const canInstall = s.account.present && s.account.password_saved && s.account.team && p.reachable === true && !s.busy; $("btn-install").disabled = !canInstall || uploading; @@ -283,9 +323,11 @@

    Recent renewals

    st.className = "banner"; st.textContent = job.kind === "install" ? "Signing and installing the app. Keep this page open until it finishes…" + : job.kind === "remove_profile" + ? "Backing up and deleting the selected profile…" : "Renewal in progress. Keep this page open until it finishes…"; } - else if (job.ok) { st.className = "banner good"; st.textContent = job.kind === "install" ? "App installed." : "Done."; } + else if (job.ok) { st.className = "banner good"; st.textContent = job.kind === "install" ? "App installed." : job.kind === "remove_profile" ? "Profile removed and backed up." : "Done."; } else { st.className = "banner bad"; st.textContent = "It did not complete successfully. Check the last line."; } const log = $("job-log"); const atEnd = log.scrollTop + log.clientHeight >= log.scrollHeight - 8; From 3ae1a6390aeb7f8a6533940e8ce2f14faa204347 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 08:51:11 +0900 Subject: [PATCH 007/117] ci: skip duplicate PR image build; publish arm64 only on main or tag --- .github/workflows/docker.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8ddb0ef..a80414d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -61,7 +61,9 @@ jobs: if ($LASTEXITCODE -ne 0 -or -not ($output -match '^uso: altkeeper')) { throw "Italian CLI help failed: $output" } image: - needs: test + # PRs run Linux and Windows tests only; build/push the multi-arch image once on main. + if: github.event_name != 'pull_request' + needs: [test, test-windows] runs-on: ubuntu-latest permissions: contents: read From 71f01151e7f7f0281bc1eeafef21a490e052ca59 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 08:51:35 +0900 Subject: [PATCH 008/117] docs: explain installed-only renewals, fallback, per-app control and backed-up deletion --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 28eb3c8..e105318 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,25 @@ answers with errors, wait before trying again instead of repeating it. `renew` looks at each app's newest profile, renews the ones with `--min-days` days left or fewer (default 3), and signs in to Apple **only if something is due**. +**Installed-app safety filter (default for scheduled/CLI `renew` and web “Renew expiring apps”):** +Before checking expiry, AltKeeper queries the phone's installed user apps through installation_proxy. +Profiles that do not correspond to installed apps are skipped. **If that lookup fails (including +a timeout), AltKeeper explicitly falls back to renewing *all* known profiles for the configured +Apple team, ignoring the expiry threshold.** This fallback is logged as WARNING/FALLBACK. +A successful lookup returning zero apps skips all renewals. + +To renew a specific app, use `altkeeper renew --bundle --force` or the per-app +**Renew** button on the web page. The manual `--force` and web “Renew all now” operations +do not check installed apps; `--installed-only` can be used with CLI `--force` to opt in. +No unattended schedule is enabled by merely running the Docker container. + +The web page also lists **individual provisioning profiles** (including older versions) with a +**Delete profile** button. Every removal requires confirmation, is limited to the saved Apple team, +and saves then verifies a private `profili-salvati/.mobileprovision` backup before removal. +Removing a profile currently needed by an installed app can prevent that app from opening. +To restore a backup: `altkeeper profile-restore profili-salvati/.mobileprovision`. +Neither profile deletion nor renewal removes Apple Developer App IDs. + ### 6. Automatic renewal (optional) A safety net that does not depend on the iPhone running AltStore. @@ -502,7 +521,7 @@ If it says it cannot find the server, watch the DNS with | `pair-usb [file]` | creates the remote pairing over USB (once) | | `login ` | signs in with 2FA and saves the account | | `apps` | lists your App IDs and their expiry dates | -| `renew [--dry-run] [--force] [--min-days N] [--phone ip:port]` | renews the profiles that are due | +| `renew [--dry-run] [--force] [--min-days N] [--phone ip:port] [--bundle ID] [--installed-only]` | renews profiles, optionally one bundle or with installed-app filtering | | `altserver [--port N] [--dir folder] [--phone ip:port] [--dns-domain D --dns-address IP]` | acts as AltServer for AltStore | | `serve [--bind ip:port] [--pin PIN] [--dir folder] [--phone ip:port] [--altserver] [--altserver-port N]` | web page (and, with `--altserver`, AltServer) | | `profile-remove ` | takes one profile off the phone, saving a copy in `profili-salvati/` | From d40444e8d482670fcfe6ad8ee725d8943e5709e9 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 08:59:40 +0900 Subject: [PATCH 009/117] feat: safely prune expired profile backups on a persisted 30-day cadence --- src/profile.rs | 117 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/src/profile.rs b/src/profile.rs index b5016a1..22a14e8 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -1,7 +1,9 @@ //! Lettura dei profili di provisioning (.mobileprovision) presenti sul telefono. -use std::io::Cursor; -use std::time::SystemTime; +use std::fs; +use std::io::{self, Cursor}; +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub struct PhoneProfile { /// Per ora non usato: servirà a togliere dal telefono i profili sostituiti. @@ -88,6 +90,68 @@ pub fn parse(raw: &[u8]) -> Option { }) } +const BACKUP_KEEP_AFTER_EXPIRY: Duration = Duration::from_secs(30 * 24 * 60 * 60); +const CLEANUP_INTERVAL: Duration = Duration::from_secs(30 * 24 * 60 * 60); +const CLEANUP_STAMP: &str = "state/profile-backup-last-cleanup"; + +/// Remove only verified expired backup files matching their original UUID. +/// An unreadable or malformed backup is retained, never guessed to be expired. +/// Symlinks, subdirectories and unrelated files are not touched. +pub fn cleanup_expired_backups(dir: &Path, now: SystemTime) -> io::Result { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(e), + }; + let mut removed = 0; + for entry in entries { + let entry = entry?; + if !entry.file_type()?.is_file() { continue; } // no symlink following + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("mobileprovision") { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let data = fs::read(&path)?; + let Some(p) = parse(&data) else { + continue; + }; + // Never delete a file whose name does not match the embedded profile UUID. + if !p.uuid.eq_ignore_ascii_case(stem) { continue; } + let Some(expiry) = p.expires else { continue; }; + let Ok(elapsed) = now.duration_since(SystemTime::from(expiry)) else { + continue; + }; + if elapsed >= BACKUP_KEEP_AFTER_EXPIRY { + fs::remove_file(&path)?; + removed += 1; + } + } + Ok(removed) +} + +/// Once per 30 days, clean backups that expired 30+ days ago. +/// Store the last successful sweep in the persistent /data/state directory. +pub fn cleanup_backups_if_due(now: SystemTime) -> io::Result> { + let stamp = Path::new(CLEANUP_STAMP); + if let Ok(raw) = fs::read_to_string(stamp) { + if let Ok(last_seconds) = raw.trim().parse::() { + if let Some(last) = UNIX_EPOCH.checked_add(Duration::from_secs(last_seconds)) { + if now.duration_since(last).is_ok_and(|elapsed| elapsed < CLEANUP_INTERVAL) { + return Ok(None); + } + } + } + } + let removed = cleanup_expired_backups(Path::new(crate::SAVED_DIR), now)?; + fs::create_dir_all("state")?; + let timestamp = now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + crate::fsperm::write_private(stamp, timestamp.to_string().as_bytes())?; + Ok(Some(removed)) +} + #[cfg(test)] mod tests { use super::*; @@ -127,6 +191,55 @@ mod tests { assert_eq!(latest_by_bundle(&all).len(), 2); } + + fn backup_xml(uuid: &str, expires: Option) -> Vec { + let mut d = plist::Dictionary::new(); + d.insert("UUID".into(), plist::Value::String(uuid.into())); + if let Some(t) = expires { + d.insert("ExpirationDate".into(), plist::Value::Date(plist::Date::from(t))); + } + let mut out = Vec::new(); + plist::to_writer_xml(&mut out, &plist::Value::Dictionary(d)).unwrap(); + out + } + + #[test] + fn cleanup_only_expired_over_30_days_after_expiration() { + let now = SystemTime::now(); + let dir = std::env::temp_dir().join(format!( + "altkeeper-profile-cleanup-{}-{:?}", std::process::id(), std::thread::current().id())); + fs::create_dir_all(&dir).unwrap(); + + let old = dir.join("old.mobileprovision"); + let recent = dir.join("recent.mobileprovision"); + let active = dir.join("active.mobileprovision"); + let corrupt = dir.join("corrupt.mobileprovision"); + let mismatch = dir.join("mismatch.mobileprovision"); + let no_expiry = dir.join("unknown.mobileprovision"); + let unrelated = dir.join("notes.txt"); + fs::write(&old, backup_xml("old", Some(now - Duration::from_secs(31 * 86400)))).unwrap(); + fs::write(&recent, backup_xml("recent", Some(now - Duration::from_secs(29 * 86400)))).unwrap(); + fs::write(&active, backup_xml("active", Some(now + Duration::from_secs(86400)))).unwrap(); + fs::write(&corrupt, b"bad content").unwrap(); + fs::write(&mismatch, backup_xml("another", Some(now - Duration::from_secs(60 * 86400)))).unwrap(); + fs::write(&no_expiry, backup_xml("unknown", None)).unwrap(); + fs::write(&unrelated, b"unrelated").unwrap(); + + assert_eq!(cleanup_expired_backups(&dir, now).unwrap(), 1); + assert!(!old.exists()); + for file in [recent, active, corrupt, mismatch, no_expiry, unrelated] { + assert!(file.exists(), "unexpected deletion: {}", file.display()); + } + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn nonexistent_backup_folder_is_a_noop() { + let dir = std::env::temp_dir().join(format!( + "altkeeper-no-backups-{}-{:?}", std::process::id(), std::thread::current().id())); + assert_eq!(cleanup_expired_backups(&dir, SystemTime::now()).unwrap(), 0); + } + #[test] fn senza_profili_non_c_e_niente() { assert!(latest_by_bundle(&[]).is_empty()); From 949f748ed135ff939bb7ae5ef3d6097062f445a7 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 09:00:00 +0900 Subject: [PATCH 010/117] feat: run monthly backup cleanup in persistent AltKeeper web service --- src/web.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/web.rs b/src/web.rs index fe1fb63..ef8c253 100644 --- a/src/web.rs +++ b/src/web.rs @@ -227,6 +227,20 @@ pub async fn serve( "PIN-protected: any username is accepted" } else { "only accessible from this computer" }) )); + // Runs independently of iPhone communications and renewal. Check daily but + // perform a sweep only if 30 days have passed since the last successful sweep. + tokio::spawn(async { + loop { + match profile::cleanup_backups_if_due(SystemTime::now()) { + Ok(Some(removed)) => println!( + "[backup-cleanup] Monthly sweep: removed {removed} profiles expired over 30 days ago" + ), + Ok(None) => {} + Err(e) => eprintln!("[backup-cleanup] Failed: {e}"), + } + tokio::time::sleep(Duration::from_secs(24 * 60 * 60)).await; + } + }); axum::serve(listener, router).await.map_err(|e| format!("{e:?}")) } From 1f134fe1ef73bdf17c44acedb0382667183a0486 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 09:00:16 +0900 Subject: [PATCH 011/117] docs: describe safe 30-day profile backup cleanup and retention --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index e105318..cc61c31 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,16 @@ Removing a profile currently needed by an installed app can prevent that app fro To restore a backup: `altkeeper profile-restore profili-salvati/.mobileprovision`. Neither profile deletion nor renewal removes Apple Developer App IDs. +**Monthly backup cleanup:** When the web service (`serve`, including Docker `serve --altserver`) +is running, it checks once a day whether a cleanup is due. Every **30 days after the last +successful sweep** (including the first startup), it removes only saved `.mobileprovision` +files whose embedded expiry date is at least **30 days in the past**. A verified UUID/file name +match is required; malformed, unreadable, future-dated or unrelated files are preserved. +A cleanup log line reports the number removed. The last successful sweep is recorded at +`state/profile-backup-last-cleanup` so Docker restarts do not reset the schedule. +No iPhone communication, Apple login, cron job, or new systemd timer is required. +This cleanup does not touch live iPhone profiles or Apple Developer App IDs. + ### 6. Automatic renewal (optional) A safety net that does not depend on the iPhone running AltStore. From 1e8e8232a041dbe17f2740f33d2c53026aa9d4af Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 09:12:19 +0900 Subject: [PATCH 012/117] feat(web): persistent auto renewal ON/OFF and daily KST scheduler --- src/web.rs | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 187 insertions(+), 1 deletion(-) diff --git a/src/web.rs b/src/web.rs index ef8c253..21cbe72 100644 --- a/src/web.rs +++ b/src/web.rs @@ -24,13 +24,16 @@ use axum::{ response::{Html, IntoResponse, Response}, routing::{get, post}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::{PAIRING_FILE, apple, phone, profile}; const INDEX_HTML: &str = include_str!("web/index.html"); const RENEW_LOG: &str = "renew.log"; +const AUTO_RENEW_CONFIG: &str = "state/auto-renew.json"; +const KST_OFFSET_SECS: u64 = 9 * 60 * 60; +const DAY_SECS: u64 = 24 * 60 * 60; const MAX_JOB_LINES: usize = 300; const MAX_IPA_BYTES: usize = 1024 * 1024 * 1024; @@ -45,6 +48,7 @@ struct App { busy: AtomicBool, inner: Mutex, tfa: Arc, + auto_renew: Mutex, } #[derive(Default)] @@ -168,6 +172,118 @@ fn now() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AutoRenewConfig { + enabled: bool, + /// HH:MM in Asia/Seoul (fixed UTC+09:00), independent of container TZ. + time: String, + /// KST calendar day of the most recent automatic attempt. + last_attempt_day: Option, +} + +impl Default for AutoRenewConfig { + fn default() -> Self { + Self { + enabled: false, + time: "21:17".into(), + last_attempt_day: None, + } + } +} + +fn parse_kst_time(value: &str) -> Option { + let (hh, mm) = value.split_once(':')?; + if hh.len() != 2 || mm.len() != 2 { return None; } + if !hh.bytes().chain(mm.bytes()).all(|b| b.is_ascii_digit()) { return None; } + let hour: u64 = hh.parse().ok()?; + let minute: u64 = mm.parse().ok()?; + (hour < 24 && minute < 60).then_some(hour * 3600 + minute * 60) +} + +fn kst_day(now_secs: u64) -> u64 { + now_secs.saturating_add(KST_OFFSET_SECS) / DAY_SECS +} + +fn kst_seconds_of_day(now_secs: u64) -> u64 { + now_secs.saturating_add(KST_OFFSET_SECS) % DAY_SECS +} + +fn next_auto_run(config: &AutoRenewConfig, now_secs: u64) -> Option { + if !config.enabled { return None; } + let target = parse_kst_time(&config.time)?; + let today = kst_day(now_secs); + let can_run_today = kst_seconds_of_day(now_secs) < target + && config.last_attempt_day != Some(today); + let scheduled_day = if can_run_today { today } else { today + 1 }; + Some(scheduled_day * DAY_SECS + target - KST_OFFSET_SECS) +} + +fn load_auto_renew() -> AutoRenewConfig { + let raw = match std::fs::read_to_string(AUTO_RENEW_CONFIG) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return AutoRenewConfig::default(), + Err(e) => { + eprintln!("[auto-renew] Cannot read config ({e}); automatic renewal disabled"); + return AutoRenewConfig::default(); + } + }; + match serde_json::from_str::(&raw) { + Ok(config) if parse_kst_time(&config.time).is_some() => config, + _ => { + eprintln!("[auto-renew] Invalid schedule config; automatic renewal disabled"); + AutoRenewConfig::default() + } + } +} + +fn save_auto_renew(config: &AutoRenewConfig) -> Result<(), String> { + std::fs::create_dir_all("state").map_err(crate::phone::dbg)?; + crate::fsperm::restrict_dir(Path::new("state")).map_err(crate::phone::dbg)?; + let data = serde_json::to_vec_pretty(config).map_err(crate::phone::dbg)?; + // Atomic write: avoid resetting the persistent on/off state after an interrupted write. + let tmp = Path::new("state/auto-renew.json.tmp"); + crate::fsperm::write_private(tmp, &data).map_err(crate::phone::dbg)?; + std::fs::rename(tmp, AUTO_RENEW_CONFIG).map_err(crate::phone::dbg) +} + +async fn auto_renew_loop(app: Arc) { + loop { + let now_secs = now(); + let guard = { + let config = app.auto_renew.lock().unwrap_or_else(|e| e.into_inner()); + let due = config.enabled + && parse_kst_time(&config.time) + .is_some_and(|target| kst_seconds_of_day(now_secs) >= target) + && config.last_attempt_day != Some(kst_day(now_secs)); + if due { try_busy(&app) } else { None } + }; + if let Some(guard) = guard { + let mut config = app.auto_renew.lock().unwrap_or_else(|e| e.into_inner()); + if config.enabled && config.last_attempt_day != Some(kst_day(now_secs)) { + let mut updated = config.clone(); + updated.last_attempt_day = Some(kst_day(now_secs)); + match save_auto_renew(&updated) { + Ok(()) => { + *config = updated; + app.lock().job = JobView { + kind: "auto_renew".into(), + running: true, + lines: vec!["Automatic renewal started (Asia/Seoul schedule).".into()], + ok: None, + }; + drop(config); + println!("[auto-renew] Starting scheduled renewal."); + spawn_job(guard, |app| run_renew(app, false, None, true)); + } + Err(err) => eprintln!("[auto-renew] Could not persist attempt; skipping: {err}"), + } + } + } + tokio::time::sleep(Duration::from_secs(60)).await; + } +} + // --------------------------------------------------------------------------------------------- // Avvio // --------------------------------------------------------------------------------------------- @@ -199,12 +315,14 @@ pub async fn serve( busy: AtomicBool::new(false), inner: Mutex::new(Inner::default()), tfa: Arc::new(apple::TfaChannel::default()), + auto_renew: Mutex::new(load_auto_renew()), }); let router = Router::new() .route("/", get(index)) .route("/api/state", get(state)) .route("/api/check", post(check)) .route("/api/renew", post(renew)) + .route("/api/auto-renew", post(update_auto_renew)) .route("/api/profile/remove", post(remove_profile)) .route( "/api/install", @@ -227,6 +345,8 @@ pub async fn serve( "PIN-protected: any username is accepted" } else { "only accessible from this computer" }) )); + // Built-in scheduler; no systemd socket/host privileges or separate cron required. + tokio::spawn(auto_renew_loop(app.clone())); // Runs independently of iPhone communications and renewal. Check daily but // perform a sweep only if 30 days have passed since the last successful sweep. tokio::spawn(async { @@ -390,6 +510,8 @@ async fn state(State(app): State>) -> Json { .map(|t| parse_history(&t, 6)) .unwrap_or_default(); let g = app.lock(); + let schedule = app.auto_renew.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let next_scheduled = next_auto_run(&schedule, now()); let asking = app.tfa.asking.load(Ordering::SeqCst); let login_phase = if asking { "needs_code" } else { g.login.phase }; let code_error = app.tfa.last_error.lock().ok().and_then(|e| e.clone()); @@ -398,6 +520,12 @@ async fn state(State(app): State>) -> Json { "now": now(), "busy": app.busy.load(Ordering::SeqCst), "pairing": pairing, + "auto_renew": { + "enabled": schedule.enabled, + "time": schedule.time, + "time_zone": "Asia/Seoul", + "next_run": next_scheduled, + }, "account": { "present": account.is_some(), "apple_id": account.as_ref().map(|a| a.apple_id.clone()), @@ -443,6 +571,45 @@ async fn check(State(app): State>) -> (StatusCode, Json) { json_ok() } + +#[derive(Deserialize)] +struct AutoRenewReq { + enabled: bool, + time: String, +} + +async fn update_auto_renew( + State(app): State>, + Json(req): Json, +) -> (StatusCode, Json) { + let Some(target) = parse_kst_time(&req.time) else { + return json_err(StatusCode::BAD_REQUEST, "Use a valid 24-hour time (HH:MM)."); + }; + if req.enabled { + match apple::read_account(Path::new(".")) { + Some(acc) if !acc.password.is_empty() && acc.team_id.is_some() => {} + _ => return json_err(StatusCode::BAD_REQUEST, + "Save your Apple ID password before enabling automatic renewal."), + } + } + let mut config = app.auto_renew.lock().unwrap_or_else(|e| e.into_inner()); + let mut updated = config.clone(); + // Turning on or changing the clock time after today's scheduled time + // must NOT immediately trigger an unrequested refresh. + if req.enabled && (!config.enabled || config.time != req.time) + && kst_seconds_of_day(now()) >= target + { + updated.last_attempt_day = Some(kst_day(now())); + } + updated.enabled = req.enabled; + updated.time = req.time; + if let Err(err) = save_auto_renew(&updated) { + return json_err(StatusCode::INTERNAL_SERVER_ERROR, &format!("Cannot save schedule: {err}")); + } + *config = updated; + json_ok() +} + #[derive(Deserialize)] struct RenewReq { /// "due" renews expiring installed apps; "now" all profiles; "single" one bundle. @@ -1225,6 +1392,25 @@ fn append_log(lines: &[String], ok: bool) { #[cfg(test)] mod tests { + #[test] + fn auto_renew_kst_time_validation_and_next_run() { + assert_eq!(parse_kst_time("21:17"), Some(21 * 3600 + 17 * 60)); + for invalid in ["24:00", "21:60", "9:01", "abc", "21:17:00"] { + assert_eq!(parse_kst_time(invalid), None); + } + // Day 20 in KST: scheduled for 21:17, 20:00 currently. + let day = 20_u64; + let now_secs = day * DAY_SECS + 20 * 3600 - KST_OFFSET_SECS; + let mut conf = AutoRenewConfig { enabled: true, time: "21:17".into(), last_attempt_day: None }; + assert_eq!(next_auto_run(&conf, now_secs), + Some(day * DAY_SECS + 21 * 3600 + 17 * 60 - KST_OFFSET_SECS)); + conf.last_attempt_day = Some(day); + assert_eq!(next_auto_run(&conf, now_secs), + Some((day + 1) * DAY_SECS + 21 * 3600 + 17 * 60 - KST_OFFSET_SECS)); + conf.enabled = false; + assert_eq!(next_auto_run(&conf, now_secs), None); + } + #[test] fn il_progresso_si_scrive_ogni_dieci_punti() { let mut last = -1; From 5e749ad38c65695b832c7feccee78a44aea380b5 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 09:12:39 +0900 Subject: [PATCH 013/117] fix: avoid cross-lock deadlock between automatic job and web state --- src/web.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web.rs b/src/web.rs index 21cbe72..256a082 100644 --- a/src/web.rs +++ b/src/web.rs @@ -266,13 +266,13 @@ async fn auto_renew_loop(app: Arc) { match save_auto_renew(&updated) { Ok(()) => { *config = updated; + drop(config); // avoid lock-order inversion with /api/state app.lock().job = JobView { kind: "auto_renew".into(), running: true, lines: vec!["Automatic renewal started (Asia/Seoul schedule).".into()], ok: None, }; - drop(config); println!("[auto-renew] Starting scheduled renewal."); spawn_job(guard, |app| run_renew(app, false, None, true)); } From 4081e967eefb32f33c8ef00dff35b90f3ab76752 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 09:12:57 +0900 Subject: [PATCH 014/117] feat(web ui): enable/disable and set local-time auto-renew schedule --- src/web/index.html | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/web/index.html b/src/web/index.html index f3f1aab..c0ea9bb 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -116,6 +116,22 @@

    Your apps

    +
    +

    Automatic renewal

    +

    Runs once daily while this server is online. Renews expiring installed apps. If installed-app lookup fails, it falls back to all saved-team profiles. Default is OFF.

    +
    + OFF + Automatic renewal disabled +
    + +
    + + + +
    + +
    +

    Provisioning profiles on iPhone

    Old profiles may remain after renewal. Deleting a profile still needed by an installed app can stop it opening. A verified server-side backup is saved before removal.

    @@ -281,6 +297,23 @@

    Recent renewals

    : !s.account.password_saved ? "The password must be saved to renew: sign in again and leave the checkbox selected." : s.busy ? "Working…" : ""; + // Persistent server-side automatic renewal; UI does not call host systemctl. + const auto = s.auto_renew || {enabled:false, time:"21:17", next_run:null}; + $("auto-pill").textContent = auto.enabled ? "ON" : "OFF"; + $("auto-pill").className = "pill " + (auto.enabled ? "ok" : ""); + const next = auto.next_run + ? new Date(auto.next_run * 1000).toLocaleString("ko-KR", { + timeZone:"Asia/Seoul", year:"numeric", month:"2-digit", day:"2-digit", + hour:"2-digit", minute:"2-digit", hour12:false + }) + : null; + $("auto-next").textContent = auto.enabled ? "Next: " + (next || "checking") + " KST" + : "Automatic renewal disabled"; + if (document.activeElement !== $("auto-time")) $("auto-time").value = auto.time || "21:17"; + $("auto-enable").textContent = auto.enabled ? "Save time" : "Enable auto-renew"; + $("auto-enable").disabled = !s.account.password_saved || !s.account.team; + $("auto-disable").hidden = !auto.enabled; + // Manual, individually backed-up profile removal. Never deletes apps or Apple App IDs. const pu = $("profiles"); pu.replaceChildren(); @@ -380,6 +413,14 @@

    Recent renewals

    refresh(); } +$("auto-enable").onclick = () => { + banner("auto-error", ""); + act(() => api("/api/auto-renew", {enabled:true, time:$("auto-time").value}), "auto-error"); +}; +$("auto-disable").onclick = () => { + banner("auto-error", ""); + act(() => api("/api/auto-renew", {enabled:false, time:$("auto-time").value}), "auto-error"); +}; $("btn-check").onclick = () => act(() => api("/api/check", {})); $("btn-due").onclick = () => act(() => api("/api/renew", { mode: "due" })); $("btn-all").onclick = () => { From de59d038985ce82da4986a61a336380fde5676cc Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 09:13:20 +0900 Subject: [PATCH 015/117] docs: built-in KST web auto-renew schedule and no additional systemd requirement --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cc61c31..3e62b6c 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,23 @@ A successful lookup returning zero apps skips all renewals. To renew a specific app, use `altkeeper renew --bundle --force` or the per-app **Renew** button on the web page. The manual `--force` and web “Renew all now” operations do not check installed apps; `--installed-only` can be used with CLI `--force` to opt in. -No unattended schedule is enabled by merely running the Docker container. +No unattended schedule is enabled by merely running the Docker container (default: OFF). + +**Optional built-in scheduler in the web UI:** Use **Automatic renewal** to enable/disable a +once-daily job and choose a time in **Asia/Seoul (KST, UTC+09:00)**. The default clock +time is 21:17 KST and the switch is initially OFF. When enabled, AltKeeper runs +the same installed-app-aware renewal as **Renew expiring apps**, with the documented +all-profile fallback if installed-app listing fails. The server needs a saved +Apple ID password, must be running, and the iPhone must be reachable. +Only one scheduled *attempt* is made per KST calendar day (including failures). +A server that starts after the scheduled time on a day with no attempt will run +that day's job when it starts; enabling or changing the scheduled time after +today's slot does **not** trigger an immediate refresh. The next slot is tomorrow. +The schedule and last attempted day are stored in `state/auto-renew.json` under +the persistent `/data` volume, surviving Docker restarts. No host systemd, +cron, privileged host socket, or extra package is needed. **Do not enable +an external cron/systemd renewal job at the same time** unless deliberate. + The web page also lists **individual provisioning profiles** (including older versions) with a **Delete profile** button. Every removal requires confirmation, is limited to the saved Apple team, From d9aa2cf5a662be1f7d328a1f81aa21e092263818 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 09:14:29 +0900 Subject: [PATCH 016/117] chore: set default automatic renewal schedule to 06:00 KST --- src/web.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/web.rs b/src/web.rs index 256a082..335d79c 100644 --- a/src/web.rs +++ b/src/web.rs @@ -186,7 +186,7 @@ impl Default for AutoRenewConfig { fn default() -> Self { Self { enabled: false, - time: "21:17".into(), + time: "06:00".into(), last_attempt_day: None, } } @@ -1394,19 +1394,19 @@ fn append_log(lines: &[String], ok: bool) { mod tests { #[test] fn auto_renew_kst_time_validation_and_next_run() { - assert_eq!(parse_kst_time("21:17"), Some(21 * 3600 + 17 * 60)); + assert_eq!(parse_kst_time("06:00"), Some(6 * 3600)); for invalid in ["24:00", "21:60", "9:01", "abc", "21:17:00"] { assert_eq!(parse_kst_time(invalid), None); } - // Day 20 in KST: scheduled for 21:17, 20:00 currently. + // Day 20 in KST: scheduled for 06:00, 05:00 currently. let day = 20_u64; - let now_secs = day * DAY_SECS + 20 * 3600 - KST_OFFSET_SECS; - let mut conf = AutoRenewConfig { enabled: true, time: "21:17".into(), last_attempt_day: None }; + let now_secs = day * DAY_SECS + 5 * 3600 - KST_OFFSET_SECS; + let mut conf = AutoRenewConfig { enabled: true, time: "06:00".into(), last_attempt_day: None }; assert_eq!(next_auto_run(&conf, now_secs), - Some(day * DAY_SECS + 21 * 3600 + 17 * 60 - KST_OFFSET_SECS)); + Some(day * DAY_SECS + 6 * 3600 - KST_OFFSET_SECS)); conf.last_attempt_day = Some(day); assert_eq!(next_auto_run(&conf, now_secs), - Some((day + 1) * DAY_SECS + 21 * 3600 + 17 * 60 - KST_OFFSET_SECS)); + Some((day + 1) * DAY_SECS + 6 * 3600 - KST_OFFSET_SECS)); conf.enabled = false; assert_eq!(next_auto_run(&conf, now_secs), None); } From e2125066cfe54fb97e1c1d933293f3da1b9f66bd Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 09:14:35 +0900 Subject: [PATCH 017/117] chore: set default automatic renewal schedule to 06:00 KST --- src/web/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/web/index.html b/src/web/index.html index c0ea9bb..a2a42b5 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -125,7 +125,7 @@

    Automatic renewal

    - +
    @@ -298,7 +298,7 @@

    Recent renewals

    : s.busy ? "Working…" : ""; // Persistent server-side automatic renewal; UI does not call host systemctl. - const auto = s.auto_renew || {enabled:false, time:"21:17", next_run:null}; + const auto = s.auto_renew || {enabled:false, time:"06:00", next_run:null}; $("auto-pill").textContent = auto.enabled ? "ON" : "OFF"; $("auto-pill").className = "pill " + (auto.enabled ? "ok" : ""); const next = auto.next_run @@ -309,7 +309,7 @@

    Recent renewals

    : null; $("auto-next").textContent = auto.enabled ? "Next: " + (next || "checking") + " KST" : "Automatic renewal disabled"; - if (document.activeElement !== $("auto-time")) $("auto-time").value = auto.time || "21:17"; + if (document.activeElement !== $("auto-time")) $("auto-time").value = auto.time || "06:00"; $("auto-enable").textContent = auto.enabled ? "Save time" : "Enable auto-renew"; $("auto-enable").disabled = !s.account.password_saved || !s.account.team; $("auto-disable").hidden = !auto.enabled; From 9684b231fef33b1fc1ea5803dede24d694d0b55e Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 09:14:38 +0900 Subject: [PATCH 018/117] chore: set default automatic renewal schedule to 06:00 KST --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e62b6c..093af53 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ No unattended schedule is enabled by merely running the Docker container (defaul **Optional built-in scheduler in the web UI:** Use **Automatic renewal** to enable/disable a once-daily job and choose a time in **Asia/Seoul (KST, UTC+09:00)**. The default clock -time is 21:17 KST and the switch is initially OFF. When enabled, AltKeeper runs +time is 06:00 KST and the switch is initially OFF. When enabled, AltKeeper runs the same installed-app-aware renewal as **Renew expiring apps**, with the documented all-profile fallback if installed-app listing fails. The server needs a saved Apple ID password, must be running, and the iPhone must be reachable. From 918de3464c190d82af2d262f14026e0022b2c10f Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 10:15:49 +0900 Subject: [PATCH 019/117] feat: identify host/extension profile groups with exact team marker --- src/profile.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/profile.rs b/src/profile.rs index 22a14e8..e693743 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -33,6 +33,25 @@ impl PhoneProfile { } } +/// Associate extension bundles with their host app only when the embedded +/// Team ID marks them as extensions of an existing, exact host bundle. +/// Never infer a parent from arbitrary dotted reverse-DNS prefixes. +pub fn group_root<'a>(bundle: &'a str, bundles: &[&'a str], team: &str) -> &'a str { + bundles.iter().copied() + .filter(|candidate| { + *candidate != bundle + && bundle.starts_with(&format!("{candidate}.{team}.")) + }) + .max_by_key(|candidate| candidate.len()) + .unwrap_or(bundle) +} + +pub fn members_of_group<'a>(root: &str, bundles: &'a [&'a str], team: &str) -> Vec<&'a str> { + bundles.iter().copied() + .filter(|b| group_root(b, bundles, team) == root) + .collect() +} + /// Per ogni bundle vale il profilo che scade più tardi: dopo un rinnovo quelli vecchi restano /// sul telefono. I profili senza bundle ID restano ciascuno per conto suo. pub fn latest_by_bundle<'a>( @@ -240,6 +259,20 @@ mod tests { assert_eq!(cleanup_expired_backups(&dir, SystemTime::now()).unwrap(), 0); } + #[test] + fn extension_groups_do_not_swallow_unrelated_apps() { + let bundles = [ + "com.google.ios.youtube", + "com.google.ios.youtube.TEAM.OpenYouTube.Extension", + "com.kdt.livecontainer", + "com.kdt.livecontainer.TEAM.LiveProcess", + "com.google.ios.youtube.another.app", + ]; + assert_eq!(group_root(bundles[1], &bundles, "TEAM"), bundles[0]); + assert_eq!(group_root(bundles[3], &bundles, "TEAM"), bundles[2]); + assert_eq!(group_root(bundles[4], &bundles, "TEAM"), bundles[4]); + assert_eq!(members_of_group(bundles[2], &bundles, "TEAM").len(), 2); + } #[test] fn senza_profili_non_c_e_niente() { assert!(latest_by_bundle(&[]).is_empty()); From c863f9bd8f46ea173701a89ccab23790b734c429 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 10:16:07 +0900 Subject: [PATCH 020/117] fix: renew host and extensions as one app, including installed-only and due checks --- src/main.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 74e049b..4cf7332 100644 --- a/src/main.rs +++ b/src/main.rs @@ -256,6 +256,21 @@ async fn cmd_renew( // 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. @@ -290,18 +305,20 @@ async fn cmd_renew( 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 != bundle { continue; } + if selected != root { continue; } requested_found = true; } if let Some(apps) = &installed { - if !apps.contains(bundle) { - say(&format!(" {bundle}: no installed app found, skipping")); + if !apps.contains(root) && !apps.contains(bundle) { + say(&format!(" {bundle}: host app not installed, skipping")); continue; } } let days = p.days_left().unwrap_or(-1); - if !force && !fallback_renew_all && days > min_days { + 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; } @@ -342,6 +359,7 @@ async fn cmd_renew( ) .await?; say(" installed on the phone"); + say(&format!("RENEWED {bundle}")); done += 1; } From 1650d3ea9a475bb4bf9c89e0f8577b536dba68ad Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 10:17:09 +0900 Subject: [PATCH 021/117] feat(web): group app and extension profiles, show earliest member expiry --- src/web.rs | 87 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 18 deletions(-) diff --git a/src/web.rs b/src/web.rs index 335d79c..7db3b60 100644 --- a/src/web.rs +++ b/src/web.rs @@ -78,12 +78,22 @@ struct AppView { days: i64, expires: String, level: &'static str, + members: Vec, +} + +#[derive(Clone, PartialEq, Debug)] +struct MemberView { + bundle: String, + label: String, + days: i64, + expires: String, } #[derive(Clone, PartialEq, Debug)] struct ProfileView { uuid: String, bundle: String, + root: String, label: String, days: i64, expires: String, @@ -544,9 +554,12 @@ async fn state(State(app): State>) -> Json { "apps": g.snapshot.apps.iter().map(|a| json!({ "bundle": a.bundle, "label": a.label, "days": a.days, "expires": a.expires, "level": a.level, + "members": a.members.iter().map(|m| json!({ + "bundle":m.bundle,"label":m.label,"days":m.days,"expires":m.expires + })).collect::>(), })).collect::>(), "profiles": g.snapshot.profiles.iter().map(|p| json!({ - "uuid": p.uuid, "bundle": p.bundle, "label": p.label, + "uuid": p.uuid, "bundle": p.bundle, "root": p.root, "label": p.label, "days": p.days, "expires": p.expires, })).collect::>(), "job": { @@ -1121,42 +1134,62 @@ fn level_for(days: i64) -> &'static str { } } -/// Le app del team, una riga per bundle (il profilo più recente), dalla più urgente. +/// Display one host app with all its latest extension profiles nested below. fn app_views(profiles: &[profile::PhoneProfile], team: &str) -> Vec { let mine = profiles.iter().filter(|p| p.team_id.as_deref() == Some(team)); - let mut views: Vec = profile::latest_by_bundle(mine) - .into_iter() - .filter_map(|p| { - let bundle = p.bundle_id.clone()?; - let days = p.days_left().unwrap_or(-1); - Some(AppView { - label: label_of(&bundle, team), - bundle, - days, - expires: p.expires_text(), - level: level_for(days), - }) - }) - .collect(); - views.sort_by(|a, b| a.days.cmp(&b.days).then_with(|| a.label.cmp(&b.label))); + let latest = profile::latest_by_bundle(mine); + let bundles: Vec<&str> = latest.iter().filter_map(|p| p.bundle_id.as_deref()).collect(); + let mut groups: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for p in latest { + let Some(bundle) = p.bundle_id.as_deref() else { continue; }; + let root = profile::group_root(bundle, &bundles, team).to_owned(); + groups.entry(root).or_default().push(MemberView { + bundle: bundle.to_owned(), + label: label_of(bundle, team), + days: p.days_left().unwrap_or(-1), + expires: p.expires_text(), + }); + } + let mut views = Vec::new(); + for (bundle, mut members) in groups { + members.sort_by(|a,b| a.bundle.cmp(&b.bundle)); + let earliest = members.iter().min_by_key(|m| m.days).unwrap(); + views.push(AppView { + label: label_of(&bundle, team), + bundle, + days: earliest.days, + expires: earliest.expires.clone(), + level: level_for(earliest.days), + members, + }); + } + views.sort_by(|a,b| a.days.cmp(&b.days).then_with(|| a.label.cmp(&b.label))); views } fn profile_views(profiles: &[profile::PhoneProfile], team: &str) -> Vec { + let bundles: Vec<&str> = profiles.iter() + .filter(|p| p.team_id.as_deref() == Some(team)) + .filter_map(|p| p.bundle_id.as_deref()) + .collect(); let mut views: Vec<_> = profiles.iter() .filter(|p| p.team_id.as_deref() == Some(team)) .filter_map(|p| { let bundle = p.bundle_id.clone()?; + let root = profile::group_root(&bundle, &bundles, team).to_owned(); Some(ProfileView { uuid: p.uuid.clone(), label: label_of(&bundle, team), bundle, + root, days: p.days_left().unwrap_or(-1), expires: p.expires_text(), }) }) .collect(); - views.sort_by(|a, b| a.label.cmp(&b.label).then_with(|| b.expires.cmp(&a.expires))); + views.sort_by(|a,b| a.root.cmp(&b.root).then_with(|| a.label.cmp(&b.label)) + .then_with(|| b.expires.cmp(&a.expires))); views } @@ -1513,6 +1546,24 @@ mod tests { assert_eq!(v[0].days, 6); assert_eq!(v[1].label, "com.b"); assert!(v.iter().all(|a| a.level == "ok")); + assert_eq!(v[0].members.len(), 1); + } + + #[test] + fn hosts_group_extensions_and_take_earliest_expiration() { + let p = vec![ + profile("a", "TEAM", "com.kdt.livecontainer", 6), + profile("b", "TEAM", "com.kdt.livecontainer.TEAM.LiveProcess", 1), + profile("c", "TEAM", "com.kdt.livecontainer.TEAM.ShareExtension", 5), + profile("d", "TEAM", "com.google.ios.youtube", 6), + ]; + let apps = app_views(&p, "TEAM"); + assert_eq!(apps.len(), 2); + let live = apps.iter().find(|a| a.bundle == "com.kdt.livecontainer").unwrap(); + assert_eq!(live.members.len(), 3); + assert_eq!(live.days, 1); + let profiles = profile_views(&p, "TEAM"); + assert!(profiles.iter().filter(|p| p.root == "com.kdt.livecontainer").count() == 3); } #[test] From 8b8f70988594486ac6b790f8dc2b93ad6f52c0ba Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 10:17:55 +0900 Subject: [PATCH 022/117] feat: persisted half-hour retry schedule and protected Discord failure webhook delivery --- src/web.rs | 112 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 96 insertions(+), 16 deletions(-) diff --git a/src/web.rs b/src/web.rs index 7db3b60..41433c9 100644 --- a/src/web.rs +++ b/src/web.rs @@ -32,6 +32,10 @@ use crate::{PAIRING_FILE, apple, phone, profile}; const INDEX_HTML: &str = include_str!("web/index.html"); const RENEW_LOG: &str = "renew.log"; const AUTO_RENEW_CONFIG: &str = "state/auto-renew.json"; +const DISCORD_WEBHOOK_FILE: &str = "state/discord-webhook"; +const APP_STATUS_FILE: &str = "state/app-renew-status.json"; +const RETRY_SECONDS: u64 = 30 * 60; +const MAX_RETRIES: u8 = 2; const KST_OFFSET_SECS: u64 = 9 * 60 * 60; const DAY_SECS: u64 = 24 * 60 * 60; const MAX_JOB_LINES: usize = 300; @@ -188,16 +192,29 @@ struct AutoRenewConfig { enabled: bool, /// HH:MM in Asia/Seoul (fixed UTC+09:00), independent of container TZ. time: String, + #[serde(default = "default_min_days")] + min_days: u8, /// KST calendar day of the most recent automatic attempt. last_attempt_day: Option, + #[serde(default)] + retry_count: u8, + #[serde(default)] + retry_at: Option, + #[serde(default)] + last_error: Option, } +fn default_min_days() -> u8 { 3 } impl Default for AutoRenewConfig { fn default() -> Self { Self { enabled: false, time: "06:00".into(), + min_days: 3, last_attempt_day: None, + retry_count: 0, + retry_at: None, + last_error: None, } } } @@ -221,6 +238,7 @@ fn kst_seconds_of_day(now_secs: u64) -> u64 { fn next_auto_run(config: &AutoRenewConfig, now_secs: u64) -> Option { if !config.enabled { return None; } + if let Some(retry_at) = config.retry_at { return Some(retry_at); } let target = parse_kst_time(&config.time)?; let today = kst_day(now_secs); let can_run_today = kst_seconds_of_day(now_secs) < target @@ -239,7 +257,7 @@ fn load_auto_renew() -> AutoRenewConfig { } }; match serde_json::from_str::(&raw) { - Ok(config) if parse_kst_time(&config.time).is_some() => config, + Ok(config) if parse_kst_time(&config.time).is_some() && (1..=6).contains(&config.min_days) => config, _ => { eprintln!("[auto-renew] Invalid schedule config; automatic renewal disabled"); AutoRenewConfig::default() @@ -257,40 +275,102 @@ fn save_auto_renew(config: &AutoRenewConfig) -> Result<(), String> { std::fs::rename(tmp, AUTO_RENEW_CONFIG).map_err(crate::phone::dbg) } +fn retryable_renew_error(err: &str) -> bool { + let lower = err.to_ascii_lowercase(); + !["2fa", "incorrect password", "invalid password", "invalid credentials", + "unauthorized", "forbidden", "rate.limit", "too many requests", "429", + "apple id sign-in", "apple id login"].iter().any(|part| lower.contains(part)) +} + +fn validate_discord_webhook(url: &str) -> bool { + let prefixes = ["https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/"]; + let Some(rest) = prefixes.iter().find_map(|prefix| url.strip_prefix(prefix)) else { + return false; + }; + let mut parts = rest.split('/'); + let Some(id) = parts.next() else { return false }; + let Some(token) = parts.next() else { return false }; + parts.next().is_none() && id.len() >= 10 && token.len() >= 20 + && id.bytes().all(|b| b.is_ascii_digit()) + && token.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +fn discord_configured() -> bool { + std::fs::read_to_string(DISCORD_WEBHOOK_FILE) + .ok().is_some_and(|url| validate_discord_webhook(url.trim())) +} + +fn send_discord_failure(error: &str) -> Result<(), String> { + use std::io::Write; + let url = std::fs::read_to_string(DISCORD_WEBHOOK_FILE).map_err(crate::phone::dbg)?; + let url = url.trim(); + if !validate_discord_webhook(url) { return Err("Invalid Discord webhook configuration".into()); } + // Pass the sensitive webhook URL via stdin (curl config), not process arguments. + let msg = format!("AltKeeper: automatic renewal failed after retries.\\n{}", + error.chars().take(240).collect::()); + let body = json!({ "content": msg }).to_string(); + let mut child = std::process::Command::new("curl") + .args(["--fail", "--silent", "--show-error", "--max-time", "12", + "--request", "POST", "--header", "Content-Type: application/json", + "--data-raw", &body, "--config", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn().map_err(crate::phone::dbg)?; + // Validated webhook has no quote/backslash/newline, so it is safe for curl config. + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(format!("url = \\"{url}\\"\\n").as_bytes()).map_err(crate::phone::dbg)?; + } + let output = child.wait_with_output().map_err(crate::phone::dbg)?; + if output.status.success() { Ok(()) } + else { Err("Discord rejected webhook notification".into()) } +} + async fn auto_renew_loop(app: Arc) { loop { let now_secs = now(); let guard = { let config = app.auto_renew.lock().unwrap_or_else(|e| e.into_inner()); - let due = config.enabled - && parse_kst_time(&config.time) - .is_some_and(|target| kst_seconds_of_day(now_secs) >= target) + let retry_due = config.retry_at.is_some_and(|at| at <= now_secs); + let scheduled_due = parse_kst_time(&config.time) + .is_some_and(|target| kst_seconds_of_day(now_secs) >= target) && config.last_attempt_day != Some(kst_day(now_secs)); - if due { try_busy(&app) } else { None } + if config.enabled && (retry_due || scheduled_due) { try_busy(&app) } else { None } }; if let Some(guard) = guard { let mut config = app.auto_renew.lock().unwrap_or_else(|e| e.into_inner()); - if config.enabled && config.last_attempt_day != Some(kst_day(now_secs)) { - let mut updated = config.clone(); - updated.last_attempt_day = Some(kst_day(now_secs)); - match save_auto_renew(&updated) { - Ok(()) => { + if config.enabled { + let is_retry = config.retry_at.is_some_and(|t| t <= now_secs); + let is_new_day = config.last_attempt_day != Some(kst_day(now_secs)) + && config.retry_at.is_none(); + if is_retry || is_new_day { + let mut updated = config.clone(); + if is_retry { + updated.retry_count = updated.retry_count.saturating_add(1); + } else { + updated.last_attempt_day = Some(kst_day(now_secs)); + updated.retry_count = 0; + } + updated.retry_at = None; + if let Err(err) = save_auto_renew(&updated) { + eprintln!("[auto-renew] Could not persist attempt: {err}"); + } else { + let threshold = updated.min_days as i64; + let attempt = updated.retry_count + 1; *config = updated; - drop(config); // avoid lock-order inversion with /api/state + drop(config); app.lock().job = JobView { kind: "auto_renew".into(), running: true, - lines: vec!["Automatic renewal started (Asia/Seoul schedule).".into()], + lines: vec![format!("Scheduled renewal attempt {attempt}/3 (Asia/Seoul).")], ok: None, }; - println!("[auto-renew] Starting scheduled renewal."); - spawn_job(guard, |app| run_renew(app, false, None, true)); + spawn_job(guard, move |app| run_renew(app, false, None, true, threshold, true)); } - Err(err) => eprintln!("[auto-renew] Could not persist attempt; skipping: {err}"), } } } - tokio::time::sleep(Duration::from_secs(60)).await; + tokio::time::sleep(Duration::from_secs(30)).await; } } From e5784b989fca02987dc03fcc353a1b6b23bc48ab Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 10:18:54 +0900 Subject: [PATCH 023/117] =?UTF-8?q?feat:=20configurable=201=E2=80=936=20da?= =?UTF-8?q?y=20threshold,=2030m=20retry=20twice=20and=20per-group=20outcom?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web.rs | 107 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 9 deletions(-) diff --git a/src/web.rs b/src/web.rs index 41433c9..7fe05df 100644 --- a/src/web.rs +++ b/src/web.rs @@ -319,7 +319,7 @@ fn send_discord_failure(error: &str) -> Result<(), String> { .spawn().map_err(crate::phone::dbg)?; // Validated webhook has no quote/backslash/newline, so it is safe for curl config. if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(format!("url = \\"{url}\\"\\n").as_bytes()).map_err(crate::phone::dbg)?; + stdin.write_all(format!("url = \"{url}\"\n").as_bytes()).map_err(crate::phone::dbg)?; } let output = child.wait_with_output().map_err(crate::phone::dbg)?; if output.status.success() { Ok(()) } @@ -613,6 +613,11 @@ async fn state(State(app): State>) -> Json { "auto_renew": { "enabled": schedule.enabled, "time": schedule.time, + "min_days": schedule.min_days, + "retry_count": schedule.retry_count, + "retry_at": schedule.retry_at, + "last_error": schedule.last_error, + "discord_configured": discord_configured(), "time_zone": "Asia/Seoul", "next_run": next_scheduled, }, @@ -652,6 +657,9 @@ async fn state(State(app): State>) -> Json { "code_error": if asking { code_error } else { None }, }, "history": history, + "app_status": std::fs::read_to_string(APP_STATUS_FILE).ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or(json!({})), })) } @@ -669,6 +677,8 @@ async fn check(State(app): State>) -> (StatusCode, Json) { struct AutoRenewReq { enabled: bool, time: String, + #[serde(default = "default_min_days")] + min_days: u8, } async fn update_auto_renew( @@ -678,6 +688,9 @@ async fn update_auto_renew( let Some(target) = parse_kst_time(&req.time) else { return json_err(StatusCode::BAD_REQUEST, "Use a valid 24-hour time (HH:MM)."); }; + if !(1..=6).contains(&req.min_days) { + return json_err(StatusCode::BAD_REQUEST, "Renewal threshold must be 1-6 days."); + } if req.enabled { match apple::read_account(Path::new(".")) { Some(acc) if !acc.password.is_empty() && acc.team_id.is_some() => {} @@ -696,6 +709,11 @@ async fn update_auto_renew( } updated.enabled = req.enabled; updated.time = req.time; + updated.min_days = req.min_days; + if !req.enabled { + updated.retry_at = None; + updated.retry_count = 0; + } if let Err(err) = save_auto_renew(&updated) { return json_err(StatusCode::INTERNAL_SERVER_ERROR, &format!("Cannot save schedule: {err}")); } @@ -750,7 +768,8 @@ async fn renew( ok: None, }; } - spawn_job(guard, move |app| run_renew(app, force, bundle, installed_only)); + let threshold = app.auto_renew.lock().unwrap_or_else(|e| e.into_inner()).min_days as i64; + spawn_job(guard, move |app| run_renew(app, force, bundle, installed_only, threshold, false)); json_ok() } @@ -1033,13 +1052,16 @@ async fn run_check(app: Arc) { } } -async fn run_renew(app: Arc, force: bool, bundle: Option, installed_only: bool) { +async fn run_renew( + app: Arc, force: bool, bundle: Option, installed_only: bool, + min_days: i64, automatic: bool, +) { let mut say = |s: &str| app.push_line(&english_job_line(s)); - let result = crate::cmd_renew(app.phone, false, force, 3, bundle.as_deref(), installed_only, &mut say).await; - + let result = crate::cmd_renew(app.phone, false, force, min_days, bundle.as_deref(), installed_only, &mut say).await; let ok = result.is_ok(); - if let Err(e) = &result { - app.push_line(&format!("Error: {}", short_error(e))); + let error = result.as_ref().err().map(|err| short_error(err)); + if let Some(err) = &error { + app.push_line(&format!("Error: {err}")); } let lines = { let mut g = app.lock(); @@ -1048,10 +1070,77 @@ async fn run_renew(app: Arc, force: bool, bundle: Option, installed g.job.lines.clone() }; append_log(&lines, ok); - // Poi si aggiorna lo stato mostrato in pagina. + save_app_renew_status(&app, &lines, ok, bundle.as_deref()); + + if automatic { + let mut config = app.auto_renew.lock().unwrap_or_else(|e| e.into_inner()); + let mut updated = config.clone(); + let mut notify = false; + if ok { + updated.retry_count = 0; + updated.retry_at = None; + updated.last_error = None; + } else { + let err = error.as_deref().unwrap_or("Unknown renewal error"); + updated.last_error = Some(err.to_owned()); + if updated.enabled && updated.retry_count < MAX_RETRIES + && retryable_renew_error(err) + { + updated.retry_at = Some(now() + RETRY_SECONDS); + app.push_line(&format!( + "Automatic retry {}/{} in 30 minutes.", + updated.retry_count + 1, MAX_RETRIES + )); + } else { + updated.retry_at = None; + notify = true; + } + } + if let Err(e) = save_auto_renew(&updated) { + eprintln!("[auto-renew] Could not persist retry state: {e}"); + } else { + *config = updated; + } + drop(config); + if notify && discord_configured() { + let err = error.as_deref().unwrap_or("Unknown renewal error"); + match send_discord_failure(err) { + Ok(()) => app.push_line("Discord failure notification sent."), + Err(e) => app.push_line(&format!("Discord notification failed: {e}")), + } + } + } run_check(app).await; } +fn save_app_renew_status(app: &App, lines: &[String], ok: bool, selected: Option<&str>) { + let renewed: std::collections::HashSet<&str> = lines.iter() + .filter_map(|line| line.strip_prefix("RENEWED ")).collect(); + let attempted: std::collections::HashSet<&str> = lines.iter() + .filter_map(|line| line.trim().split_once(": expires in ")) + .filter(|(_, tail)| tail.contains("downloading a new profile")) + .map(|(bundle,_)| bundle).collect(); + let groups = app.lock().snapshot.apps.clone(); + let mut existing = std::fs::read_to_string(APP_STATUS_FILE).ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default(); + for group in groups { + if selected.is_some_and(|s| s != group.bundle) { continue; } + let in_group = group.members.iter().filter(|m| renewed.contains(m.bundle.as_str())).count(); + let any_attempted = group.members.iter().any(|m| attempted.contains(m.bundle.as_str())); + if in_group == 0 && !any_attempted && selected.is_none() { continue; } + let status = if ok && in_group == group.members.len() { "success" } + else if in_group > 0 { "partial" } else { "failed" }; + existing.insert(group.bundle, json!({"status":status,"at":now(),"renewed":in_group, + "total":group.members.len()})); + } + if std::fs::create_dir_all("state").is_ok() { + if let Ok(bytes) = serde_json::to_vec(&existing) { + let _ = crate::fsperm::write_private(Path::new(APP_STATUS_FILE), &bytes); + } + } +} + async fn run_remove_profile(app: Arc, uuid: String) { let result: Result<(), String> = async { let team = apple::read_account(Path::new(".")) @@ -1514,7 +1603,7 @@ mod tests { // Day 20 in KST: scheduled for 06:00, 05:00 currently. let day = 20_u64; let now_secs = day * DAY_SECS + 5 * 3600 - KST_OFFSET_SECS; - let mut conf = AutoRenewConfig { enabled: true, time: "06:00".into(), last_attempt_day: None }; + let mut conf = AutoRenewConfig { enabled: true, time: "06:00".into(), last_attempt_day: None, ..AutoRenewConfig::default() }; assert_eq!(next_auto_run(&conf, now_secs), Some(day * DAY_SECS + 6 * 3600 - KST_OFFSET_SECS)); conf.last_attempt_day = Some(day); From 219c82608f60775f85316b938c35d4c409032118 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 10:19:32 +0900 Subject: [PATCH 024/117] feat: secured Discord webhook settings and web backup restore --- src/web.rs | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/src/web.rs b/src/web.rs index 7fe05df..548e354 100644 --- a/src/web.rs +++ b/src/web.rs @@ -413,7 +413,9 @@ pub async fn serve( .route("/api/check", post(check)) .route("/api/renew", post(renew)) .route("/api/auto-renew", post(update_auto_renew)) + .route("/api/discord", post(update_discord)) .route("/api/profile/remove", post(remove_profile)) + .route("/api/profile/restore", post(restore_profile)) .route( "/api/install", post(install).layer(DefaultBodyLimit::max(MAX_IPA_BYTES + 1024 * 1024)), @@ -657,6 +659,8 @@ async fn state(State(app): State>) -> Json { "code_error": if asking { code_error } else { None }, }, "history": history, + "backups": account.as_ref().and_then(|a| a.team_id.as_deref()) + .map(backup_views).unwrap_or_default(), "app_status": std::fs::read_to_string(APP_STATUS_FILE).ok() .and_then(|s| serde_json::from_str::(&s).ok()) .unwrap_or(json!({})), @@ -721,6 +725,117 @@ async fn update_auto_renew( json_ok() } + +fn backup_views(team: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(crate::SAVED_DIR) else { return Vec::new(); }; + let mut result = Vec::new(); + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|ft| ft.is_file()) { continue; } + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("mobileprovision") { continue; } + let Ok(bytes) = std::fs::read(&path) else { continue; }; + let Some(p) = profile::parse(&bytes) else { continue; }; + if p.team_id.as_deref() != Some(team) + || path.file_stem().and_then(|s| s.to_str()) + .is_none_or(|stem| !stem.eq_ignore_ascii_case(&p.uuid)) { continue; } + result.push(json!({ + "uuid": p.uuid, "bundle": p.bundle_id, "expires":p.expires_text(), + "days": p.days_left() + })); + } + result.sort_by(|a,b| a["bundle"].as_str().cmp(&b["bundle"].as_str())); + result +} + +#[derive(Deserialize)] +struct DiscordReq { + #[serde(default)] + webhook: Option, + #[serde(default)] + clear: bool, +} + +async fn update_discord( + Json(req): Json, +) -> (StatusCode, Json) { + if req.clear { + match std::fs::remove_file(DISCORD_WEBHOOK_FILE) { + Ok(()) => return json_ok(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return json_ok(), + Err(_) => return json_err(StatusCode::INTERNAL_SERVER_ERROR, "Could not clear webhook"), + } + } + let Some(webhook) = req.webhook else { + return json_err(StatusCode::BAD_REQUEST, "Supply a Discord webhook URL."); + }; + if !validate_discord_webhook(&webhook) { + return json_err(StatusCode::BAD_REQUEST, "Expected a valid Discord webhook URL."); + } + if std::fs::create_dir_all("state").is_err() + || crate::fsperm::restrict_dir(Path::new("state")).is_err() + || crate::fsperm::write_private(Path::new(DISCORD_WEBHOOK_FILE), webhook.as_bytes()).is_err() + { + return json_err(StatusCode::INTERNAL_SERVER_ERROR, "Could not save webhook"); + } + json_ok() +} + +#[derive(Deserialize)] +struct RestoreReq { + uuid: String, +} + +async fn restore_profile( + State(app): State>, + Json(req): Json, +) -> (StatusCode, Json) { + let team = apple::read_account(Path::new(".")).and_then(|a| a.team_id); + let Some(team) = team else { + return json_err(StatusCode::BAD_REQUEST, "Sign in before restoring profiles."); + }; + if !backup_views(&team).iter().any(|p| p["uuid"].as_str() == Some(&req.uuid)) { + return json_err(StatusCode::NOT_FOUND, "Verified profile backup was not found."); + } + let Some(guard) = try_busy(&app) else { + return json_err(StatusCode::CONFLICT, BUSY_MSG); + }; + app.lock().job = JobView { + kind:"restore_profile".into(), running:true, lines:Vec::new(), ok:None + }; + spawn_job(guard, move |app| run_restore_profile(app, req.uuid, team)); + json_ok() +} + +async fn run_restore_profile(app: Arc, uuid: String, team: String) { + let result: Result = async { + // Only original verified UUID-named files for the configured Apple team. + if uuid.len() > 64 || uuid.is_empty() + || !uuid.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') { + return Err("Invalid profile UUID".into()); + } + let path = Path::new(crate::SAVED_DIR).join(format!("{uuid}.mobileprovision")); + let bytes = std::fs::read(path).map_err(crate::phone::dbg)?; + let p = profile::parse(&bytes).ok_or("Invalid backup data")?; + if !p.uuid.eq_ignore_ascii_case(&uuid) || p.team_id.as_deref() != Some(&team) { + return Err("Backup does not belong to the configured Apple team".into()); + } + let (_, mut link) = phone::connect_auto(app.phone, PAIRING_FILE, |_: &str| {}).await?; + phone::with_timeout("Profile restore", Duration::from_secs(60), + link.install_profile(bytes)).await?; + Ok(format!("Restored profile {}. Original backup retained.", uuid)) + }.await; + let ok = result.is_ok(); + match result { + Ok(line) => app.push_line(&line), + Err(e) => app.push_line(&format!("Error: {}", short_error(&e))), + } + { + let mut g=app.lock(); + g.job.running=false; g.job.ok=Some(ok); + } + run_check(app).await; +} + #[derive(Deserialize)] struct RenewReq { /// "due" renews expiring installed apps; "now" all profiles; "single" one bundle. From 755e4018ba9cddbf33a0a6cafef3e1bb5d4b3d81 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 10:20:45 +0900 Subject: [PATCH 025/117] feat(ui): expandable grouped apps, renew preview, profile restore, retry threshold and Discord settings --- src/web/index.html | 230 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 191 insertions(+), 39 deletions(-) diff --git a/src/web/index.html b/src/web/index.html index a2a42b5..b310aaa 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -35,6 +35,15 @@ } .card h2 { font-size: 1.05rem; margin: 0 0 10px; } .row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + .item-head { display: flex; align-items: flex-start; gap: 10px; flex-wrap: nowrap; } + .item-info { flex: 1 1 0; min-width: 0; overflow-wrap: anywhere; } + .item-head button { flex: 0 0 auto; white-space: nowrap; } + .nested { padding: 9px 12px; border-radius: 10px; background: var(--bg); margin-top: 9px; } + .nested > div + div { border-top: 1px solid var(--line); padding-top: 8px; margin-top: 8px; } + .mini { font-size: .82rem; padding: 7px 10px; } + select, input[type=time] { font: inherit; padding: 9px; border: 1px solid var(--line); + border-radius: 10px; background: var(--bg); color: var(--text); } + @media(max-width:420px){ .item-head { gap: 6px; } .item-head button { padding: 8px; font-size: .84rem; } } .grow { flex: 1 1 auto; } .pill { display: inline-block; padding: 3px 11px; border-radius: 999px; font-weight: 600; @@ -108,8 +117,10 @@

    Your apps

    +

    + -
    - +
    + + +
    +
    + + +
    +
    +

    If a scheduled renewal fails: retry after 30 minutes, up to twice more. Send Discord notification only after the final failure (or an unretryable authentication error).

    +
    +
    + +
    + Not configured + Only sent when automatic renewal fails after retries. +
    + +
    + + +
    +
    @@ -139,6 +175,13 @@

    Provisioning profiles on iPhone

      +
      +

      Saved profile backups

      +

      Restore a backed-up profile by UUID. Restoring does not extend its original expiry date. The backup is retained.

      +

      +
        +
        +

        Install an app from this PC

        Choose an IPA. AltKeeper signs it with the configured Apple ID and installs it over Wi-Fi. The Apple ID password must be saved on the server.

        @@ -199,6 +242,7 @@

        Recent renewals

        "use strict"; const $ = (id) => document.getElementById(id); let state = null, showForm = false, timer = null, sending = false, uploading = false; +let expandedApps = new Set(), expandedProfiles = new Set(), showPreview = false; async function api(path, body) { const opt = body === undefined ? {} : { @@ -261,27 +305,54 @@

        Recent renewals

        banner("phone-error", err, "bad"); $("phone-error").title = p.detail || ""; - // App + // One app per host bundle. Members are separate signing profiles. const canRenew = s.account.present && s.account.password_saved && s.account.team && !s.busy; const ul = $("apps"); ul.replaceChildren(); s.apps.forEach((a) => { + const members = a.members || [{bundle:a.bundle,label:a.label,days:a.days,expires:a.expires}]; const pct = Math.max(4, Math.min(100, Math.round((Math.max(a.days, 0) / 7) * 100))); const bar = div("bar", span("")); bar.firstChild.style.width = pct + "%"; - const details = div("grow", + const details = div("item-info", div("app-name", document.createTextNode(a.label)), - div("muted", span(days(a.days), "when"), document.createTextNode(" · " + a.expires.slice(0, 10)))); + div("muted", span(days(a.days), "when"), + document.createTextNode(" · " + (a.expires || "").slice(0, 10) + + " · " + members.length + " profiles"))); + const last = (s.app_status || {})[a.bundle]; + if (last) { + const date = new Date(last.at * 1000).toLocaleString("ko-KR", {timeZone:"Asia/Seoul"}); + details.append(div("muted", document.createTextNode("Last profile install: " + + last.status + " · " + date + " KST (" + last.renewed + "/" + last.total + ")"))); + } const renewBtn = document.createElement("button"); renewBtn.type = "button"; - renewBtn.textContent = "Renew"; + renewBtn.className = "mini"; + renewBtn.textContent = "Renew app"; renewBtn.disabled = !canRenew; renewBtn.onclick = () => { - if (confirm("Renew only " + a.label + " now?")) { - act(() => api("/api/renew", { mode: "single", bundle: a.bundle })); + if (confirm("Renew " + a.label + " and all " + members.length + " profiles now?")) { + act(() => api("/api/renew", {mode:"single",bundle:a.bundle})); } }; - ul.append(li(a.level, div("row", details, renewBtn), bar)); + const expandBtn = document.createElement("button"); + expandBtn.type = "button"; expandBtn.className = "mini"; + expandBtn.textContent = expandedApps.has(a.bundle) ? "Less ▲" : "Details ▼"; + expandBtn.onclick = () => { + if (expandedApps.has(a.bundle)) expandedApps.delete(a.bundle); + else expandedApps.add(a.bundle); + render(state); + }; + const item = li(a.level, + div("item-head", details, renewBtn, expandBtn), bar); + if (expandedApps.has(a.bundle)) { + const nested = div("nested"); + members.forEach(m => nested.append(div("", + div("app-name", document.createTextNode(m.label)), + div("muted", document.createTextNode(days(m.days) + " · " + m.bundle))))); + item.append(nested); + } + ul.append(item); }); const empty = $("apps-empty"); if (!s.account.present) { empty.textContent = "Sign in with your Apple ID to see your apps."; empty.hidden = false; } @@ -289,54 +360,115 @@

        Recent renewals

        else if (!s.apps.length && p.reachable === true) { empty.textContent = "No apps found for this Apple ID."; empty.hidden = false; } else { empty.hidden = true; } - // Renewal buttons $("btn-due").disabled = !canRenew; $("btn-all").disabled = !canRenew; $("btn-check").disabled = s.busy; $("renew-hint").textContent = !s.account.present ? "Sign in with your Apple ID first." - : !s.account.password_saved ? "The password must be saved to renew: sign in again and leave the checkbox selected." + : !s.account.password_saved ? "Save the Apple ID password to renew." : s.busy ? "Working…" : ""; + show("renew-preview", showPreview); + if (showPreview) { + const box = $("renew-preview"); + box.replaceChildren(div("app-name", document.createTextNode( + "Potential renewals (expires in " + (s.auto_renew.min_days || 3) + " days or less):"))); + const due = s.apps.filter(a => a.days <= (s.auto_renew.min_days || 3)); + if (!due.length) box.append(div("muted", document.createTextNode("No profiles appear due."))); + due.forEach(a => box.append(div("muted", + document.createTextNode(a.label + " · " + a.members.length + " profiles · " + days(a.days))))); + box.append(div("muted", document.createTextNode( + "Preview only: final renewal also verifies installed apps on the iPhone. " + + "If installed-app lookup fails, all known team profiles are renewed."))); + } - // Persistent server-side automatic renewal; UI does not call host systemctl. - const auto = s.auto_renew || {enabled:false, time:"06:00", next_run:null}; + // Persistent schedule and retry details + const auto = s.auto_renew || {enabled:false,time:"06:00",min_days:3,next_run:null}; $("auto-pill").textContent = auto.enabled ? "ON" : "OFF"; $("auto-pill").className = "pill " + (auto.enabled ? "ok" : ""); const next = auto.next_run ? new Date(auto.next_run * 1000).toLocaleString("ko-KR", { timeZone:"Asia/Seoul", year:"numeric", month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit", hour12:false - }) - : null; - $("auto-next").textContent = auto.enabled ? "Next: " + (next || "checking") + " KST" - : "Automatic renewal disabled"; + }) : null; + $("auto-next").textContent = auto.enabled ? (auto.retry_at ? "Retry: " : "Next: ") + + (next || "checking") + " KST" : "Automatic renewal disabled"; if (document.activeElement !== $("auto-time")) $("auto-time").value = auto.time || "06:00"; - $("auto-enable").textContent = auto.enabled ? "Save time" : "Enable auto-renew"; + if (document.activeElement !== $("auto-days")) $("auto-days").value = String(auto.min_days || 3); + $("auto-enable").textContent = auto.enabled ? "Save settings" : "Enable auto-renew"; $("auto-enable").disabled = !s.account.password_saved || !s.account.team; $("auto-disable").hidden = !auto.enabled; + $("discord-pill").textContent = auto.discord_configured ? "Configured" : "Not configured"; + $("discord-pill").className = "pill" + (auto.discord_configured ? " ok" : ""); + $("discord-clear").disabled = !auto.discord_configured; - // Manual, individually backed-up profile removal. Never deletes apps or Apple App IDs. + // Show provisioning profiles only when expanded, without moving controls when labels wrap. const pu = $("profiles"); pu.replaceChildren(); - (s.profiles || []).forEach((p) => { - const details = div("grow", - div("app-name", document.createTextNode(p.label)), - div("muted", document.createTextNode(days(p.days) + " · " + p.expires.slice(0, 10))), - div("muted", document.createTextNode("Profile " + p.uuid))); - const removeBtn = document.createElement("button"); - removeBtn.type = "button"; - removeBtn.textContent = "Delete profile"; - removeBtn.disabled = s.busy || s.phone.reachable !== true; - removeBtn.onclick = () => { - if (confirm("Remove this provisioning profile for " + p.label + - "? This may prevent the app from opening. A backup will be saved first.\n\n" + p.uuid)) { - act(() => api("/api/profile/remove", { uuid: p.uuid })); - } + const grouped = new Map(); + (s.profiles || []).forEach(profile => { + const root = profile.root || profile.bundle; + if (!grouped.has(root)) grouped.set(root, []); + grouped.get(root).push(profile); + }); + grouped.forEach((profiles, root) => { + const label = (s.apps.find(a => a.bundle === root) || {}).label || root; + const details = div("item-info", div("app-name", document.createTextNode(label)), + div("muted", document.createTextNode(profiles.length + " saved-on-phone profiles"))); + const toggle = document.createElement("button"); + toggle.type = "button"; toggle.className = "mini"; + toggle.textContent = expandedProfiles.has(root) ? "Hide ▲" : "Show ▼"; + toggle.onclick = () => { + if (expandedProfiles.has(root)) expandedProfiles.delete(root); + else expandedProfiles.add(root); + render(state); }; - pu.append(li("", div("row", details, removeBtn))); + const item = li("", div("item-head", details, toggle)); + if (expandedProfiles.has(root)) { + const nested = div("nested"); + profiles.forEach(prof => { + const info = div("item-info", + div("app-name", document.createTextNode(prof.label)), + div("muted", document.createTextNode(days(prof.days) + " · " + prof.expires.slice(0,10))), + div("muted", document.createTextNode("Profile " + prof.uuid))); + const remove = document.createElement("button"); + remove.type = "button"; remove.className = "mini"; + remove.textContent = "Delete profile"; + remove.disabled = s.busy || s.phone.reachable !== true; + remove.onclick = () => { + if (confirm("Delete profile " + prof.uuid + "? An original verified backup will be saved first. " + + "Removing an active profile may prevent the app from opening.")) { + act(() => api("/api/profile/remove", {uuid:prof.uuid})); + } + }; + nested.append(div("item-head", info, remove)); + }); + item.append(nested); + } + pu.append(item); }); const pempty = $("profiles-empty"); - pempty.textContent = "No provisioning profiles found for the current Apple team."; - pempty.hidden = (s.profiles || []).length > 0 || s.phone.reachable !== true; + pempty.textContent = "No provisioning profiles found for this Apple team."; + pempty.hidden = grouped.size > 0 || s.phone.reachable !== true; + + const backups = $("backups"); + backups.replaceChildren(); + (s.backups || []).forEach(b => { + const info = div("item-info", + div("app-name", document.createTextNode(b.bundle || "Unknown bundle")), + div("muted", document.createTextNode("Profile " + b.uuid)), + div("muted", document.createTextNode("Expires " + b.expires.slice(0,10)))); + const restore = document.createElement("button"); + restore.type = "button"; restore.className = "mini"; + restore.textContent = "Restore"; + restore.disabled = s.busy; + restore.onclick = () => { + if (confirm("Restore profile " + b.uuid + " to the iPhone? This does not renew the expiry.")) { + act(() => api("/api/profile/restore", {uuid:b.uuid})); + } + }; + backups.append(li("", div("item-head", info, restore))); + }); + $("backups-empty").hidden = (s.backups || []).length > 0; + $("backups-empty").textContent = "No saved profile backups for this Apple team."; const canInstall = s.account.present && s.account.password_saved && s.account.team && p.reachable === true && !s.busy; @@ -358,9 +490,10 @@

        Recent renewals

        ? "Signing and installing the app. Keep this page open until it finishes…" : job.kind === "remove_profile" ? "Backing up and deleting the selected profile…" + : job.kind === "restore_profile" ? "Restoring the selected profile…" : "Renewal in progress. Keep this page open until it finishes…"; } - else if (job.ok) { st.className = "banner good"; st.textContent = job.kind === "install" ? "App installed." : job.kind === "remove_profile" ? "Profile removed and backed up." : "Done."; } + else if (job.ok) { st.className = "banner good"; st.textContent = job.kind === "install" ? "App installed." : job.kind === "remove_profile" ? "Profile removed and backed up." : job.kind === "restore_profile" ? "Profile restored." : "Done."; } else { st.className = "banner bad"; st.textContent = "It did not complete successfully. Check the last line."; } const log = $("job-log"); const atEnd = log.scrollTop + log.clientHeight >= log.scrollHeight - 8; @@ -415,12 +548,31 @@

        Recent renewals

        $("auto-enable").onclick = () => { banner("auto-error", ""); - act(() => api("/api/auto-renew", {enabled:true, time:$("auto-time").value}), "auto-error"); + act(() => api("/api/auto-renew", { + enabled:true, time:$("auto-time").value, min_days:Number($("auto-days").value) + }), "auto-error"); }; $("auto-disable").onclick = () => { banner("auto-error", ""); - act(() => api("/api/auto-renew", {enabled:false, time:$("auto-time").value}), "auto-error"); + act(() => api("/api/auto-renew", { + enabled:false, time:$("auto-time").value, min_days:Number($("auto-days").value) + }), "auto-error"); +}; +$("discord-save").onclick = () => { + const url = $("discord-webhook").value.trim(); + banner("discord-error", ""); + if (!url) return banner("discord-error", "Enter the Discord webhook URL.", "bad"); + act(async () => { + await api("/api/discord", {webhook:url}); + $("discord-webhook").value = ""; + }, "discord-error"); +}; +$("discord-clear").onclick = () => { + if (confirm("Remove the saved Discord webhook?")) { + act(() => api("/api/discord", {clear:true}), "discord-error"); + } }; +$("btn-preview").onclick = () => {showPreview = !showPreview; if(state)render(state);}; $("btn-check").onclick = () => act(() => api("/api/check", {})); $("btn-due").onclick = () => act(() => api("/api/renew", { mode: "due" })); $("btn-all").onclick = () => { From 40b3d37f6275ca4d5aa84f85047310da16d52df0 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 10:21:12 +0900 Subject: [PATCH 026/117] fix: borrow latest profiles while grouping extension bundles --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 4cf7332..dad8cb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -300,7 +300,7 @@ async fn cmd_renew( // 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 { + for p in &latest { let Some(bundle) = p.bundle_id.as_deref() else { say(&format!(" {}: bundle ID cannot be read, skipping", p.name)); continue; From a41655232a7defc22d38e9e02aa188a27113557d Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 10:21:21 +0900 Subject: [PATCH 027/117] fix: borrow latest profiles while grouping extension bundles --- src/web.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web.rs b/src/web.rs index 548e354..8d24e68 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1425,7 +1425,7 @@ fn app_views(profiles: &[profile::PhoneProfile], team: &str) -> Vec { let bundles: Vec<&str> = latest.iter().filter_map(|p| p.bundle_id.as_deref()).collect(); let mut groups: std::collections::BTreeMap> = std::collections::BTreeMap::new(); - for p in latest { + for p in &latest { let Some(bundle) = p.bundle_id.as_deref() else { continue; }; let root = profile::group_root(bundle, &bundles, team).to_owned(); groups.entry(root).or_default().push(MemberView { From 3c75baadaef7a9a9a402e35e532ac726dfac1d8a Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 10:21:24 +0900 Subject: [PATCH 028/117] build: install curl for Discord notifications --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9b5fd76..36fa38e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ RUN cargo build --release --locked FROM debian:bookworm-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates tzdata \ + && apt-get install -y --no-install-recommends ca-certificates tzdata curl \ && rm -rf /var/lib/apt/lists/* COPY --from=build /src/target/release/altkeeper /usr/local/bin/altkeeper WORKDIR /data From 6dbddb51e4baeb7e2853d77c119804384eead365 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 10:21:43 +0900 Subject: [PATCH 029/117] docs: grouped renewal, adjustable threshold, 30-minute retries and Discord failure alerts --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 093af53..2aa31b2 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,34 @@ Removing a profile currently needed by an installed app can prevent that app fro To restore a backup: `altkeeper profile-restore profili-salvati/.mobileprovision`. Neither profile deletion nor renewal removes Apple Developer App IDs. +**Grouped app management and retry controls:** The web UI groups extension profiles with +their host app when their bundle ID contains the saved team's extension marker +(e.g. `com.kdt.livecontainer.TEAM.LiveProcess` under `com.kdt.livecontainer`). +The expandable **Your apps** card renews *every* member profile in a group, rather +than only the selected host profile. Installed-app filtering also checks the +host app for an extension; the earliest-expiring member determines when the entire +group is due. The web preview lists groups that appear due, but the actual run +performs a fresh phone check and may fall back to all team profiles if the +installed-app inventory fails. + +The web scheduler's **renewal threshold** is adjustable from **1 to 6 days**, +default **3 days**. On automatic renewal failure it retries up to **two** more +times, **30 minutes apart** (up to 3 attempts in total). Authentication and +rate-limit failures are not automatically retried. Enabling Discord failure +notifications requires saving a webhook URL in the web UI. The secret is saved in +the private `state/discord-webhook` file and is never returned to the browser; +only a boolean indicates configuration. The Docker image includes `curl` to send +an alert only after the last failed attempt (or a non-retryable failure). +Successful automatic renewals do not send Discord messages. + +The web UI also lists previously saved profile backups and offers an explicit +**Restore** action after confirmation. Restoration verifies the saved profile +UUID and Apple Team ID and does not change its expiry date. The original file +is retained. The app summary shows the last known per-group profile-install +result from `state/app-renew-status.json`, and **Preview renewals** provides a +non-authoritative overview without signing in to Apple. + + **Monthly backup cleanup:** When the web service (`serve`, including Docker `serve --altserver`) is running, it checks once a day whether a cleanup is due. Every **30 days after the last successful sweep** (including the first startup), it removes only saved `.mobileprovision` From 52b2ef1706db9ae45d61ed656edf5e37283c9d17 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 11:22:48 +0900 Subject: [PATCH 030/117] fix: group extensions using embedded signing Team IDs, not only active profile team --- src/profile.rs | 70 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/src/profile.rs b/src/profile.rs index e693743..c1d0859 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -33,16 +33,30 @@ impl PhoneProfile { } } -/// Associate extension bundles with their host app only when the embedded -/// Team ID marks them as extensions of an existing, exact host bundle. -/// Never infer a parent from arbitrary dotted reverse-DNS prefixes. +/// AltStore and other signers may insert their *original* ten-character +/// Team ID into an extension bundle, even when the provisioned profile +/// belongs to a different (current) developer team. Match only a complete +/// parent + team-marker + nonempty extension suffix, not arbitrary prefixes. +fn extension_of(bundle: &str, parent: &str, team: &str) -> bool { + let Some(suffix) = bundle.strip_prefix(parent).and_then(|s| s.strip_prefix('.')) else { + return false; + }; + let Some((marker, extension_name)) = suffix.split_once('.') else { + return false; + }; + !extension_name.is_empty() && ( + marker == team || + (marker.len() == 10 && + marker.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())) + ) +} + +/// Resolve an extension to an *existing* host bundle: the longest valid +/// parent wins to avoid matching adjacent/unrelated dotted app identifiers. pub fn group_root<'a>(bundle: &'a str, bundles: &[&'a str], team: &str) -> &'a str { bundles.iter().copied() - .filter(|candidate| { - *candidate != bundle - && bundle.starts_with(&format!("{candidate}.{team}.")) - }) - .max_by_key(|candidate| candidate.len()) + .filter(|parent| *parent != bundle && extension_of(bundle, parent, team)) + .max_by_key(|parent| parent.len()) .unwrap_or(bundle) } @@ -273,6 +287,46 @@ mod tests { assert_eq!(group_root(bundles[4], &bundles, "TEAM"), bundles[4]); assert_eq!(members_of_group(bundles[2], &bundles, "TEAM").len(), 2); } + #[test] + fn real_altstore_extension_bundles_group_even_if_embedded_team_differs() { + // Real bundle shapes seen on iOS; the embedded marker is the app's + // original signer, which need not equal this profile's Apple Team ID. + let bundles = [ + "com.google.ios.youtube", + "com.google.ios.youtube.PXUL22799Q.OpenYouTube.Extension", + "com.kdt.livecontainer", + "com.kdt.livecontainer.PXUL22799Q.LaunchAppExtension", + "com.kdt.livecontainer.PXUL22799Q.LiveProcess", + "com.kdt.livecontainer.PXUL22799Q.ShareExtension", + ]; + let profile_team = "DIFFERENT1"; + assert_eq!(group_root(bundles[1], &bundles, profile_team), bundles[0]); + for extension in &bundles[3..] { + assert_eq!(group_root(extension, &bundles, profile_team), bundles[2]); + } + assert_eq!(members_of_group(bundles[0], &bundles, profile_team).len(), 2); + assert_eq!(members_of_group(bundles[2], &bundles, profile_team).len(), 4); + } + + #[test] + fn malformed_team_markers_do_not_group_unrelated_bundles() { + let bundles = [ + "com.example.app", + "com.example.app.feature.widget", + "com.example.app.short.Widget", + "com.example.app.PXUL22799Q", + "com.example.app.PXUL22799q.Widget", + "com.example.app.PXUL22799Q.Widget", + "com.example.app.PXUL22799Q.", // no extension name + "com.example.app.PXUL22799Q.Widget.More", + ]; + for index in [1,2,3,4,6] { + assert_eq!(group_root(bundles[index], &bundles, "SAVEDTEAM1"), bundles[index]); + } + assert_eq!(group_root(bundles[5], &bundles, "SAVEDTEAM1"), bundles[0]); + assert_eq!(group_root(bundles[7], &bundles, "SAVEDTEAM1"), bundles[0]); + } + #[test] fn senza_profili_non_c_e_niente() { assert!(latest_by_bundle(&[]).is_empty()); From 13795e2332daf9e9b90dcc8637cca97701248880 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 11:23:12 +0900 Subject: [PATCH 031/117] test(web): assert real iOS bundle names display as 2 apps and 6 member profiles --- src/web.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/web.rs b/src/web.rs index 8d24e68..9413501 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1850,6 +1850,30 @@ mod tests { assert!(profiles.iter().filter(|p| p.root == "com.kdt.livecontainer").count() == 3); } + #[test] + fn production_bundle_names_render_as_two_apps_not_six_profiles() { + let team = "SAVEDTEAM1"; + let all = vec![ + profile("a", team, "com.google.ios.youtube", 6), + profile("b", team, "com.google.ios.youtube.PXUL22799Q.OpenYouTube.Extension", 5), + profile("c", team, "com.kdt.livecontainer", 6), + profile("d", team, "com.kdt.livecontainer.PXUL22799Q.LaunchAppExtension", 6), + profile("e", team, "com.kdt.livecontainer.PXUL22799Q.LiveProcess", 2), + profile("f", team, "com.kdt.livecontainer.PXUL22799Q.ShareExtension", 6), + ]; + let apps = app_views(&all, team); + assert_eq!(apps.len(), 2); + let youtube = apps.iter().find(|a| a.bundle == "com.google.ios.youtube").unwrap(); + assert_eq!(youtube.members.len(), 2); + assert_eq!(youtube.days, 5); + let live = apps.iter().find(|a| a.bundle == "com.kdt.livecontainer").unwrap(); + assert_eq!(live.members.len(), 4); + assert_eq!(live.days, 2); + let views = profile_views(&all, team); + assert_eq!(views.iter().filter(|p| p.root == youtube.bundle).count(), 2); + assert_eq!(views.iter().filter(|p| p.root == live.bundle).count(), 4); + } + #[test] fn errori_in_parole_semplici() { assert!(friendly_error("manca il pairing remoto rp-pairing.plist: crealo").contains("missing")); From 6ddd862ab4e50d30556ab1420364618157eca2fd Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 12:03:40 +0900 Subject: [PATCH 032/117] fix: strip profile Team ID suffix only for host/extension comparisons; keep raw App IDs --- src/profile.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/profile.rs b/src/profile.rs index c1d0859..7900756 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -37,8 +37,19 @@ impl PhoneProfile { /// Team ID into an extension bundle, even when the provisioned profile /// belongs to a different (current) developer team. Match only a complete /// parent + team-marker + nonempty extension suffix, not arbitrary prefixes. +/// Apple provisioning App IDs may have a *second* Team ID suffix that +/// the UI deliberately hides (e.g. host "com.foo.PXUL22799Q" is shown as +/// "com.foo"). Keep the raw ID for Apple renewals, but ignore this final +/// suffix while comparing parent and extension relationships. +pub fn profile_app_id<'a>(bundle: &'a str, team: &str) -> &'a str { + if team.is_empty() { return bundle; } + bundle.strip_suffix(&format!(".{team}")).unwrap_or(bundle) +} + fn extension_of(bundle: &str, parent: &str, team: &str) -> bool { - let Some(suffix) = bundle.strip_prefix(parent).and_then(|s| s.strip_prefix('.')) else { + let child_id = profile_app_id(bundle, team); + let parent_id = profile_app_id(parent, team); + let Some(suffix) = child_id.strip_prefix(parent_id).and_then(|s| s.strip_prefix('.')) else { return false; }; let Some((marker, extension_name)) = suffix.split_once('.') else { @@ -308,6 +319,45 @@ mod tests { assert_eq!(members_of_group(bundles[2], &bundles, profile_team).len(), 4); } + #[test] + fn real_provisioning_app_ids_include_trailing_team_suffix() { + // The web UI strips the final ".PXUL22799Q" from each raw profile + // bundle when it displays the names seen in the user screenshot. + // Previous tests used the displayed IDs and missed this failure. + let team = "PXUL22799Q"; + let raw = [ + "com.google.ios.youtube.PXUL22799Q", + "com.google.ios.youtube.PXUL22799Q.OpenYouTube.Extension.PXUL22799Q", + "com.kdt.livecontainer.PXUL22799Q", + "com.kdt.livecontainer.PXUL22799Q.LaunchAppExtension.PXUL22799Q", + "com.kdt.livecontainer.PXUL22799Q.LiveProcess.PXUL22799Q", + "com.kdt.livecontainer.PXUL22799Q.ShareExtension.PXUL22799Q", + ]; + assert_eq!(profile_app_id(raw[0], team), "com.google.ios.youtube"); + assert_eq!( + profile_app_id(raw[1], team), + "com.google.ios.youtube.PXUL22799Q.OpenYouTube.Extension" + ); + assert_eq!(group_root(raw[1], &raw, team), raw[0]); + for ext in &raw[3..] { + assert_eq!(group_root(ext, &raw, team), raw[2]); + } + assert_eq!(members_of_group(raw[0], &raw, team).len(), 2); + assert_eq!(members_of_group(raw[2], &raw, team).len(), 4); + } + + #[test] + fn trailing_suffix_must_match_current_profile_team_exactly() { + let raw = [ + "com.example.app.AAAAAAAAAA", + "com.example.app.BBBBBBBBBB.Ext.AAAAAAAAAA", + "com.example.app.AAAAAAAAAA.other", + ]; + assert_eq!(group_root(raw[1], &raw, "AAAAAAAAAA"), raw[0]); + assert_eq!(group_root(raw[2], &raw, "AAAAAAAAAA"), raw[2]); + assert_eq!(profile_app_id(raw[0], "BBBBBBBBBB"), raw[0]); + } + #[test] fn malformed_team_markers_do_not_group_unrelated_bundles() { let bundles = [ From 66130954c90576298ac22fb4f5be2b22a18bd87c Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 12:04:13 +0900 Subject: [PATCH 033/117] test: use genuinely unrelated bundle name for negative grouping case --- src/profile.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/profile.rs b/src/profile.rs index 7900756..e4e6cab 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -351,7 +351,7 @@ mod tests { let raw = [ "com.example.app.AAAAAAAAAA", "com.example.app.BBBBBBBBBB.Ext.AAAAAAAAAA", - "com.example.app.AAAAAAAAAA.other", + "com.example.app.feature.other", ]; assert_eq!(group_root(raw[1], &raw, "AAAAAAAAAA"), raw[0]); assert_eq!(group_root(raw[2], &raw, "AAAAAAAAAA"), raw[2]); From 3fb47e7c0d1352d4f2180d742513ba8aafb05ab0 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 12:04:49 +0900 Subject: [PATCH 034/117] fix: installed-only renewal recognizes raw and normalized host app IDs --- src/main.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index dad8cb3..2253699 100644 --- a/src/main.rs +++ b/src/main.rs @@ -311,7 +311,14 @@ async fn cmd_renew( requested_found = true; } if let Some(apps) = &installed { - if !apps.contains(root) && !apps.contains(bundle) { + // 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; } From 5c1e5f6801572639405df447825ebdf599774ac6 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 12:05:19 +0900 Subject: [PATCH 035/117] test(web): regress using raw phone provisioning IDs with trailing signing Team suffix --- src/web.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/web.rs b/src/web.rs index 9413501..94a5a0f 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1405,7 +1405,7 @@ async fn run_login(app: Arc, apple_id: String, password: String, save: bool // --------------------------------------------------------------------------------------------- fn label_of(bundle: &str, team: &str) -> String { - bundle.strip_suffix(&format!(".{team}")).unwrap_or(bundle).to_string() + profile::profile_app_id(bundle, team).to_string() } fn level_for(days: i64) -> &'static str { @@ -1874,6 +1874,33 @@ mod tests { assert_eq!(views.iter().filter(|p| p.root == live.bundle).count(), 4); } + #[test] + fn real_raw_profile_ids_from_phone_display_as_two_grouped_apps() { + // Screenshots show these IDs after the UI has hidden the last + // ".PXUL22799Q". The phone's raw provisioning IDs contain that + // extra suffix; testing display-only names missed the regression. + let team = "PXUL22799Q"; + let all = vec![ + profile("a", team, "com.google.ios.youtube.PXUL22799Q", 6), + profile("b", team, "com.google.ios.youtube.PXUL22799Q.OpenYouTube.Extension.PXUL22799Q", 6), + profile("c", team, "com.kdt.livecontainer.PXUL22799Q", 6), + profile("d", team, "com.kdt.livecontainer.PXUL22799Q.LaunchAppExtension.PXUL22799Q", 6), + profile("e", team, "com.kdt.livecontainer.PXUL22799Q.LiveProcess.PXUL22799Q", 6), + profile("f", team, "com.kdt.livecontainer.PXUL22799Q.ShareExtension.PXUL22799Q", 6), + ]; + let apps = app_views(&all, team); + assert_eq!(apps.len(), 2, "UI must show two apps, not six profiles"); + let youtube = apps.iter().find(|a| a.label == "com.google.ios.youtube").unwrap(); + assert_eq!(youtube.members.len(), 2); + assert_eq!(youtube.bundle, "com.google.ios.youtube.PXUL22799Q"); + let live = apps.iter().find(|a| a.label == "com.kdt.livecontainer").unwrap(); + assert_eq!(live.members.len(), 4); + assert_eq!(live.bundle, "com.kdt.livecontainer.PXUL22799Q"); + let views = profile_views(&all, team); + assert_eq!(views.iter().filter(|p| p.root == youtube.bundle).count(), 2); + assert_eq!(views.iter().filter(|p| p.root == live.bundle).count(), 4); + } + #[test] fn errori_in_parole_semplici() { assert!(friendly_error("manca il pairing remoto rp-pairing.plist: crealo").contains("missing")); From ffc0ac0d4b181a8bbb00e917a9416770d7e6d2f4 Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 13:10:37 +0900 Subject: [PATCH 036/117] feat(web): add Apple touch and Safari bookmark PNG icon assets --- src/web/apple-touch-icon.png | Bin 0 -> 129853 bytes src/web/favicon-32.png | Bin 0 -> 4196 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/web/apple-touch-icon.png create mode 100644 src/web/favicon-32.png diff --git a/src/web/apple-touch-icon.png b/src/web/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..797038691e6265434a00ef986773da8e97f5da86 GIT binary patch literal 129853 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 literal 0 HcmV?d00001 From e1ee4d1d1174338447a984ad4f374b2a4062aaac Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 13:11:43 +0900 Subject: [PATCH 037/117] feat(web): serve public Safari bookmark PNGs and safe Discord webhook test API --- src/web.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/src/web.rs b/src/web.rs index 94a5a0f..1b349c4 100644 --- a/src/web.rs +++ b/src/web.rs @@ -30,6 +30,8 @@ use serde_json::{Value, json}; use crate::{PAIRING_FILE, apple, phone, profile}; const INDEX_HTML: &str = include_str!("web/index.html"); +const APPLE_TOUCH_ICON: &[u8] = include_bytes!("web/apple-touch-icon.png"); +const FAVICON_32: &[u8] = include_bytes!("web/favicon-32.png"); const RENEW_LOG: &str = "renew.log"; const AUTO_RENEW_CONFIG: &str = "state/auto-renew.json"; const DISCORD_WEBHOOK_FILE: &str = "state/discord-webhook"; @@ -301,14 +303,18 @@ fn discord_configured() -> bool { } fn send_discord_failure(error: &str) -> Result<(), String> { + let message = format!("AltKeeper: automatic renewal failed after retries.\\n{}", + error.chars().take(240).collect::()); + send_discord_message(&message) +} + +fn send_discord_message(message: &str) -> Result<(), String> { use std::io::Write; let url = std::fs::read_to_string(DISCORD_WEBHOOK_FILE).map_err(crate::phone::dbg)?; let url = url.trim(); if !validate_discord_webhook(url) { return Err("Invalid Discord webhook configuration".into()); } // Pass the sensitive webhook URL via stdin (curl config), not process arguments. - let msg = format!("AltKeeper: automatic renewal failed after retries.\\n{}", - error.chars().take(240).collect::()); - let body = json!({ "content": msg }).to_string(); + let body = json!({ "content": message }).to_string(); let mut child = std::process::Command::new("curl") .args(["--fail", "--silent", "--show-error", "--max-time", "12", "--request", "POST", "--header", "Content-Type: application/json", @@ -409,11 +415,14 @@ pub async fn serve( }); let router = Router::new() .route("/", get(index)) + .route("/apple-touch-icon.png", get(apple_touch_icon)) + .route("/favicon-32.png", get(favicon_32)) .route("/api/state", get(state)) .route("/api/check", post(check)) .route("/api/renew", post(renew)) .route("/api/auto-renew", post(update_auto_renew)) .route("/api/discord", post(update_discord)) + .route("/api/discord/test", post(test_discord)) .route("/api/profile/remove", post(remove_profile)) .route("/api/profile/restore", post(restore_profile)) .route( @@ -461,11 +470,16 @@ pub async fn serve( // --------------------------------------------------------------------------------------------- async fn guard(State(app): State>, req: Request, next: Next) -> Response { - if app.pin.is_none() && !host_is_local(req.headers()) { + // iOS Safari can fetch favorite icons separately from the authenticated tab, + // so only these two fixed, non-sensitive PNGs are served without the PIN. + let public_icon = req.method() == Method::GET + && matches!(req.uri().path(), "/apple-touch-icon.png" | "/favicon-32.png"); + if !public_icon && app.pin.is_none() && !host_is_local(req.headers()) { return (StatusCode::FORBIDDEN, "host non ammesso").into_response(); } - if let Some(pin) = &app.pin { - if !basic_password_ok(req.headers(), pin) { + if !public_icon { + if let Some(pin) = &app.pin { + if !basic_password_ok(req.headers(), pin) { // Rallenta chi prova PIN a caso. tokio::time::sleep(Duration::from_secs(1)).await; let mut res = (StatusCode::UNAUTHORIZED, "serve il PIN").into_response(); @@ -474,6 +488,7 @@ async fn guard(State(app): State>, req: Request, next: Next) -> Respons HeaderValue::from_static("Basic realm=\"altkeeper\""), ); return res; + } } } if req.method() == Method::POST @@ -483,14 +498,16 @@ async fn guard(State(app): State>, req: Request, next: Next) -> Respons } let mut res = next.run(req).await; let h = res.headers_mut(); - h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + h.insert(header::CACHE_CONTROL, HeaderValue::from_static( + if public_icon { "public, max-age=86400" } else { "no-store" } + )); h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); h.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")); h.insert( header::CONTENT_SECURITY_POLICY, HeaderValue::from_static( - "default-src 'none'; connect-src 'self'; style-src 'unsafe-inline'; \ + "default-src 'none'; connect-src 'self'; img-src 'self' data:; style-src 'unsafe-inline'; \ script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'", ), ); @@ -572,6 +589,13 @@ async fn index() -> Html<&'static str> { Html(INDEX_HTML) } +fn png_response(bytes: &'static [u8]) -> Response { + ([(header::CONTENT_TYPE, "image/png")], bytes).into_response() +} + +async fn apple_touch_icon() -> Response { png_response(APPLE_TOUCH_ICON) } +async fn favicon_32() -> Response { png_response(FAVICON_32) } + fn json_err(status: StatusCode, msg: &str) -> (StatusCode, Json) { (status, Json(json!({ "error": msg }))) } @@ -780,6 +804,23 @@ async fn update_discord( json_ok() } +/// User-initiated delivery check; never creates or renews a profile. +async fn test_discord() -> (StatusCode, Json) { + if !discord_configured() { + return json_err(StatusCode::BAD_REQUEST, "Configure a Discord webhook first."); + } + // curl is synchronous; leave Tokio free while we wait for Discord. + let sent = tokio::task::spawn_blocking(|| { + send_discord_message( + "AltKeeper test notification: Discord webhook is working. No renewal was performed." + ) + }).await; + match sent { + Ok(Ok(())) => json_ok(), + _ => json_err(StatusCode::BAD_GATEWAY, "Discord test delivery failed. Check webhook permissions and network access."), + } +} + #[derive(Deserialize)] struct RestoreReq { uuid: String, @@ -1728,6 +1769,14 @@ mod tests { assert_eq!(next_auto_run(&conf, now_secs), None); } + #[test] + fn safari_icons_are_real_pngs() { + for icon in [APPLE_TOUCH_ICON, FAVICON_32] { + assert!(icon.starts_with(b"\\x89PNG\\r\\n\\x1a\\n")); + assert!(icon.len() > 100); + } + } + #[test] fn il_progresso_si_scrive_ogni_dieci_punti() { let mut last = -1; From 5287bc12b0f8dcb757e44eaaa43c6204ecb7131e Mon Sep 17 00:00:00 2001 From: pingpingjinping Date: Sun, 25 Oct 2026 13:11:57 +0900 Subject: [PATCH 038/117] feat(web): link Safari touch icon and add Discord send-test button --- src/web/index.html | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/web/index.html b/src/web/index.html index b310aaa..25c7b8a 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -5,7 +5,9 @@ AltKeeper - + + + @@ -308,10 +321,19 @@

        Recent renewals

        For use on your home network only.
        + + +