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 |