diff --git a/PROGRESS.md b/PROGRESS.md index 1e5aaf8..162755d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -27,7 +27,8 @@ The source-of-truth layout is: - Exercise iOS background/lock, interruption, Bluetooth, route-change, ReplayKit, and iOS 27 ScreenCaptureKit paths on devices. Verify distinct left/right capture with the stereo microphone preset, including changing mono/stereo while joined, and Windows desktop/per-app stereo sharing. -- Complete Developer ID signing/notarization and iOS distribution signing. +- Complete Developer ID signing/notarization. The iOS host and ReplayKit extension have been + distribution-signed and packaged locally; upload the IPA for Apple's server-side validation. - Run the published Linux container and a 30-minute-or-longer server soak. ## Working rule diff --git a/clients/apple/README.md b/clients/apple/README.md index 1c85a34..7f3b3a6 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -26,4 +26,11 @@ ReplayKit extension require signing profiles with App Group `group.me.iamtalon.v Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route changes, Bluetooth, ReplayKit, and iOS 27 ScreenCaptureKit audio. -The shared ring contract is documented in `docs/broadcast-ring-format.md`. +App Store builds use the same device builder with `--configuration Release`. Set +`VOICECAT_BUILD_NUMBER`, `VOICECAT_DISPLAY_VERSION`, `VOICECAT_DEVELOPMENT_TEAM`, the host +`VOICECAT_CODESIGN_KEY`/`VOICECAT_CODESIGN_PROVISION` pair, and the extension +`VOICECAT_BROADCAST_CODESIGN_KEY`/`VOICECAT_BROADCAST_CODESIGN_PROVISION` pair. The host and +extension profiles must both be App Store Connect profiles and include the shared App Group. + +The complete TestFlight workflow is documented in `docs/apple-ios-release.md`. The shared ring +contract is documented in `docs/broadcast-ring-format.md`. diff --git a/clients/apple/VoiceCat.iOS/Info.plist b/clients/apple/VoiceCat.iOS/Info.plist index 0470fb9..9faa79a 100644 --- a/clients/apple/VoiceCat.iOS/Info.plist +++ b/clients/apple/VoiceCat.iOS/Info.plist @@ -3,8 +3,6 @@ CFBundleDisplayNameVoiceCat CFBundleIdentifierme.iamtalon.voicecat - CFBundleShortVersionString0.0.1 - CFBundleVersion1 LSRequiresIPhoneOS NSMicrophoneUsageDescriptionVoiceCat needs microphone access to transmit your voice in channels. UIBackgroundModesaudio diff --git a/clients/apple/VoiceCat.iOS/build-broadcast-extension.sh b/clients/apple/VoiceCat.iOS/build-broadcast-extension.sh index 0887980..b75f564 100755 --- a/clients/apple/VoiceCat.iOS/build-broadcast-extension.sh +++ b/clients/apple/VoiceCat.iOS/build-broadcast-extension.sh @@ -10,8 +10,19 @@ project="$script_dir/../../../native/apple/broadcast/VoiceCatBroadcast.xcodeproj mkdir -p "$output" signing=() +versioning=(MARKETING_VERSION="${VOICECAT_DISPLAY_VERSION:-0.0.1}") +if [[ -n "${VOICECAT_BUILD_NUMBER:-}" ]]; then + versioning+=(CURRENT_PROJECT_VERSION="$VOICECAT_BUILD_NUMBER") +fi if [[ "$sdk" == "iphonesimulator" ]]; then signing+=(CODE_SIGNING_ALLOWED=NO) +elif [[ -n "${VOICECAT_BROADCAST_CODESIGN_KEY:-}" && -n "${VOICECAT_BROADCAST_CODESIGN_PROVISION:-}" ]]; then + signing+=( + DEVELOPMENT_TEAM="${VOICECAT_DEVELOPMENT_TEAM:?VOICECAT_DEVELOPMENT_TEAM is required for distribution signing}" + CODE_SIGN_STYLE=Manual + CODE_SIGN_IDENTITY="$VOICECAT_BROADCAST_CODESIGN_KEY" + PROVISIONING_PROFILE_SPECIFIER="$VOICECAT_BROADCAST_CODESIGN_PROVISION" + ) elif [[ -n "${VOICECAT_DEVELOPMENT_TEAM:-}" ]]; then signing+=(DEVELOPMENT_TEAM="$VOICECAT_DEVELOPMENT_TEAM" CODE_SIGN_STYLE=Automatic) if [[ "${VOICECAT_ALLOW_PROVISIONING_UPDATES:-}" == "1" ]]; then @@ -26,6 +37,7 @@ xcodebuild \ -arch "$architecture" \ -derivedDataPath "$output/derived" \ CONFIGURATION_BUILD_DIR="$output" \ + "${versioning[@]}" \ "${signing[@]}" \ build diff --git a/clients/apple/build-ios-device.sh b/clients/apple/build-ios-device.sh index 2bad783..32c89d6 100755 --- a/clients/apple/build-ios-device.sh +++ b/clients/apple/build-ios-device.sh @@ -12,7 +12,8 @@ while [ "$#" -gt 0 ]; do --output) output="$2"; shift 2 ;; -h|--help) echo "usage: $0 [--configuration Debug|Release] [--output DIR]" - echo "optional env: VOICECAT_DOTNET, VOICECAT_CODESIGN_KEY, VOICECAT_CODESIGN_PROVISION" + echo "optional env: VOICECAT_DOTNET, VOICECAT_BUILD_NUMBER, VOICECAT_DISPLAY_VERSION, VOICECAT_CODESIGN_KEY, VOICECAT_CODESIGN_PROVISION" + echo "distribution extension env: VOICECAT_DEVELOPMENT_TEAM, VOICECAT_BROADCAST_CODESIGN_KEY, VOICECAT_BROADCAST_CODESIGN_PROVISION" exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac @@ -29,6 +30,7 @@ set -- build "$project" -c "$configuration" -r ios-arm64 --no-restore -m:1 \ -p:NuGetLockFilePath="$device_lock" if [ -n "${VOICECAT_CODESIGN_KEY:-}" ]; then set -- "$@" -p:CodesignKey="$VOICECAT_CODESIGN_KEY"; fi if [ -n "${VOICECAT_CODESIGN_PROVISION:-}" ]; then set -- "$@" -p:CodesignProvision="$VOICECAT_CODESIGN_PROVISION"; fi +if [ -n "${VOICECAT_BUILD_NUMBER:-}" ]; then set -- "$@" -p:ApplicationVersion="$VOICECAT_BUILD_NUMBER"; fi "$dotnet_host" "$@" app="$root/clients/apple/VoiceCat.iOS/bin/$configuration/net10.0-ios27.0/ios-arm64/VoiceCat.iOS.app" diff --git a/docs/apple-ios-release.md b/docs/apple-ios-release.md new file mode 100644 index 0000000..bc34b6f --- /dev/null +++ b/docs/apple-ios-release.md @@ -0,0 +1,247 @@ +# Releasing the iOS app to TestFlight + +VoiceCat's supported iOS client is the .NET 10 UIKit app in +`clients/apple/VoiceCat.iOS`. Its ReplayKit upload extension remains a separate Swift/Xcode +target in `native/apple/broadcast`. A release must sign the host and extension separately, with +matching versions and the shared App Group entitlement. + +This guide produces an App Store Connect distribution-signed IPA. Uploading it is a separate +step that performs Apple's server-side validation. + +## Stable identifiers + +Do not change these when replacing an earlier Swift build with the managed client: + +| Component | Identifier | +|---|---| +| Apple Developer team | `FJV8L966W4` | +| Host app | `me.iamtalon.voicecat` | +| ReplayKit extension | `me.iamtalon.voicecat.broadcast` | +| Shared App Group | `group.me.iamtalon.voicecat` | + +Provisioning-profile UUIDs are disposable and may change. TestFlight continuity depends on the +team, host bundle ID, marketing version, and a build number greater than every build already +uploaded for that marketing version. The profile UUID does not identify the App Store app. + +## Secrets and credentials + +An App Store Connect `.p8` API key is not a code-signing key and is not needed to build the IPA. +It is only an alternative way to authenticate automated uploads. Keep the `.p8`, Key ID, and +Issuer ID private. This workflow uses the distribution private key stored locally in the macOS +login Keychain and can upload through Transporter without exposing API credentials. + +## Prerequisites + +- The macOS, Xcode, .NET SDK, and iOS workload versions documented in `CLAUDE.md`. +- An Apple Distribution certificate with its private key in the login Keychain. +- Two current **App Store Connect** distribution provisioning profiles: one for the host and + one for the ReplayKit extension. +- Both App IDs associated with `group.me.iamtalon.voicecat` in Certificates, Identifiers & + Profiles. + +Create or inspect certificates in **Xcode > Settings > Accounts > Manage Certificates**. Verify +the resulting local identity from an ordinary Terminal session: + +```bash +security find-identity -v -p codesigning +``` + +The output must include an `Apple Distribution` identity for team `FJV8L966W4`. A certificate +visible in the developer portal is insufficient if its private key is not in the Keychain. + +## Create the provisioning profiles + +In [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/profiles/list), +create two profiles using **Distribution > App Store Connect**: + +1. `me.iamtalon.voicecat`, with a descriptive name such as `VoiceCatProfile`. +2. `me.iamtalon.voicecat.broadcast`, with a name such as `VoiceCatBroadcastProfile`. + +Select the current Apple Distribution certificate for both. Download the `.mobileprovision` +files and keep them in a known directory. Opening a profile in Xcode can fail silently when an +older Xcode-managed profile is cached, so verify the installed result rather than assuming the +double-click worked. + +Each profile must have: + +- the expected explicit application identifier; +- `group.me.iamtalon.voicecat` in `com.apple.security.application-groups`; +- `get-task-allow=false`; +- the same public-certificate fingerprint as the usable local distribution identity. + +If Xcode does not install a downloaded profile, decode it, read its UUID, and install it under +that UUID without deleting older profiles: + +```bash +profile="$HOME/Downloads/VoiceCatProfile.mobileprovision" +decoded="$(mktemp /tmp/voicecat-profile.XXXXXX.plist)" +openssl cms -verify -inform DER -in "$profile" -noverify -out "$decoded" +uuid="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$decoded")" +mkdir -p "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" +install -m 0644 "$profile" \ + "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/$uuid.mobileprovision" +rm "$decoded" +``` + +Repeat for the extension profile. Profile names passed to the build are their internal `Name` +values, not necessarily their filenames. + +## Build the distribution app + +Choose a monotonically increasing numeric build number. Keep the host and extension marketing +and build versions identical. + +```bash +export VOICECAT_BUILD_NUMBER=2026092101 +export VOICECAT_DISPLAY_VERSION=0.0.1 +export VOICECAT_DEVELOPMENT_TEAM=FJV8L966W4 + +export VOICECAT_CODESIGN_KEY='Apple Distribution: Your Name (FJV8L966W4)' +export VOICECAT_CODESIGN_PROVISION='VoiceCatProfile' + +export VOICECAT_BROADCAST_CODESIGN_KEY="$VOICECAT_CODESIGN_KEY" +export VOICECAT_BROADCAST_CODESIGN_PROVISION='VoiceCatBroadcastProfile' + +clients/apple/build-ios-device.sh \ + --configuration Release \ + --output dist/ios-appstore +``` + +The host is built by .NET and the extension by Xcode. The explicit extension variables prevent +Xcode from silently selecting a development certificate/profile during a Release build. + +The build output is `dist/ios-appstore/VoiceCat.iOS.app`. Release trimming can report the known +aggregate `IL2104` warning for Google.Protobuf; other warnings and all errors remain release +blockers. + +### Version metadata and incremental builds + +The host manifest deliberately omits `CFBundleShortVersionString` and `CFBundleVersion` so the +.NET `ApplicationDisplayVersion` and `ApplicationVersion` properties own those values. The +extension manifest expands Xcode's `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION`. + +After changing this version plumbing or either source manifest, stale iOS intermediates can +retain an earlier generated `Info.plist`. Clean the iOS project and rebuild if the packaged +values do not match the requested values: + +```bash +dotnet clean clients/apple/VoiceCat.iOS/VoiceCat.iOS.csproj \ + -c Release -r ios-arm64 \ + -p:NuGetLockFilePath=obj/packages.device.lock.json +``` + +Closing Finder windows that display the output directory can help if cleanup appears to linger. + +## Validate the app locally + +```bash +app=dist/ios-appstore/VoiceCat.iOS.app +extension="$app/PlugIns/VoiceCatBroadcast.appex" + +codesign --verify --deep --strict --verbose=4 "$app" +codesign -dvvv "$app" +codesign -dvvv "$extension" +codesign -d --entitlements :- "$app" +codesign -d --entitlements :- "$extension" + +/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \ + -c 'Print :CFBundleShortVersionString' \ + -c 'Print :CFBundleVersion' "$app/Info.plist" +/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \ + -c 'Print :CFBundleShortVersionString' \ + -c 'Print :CFBundleVersion' "$extension/Info.plist" + +lipo -archs "$app/VoiceCat.iOS" +lipo -archs "$extension/VoiceCatBroadcast" +``` + +Before packaging, confirm: + +- both authorities are the intended Apple Distribution identity; +- both team identifiers are `FJV8L966W4`; +- both entitlements contain the shared App Group; +- both have `get-task-allow=false` and `beta-reports-active=true`; +- the bundle identifiers are the stable identifiers above; +- both version pairs match; and +- both executables contain `arm64`. + +Also decode each embedded profile and verify its name, UUID, application identifier, and +`get-task-allow` value: + +```bash +for profile in "$app/embedded.mobileprovision" \ + "$extension/embedded.mobileprovision"; do + decoded="$(mktemp /tmp/voicecat-embedded.XXXXXX.plist)" + openssl cms -verify -inform DER -in "$profile" -noverify -out "$decoded" + /usr/libexec/PlistBuddy \ + -c 'Print :Name' \ + -c 'Print :UUID' \ + -c 'Print :Entitlements:application-identifier' \ + -c 'Print :Entitlements:get-task-allow' "$decoded" + rm "$decoded" +done +``` + +## Package the IPA + +An IPA is a ZIP archive whose top-level `Payload` directory contains the signed app. Copying the +already-signed bundle with `ditto` preserves extended attributes and nested signatures. + +```bash +package_root="$(mktemp -d /tmp/voicecat-ipa.XXXXXX)" +mkdir "$package_root/Payload" +ditto dist/ios-appstore/VoiceCat.iOS.app \ + "$package_root/Payload/VoiceCat.app" +ditto -c -k --sequesterRsrc --keepParent "$package_root/Payload" \ + "dist/VoiceCat-${VOICECAT_DISPLAY_VERSION}-${VOICECAT_BUILD_NUMBER}.ipa" +unzip -t "dist/VoiceCat-${VOICECAT_DISPLAY_VERSION}-${VOICECAT_BUILD_NUMBER}.ipa" +shasum -a 256 \ + "dist/VoiceCat-${VOICECAT_DISPLAY_VERSION}-${VOICECAT_BUILD_NUMBER}.ipa" +``` + +## Upload to TestFlight + +Open Apple's Transporter app, sign in, drag the IPA into it, and choose **Deliver**. Transporter +performs the server-side validation that local `codesign` cannot. Uploading is intentionally not +part of the build script because it changes external state and requires App Store Connect +authentication. + +After processing completes in App Store Connect, inspect any warnings, complete export-compliance +questions, assign the build to an internal testing group, and run the physical-device release +gates listed in `PROGRESS.md`. + +## Troubleshooting signatures + +### No signing identities match the profile + +The profile was probably generated for an older distribution certificate. Creating a new +certificate does not update existing profiles. Regenerate both App Store Connect profiles with +the new certificate, download them, verify their certificate fingerprints, and install them by +UUID. + +### `security find-identity` returns zero identities + +Keychain access can be hidden by a sandboxed shell. Retry from ordinary Terminal before changing +certificates. If Terminal also reports zero, confirm that the certificate has an associated +private key in Keychain Access; a portal certificate or cloud-managed certificate alone cannot +sign locally. + +### Automatic provisioning cannot refresh profiles + +Xcode command-line builds can report a missing `Xcode-Token` even when the GUI displays an Apple +account. Manual App Store Connect profiles avoid that dependency and make the two-target signing +inputs explicit. + +### App and extension versions differ + +Apple validates nested bundle metadata. Confirm the host does not hard-code version keys, the +extension uses the Xcode version placeholders, both release environment variables are set, and a +clean build regenerated the app manifests. + +## Last verified release build + +On 2026-09-21, version `0.0.1`, build `2026092101` was built with .NET 10.0.401 and Xcode 27.0. +The host and ReplayKit extension passed strict nested-signature validation with App Store Connect +profiles, matching distribution identities, matching versions, the shared App Group, and +`get-task-allow=false`. The resulting IPA was packaged locally; Apple server-side upload +validation remains a separate gate. diff --git a/native/apple/broadcast/Info.plist b/native/apple/broadcast/Info.plist index 7df96ab..caa4129 100644 --- a/native/apple/broadcast/Info.plist +++ b/native/apple/broadcast/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 0.0.1 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionPointIdentifier diff --git a/native/apple/broadcast/VoiceCatBroadcast.entitlements b/native/apple/broadcast/VoiceCatBroadcast.entitlements index 0c67376..dc41a71 100644 --- a/native/apple/broadcast/VoiceCatBroadcast.entitlements +++ b/native/apple/broadcast/VoiceCatBroadcast.entitlements @@ -1,5 +1,10 @@ - + + com.apple.security.application-groups + + group.me.iamtalon.voicecat + + diff --git a/tests/VoiceCat.Tests/PublishServerScriptTests.cs b/tests/VoiceCat.Tests/PublishServerScriptTests.cs index 87992ba..92be2e9 100644 --- a/tests/VoiceCat.Tests/PublishServerScriptTests.cs +++ b/tests/VoiceCat.Tests/PublishServerScriptTests.cs @@ -53,6 +53,34 @@ public class PublishServerScriptTests Assert.DoesNotContain("false", project); } + [Fact] + public async Task IosBroadcastExtensionPreservesSharedAppGroupAndSupportsDistributionSigning() + { + string root = FindRoot(); + string entitlements = await File.ReadAllTextAsync(Path.Combine( + root, "native", "apple", "broadcast", "VoiceCatBroadcast.entitlements")); + string script = await File.ReadAllTextAsync(Path.Combine( + root, "clients", "apple", "VoiceCat.iOS", "build-broadcast-extension.sh")); + + Assert.Contains("com.apple.security.application-groups", entitlements); + Assert.Contains("group.me.iamtalon.voicecat", entitlements); + Assert.Contains("CODE_SIGN_STYLE=Manual", script); + Assert.Contains("PROVISIONING_PROFILE_SPECIFIER=\"$VOICECAT_BROADCAST_CODESIGN_PROVISION\"", script); + Assert.Contains("CURRENT_PROJECT_VERSION=\"$VOICECAT_BUILD_NUMBER\"", script); + Assert.Contains("MARKETING_VERSION=\"${VOICECAT_DISPLAY_VERSION:-0.0.1}\"", script); + + string deviceScript = await File.ReadAllTextAsync(Path.Combine( + root, "clients", "apple", "build-ios-device.sh")); + Assert.Contains("-p:ApplicationVersion=\"$VOICECAT_BUILD_NUMBER\"", deviceScript); + + string appManifest = await File.ReadAllTextAsync(Path.Combine( + root, "clients", "apple", "VoiceCat.iOS", "Info.plist")); + string extensionManifest = await File.ReadAllTextAsync(Path.Combine( + root, "native", "apple", "broadcast", "Info.plist")); + Assert.DoesNotContain("CFBundleVersion", appManifest); + Assert.Contains("$(CURRENT_PROJECT_VERSION)", extensionManifest); + } + [Fact] public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne() {