Skip to content

feat(watchos): App Groups + bundle entitlements/codesign (#675)#6204

Merged
proggeramlug merged 8 commits into
mainfrom
timestable/watchos-appgroups
Jul 9, 2026
Merged

feat(watchos): App Groups + bundle entitlements/codesign (#675)#6204
proggeramlug merged 8 commits into
mainfrom
timestable/watchos-appgroups

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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/delete in perry-ui-watchos now use NSUserDefaults(suiteName:) (mirrors iOS/macOS), suite name read via the geisterhand-safe perry_app_group_suite_name accessor. ILP32-correct (usize for NSUInteger, not the iOS copy's u64).

Part B — Entitlements + codesign. New apple_codesign.rs (read_watch_signing_config, codesign_apple_bundle); bundle_for_watchos and compile_for_watchos_widget now, only when [watchos] app_group is 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-std clean on watchos-sim/device/arm64_32. perry-dispatch ✓, perry-api-manifest incl. stub_inventory ✓ (no count change), apple_info_plist 13 tests ✓. Sim smoke: the real app bundle carries the app-group entitlement (codesign -d --entitlements), is codesigned (_CodeSignature/), and appGroupSetappGroupGet round-trips; the suite plist gets the written values; the container is provisioned from the entitlement. No-regression confirmed for plain apps.

Caveats

  • Cross-process read is device-only: on the simulator, cfprefsd doesn't redirect an app-group 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.
  • Widget appex signing is code-complete but couldn't be exercised locally (standalone WidgetKit swiftc -emit-executable fails here for an unrelated SwiftUICore reason); the same inject+codesign path runs for the .appex on a working toolchain.
  • Device signing path is untested (no device); absent an identity, the bundle is left unsigned with a hint.

Note for reviewers

Touches bundle_for_watchos in bundle_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

    • Added watchOS support for app group entitlements, so shared app data can now work on watch builds.
    • Improved Apple bundle signing during build so watchOS apps and widgets can be signed correctly when app groups are enabled.
  • Bug Fixes

    • Fixed duration list formatting so separator behavior stays consistent across locales.
    • Improved Spanish list formatting for unit-style strings to match expected language rules.
    • Added better handling for missing signing settings, with clearer build-time guidance.

Ralph Kuepper added 3 commits July 4, 2026 22:16
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).
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR replaces watchOS App Group stub functions with real NSUserDefaults-backed storage, adds an Apple codesigning module (apple_codesign.rs) for watchOS bundles/widgets that embeds App Group entitlements and provisioning profiles, renames/shares the App Group entitlement injector across iOS and watchOS, and threads locale through Intl.ListFormat/DurationFormat list-separator logic.

Changes

Intl Locale-Aware List/Duration Formatting

Layer / File(s) Summary
Locale threading through separators
crates/perry-runtime/src/intl/list_relative_plural.rs, crates/perry-runtime/src/intl/duration_format.rs
list_separators, list_format_parts, and list_format_instance_parts now accept/propagate locale with Spanish-specific "unit" connectors; duration_format.rs passes explicit "en-US" locale to list_format_parts.

Estimated code review effort: 2 (Simple) | ~12 minutes

watchOS App Group Storage and Codesigning

Layer / File(s) Summary
NSUserDefaults-backed App Group storage
crates/perry-ui-watchos/src/lib.rs
Replaces stub perry_system_app_group_set/get/delete with real NSUserDefaults reads/writes via a new suite-name lookup helper, emitting a #675 warning when the suite is unconfigured.
Apple codesign helper module
crates/perry/src/commands/compile.rs, crates/perry/src/commands/compile/apple_codesign.rs
Adds apple_codesign module with WatchSigningConfig, read_watch_signing_config (walks up to nearest perry.toml), and codesign_apple_bundle (ad-hoc simulator signing vs. identity-based device signing with provisioning profile embedding).
Shared App Group entitlement injector
crates/perry/src/commands/compile/apple_info_plist.rs, crates/perry/src/commands/compile/bundle_ios.rs
Renames inject_ios_app_group_entitlement to inject_app_group_entitlement, updates docs/cross-references, and switches bundle_ios.rs to call the shared helper.
watchOS bundle/widget signing wiring
crates/perry/src/commands/compile/bundle_apple.rs, crates/perry/src/commands/compile/targets.rs
Injects App Group entitlements into watchOS app and widget .appex bundles, then re-signs them via codesign_apple_bundle when entitlements are present.

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
Loading

Possibly related PRs

  • PerryTS/perry#6000: Modifies the same duration_format.rs and list_relative_plural.rs functions to thread locale (e.g. en-US, Spanish unit separators) through DurationFormat/ListFormat.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately captures the watchOS App Group entitlement and codesigning changes.
Description check ✅ Passed The description covers summary, changes, related issue, and verification, though it doesn't follow the template headings exactly.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch timestable/watchos-appgroups

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 01a7f6f and 560ccc2.

📒 Files selected for processing (9)
  • crates/perry-runtime/src/intl/duration_format.rs
  • crates/perry-runtime/src/intl/list_relative_plural.rs
  • crates/perry-ui-watchos/src/lib.rs
  • crates/perry/src/commands/compile.rs
  • crates/perry/src/commands/compile/apple_codesign.rs
  • crates/perry/src/commands/compile/apple_info_plist.rs
  • crates/perry/src/commands/compile/bundle_apple.rs
  • crates/perry/src/commands/compile/bundle_ios.rs
  • crates/perry/src/commands/compile/targets.rs

Comment on lines +109 to +130
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."
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

Comment on lines +676 to +691
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,
)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

Ralph Küpper added 3 commits July 9, 2026 19:05
…t conflicting widget App Groups

Addresses CodeRabbit review on #6204.
…ppgroups

# Conflicts:
#	crates/perry/src/commands/compile/bundle_apple.rs
@proggeramlug proggeramlug merged commit 982e49c into main Jul 9, 2026
25 checks passed
@proggeramlug proggeramlug deleted the timestable/watchos-appgroups branch July 9, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant