feat(watchos): App Groups + bundle entitlements/codesign (#675)#6204
Conversation
Intl.ListFormat with locale "es"/"es-*" and type "unit" must join with the
Spanish connectors from CLDR:
- long: "foo, bar y baz" (last boundary " y ", pair " y ")
- short: "foo, bar, baz" (comma-joined for 3+, pair still " y ")
- narrow: "foo bar baz" (space-joined, same as the base — unchanged)
list_separators was hardcoded to the en-US patterns, so es lists came out
comma-joined ("foo, bar" instead of "foo y bar").
Thread the resolved locale through list_format_parts / list_separators and
add the es unit branch. The single DurationFormat caller passes "en-US"
explicitly to preserve its existing (locale-agnostic) output.
Fixes test262 intl402/ListFormat/prototype/{format,formatToParts}/
{es-es-long,es-es-short}.js (4 cases); es-es-narrow stays passing.
Replace the #675 perry_system_app_group_set/get/delete stubs in perry-ui-watchos with a real NSUserDefaults(suiteName:) backing, mirroring the perry-ui-ios / perry-ui-macos implementations. The suite name is the one baked into main()'s prelude from [watchos] app_group in perry.toml, reached through the stable C-ABI perry_app_group_suite_name accessor so it resolves under geisterhand feature-set skew (#1311). NSUInteger arg/return types use usize to stay ABI-correct on 32-bit-pointer arm64_32 (ILP32). When no suite is configured the functions emit the same one-shot stub-warn diagnostic naming the missing [watchos] app_group key instead of a silent in-process map.
Standalone watch apps that declare [watchos] app_group can now share their NSUserDefaults(suiteName:) container with a WidgetKit extension: the bundler emits an app.entitlements carrying com.apple.security.application-groups and codesigns the .app so the entitlement is honored. - Generalize inject_ios_app_group_entitlement -> inject_app_group_entitlement (the XML is identical across Apple platforms) and reuse it from the watch bundler; iOS call site updated. - New apple_codesign module: read_watch_signing_config reads [watchos] signing_identity / provisioning_profile; codesign_apple_bundle ad-hoc signs (-s -) for the simulator and uses the real identity + embedded profile on device. Nothing is hardcoded. - bundle_for_watchos: after staging, inject the app-group entitlement and sign the .app. Only fires when app_group is set, so plain watch apps still build unsigned (verified: no app.entitlements / _CodeSignature emitted). - compile_for_watchos_widget: same entitlement + sign step for the produced WidgetExtension.appex when a widget declares appGroup. On the simulator ad-hoc signing + the entitlement provisions the shared App Group container; NSUserDefaults suite redirection to that container is enforced only on device (simulator writes the suite app-locally).
📝 WalkthroughWalkthroughThis PR replaces watchOS App Group stub functions with real NSUserDefaults-backed storage, adds an Apple codesigning module ( ChangesIntl Locale-Aware List/Duration Formatting
Estimated code review effort: 2 (Simple) | ~12 minutes watchOS App Group Storage and Codesigning
Estimated code review effort: 3 (Moderate) | ~28 minutes Sequence Diagram(s)sequenceDiagram
participant Compile as bundle_for_watchos
participant Inject as inject_app_group_entitlement
participant Config as read_watch_signing_config
participant Sign as codesign_apple_bundle
participant JS as watchOS Runtime
participant Defaults as NSUserDefaults
Compile->>Inject: app_dir, app_group
Inject-->>Compile: entitlements path or none
alt entitlements present
Compile->>Config: locate perry.toml [watchos]
Config-->>Compile: WatchSigningConfig
Compile->>Sign: bundle, entitlements, config, is_sim
Sign-->>Compile: signed (bool) or error
end
JS->>Defaults: perry_system_app_group_set(key, value)
Defaults-->>JS: synchronize
JS->>Defaults: perry_system_app_group_get(key)
Defaults-->>JS: stringForKey value
JS->>Defaults: perry_system_app_group_delete(key)
Defaults-->>JS: removeObjectForKey + synchronize
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/perry/src/commands/compile/apple_codesign.rs`:
- Around line 109-130: In apple_codesign.rs, the device-signing branch in the
provisioning_profile handling only logs warnings in the fs::read failure and
missing-profile cases and then continues; update the signing flow so those cases
return an error instead of proceeding. Use the existing bundle/signing path
around cfg.provisioning_profile, fs::read, and fs::write to fail device signing
when the embedded.mobileprovision cannot be produced, while keeping the
simulator path unchanged.
In `@crates/perry/src/commands/compile/targets.rs`:
- Around line 676-691: The widget App Group handling in the .appex signing flow
is only taking the first app_group via widgets.iter().find_map in the compile
target logic, which can hide conflicts when multiple widgets declare different
groups. Update the app-group selection in the target compilation path to inspect
all widgets, reject builds when more than one distinct app_group is present, and
only proceed to inject entitlements and call codesign_apple_bundle when the
widget set is consistent. Use the existing symbols widgets,
inject_app_group_entitlement, and codesign_apple_bundle to locate the change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 248191d2-c03b-4079-bc5f-37e0d8b4d2cd
📒 Files selected for processing (9)
crates/perry-runtime/src/intl/duration_format.rscrates/perry-runtime/src/intl/list_relative_plural.rscrates/perry-ui-watchos/src/lib.rscrates/perry/src/commands/compile.rscrates/perry/src/commands/compile/apple_codesign.rscrates/perry/src/commands/compile/apple_info_plist.rscrates/perry/src/commands/compile/bundle_apple.rscrates/perry/src/commands/compile/bundle_ios.rscrates/perry/src/commands/compile/targets.rs
| if !is_sim { | ||
| if let Some(profile) = cfg.provisioning_profile.as_deref().filter(|p| !p.is_empty()) { | ||
| match fs::read(profile) { | ||
| Ok(bytes) => { | ||
| fs::write(bundle.join("embedded.mobileprovision"), bytes)?; | ||
| } | ||
| Err(e) => { | ||
| if let OutputFormat::Text = format { | ||
| println!( | ||
| " Warning: could not read provisioning_profile {}: {} — signing \ | ||
| without an embedded profile; on-device App Group access will fail.", | ||
| profile, e | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } else if let OutputFormat::Text = format { | ||
| println!( | ||
| " Warning: `[watchos] provisioning_profile` not set — signing without an \ | ||
| embedded profile; the profile must include the App Group for on-device access." | ||
| ); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Fail device signing when the provisioning profile is unavailable.
Line 109 enters the device path, but Lines 115-130 only warn when the profile is unreadable or missing and then still sign successfully. That can produce a “Codesigned” bundle that the code already says will not have on-device App Group access.
🐛 Proposed fix
if !is_sim {
- if let Some(profile) = cfg.provisioning_profile.as_deref().filter(|p| !p.is_empty()) {
- match fs::read(profile) {
- Ok(bytes) => {
- fs::write(bundle.join("embedded.mobileprovision"), bytes)?;
- }
- Err(e) => {
- if let OutputFormat::Text = format {
- println!(
- " Warning: could not read provisioning_profile {}: {} — signing \
- without an embedded profile; on-device App Group access will fail.",
- profile, e
- );
- }
- }
- }
- } else if let OutputFormat::Text = format {
- println!(
- " Warning: `[watchos] provisioning_profile` not set — signing without an \
- embedded profile; the profile must include the App Group for on-device access."
- );
- }
+ let profile = cfg
+ .provisioning_profile
+ .as_deref()
+ .filter(|p| !p.is_empty())
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "`[watchos] provisioning_profile` is required when signing a device bundle \
+ with App Group entitlements"
+ )
+ })?;
+ let bytes = fs::read(profile)
+ .map_err(|e| anyhow::anyhow!("could not read provisioning_profile {profile}: {e}"))?;
+ fs::write(bundle.join("embedded.mobileprovision"), bytes)?;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if !is_sim { | |
| if let Some(profile) = cfg.provisioning_profile.as_deref().filter(|p| !p.is_empty()) { | |
| match fs::read(profile) { | |
| Ok(bytes) => { | |
| fs::write(bundle.join("embedded.mobileprovision"), bytes)?; | |
| } | |
| Err(e) => { | |
| if let OutputFormat::Text = format { | |
| println!( | |
| " Warning: could not read provisioning_profile {}: {} — signing \ | |
| without an embedded profile; on-device App Group access will fail.", | |
| profile, e | |
| ); | |
| } | |
| } | |
| } | |
| } else if let OutputFormat::Text = format { | |
| println!( | |
| " Warning: `[watchos] provisioning_profile` not set — signing without an \ | |
| embedded profile; the profile must include the App Group for on-device access." | |
| ); | |
| } | |
| if !is_sim { | |
| let profile = cfg | |
| .provisioning_profile | |
| .as_deref() | |
| .filter(|p| !p.is_empty()) | |
| .ok_or_else(|| { | |
| anyhow::anyhow!( | |
| "`[watchos] provisioning_profile` is required when signing a device bundle \ | |
| with App Group entitlements" | |
| ) | |
| })?; | |
| let bytes = fs::read(profile) | |
| .map_err(|e| anyhow::anyhow!("could not read provisioning_profile {profile}: {e}"))?; | |
| fs::write(bundle.join("embedded.mobileprovision"), bytes)?; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/perry/src/commands/compile/apple_codesign.rs` around lines 109 - 130,
In apple_codesign.rs, the device-signing branch in the provisioning_profile
handling only logs warnings in the fs::read failure and missing-profile cases
and then continues; update the signing flow so those cases return an error
instead of proceeding. Use the existing bundle/signing path around
cfg.provisioning_profile, fs::read, and fs::write to fail device signing when
the embedded.mobileprovision cannot be produced, while keeping the simulator
path unchanged.
| let widget_app_group = widgets.iter().find_map(|w| w.app_group.as_deref()); | ||
| if super::apple_info_plist::inject_app_group_entitlement( | ||
| &appex_path, | ||
| widget_app_group, | ||
| format, | ||
| ) | ||
| .is_some() | ||
| { | ||
| let signing_cfg = super::apple_codesign::read_watch_signing_config(&args.input); | ||
| super::apple_codesign::codesign_apple_bundle( | ||
| &appex_path, | ||
| &appex_path.join("app.entitlements"), | ||
| is_simulator, | ||
| &signing_cfg, | ||
| format, | ||
| )?; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reject conflicting widget App Groups before signing.
Line 676 picks the first declared appGroup, but this .appex can contain multiple widgets. If two widgets declare different groups, only one entitlement is emitted and the others cannot access their configured shared suite.
🐛 Proposed fix
- let widget_app_group = widgets.iter().find_map(|w| w.app_group.as_deref());
+ let widget_app_groups = widgets
+ .iter()
+ .filter_map(|w| w.app_group.as_deref())
+ .filter(|group| !group.is_empty())
+ .collect::<std::collections::BTreeSet<_>>();
+ if widget_app_groups.len() > 1 {
+ anyhow::bail!(
+ "watchOS widgets in the same extension must use the same appGroup; found: {}",
+ widget_app_groups.iter().copied().collect::<Vec<_>>().join(", ")
+ );
+ }
+ let widget_app_group = widget_app_groups.iter().next().copied();📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let widget_app_group = widgets.iter().find_map(|w| w.app_group.as_deref()); | |
| if super::apple_info_plist::inject_app_group_entitlement( | |
| &appex_path, | |
| widget_app_group, | |
| format, | |
| ) | |
| .is_some() | |
| { | |
| let signing_cfg = super::apple_codesign::read_watch_signing_config(&args.input); | |
| super::apple_codesign::codesign_apple_bundle( | |
| &appex_path, | |
| &appex_path.join("app.entitlements"), | |
| is_simulator, | |
| &signing_cfg, | |
| format, | |
| )?; | |
| let widget_app_groups = widgets | |
| .iter() | |
| .filter_map(|w| w.app_group.as_deref()) | |
| .filter(|group| !group.is_empty()) | |
| .collect::<std::collections::BTreeSet<_>>(); | |
| if widget_app_groups.len() > 1 { | |
| anyhow::bail!( | |
| "watchOS widgets in the same extension must use the same appGroup; found: {}", | |
| widget_app_groups.iter().copied().collect::<Vec<_>>().join(", ") | |
| ); | |
| } | |
| let widget_app_group = widget_app_groups.iter().next().copied(); | |
| if super::apple_info_plist::inject_app_group_entitlement( | |
| &appex_path, | |
| widget_app_group, | |
| format, | |
| ) | |
| .is_some() | |
| { | |
| let signing_cfg = super::apple_codesign::read_watch_signing_config(&args.input); | |
| super::apple_codesign::codesign_apple_bundle( | |
| &appex_path, | |
| &appex_path.join("app.entitlements"), | |
| is_simulator, | |
| &signing_cfg, | |
| format, | |
| )?; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/perry/src/commands/compile/targets.rs` around lines 676 - 691, The
widget App Group handling in the .appex signing flow is only taking the first
app_group via widgets.iter().find_map in the compile target logic, which can
hide conflicts when multiple widgets declare different groups. Update the
app-group selection in the target compilation path to inspect all widgets,
reject builds when more than one distinct app_group is present, and only proceed
to inject entitlements and call codesign_apple_bundle when the widget set is
consistent. Use the existing symbols widgets, inject_app_group_entitlement, and
codesign_apple_bundle to locate the change.
…t conflicting widget App Groups Addresses CodeRabbit review on #6204.
…ppgroups # Conflicts: # crates/perry/src/commands/compile/bundle_apple.rs
What
Lets a standalone watchOS app share key/value data with its WidgetKit complication via an App Group (#675) — the app-group KV was stubbed on watch, and the watch bundler had no entitlement/codesign path. Motivated by a watch app whose Smart Stack complication shows the practice streak.
Part A — App Group KV.
perry_system_app_group_set/get/deleteinperry-ui-watchosnow useNSUserDefaults(suiteName:)(mirrors iOS/macOS), suite name read via the geisterhand-safeperry_app_group_suite_nameaccessor. ILP32-correct (usizeforNSUInteger, not the iOS copy'su64).Part B — Entitlements + codesign. New
apple_codesign.rs(read_watch_signing_config,codesign_apple_bundle);bundle_for_watchosandcompile_for_watchos_widgetnow, only when[watchos] app_groupis set, inject<app>.app/app.entitlements(com.apple.security.application-groups) and codesign — ad-hoc (-s -) for the simulator, real identity + embedded profile for device (read from[watchos] signing_identity/provisioning_profile, nothing hardcoded). Plain watch apps with no app_group are unaffected (no entitlements, no codesign — verified).Verification
cargo +nightly check -Z build-stdclean on watchos-sim/device/arm64_32.perry-dispatch✓,perry-api-manifestincl. stub_inventory ✓ (no count change),apple_info_plist13 tests ✓. Sim smoke: the real app bundle carries the app-group entitlement (codesign -d --entitlements), is codesigned (_CodeSignature/), andappGroupSet→appGroupGetround-trips; the suite plist gets the written values; the container is provisioned from the entitlement. No-regression confirmed for plain apps.Caveats
NSUserDefaults(suiteName:)to the shared container, so writer→reader sharing (proven wired: entitlement embedded, container provisioned, values written) can only be fully demonstrated on a device.swiftc -emit-executablefails here for an unrelatedSwiftUICorereason); the same inject+codesign path runs for the.appexon a working toolchain.Note for reviewers
Touches
bundle_for_watchosinbundle_apple.rs, which also changes in the system-parity PR (localization) — trivial rebase (localization writes before codesign seals the bundle).🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes