diff --git a/crates/perry/src/commands/compile/apple_info_plist.rs b/crates/perry/src/commands/compile/apple_info_plist.rs index 633b49847c..353d068ca7 100644 --- a/crates/perry/src/commands/compile/apple_info_plist.rs +++ b/crates/perry/src/commands/compile/apple_info_plist.rs @@ -254,6 +254,113 @@ pub(super) fn inject_ios_app_group_entitlement( Some(()) } +/// #5074 — write (or augment) `app.entitlements` with the `aps-environment` +/// entitlement when `[ios] push_notifications = true` is set in perry.toml. +/// Without this entitlement `[UIApplication registerForRemoteNotifications]` +/// (`notificationRegisterRemote` in `perry/system`) always fails and no APNs +/// token is ever produced. +/// +/// The value defaults to `development` (matching the dev-signed bundles +/// `perry compile --target ios` produces); an explicit +/// `[ios] push_environment = "production"` overrides it for distribution +/// builds. Any value other than `production` (including a typo) resolves to +/// `development`, the safe default for on-device debugging. +/// +/// Idempotent with `inject_ios_deeplinks` / `inject_ios_app_group_entitlement`: +/// if an entitlements file already exists we splice our `...` before +/// the closing `` (and leave any hand-written `aps-environment` alone); +/// otherwise we emit the full plist wrapper. Either way the user's signing +/// pipeline picks up a single `app.entitlements` at codesign time, and the +/// dev-resign path (`build_dev_entitlements_xml`) layers its development keys +/// on top without dropping this one. +pub(super) fn inject_ios_push_entitlement( + input: &std::path::Path, + app_dir: &std::path::Path, + format: OutputFormat, +) -> Option<()> { + let (enabled, environment) = read_ios_push_config(input)?; + if !enabled { + return None; + } + + let entitlements_path = app_dir.join("app.entitlements"); + let key_block = format!( + " aps-environment\n {}\n", + environment + ); + + let new_contents = match fs::read_to_string(&entitlements_path) { + Ok(existing) => { + // Already declared (hand-written or a previous run)? leave it alone. + if existing.contains("aps-environment") { + return Some(()); + } + existing.replace( + "\n", + &format!("{}\n", key_block), + ) + } + Err(_) => format!( + "\n\n\n\n{}\n\n", + key_block + ), + }; + + fs::write(&entitlements_path, new_contents).ok()?; + + if let OutputFormat::Text = format { + println!( + " Push notifications: aps-environment={} → {} (#5074)", + environment, + entitlements_path.display() + ); + println!( + " Sign with: codesign --entitlements {} ...", + entitlements_path.display() + ); + } + Some(()) +} + +/// Resolve `[ios] push_notifications` (bool) and `[ios] push_environment` +/// (string) from the nearest `perry.toml` walking up from `input`. Returns +/// `(enabled, environment)` where `environment` is normalized to +/// `"development"` unless explicitly set to `"production"`. `None` on any +/// missing-file / parse failure (caller skips injection — matches the +/// config-helper convention in this file). +fn read_ios_push_config(input: &std::path::Path) -> Option<(bool, String)> { + let mut dir = input.canonicalize().ok()?; + let mut data: Option = None; + for _ in 0..5 { + dir = dir.parent()?.to_path_buf(); + let toml_path = dir.join("perry.toml"); + if toml_path.exists() { + data = fs::read_to_string(&toml_path).ok(); + break; + } + } + let doc: toml::Table = data?.parse().ok()?; + Some(parse_ios_push_config(&doc)) +} + +/// Pure resolver shared by `read_ios_push_config` and the unit tests. +/// `[ios] push_notifications` opts in; `[ios] push_environment` selects the +/// APNs environment (`production`, else `development`). +fn parse_ios_push_config(doc: &toml::Table) -> (bool, String) { + let ios = doc.get("ios").and_then(|v| v.as_table()); + let enabled = ios + .and_then(|t| t.get("push_notifications")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let environment = ios + .and_then(|t| t.get("push_environment")) + .and_then(|v| v.as_str()) + .filter(|s| *s == "production") + .unwrap_or("development") + .to_string(); + (enabled, environment) +} + /// Cheap CFBundleIdentifier extraction from an in-memory Info.plist string. /// We need it for the CFBundleURLName field (Apple's convention is /// `.`). Falls back to `perry.deeplink` when the @@ -266,3 +373,141 @@ fn lookup_bundle_id_from_info_plist(info_plist: &str) -> Option { let end = rest[start..].find("")?; Some(rest[start..start + end].trim().to_string()) } + +#[cfg(test)] +mod push_entitlement_tests { + use super::{inject_ios_push_entitlement, parse_ios_push_config}; + use crate::OutputFormat; + + fn parse(src: &str) -> toml::Table { + src.parse::().unwrap() + } + + #[test] + fn push_config_defaults_to_development_when_opted_in() { + // #5074 — `push_notifications = true` opts in; environment defaults to + // `development` (the dev-signed bundles `perry compile --target ios` + // produces). + let (enabled, env) = parse_ios_push_config(&parse("[ios]\npush_notifications = true\n")); + assert!(enabled); + assert_eq!(env, "development"); + } + + #[test] + fn push_config_honors_production_environment() { + let (enabled, env) = parse_ios_push_config(&parse( + "[ios]\npush_notifications = true\npush_environment = \"production\"\n", + )); + assert!(enabled); + assert_eq!(env, "production"); + } + + #[test] + fn push_config_clamps_unknown_environment_to_development() { + let (enabled, env) = parse_ios_push_config(&parse( + "[ios]\npush_notifications = true\npush_environment = \"sandbox\"\n", + )); + assert!(enabled); + assert_eq!(env, "development"); + } + + #[test] + fn push_config_off_when_absent_or_false() { + assert!(!parse_ios_push_config(&parse("[ios]\nbundle_id = \"a\"\n")).0); + assert!(!parse_ios_push_config(&parse("[ios]\npush_notifications = false\n")).0); + assert!(!parse_ios_push_config(&parse("")).0); + } + + #[test] + fn injects_full_plist_when_no_entitlements_exist() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("src").join("main.ts"); + std::fs::create_dir_all(input.parent().unwrap()).unwrap(); + std::fs::write(&input, "console.log('x')").unwrap(); + std::fs::write( + dir.path().join("perry.toml"), + "[ios]\npush_notifications = true\n", + ) + .unwrap(); + let app_dir = dir.path().join("out.app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + assert!(inject_ios_push_entitlement(&input, &app_dir, OutputFormat::Json).is_some()); + + let ent = std::fs::read_to_string(app_dir.join("app.entitlements")).unwrap(); + assert!(ent.starts_with("aps-environment")); + assert!(ent.contains("development")); + assert_eq!(ent.matches("").count(), 1); + assert_eq!(ent.matches("").count(), 1); + } + + #[test] + fn splices_into_existing_app_group_entitlements_without_clobbering() { + // Idempotent with #1178: an existing app.entitlements (e.g. App Group) + // gets the aps-environment key spliced in, both keys survive, and the + // wrapper stays single. + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("main.ts"); + std::fs::write(&input, "console.log('x')").unwrap(); + std::fs::write( + dir.path().join("perry.toml"), + "[ios]\npush_notifications = true\npush_environment = \"production\"\n", + ) + .unwrap(); + let app_dir = dir.path().join("out.app"); + std::fs::create_dir_all(&app_dir).unwrap(); + std::fs::write( + app_dir.join("app.entitlements"), + "\n\n\n \ + com.apple.security.application-groups\n \n \ + group.com.example.shared\n \n\n\n", + ) + .unwrap(); + + assert!(inject_ios_push_entitlement(&input, &app_dir, OutputFormat::Json).is_some()); + + let ent = std::fs::read_to_string(app_dir.join("app.entitlements")).unwrap(); + assert!(ent.contains("com.apple.security.application-groups")); + assert!(ent.contains("group.com.example.shared")); + assert!(ent.contains("aps-environment")); + assert!(ent.contains("production")); + assert_eq!(ent.matches("").count(), 1); + assert_eq!(ent.matches("").count(), 1); + } + + #[test] + fn idempotent_when_aps_environment_already_present() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("main.ts"); + std::fs::write(&input, "console.log('x')").unwrap(); + std::fs::write( + dir.path().join("perry.toml"), + "[ios]\npush_notifications = true\n", + ) + .unwrap(); + let app_dir = dir.path().join("out.app"); + std::fs::create_dir_all(&app_dir).unwrap(); + let hand_written = "\n\n\n aps-environment\n production\n\n\n"; + std::fs::write(app_dir.join("app.entitlements"), hand_written).unwrap(); + + assert!(inject_ios_push_entitlement(&input, &app_dir, OutputFormat::Json).is_some()); + + // Hand-written value preserved (not downgraded to development). + let ent = std::fs::read_to_string(app_dir.join("app.entitlements")).unwrap(); + assert_eq!(ent, hand_written); + } + + #[test] + fn skips_when_not_opted_in() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("main.ts"); + std::fs::write(&input, "console.log('x')").unwrap(); + std::fs::write(dir.path().join("perry.toml"), "[ios]\nbundle_id = \"a\"\n").unwrap(); + let app_dir = dir.path().join("out.app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + assert!(inject_ios_push_entitlement(&input, &app_dir, OutputFormat::Json).is_none()); + assert!(!app_dir.join("app.entitlements").exists()); + } +} diff --git a/crates/perry/src/commands/compile/bundle_ios.rs b/crates/perry/src/commands/compile/bundle_ios.rs index 93dc6aec34..002f4687c3 100644 --- a/crates/perry/src/commands/compile/bundle_ios.rs +++ b/crates/perry/src/commands/compile/bundle_ios.rs @@ -18,6 +18,7 @@ use crate::OutputFormat; use super::apple_info_plist::{ inject_google_auth_info_plist, inject_ios_app_group_entitlement, inject_ios_deeplinks, + inject_ios_push_entitlement, }; use super::resources::stage_native_library_artifacts; use super::targets::compile_metallib_for_bundle; @@ -446,6 +447,12 @@ pub(super) fn build_ios_app_bundle( // entries) intact. inject_ios_app_group_entitlement(&app_dir, ctx.app_metadata.app_group.as_deref(), format); + // #5074 — emit the `aps-environment` entitlement when + // `[ios] push_notifications = true` is set in perry.toml. Without it + // `registerForRemoteNotifications` always fails and no APNs token is + // produced. Idempotent with the deeplinks / app-group passes above. + inject_ios_push_entitlement(&input, &app_dir, format); + // #1138 — `[google_auth]` block in perry.toml feeds the // GoogleSignIn SDK via Info.plist keys the Swift bridge in // `@perryts/google-auth` reads at runtime. diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index 629cee4104..4f1f6d0862 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -44,7 +44,8 @@ pub use remote::{ pub use resign::{ create_dev_profile_via_api, embed_profile_and_sign, find_dev_identity_for_team, find_identity_for_team, find_system_dev_profile, generate_asc_jwt, read_bundle_id_from_app, - read_ios_app_group_from_toml, resign_for_development, try_sign_existing_dev_profile, + read_ios_app_group_from_toml, read_ios_push_notifications_from_toml, resign_for_development, + try_sign_existing_dev_profile, }; #[derive(Args, Debug)] diff --git a/crates/perry/src/commands/run/resign.rs b/crates/perry/src/commands/run/resign.rs index ad9165abf8..3b3b5c7cab 100644 --- a/crates/perry/src/commands/run/resign.rs +++ b/crates/perry/src/commands/run/resign.rs @@ -54,12 +54,14 @@ pub async fn resign_for_development( println!(" Creating development provisioning profile via App Store Connect..."); } let app_group = read_ios_app_group_from_toml(); + let push = read_ios_push_notifications_from_toml().unwrap_or(false); create_dev_profile_via_api( config, &bundle_id, &team_id, device_udid, app_group.as_deref(), + push, format, ) .await @@ -320,6 +322,7 @@ pub async fn create_dev_profile_via_api( _team_id: &str, device_udid: &str, app_group: Option<&str>, + push_notifications: bool, format: OutputFormat, ) -> Result> { let apple = config.apple.as_ref().ok_or_else(|| { @@ -473,6 +476,41 @@ pub async fn create_dev_profile_via_api( } } + // 2c. Best-effort Push Notifications capability enablement (#5074). + // + // Unlike App Groups, PUSH_NOTIFICATIONS has no identifier to register — the + // capability toggle on the App ID is all that's needed for the minted + // profile to validate the `aps-environment` entitlement that + // `inject_ios_push_entitlement` writes at compile time. An already-enabled + // capability comes back as a 409 conflict; treat that as success and never + // fail profile creation over the toggle. + if push_notifications { + if let OutputFormat::Text = format { + print!(" Enabling Push Notifications capability..."); + std::io::Write::flush(&mut std::io::stdout()).ok(); + } + let resp = client + .post(format!("{base}/bundleIdCapabilities")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "data": { + "type": "bundleIdCapabilities", + "attributes": { "capabilityType": "PUSH_NOTIFICATIONS" }, + "relationships": { + "bundleId": { + "data": { "type": "bundleIds", "id": bundle_id_resource_id } + } + } + } + })) + .send() + .await; + let enabled = matches!(&resp, Ok(r) if r.status().is_success()); + if let OutputFormat::Text = format { + println!(" {}", if enabled { "done" } else { "already enabled" }); + } + } + // 3. Find a development certificate if let OutputFormat::Text = format { print!(" Finding development certificate..."); @@ -667,6 +705,28 @@ fn parse_ios_app_group(content: &str) -> Option { .map(|s| s.to_string()) } +/// Read `[ios] push_notifications` from `./perry.toml` (#5074). Best-effort — +/// any missing file / key / parse error yields `false`, since the capability +/// toggle never gates provisioning. The matching `aps-environment` entitlement +/// is written separately by `inject_ios_push_entitlement` at compile time. +pub fn read_ios_push_notifications_from_toml() -> Option { + let path = std::env::current_dir().ok()?.join("perry.toml"); + let content = std::fs::read_to_string(path).ok()?; + Some(parse_ios_push_notifications(&content)) +} + +fn parse_ios_push_notifications(content: &str) -> bool { + toml::from_str::(content) + .ok() + .and_then(|parsed| { + parsed + .get("ios") + .and_then(|i| i.get("push_notifications")) + .and_then(|v| v.as_bool()) + }) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; @@ -748,4 +808,47 @@ mod tests { assert_eq!(parse_ios_app_group("[ios]\napp_group = \"\"\n"), None); assert_eq!(parse_ios_app_group("not = valid = toml"), None); } + + /// #5074: re-signing must not drop the `aps-environment` entitlement that + /// `perry compile --target ios` writes to `app.entitlements` — otherwise + /// `registerForRemoteNotifications` fails on the dev-signed bundle. + #[test] + fn dev_entitlements_preserve_compile_emitted_push() { + let dir = std::env::temp_dir().join(format!("perry_resign_push_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("app.entitlements"), + "\n\ + \n\n \ + aps-environment\n development\n\n\n", + ) + .unwrap(); + + let xml = build_dev_entitlements_xml(&dir, "ABCDE12345", "com.example.app"); + + // Push entitlement survives, development keys are layered in. + assert!(xml.contains("aps-environment")); + assert!(xml.contains("development")); + assert!(xml.contains("application-identifier")); + assert!(xml.contains("ABCDE12345.com.example.app")); + assert_eq!(xml.matches("").count(), 1); + assert_eq!(xml.matches("").count(), 1); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// #5074: `[ios] push_notifications` drives the best-effort + /// PUSH_NOTIFICATIONS capability toggle during dev provisioning. + #[test] + fn parse_ios_push_notifications_opt_in() { + assert!(parse_ios_push_notifications( + "[ios]\npush_notifications = true\n" + )); + assert!(!parse_ios_push_notifications( + "[ios]\npush_notifications = false\n" + )); + assert!(!parse_ios_push_notifications("[ios]\nbundle_id = \"a\"\n")); + assert!(!parse_ios_push_notifications("not = valid = toml")); + } } diff --git a/crates/perry/src/commands/setup/ios.rs b/crates/perry/src/commands/setup/ios.rs index 9a4c8d9b46..ff4550615e 100644 --- a/crates/perry/src/commands/setup/ios.rs +++ b/crates/perry/src/commands/setup/ios.rs @@ -893,6 +893,7 @@ pub fn ios_development_setup(saved: &PerryConfig) -> Result<()> { // A declared `[ios] app_group` is enabled (best-effort) on the bundle ID and // the remaining manual portal step is surfaced inside the API call (#1301). let app_group = crate::commands::run::read_ios_app_group_from_toml(); + let push = crate::commands::run::read_ios_push_notifications_from_toml().unwrap_or(false); let rt = tokio::runtime::Runtime::new()?; let profile_data = rt.block_on(crate::commands::run::create_dev_profile_via_api( saved, @@ -900,6 +901,7 @@ pub fn ios_development_setup(saved: &PerryConfig) -> Result<()> { &team_id, &udid, app_group.as_deref(), + push, crate::OutputFormat::Text, ))?; diff --git a/docs/src/system/notifications.md b/docs/src/system/notifications.md index ea38d4436c..6e290f40ed 100644 --- a/docs/src/system/notifications.md +++ b/docs/src/system/notifications.md @@ -47,6 +47,25 @@ Firebase Messaging on Android — wired via JNI through No-op on platforms without a push pipeline (tvOS, visionOS, watchOS, GTK4, Windows, Web). +### Enabling APNs on iOS + +`registerForRemoteNotifications` only succeeds when the signed `.app` carries +the `aps-environment` entitlement. Opt in from `perry.toml` +([#5074](https://github.com/PerryTS/perry/issues/5074)): + +```toml +[ios] +push_notifications = true # emit the aps-environment entitlement +# push_environment = "production" # default "development"; set for distribution +``` + +With this set, `perry compile --target ios` writes `aps-environment` into the +bundle's `app.entitlements` (defaulting to `development`, which matches +dev-signed builds), and `perry setup ios` / `perry run --target ios` enable the +Push Notifications capability on the App ID when minting the development +provisioning profile. For App Store / Ad Hoc distribution set +`push_environment = "production"`. + ## Platform Implementation | Platform | Backend |