mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat: harden ios app store push release mode
This commit is contained in:
parent
606706492f
commit
d4f666874f
18 changed files with 648 additions and 185 deletions
|
|
@ -67,8 +67,9 @@ Release behavior:
|
|||
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
|
||||
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
|
||||
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, `OpenClawPushRelayProfile=production`, `OpenClawPushProofPolicy=appleStrict`, and the App-Attest-capable entitlement file.
|
||||
- App Store release also switches the app to `OpenClawPushMode=appStore`, which derives relay transport, official distribution, the canonical production relay, production APNs, production relay profile, `appleStrict` proof, and the App-Attest-capable entitlement file.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
|
||||
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
|
||||
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
|
||||
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
|
|
@ -83,9 +84,8 @@ Release behavior:
|
|||
|
||||
Relay behavior for App Store builds:
|
||||
|
||||
- Release builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
- App Store release builds use the canonical hosted relay at `https://ios-push-relay.openclaw.ai`.
|
||||
- App Store release builds reject custom relay URL overrides. Future self-hosted relay support should use a separate explicit release path, not the public App Store build lane.
|
||||
|
||||
Signing setup commands:
|
||||
|
||||
|
|
@ -162,25 +162,19 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
|
|||
|
||||
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
|
||||
|
||||
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
6. Upload the build:
|
||||
5. Upload the build:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
7. Expected behavior:
|
||||
6. Expected behavior:
|
||||
- Fastlane reads `apps/ios/version.json`
|
||||
- verifies synced iOS versioning artifacts
|
||||
- resolves the next App Store Connect build number for that short version
|
||||
|
|
@ -188,15 +182,16 @@ pnpm ios:release:upload
|
|||
- uploads release notes and screenshots to the editable App Store version
|
||||
- generates `apps/ios/build/AppStoreRelease.xcconfig`
|
||||
- archives `OpenClaw`
|
||||
- validates the exported IPA's push mode, signed entitlements, and embedded App Store profile
|
||||
- uploads the IPA to App Store Connect for TestFlight/App Review use
|
||||
- leaves App Review submission for a maintainer to complete manually
|
||||
|
||||
8. Expected outputs after a successful run:
|
||||
7. Expected outputs after a successful run:
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
|
||||
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
|
||||
## iOS Versioning Workflow
|
||||
|
||||
|
|
@ -246,13 +241,13 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
|||
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
|
||||
- App Attest relay builds use `apps/ios/Sources/OpenClawAppAttest.entitlements`; local/direct builds do not require App Attest provisioning.
|
||||
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
|
||||
- Local/manual Debug builds default to `OpenClawPushMode=localSandbox`, direct APNs registration, and a development `aps-environment` entitlement. Local/manual Release builds default to `OpenClawPushMode=localProduction` and direct production APNs registration.
|
||||
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
||||
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
||||
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
|
||||
- Recommended gateway-host storage for the APNs `.p8` file is `~/.openclaw/credentials/apns/AuthKey_<KEYID>.p8` with restrictive permissions, then point `OPENCLAW_APNS_PRIVATE_KEY_PATH` at that file.
|
||||
- `apps/ios/fastlane/.env` only covers App Store Connect / Fastlane auth; it does not provide gateway APNs credentials for local direct-push testing.
|
||||
- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
|
||||
- Debug builds default to sandbox APNs through `OpenClawPushMode=localSandbox`; Release builds default to production APNs through `OpenClawPushMode=localProduction`.
|
||||
|
||||
## APNs Expectations For Official Builds
|
||||
|
||||
|
|
@ -261,7 +256,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
|||
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
|
||||
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
|
||||
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
|
||||
- Production relay mode uses the `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
|
||||
- App Store release mode uses the internal `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
|
||||
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
|
||||
|
||||
## Official Build Relay Trust Model
|
||||
|
|
|
|||
|
|
@ -82,18 +82,10 @@
|
|||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
<key>OpenClawCanonicalVersion</key>
|
||||
<string>$(OPENCLAW_IOS_VERSION)</string>
|
||||
<key>OpenClawPushAPNsEnvironment</key>
|
||||
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
|
||||
<key>OpenClawPushDistribution</key>
|
||||
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
|
||||
<key>OpenClawPushProofPolicy</key>
|
||||
<string>$(OPENCLAW_PUSH_PROOF_POLICY)</string>
|
||||
<key>OpenClawPushMode</key>
|
||||
<string>$(OPENCLAW_PUSH_MODE)</string>
|
||||
<key>OpenClawPushRelayBaseURL</key>
|
||||
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
|
||||
<key>OpenClawPushRelayProfile</key>
|
||||
<string>$(OPENCLAW_PUSH_RELAY_PROFILE)</string>
|
||||
<key>OpenClawPushTransport</key>
|
||||
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,16 @@ enum PushProofPolicy: String {
|
|||
case internalSimulator
|
||||
}
|
||||
|
||||
enum PushBuildMode: String {
|
||||
case localSandbox
|
||||
case localProduction
|
||||
case appStore
|
||||
case deviceSandbox
|
||||
case simulatorSandbox
|
||||
}
|
||||
|
||||
struct PushBuildConfig {
|
||||
let mode: PushBuildMode
|
||||
let transport: PushTransportMode
|
||||
let distribution: PushDistributionMode
|
||||
let relayBaseURL: URL?
|
||||
|
|
@ -54,31 +63,64 @@ struct PushBuildConfig {
|
|||
}
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
self.transport = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushTransport",
|
||||
fallback: .direct)
|
||||
self.distribution = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushDistribution",
|
||||
fallback: .local)
|
||||
self.apnsEnvironment = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushAPNsEnvironment",
|
||||
fallback: Self.defaultAPNsEnvironment)
|
||||
self.relayProfile = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushRelayProfile",
|
||||
fallback: Self.defaultRelayProfile(apnsEnvironment: self.apnsEnvironment))
|
||||
self.proofPolicy = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushProofPolicy",
|
||||
fallback: Self.defaultProofPolicy(relayProfile: self.relayProfile))
|
||||
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
|
||||
self.init(readValue: { bundle.object(forInfoDictionaryKey: $0) })
|
||||
}
|
||||
|
||||
private static func readURL(bundle: Bundle, key: String) -> URL? {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
|
||||
init(infoDictionary: [String: Any]) {
|
||||
self.init(readValue: { infoDictionary[$0] })
|
||||
}
|
||||
|
||||
private init(readValue: (String) -> Any?) {
|
||||
self.mode = Self.readEnum(
|
||||
readValue: readValue,
|
||||
key: "OpenClawPushMode",
|
||||
fallback: .localSandbox)
|
||||
let relayBaseURLOverride = Self.readURL(
|
||||
readValue: readValue,
|
||||
key: "OpenClawPushRelayBaseURL")
|
||||
switch self.mode {
|
||||
case .localSandbox:
|
||||
self.transport = .direct
|
||||
self.distribution = .local
|
||||
self.relayBaseURL = nil
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .deviceSandbox
|
||||
self.proofPolicy = .appleDevelopment
|
||||
case .localProduction:
|
||||
self.transport = .direct
|
||||
self.distribution = .local
|
||||
self.relayBaseURL = nil
|
||||
self.apnsEnvironment = .production
|
||||
self.relayProfile = .production
|
||||
self.proofPolicy = .appleStrict
|
||||
case .appStore:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = URL(string: "https://\(Self.openClawHostedRelayHost)")!
|
||||
self.apnsEnvironment = .production
|
||||
self.relayProfile = .production
|
||||
self.proofPolicy = .appleStrict
|
||||
case .deviceSandbox:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = relayBaseURLOverride
|
||||
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .deviceSandbox
|
||||
self.proofPolicy = .appleDevelopment
|
||||
case .simulatorSandbox:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = relayBaseURLOverride
|
||||
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .simulatorSandbox
|
||||
self.proofPolicy = .internalSimulator
|
||||
}
|
||||
}
|
||||
|
||||
private static func readURL(readValue: (String) -> Any?, key: String) -> URL? {
|
||||
guard let raw = readValue(key) as? String else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard let components = URLComponents(string: trimmed),
|
||||
|
|
@ -96,29 +138,12 @@ struct PushBuildConfig {
|
|||
}
|
||||
|
||||
private static func readEnum<T: RawRepresentable>(
|
||||
bundle: Bundle,
|
||||
readValue: (String) -> Any?,
|
||||
key: String,
|
||||
fallback: T)
|
||||
-> T where T.RawValue == String {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
|
||||
guard let raw = readValue(key) as? String else { return fallback }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
|
||||
}
|
||||
|
||||
private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
|
||||
|
||||
private static func defaultRelayProfile(apnsEnvironment: PushAPNsEnvironment) -> PushRelayProfile {
|
||||
apnsEnvironment == .production ? .production : .deviceSandbox
|
||||
}
|
||||
|
||||
private static func defaultProofPolicy(relayProfile: PushRelayProfile) -> PushProofPolicy {
|
||||
switch relayProfile {
|
||||
case .production:
|
||||
.appleStrict
|
||||
case .deviceSandbox:
|
||||
.appleDevelopment
|
||||
case .simulatorSandbox:
|
||||
.internalSimulator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ actor PushRegistrationManager {
|
|||
async throws -> String {
|
||||
guard self.buildConfig.distribution == .official else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"Relay transport requires OpenClawPushDistribution=official")
|
||||
"Relay transport requires an official push build mode")
|
||||
}
|
||||
try Self.validateRelayContract(
|
||||
relayProfile: self.buildConfig.relayProfile,
|
||||
|
|
|
|||
50
apps/ios/Tests/PushBuildConfigTests.swift
Normal file
50
apps/ios/Tests/PushBuildConfigTests.swift
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct PushBuildConfigTests {
|
||||
@Test func `app store mode derives production relay contract`() {
|
||||
let config = PushBuildConfig(infoDictionary: [
|
||||
"OpenClawPushMode": "appStore",
|
||||
"OpenClawPushRelayBaseURL": "https://wrong.example.com",
|
||||
])
|
||||
|
||||
#expect(config.mode == .appStore)
|
||||
#expect(config.transport == .relay)
|
||||
#expect(config.distribution == .official)
|
||||
#expect(config.relayBaseURL?.absoluteString == "https://ios-push-relay.openclaw.ai")
|
||||
#expect(config.apnsEnvironment == .production)
|
||||
#expect(config.relayProfile == .production)
|
||||
#expect(config.proofPolicy == .appleStrict)
|
||||
}
|
||||
|
||||
@Test func `simulator sandbox mode derives internal proof contract`() {
|
||||
let config = PushBuildConfig(infoDictionary: [
|
||||
"OpenClawPushMode": "simulatorSandbox",
|
||||
"OpenClawPushRelayBaseURL": "https://staging-relay.example.com",
|
||||
])
|
||||
|
||||
#expect(config.mode == .simulatorSandbox)
|
||||
#expect(config.transport == .relay)
|
||||
#expect(config.distribution == .official)
|
||||
#expect(config.relayBaseURL?.absoluteString == "https://staging-relay.example.com")
|
||||
#expect(config.apnsEnvironment == .sandbox)
|
||||
#expect(config.relayProfile == .simulatorSandbox)
|
||||
#expect(config.proofPolicy == .internalSimulator)
|
||||
}
|
||||
|
||||
@Test func `local release mode remains direct production push`() {
|
||||
let config = PushBuildConfig(infoDictionary: [
|
||||
"OpenClawPushMode": "localProduction",
|
||||
"OpenClawPushRelayBaseURL": "https://ios-push-relay.openclaw.ai",
|
||||
])
|
||||
|
||||
#expect(config.mode == .localProduction)
|
||||
#expect(config.transport == .direct)
|
||||
#expect(config.distribution == .local)
|
||||
#expect(config.relayBaseURL == nil)
|
||||
#expect(config.apnsEnvironment == .production)
|
||||
#expect(config.relayProfile == .production)
|
||||
#expect(config.proofPolicy == .appleStrict)
|
||||
}
|
||||
}
|
||||
|
|
@ -754,6 +754,11 @@ def prepare_app_store_release!(version:, build_number:)
|
|||
release_xcconfig
|
||||
end
|
||||
|
||||
def validate_app_store_ipa!(ipa_path)
|
||||
script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
|
||||
sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
|
||||
end
|
||||
|
||||
def build_app_store_release(context)
|
||||
version = context[:version]
|
||||
project_path = File.join(ios_root, "OpenClaw.xcodeproj")
|
||||
|
|
@ -804,6 +809,7 @@ def build_app_store_release(context)
|
|||
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
|
||||
exported_ipa = exported_ipas.first
|
||||
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
|
||||
validate_app_store_ipa!(expected_ipa_path)
|
||||
|
||||
{
|
||||
archive_path: archive_path,
|
||||
|
|
|
|||
|
|
@ -144,13 +144,7 @@ fastlane ios auth_check
|
|||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
5. Set the official relay URL before release:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
6. Upload:
|
||||
5. Upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
|
|
@ -159,6 +153,7 @@ pnpm ios:release:upload
|
|||
Quick verification after upload:
|
||||
|
||||
- confirm `apps/ios/build/app-store/OpenClaw-<version>.ipa` exists
|
||||
- confirm Fastlane validates the exported IPA before upload
|
||||
- confirm Fastlane prints `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
- remember that App Store Connect/TestFlight processing can take a few minutes after the upload succeeds
|
||||
|
||||
|
|
@ -175,5 +170,7 @@ Versioning rules:
|
|||
- `pnpm ios:version:check` validates that checked-in iOS version artifacts are in sync
|
||||
- The release flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
|
||||
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
|
||||
- App Store release uses `OpenClawPushMode=appStore`, which derives the canonical production hosted relay, production APNs, production relay profile, and `appleStrict` proof. The release lane rejects custom production relay URL overrides.
|
||||
- The exported IPA is validated before upload by inspecting its push mode, signed entitlements, and embedded App Store profile.
|
||||
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
|
||||
- See `apps/ios/VERSIONING.md` for the detailed workflow
|
||||
|
|
|
|||
|
|
@ -122,21 +122,13 @@ targets:
|
|||
Debug:
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_MODE: localSandbox
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
|
||||
OPENCLAW_PUSH_RELAY_PROFILE: deviceSandbox
|
||||
OPENCLAW_PUSH_PROOF_POLICY: appleDevelopment
|
||||
Release:
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_MODE: localProduction
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_RELAY_PROFILE: production
|
||||
OPENCLAW_PUSH_PROOF_POLICY: appleStrict
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
|
|
@ -178,12 +170,8 @@ targets:
|
|||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for talk mode and voice wake.
|
||||
NSSupportsLiveActivities: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)"
|
||||
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
|
||||
OpenClawPushMode: "$(OPENCLAW_PUSH_MODE)"
|
||||
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
|
||||
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
|
||||
OpenClawPushRelayProfile: "$(OPENCLAW_PUSH_RELAY_PROFILE)"
|
||||
OpenClawPushProofPolicy: "$(OPENCLAW_PUSH_PROOF_POLICY)"
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
|
|
|
|||
|
|
@ -602,7 +602,7 @@ See [Inferred commitments](/concepts/commitments).
|
|||
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `wss://` for public hosts; plaintext `ws://` is accepted only for loopback, LAN, link-local, `.local`, `.ts.net`, and Tailscale CGNAT hosts.
|
||||
- `remote.remotePort`: gateway port on the remote SSH host. Defaults to `18789`; use this when the local tunnel port differs from the remote gateway port.
|
||||
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
|
||||
- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used by official/TestFlight iOS builds after they publish relay-backed registrations to the gateway. This URL must match the relay URL compiled into the iOS build.
|
||||
- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used after relay-backed iOS builds publish registrations to the gateway. Public App Store/TestFlight builds use the hosted OpenClaw relay. Custom relay URLs must match a deliberately separate iOS build/deployment path whose relay URL points at that relay.
|
||||
- `gateway.push.apns.relay.timeoutMs`: gateway-to-relay send timeout in milliseconds. Defaults to `10000`.
|
||||
- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration.
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.
|
||||
|
|
|
|||
|
|
@ -337,9 +337,9 @@ candidate contains redacted secret placeholders such as `***`.
|
|||
</Accordion>
|
||||
|
||||
<Accordion title="Enable relay-backed push for official iOS builds">
|
||||
Relay-backed push uses the hosted OpenClaw relay by default: `https://ios-push-relay.openclaw.ai`.
|
||||
Relay-backed push for public App Store/TestFlight builds uses the hosted OpenClaw relay: `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
To use a custom relay, set this in gateway config:
|
||||
Custom relay deployments require a deliberately separate iOS build/deployment path whose relay URL matches the gateway relay URL. If you are using a custom relay build, set this in gateway config:
|
||||
|
||||
```json5
|
||||
{
|
||||
|
|
@ -369,12 +369,12 @@ candidate contains redacted secret placeholders such as `***`.
|
|||
- Uses a registration-scoped send grant forwarded by the paired iOS app. The gateway does not need a deployment-wide relay token.
|
||||
- Binds each relay-backed registration to the gateway identity that the iOS app paired with, so another gateway cannot reuse the stored registration.
|
||||
- Keeps local/manual iOS builds on direct APNs. Relay-backed sends apply only to official distributed builds that registered through the relay.
|
||||
- Must match the relay base URL baked into the official/TestFlight iOS build, so registration and send traffic reach the same relay deployment.
|
||||
- Must match the relay base URL baked into the iOS build, so registration and send traffic reach the same relay deployment.
|
||||
|
||||
End-to-end flow:
|
||||
|
||||
1. Install an official/TestFlight iOS build.
|
||||
2. Optional: configure `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment.
|
||||
2. Optional: configure `gateway.push.apns.relay.baseUrl` on the gateway only when using a deliberately separate custom relay build.
|
||||
3. Pair the iOS app to the gateway and let both node and operator sessions connect.
|
||||
4. The iOS app fetches the gateway identity, registers with the relay using App Attest plus the app receipt, and then publishes the relay-backed `push.apns.register` payload to the paired gateway.
|
||||
5. The gateway stores the relay handle and send grant, then uses them for `push.test`, wake nudges, and reconnect wakes.
|
||||
|
|
@ -387,7 +387,7 @@ candidate contains redacted secret placeholders such as `***`.
|
|||
Compatibility note:
|
||||
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides.
|
||||
- Custom gateway relay URLs must match the relay base URL baked into the official/TestFlight iOS build.
|
||||
- Custom gateway relay URLs must match the relay base URL baked into the iOS build. The public App Store release lane rejects custom iOS relay URL overrides.
|
||||
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config.
|
||||
|
||||
See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow and [Authentication and trust flow](/platforms/ios#authentication-and-trust-flow) for the relay security model.
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ openclaw gateway call node.list --params "{}"
|
|||
Official distributed iOS builds use the external push relay instead of publishing the raw APNs
|
||||
token to the gateway.
|
||||
|
||||
By default, official/TestFlight builds and gateways use the hosted relay at `https://ios-push-relay.openclaw.ai`.
|
||||
Official/TestFlight builds from the public App Store release lane use the hosted relay at `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
Custom relay deployments can override the gateway relay URL:
|
||||
Custom relay deployments require a deliberately separate iOS build/deployment path whose relay URL matches the gateway relay URL. The public App Store release lane does not accept custom relay URL overrides. If you are using a custom relay build, set the matching gateway relay URL:
|
||||
|
||||
```json5
|
||||
{
|
||||
|
|
@ -100,7 +100,7 @@ How the flow works:
|
|||
- The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway.
|
||||
- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`.
|
||||
- The gateway uses that stored relay handle for `push.test`, background wakes, and wake nudges.
|
||||
- Custom gateway relay URLs must match the relay URL baked into the official/TestFlight iOS build.
|
||||
- Custom gateway relay URLs must match the relay URL baked into the iOS build.
|
||||
- If the app later connects to a different gateway or a build with a different relay base URL, it refreshes the relay registration instead of reusing the old binding.
|
||||
|
||||
What the gateway does **not** need for this path:
|
||||
|
|
@ -111,7 +111,7 @@ What the gateway does **not** need for this path:
|
|||
Expected operator flow:
|
||||
|
||||
1. Install the official/TestFlight iOS build.
|
||||
2. Optional: set `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment.
|
||||
2. Optional: set `gateway.push.apns.relay.baseUrl` on the gateway only when using a deliberately separate custom relay build.
|
||||
3. Pair the app to the gateway and let it finish connecting.
|
||||
4. The app publishes `push.apns.register` automatically after it has an APNs token, the operator session is connected, and relay registration succeeds.
|
||||
5. After that, `push.test`, reconnect wakes, and wake nudges can use the stored relay-backed registration.
|
||||
|
|
@ -130,7 +130,7 @@ compatible but does not count as a durable last-seen update.
|
|||
Compatibility note:
|
||||
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway.
|
||||
- `OPENCLAW_PUSH_RELAY_BASE_URL` still works as a temporary env override for official/TestFlight iOS builds.
|
||||
- The public App Store release lane rejects `OPENCLAW_PUSH_RELAY_BASE_URL` for iOS builds.
|
||||
|
||||
## Authentication and trust flow
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@ usage() {
|
|||
Usage:
|
||||
scripts/ios-release-prepare.sh --build-number 7 [--team-id TEAMID]
|
||||
|
||||
Optional custom relay:
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com \
|
||||
scripts/ios-release-prepare.sh --build-number 7 [--team-id TEAMID]
|
||||
|
||||
Prepares local App Store release inputs without touching local signing overrides:
|
||||
- reads apps/ios/version.json and writes apps/ios/build/Version.xcconfig
|
||||
- writes apps/ios/build/AppStoreRelease.xcconfig with canonical bundle IDs
|
||||
|
|
@ -32,9 +28,6 @@ CANONICAL_TEAM_ID="FWJYW4S8P8"
|
|||
|
||||
BUILD_NUMBER=""
|
||||
TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}"
|
||||
DEFAULT_IOS_PUSH_RELAY_BASE_URL="https://ios-push-relay.openclaw.ai"
|
||||
PUSH_RELAY_BASE_URL="${OPENCLAW_PUSH_RELAY_BASE_URL:-${IOS_PUSH_RELAY_BASE_URL:-${DEFAULT_IOS_PUSH_RELAY_BASE_URL}}}"
|
||||
PUSH_RELAY_BASE_URL_XCCONFIG=""
|
||||
IOS_VERSION=""
|
||||
RELEASE_SIGNING_XCCONFIG=""
|
||||
|
||||
|
|
@ -61,31 +54,6 @@ write_generated_file() {
|
|||
mv -f "${tmp_file}" "${output_path}"
|
||||
}
|
||||
|
||||
validate_push_relay_base_url() {
|
||||
local value="$1"
|
||||
|
||||
if [[ "${value}" =~ [[:space:]] ]]; then
|
||||
echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: whitespace is not allowed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${value}" == *'$'* || "${value}" == *'('* || "${value}" == *')'* || "${value}" == *'='* ]]; then
|
||||
echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: contains forbidden xcconfig characters." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "${value}" =~ ^https://[A-Za-z0-9.-]+(:([0-9]{1,5}))?(/[A-Za-z0-9._~!&*+,;:@%/-]*)?$ ]]; then
|
||||
echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: expected https://host[:port][/path]." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local port="${BASH_REMATCH[2]:-}"
|
||||
if [[ -n "${port}" ]] && (( 10#${port} > 65535 )); then
|
||||
echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: port must be between 1 and 65535." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_option_value() {
|
||||
local option="$1"
|
||||
local value="${2-}"
|
||||
|
|
@ -144,14 +112,10 @@ if [[ "${TEAM_ID}" != "${CANONICAL_TEAM_ID}" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
validate_push_relay_base_url "${PUSH_RELAY_BASE_URL}"
|
||||
|
||||
# `.xcconfig` treats `//` as a comment opener. Break the URL with a helper setting
|
||||
# so Xcode still resolves it back to `https://...` at build time.
|
||||
PUSH_RELAY_BASE_URL_XCCONFIG="$(
|
||||
printf '%s' "${PUSH_RELAY_BASE_URL}" \
|
||||
| sed 's#//#$(OPENCLAW_URL_SLASH)$(OPENCLAW_URL_SLASH)#g'
|
||||
)"
|
||||
if [[ -n "${OPENCLAW_PUSH_RELAY_BASE_URL:-}" || -n "${IOS_PUSH_RELAY_BASE_URL:-}" ]]; then
|
||||
echo "iOS App Store release uses the canonical hosted push relay; custom relay URL overrides are not allowed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
prepare_build_dir
|
||||
|
||||
|
|
@ -188,13 +152,8 @@ OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
|||
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClawAppAttest.entitlements
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = production
|
||||
OPENCLAW_APP_ATTEST_ENVIRONMENT = production
|
||||
OPENCLAW_PUSH_TRANSPORT = relay
|
||||
OPENCLAW_PUSH_DISTRIBUTION = official
|
||||
OPENCLAW_URL_SLASH = /
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL = ${PUSH_RELAY_BASE_URL_XCCONFIG}
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT = production
|
||||
OPENCLAW_PUSH_RELAY_PROFILE = production
|
||||
OPENCLAW_PUSH_PROOF_POLICY = appleStrict
|
||||
OPENCLAW_PUSH_MODE = appStore
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL =
|
||||
EOF
|
||||
|
||||
(
|
||||
|
|
|
|||
|
|
@ -97,12 +97,8 @@ if [[ "${push_sandbox_simulator}" == "1" ]]; then
|
|||
fi
|
||||
|
||||
xcodebuild_overrides+=(
|
||||
"OPENCLAW_PUSH_TRANSPORT=relay"
|
||||
"OPENCLAW_PUSH_DISTRIBUTION=official"
|
||||
"OPENCLAW_PUSH_MODE=simulatorSandbox"
|
||||
"OPENCLAW_PUSH_RELAY_BASE_URL=${push_relay_base_url}"
|
||||
"OPENCLAW_PUSH_APNS_ENVIRONMENT=sandbox"
|
||||
"OPENCLAW_PUSH_RELAY_PROFILE=simulatorSandbox"
|
||||
"OPENCLAW_PUSH_PROOF_POLICY=internalSimulator"
|
||||
"OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT=development"
|
||||
)
|
||||
fi
|
||||
|
|
|
|||
195
scripts/ios-validate-app-store-ipa.sh
Executable file
195
scripts/ios-validate-app-store-ipa.sh
Executable file
|
|
@ -0,0 +1,195 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/ios-validate-app-store-ipa.sh --ipa apps/ios/build/app-store/OpenClaw-<version>.ipa
|
||||
|
||||
Validates the exported iOS App Store IPA before App Store Connect upload.
|
||||
EOF
|
||||
}
|
||||
|
||||
IPA_PATH=""
|
||||
EXPECTED_TEAM_ID="FWJYW4S8P8"
|
||||
EXPECTED_BUNDLE_ID="ai.openclawfoundation.app"
|
||||
EXPECTED_PROFILE_NAME="OpenClaw App Store ai.openclawfoundation.app"
|
||||
EXPECTED_APP_GROUP="group.ai.openclawfoundation.app.shared"
|
||||
EXPECTED_PUSH_MODE="appStore"
|
||||
|
||||
PLIST_BUDDY_BIN="${IOS_VALIDATE_PLIST_BUDDY_BIN:-/usr/libexec/PlistBuddy}"
|
||||
CODESIGN_BIN="${IOS_VALIDATE_CODESIGN_BIN:-codesign}"
|
||||
SECURITY_BIN="${IOS_VALIDATE_SECURITY_BIN:-security}"
|
||||
UNZIP_BIN="${IOS_VALIDATE_UNZIP_BIN:-unzip}"
|
||||
|
||||
require_option_value() {
|
||||
local option="$1"
|
||||
local value="${2-}"
|
||||
|
||||
if [[ -z "${value}" || "${value}" == --* ]]; then
|
||||
echo "Missing value for ${option}." >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ipa)
|
||||
require_option_value "$1" "${2-}"
|
||||
IPA_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${IPA_PATH}" ]]; then
|
||||
echo "Missing required --ipa." >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${IPA_PATH}" ]]; then
|
||||
echo "IPA not found: ${IPA_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d -t openclaw-ios-ipa.XXXXXX)"
|
||||
trap 'rm -rf "${tmp_dir}"' EXIT
|
||||
|
||||
"${UNZIP_BIN}" -q "${IPA_PATH}" -d "${tmp_dir}"
|
||||
|
||||
payload_dir="${tmp_dir}/Payload"
|
||||
if [[ ! -d "${payload_dir}" ]]; then
|
||||
echo "Invalid IPA: missing Payload directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
app_paths=()
|
||||
while IFS= read -r app_bundle; do
|
||||
app_paths+=("${app_bundle}")
|
||||
done < <(find "${payload_dir}" -maxdepth 1 -type d -name "*.app" | sort)
|
||||
if [[ "${#app_paths[@]}" -ne 1 ]]; then
|
||||
echo "Invalid IPA: expected exactly one app bundle in Payload, found ${#app_paths[@]}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
app_path="${app_paths[0]}"
|
||||
info_plist="${app_path}/Info.plist"
|
||||
embedded_profile="${app_path}/embedded.mobileprovision"
|
||||
entitlements_plist="${tmp_dir}/entitlements.plist"
|
||||
profile_plist="${tmp_dir}/profile.plist"
|
||||
|
||||
if [[ ! -f "${info_plist}" ]]; then
|
||||
echo "Invalid IPA: missing app Info.plist." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "${embedded_profile}" ]]; then
|
||||
echo "Invalid IPA: missing embedded.mobileprovision." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plist_value() {
|
||||
local plist="$1"
|
||||
local key_path="$2"
|
||||
"${PLIST_BUDDY_BIN}" -c "Print:${key_path}" "${plist}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
plist_has_key() {
|
||||
local plist="$1"
|
||||
local key_path="$2"
|
||||
"${PLIST_BUDDY_BIN}" -c "Print:${key_path}" "${plist}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
assert_plist_string() {
|
||||
local plist="$1"
|
||||
local key_path="$2"
|
||||
local expected="$3"
|
||||
local label="$4"
|
||||
local actual
|
||||
actual="$(plist_value "${plist}" "${key_path}")"
|
||||
if [[ "${actual}" != "${expected}" ]]; then
|
||||
echo "Invalid IPA: ${label}; expected ${expected}, got ${actual:-missing}." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_plist_key_absent() {
|
||||
local plist="$1"
|
||||
local key_path="$2"
|
||||
local label="$3"
|
||||
if plist_has_key "${plist}" "${key_path}"; then
|
||||
echo "Invalid IPA: ${label} must not be present in App Store builds." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_plist_array_contains() {
|
||||
local plist="$1"
|
||||
local key_path="$2"
|
||||
local expected="$3"
|
||||
local label="$4"
|
||||
local raw
|
||||
raw="$(plist_value "${plist}" "${key_path}")"
|
||||
if ! printf '%s\n' "${raw}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -Fxq "${expected}"; then
|
||||
echo "Invalid IPA: ${label}; expected ${expected} in ${key_path}." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_plist_empty_or_absent() {
|
||||
local plist="$1"
|
||||
local key_path="$2"
|
||||
local label="$3"
|
||||
local actual
|
||||
actual="$(plist_value "${plist}" "${key_path}")"
|
||||
if [[ -n "${actual}" ]]; then
|
||||
echo "Invalid IPA: ${label} must be empty for App Store builds; got ${actual}." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_plist_string "${info_plist}" "CFBundleIdentifier" "${EXPECTED_BUNDLE_ID}" "bundle identifier mismatch"
|
||||
assert_plist_string "${info_plist}" "OpenClawPushMode" "${EXPECTED_PUSH_MODE}" "push mode mismatch"
|
||||
assert_plist_empty_or_absent "${info_plist}" "OpenClawPushRelayBaseURL" "push relay URL override"
|
||||
assert_plist_key_absent "${info_plist}" "OpenClawPushTransport" "legacy push transport"
|
||||
assert_plist_key_absent "${info_plist}" "OpenClawPushDistribution" "legacy push distribution"
|
||||
assert_plist_key_absent "${info_plist}" "OpenClawPushAPNsEnvironment" "legacy APNs environment"
|
||||
assert_plist_key_absent "${info_plist}" "OpenClawPushRelayProfile" "legacy relay profile"
|
||||
assert_plist_key_absent "${info_plist}" "OpenClawPushProofPolicy" "legacy proof policy"
|
||||
|
||||
if ! "${CODESIGN_BIN}" -d --entitlements :- "${app_path}" >"${entitlements_plist}" 2>"${tmp_dir}/codesign.err"; then
|
||||
detail="$(<"${tmp_dir}/codesign.err")"
|
||||
echo "Invalid IPA: failed to read signed entitlements${detail:+: ${detail}}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
assert_plist_string "${entitlements_plist}" "application-identifier" "${EXPECTED_TEAM_ID}.${EXPECTED_BUNDLE_ID}" "signed application identifier mismatch"
|
||||
assert_plist_string "${entitlements_plist}" "com.apple.developer.team-identifier" "${EXPECTED_TEAM_ID}" "signed team identifier mismatch"
|
||||
assert_plist_string "${entitlements_plist}" "aps-environment" "production" "signed APNs entitlement mismatch"
|
||||
assert_plist_string "${entitlements_plist}" "com.apple.developer.devicecheck.appattest-environment" "production" "signed App Attest entitlement mismatch"
|
||||
assert_plist_array_contains "${entitlements_plist}" "com.apple.security.application-groups" "${EXPECTED_APP_GROUP}" "signed App Group entitlement mismatch"
|
||||
|
||||
if ! "${SECURITY_BIN}" cms -D -i "${embedded_profile}" >"${profile_plist}" 2>"${tmp_dir}/security.err"; then
|
||||
detail="$(<"${tmp_dir}/security.err")"
|
||||
echo "Invalid IPA: failed to decode embedded provisioning profile${detail:+: ${detail}}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
assert_plist_string "${profile_plist}" "Name" "${EXPECTED_PROFILE_NAME}" "embedded profile name mismatch"
|
||||
assert_plist_array_contains "${profile_plist}" "TeamIdentifier" "${EXPECTED_TEAM_ID}" "embedded profile team mismatch"
|
||||
assert_plist_string "${profile_plist}" "Entitlements:application-identifier" "${EXPECTED_TEAM_ID}.${EXPECTED_BUNDLE_ID}" "embedded profile application identifier mismatch"
|
||||
assert_plist_string "${profile_plist}" "Entitlements:aps-environment" "production" "embedded profile APNs entitlement mismatch"
|
||||
assert_plist_array_contains "${profile_plist}" "Entitlements:com.apple.developer.devicecheck.appattest-environment" "production" "embedded profile App Attest entitlement mismatch"
|
||||
assert_plist_array_contains "${profile_plist}" "Entitlements:com.apple.security.application-groups" "${EXPECTED_APP_GROUP}" "embedded profile App Group entitlement mismatch"
|
||||
|
||||
echo "Validated iOS App Store IPA: ${IPA_PATH}"
|
||||
|
|
@ -971,10 +971,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
|||
"test/scripts/security-sensitive-guard-workflow.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/github/resolve-openclaw-ref.sh",
|
||||
["test/scripts/resolve-openclaw-ref.test.ts"],
|
||||
],
|
||||
["scripts/github/resolve-openclaw-ref.sh", ["test/scripts/resolve-openclaw-ref.test.ts"]],
|
||||
["scripts/ci-hydrate-testbox-env.sh", ["test/scripts/ci-hydrate-testbox-env.test.ts"]],
|
||||
[
|
||||
"scripts/github/run-openclaw-cross-os-release-checks.sh",
|
||||
|
|
@ -987,36 +984,22 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
|||
["scripts/ios-release-prepare.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]],
|
||||
["scripts/ios-release-signing.mjs", ["test/scripts/ios-release-signing.test.ts"]],
|
||||
["scripts/ios-release-upload.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]],
|
||||
["scripts/ios-validate-app-store-ipa.sh", ["test/scripts/ios-validate-app-store-ipa.test.ts"]],
|
||||
["scripts/lib/restart-mac-gateway.sh", ["test/scripts/restart-mac.test.ts"]],
|
||||
[
|
||||
"scripts/openclaw-release-clawhub-runtime-state.ts",
|
||||
["test/scripts/openclaw-release-clawhub-runtime-state.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/openclaw-release-clawhub-plan.ts",
|
||||
["test/scripts/release-wrapper-scripts.test.ts"],
|
||||
],
|
||||
["scripts/openclaw-release-clawhub-plan.ts", ["test/scripts/release-wrapper-scripts.test.ts"]],
|
||||
[
|
||||
"scripts/plan-release-workflow-matrix.mjs",
|
||||
["test/scripts/release-workflow-matrix-plan.test.ts"],
|
||||
],
|
||||
["scripts/release-fast-pretag-check.sh", ["test/scripts/package-acceptance-workflow.test.ts"]],
|
||||
[
|
||||
"scripts/plugin-clawhub-release-check.ts",
|
||||
["test/scripts/release-wrapper-scripts.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/plugin-clawhub-release-plan.ts",
|
||||
["test/scripts/release-wrapper-scripts.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/plugin-npm-release-check.ts",
|
||||
["test/scripts/release-wrapper-scripts.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/plugin-npm-release-plan.ts",
|
||||
["test/scripts/release-wrapper-scripts.test.ts"],
|
||||
],
|
||||
["scripts/plugin-clawhub-release-check.ts", ["test/scripts/release-wrapper-scripts.test.ts"]],
|
||||
["scripts/plugin-clawhub-release-plan.ts", ["test/scripts/release-wrapper-scripts.test.ts"]],
|
||||
["scripts/plugin-npm-release-check.ts", ["test/scripts/release-wrapper-scripts.test.ts"]],
|
||||
["scripts/plugin-npm-release-plan.ts", ["test/scripts/release-wrapper-scripts.test.ts"]],
|
||||
[
|
||||
"scripts/plugin-release-pretag-pack-check.ts",
|
||||
["test/scripts/plugin-release-pretag-pack-check.test.ts"],
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ type WrapperCase = readonly [scriptPath: string, args: readonly string[], option
|
|||
function runScript(
|
||||
scriptPath: string,
|
||||
args: readonly string[],
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
): { ok: boolean; stdout: string; stderr: string } {
|
||||
const scriptArgs =
|
||||
process.platform === "win32" ? [scriptPath] : ["--noprofile", "--norc", scriptPath];
|
||||
try {
|
||||
const stdout = execFileSync(BASH_BIN, [...scriptArgs, ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...extraEnv },
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
|
@ -52,4 +54,20 @@ describe("iOS release shell wrapper arguments", () => {
|
|||
expect(result.stdout).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects App Store release relay URL overrides before release work", () => {
|
||||
const result = runScript(
|
||||
path.join(process.cwd(), "scripts/ios-release-prepare.sh"),
|
||||
["--build-number", "7"],
|
||||
{
|
||||
IOS_DEVELOPMENT_TEAM: "FWJYW4S8P8",
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: "https://relay.example.com",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.stderr).toContain("custom relay URL overrides are not allowed");
|
||||
expect(result.stderr).not.toContain("fastlane");
|
||||
expect(result.stdout).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -189,14 +189,10 @@ describe("scripts/ios-run.sh", () => {
|
|||
);
|
||||
|
||||
const log = readFileSync(fixture.logFile, "utf8");
|
||||
expect(log).toContain("OPENCLAW_PUSH_TRANSPORT=relay");
|
||||
expect(log).toContain("OPENCLAW_PUSH_DISTRIBUTION=official");
|
||||
expect(log).toContain("OPENCLAW_PUSH_MODE=simulatorSandbox");
|
||||
expect(log).toContain(
|
||||
"OPENCLAW_PUSH_RELAY_BASE_URL=https://ios-push-relay-sandbox.openclaw.ai",
|
||||
);
|
||||
expect(log).toContain("OPENCLAW_PUSH_APNS_ENVIRONMENT=sandbox");
|
||||
expect(log).toContain("OPENCLAW_PUSH_RELAY_PROFILE=simulatorSandbox");
|
||||
expect(log).toContain("OPENCLAW_PUSH_PROOF_POLICY=internalSimulator");
|
||||
expect(log).toContain("simctl launch iPhone 17 ai.openclawfoundation.app");
|
||||
expect(log).toContain("simctl-launch-proof set");
|
||||
expect(log).not.toContain("proof-env leaked");
|
||||
|
|
|
|||
263
test/scripts/ios-validate-app-store-ipa.test.ts
Normal file
263
test/scripts/ios-validate-app-store-ipa.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
// iOS IPA validation tests cover the App Store upload gate without real signing assets.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const SCRIPT = path.join(process.cwd(), "scripts", "ios-validate-app-store-ipa.sh");
|
||||
const BASH_BIN = process.platform === "win32" ? "bash" : "/bin/bash";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function bashArgs(scriptPath: string): string[] {
|
||||
return process.platform === "win32" ? [scriptPath] : ["--noprofile", "--norc", scriptPath];
|
||||
}
|
||||
|
||||
function writeExecutable(filePath: string, body: string): void {
|
||||
writeFileSync(filePath, body, "utf8");
|
||||
chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
function plistString(key: string, value: string): string {
|
||||
return `<key>${key}</key><string>${value}</string>`;
|
||||
}
|
||||
|
||||
function plistArray(key: string, values: readonly string[]): string {
|
||||
return `<key>${key}</key><array>${values.map((value) => `<string>${value}</string>`).join("")}</array>`;
|
||||
}
|
||||
|
||||
function plistDict(key: string, body: string): string {
|
||||
return `<key>${key}</key><dict>${body}</dict>`;
|
||||
}
|
||||
|
||||
function plist(body: string): string {
|
||||
return [
|
||||
`<?xml version="1.0" encoding="UTF-8"?>`,
|
||||
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
|
||||
`<plist version="1.0"><dict>${body}</dict></plist>`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function writeFakePlistBuddy(filePath: string): void {
|
||||
writeExecutable(
|
||||
filePath,
|
||||
`#!/usr/bin/env node
|
||||
const { readFileSync } = require("node:fs");
|
||||
const commandIndex = process.argv.indexOf("-c");
|
||||
if (commandIndex < 0) process.exit(2);
|
||||
const command = process.argv[commandIndex + 1] || "";
|
||||
const file = process.argv[commandIndex + 2];
|
||||
const keyPath = command.replace(/^Print:/, "").split(":").filter(Boolean);
|
||||
const xml = readFileSync(file, "utf8");
|
||||
const tokens = [...xml.matchAll(/<key>([^<]*)<\\/key>|<string>([^<]*)<\\/string>|<array>|<\\/array>|<dict>|<\\/dict>/g)];
|
||||
let i = 0;
|
||||
function parseValue() {
|
||||
const token = tokens[i++];
|
||||
if (!token) return undefined;
|
||||
const text = token[0];
|
||||
if (token[2] !== undefined) return token[2];
|
||||
if (text === "<dict>") return parseDict();
|
||||
if (text === "<array>") {
|
||||
const values = [];
|
||||
while (i < tokens.length && tokens[i][0] !== "</array>") {
|
||||
const value = parseValue();
|
||||
if (value !== undefined) values.push(value);
|
||||
}
|
||||
i++;
|
||||
return values;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function parseDict() {
|
||||
const obj = {};
|
||||
while (i < tokens.length && tokens[i][0] !== "</dict>") {
|
||||
const key = tokens[i++][1];
|
||||
if (key === undefined) continue;
|
||||
obj[key] = parseValue();
|
||||
}
|
||||
i++;
|
||||
return obj;
|
||||
}
|
||||
while (i < tokens.length && tokens[i][0] !== "<dict>") i++;
|
||||
const root = parseValue();
|
||||
let current = root;
|
||||
for (const key of keyPath) {
|
||||
if (!current || typeof current !== "object" || Array.isArray(current) || !(key in current)) {
|
||||
process.exit(1);
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
console.log("Array {");
|
||||
for (const value of current) console.log(" " + value);
|
||||
console.log("}");
|
||||
} else if (current && typeof current === "object") {
|
||||
console.log("Dict {");
|
||||
console.log("}");
|
||||
} else if (typeof current === "string") {
|
||||
console.log(current);
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeValidFixture(
|
||||
root: string,
|
||||
options: { pushMode?: string; legacyKey?: boolean } = {},
|
||||
): {
|
||||
ipaPath: string;
|
||||
plistBuddy: string;
|
||||
codesign: string;
|
||||
security: string;
|
||||
} {
|
||||
const binDir = path.join(root, "bin");
|
||||
const payloadDir = path.join(root, "Payload");
|
||||
const appDir = path.join(payloadDir, "OpenClaw.app");
|
||||
const fixturesDir = path.join(root, "fixtures");
|
||||
mkdirSync(appDir, { recursive: true });
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
mkdirSync(fixturesDir, { recursive: true });
|
||||
|
||||
const infoBody = [
|
||||
plistString("CFBundleIdentifier", "ai.openclawfoundation.app"),
|
||||
plistString("OpenClawPushMode", options.pushMode ?? "appStore"),
|
||||
plistString("OpenClawPushRelayBaseURL", ""),
|
||||
options.legacyKey ? plistString("OpenClawPushRelayProfile", "production") : "",
|
||||
].join("");
|
||||
writeFileSync(path.join(appDir, "Info.plist"), plist(infoBody), "utf8");
|
||||
writeFileSync(path.join(appDir, "embedded.mobileprovision"), "fixture profile", "utf8");
|
||||
|
||||
const entitlementsPath = path.join(fixturesDir, "entitlements.plist");
|
||||
writeFileSync(
|
||||
entitlementsPath,
|
||||
plist(
|
||||
[
|
||||
plistString("application-identifier", "FWJYW4S8P8.ai.openclawfoundation.app"),
|
||||
plistString("com.apple.developer.team-identifier", "FWJYW4S8P8"),
|
||||
plistString("aps-environment", "production"),
|
||||
plistString("com.apple.developer.devicecheck.appattest-environment", "production"),
|
||||
plistArray("com.apple.security.application-groups", [
|
||||
"group.ai.openclawfoundation.app.shared",
|
||||
]),
|
||||
].join(""),
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const profilePath = path.join(fixturesDir, "profile.plist");
|
||||
writeFileSync(
|
||||
profilePath,
|
||||
plist(
|
||||
[
|
||||
plistString("Name", "OpenClaw App Store ai.openclawfoundation.app"),
|
||||
plistArray("TeamIdentifier", ["FWJYW4S8P8"]),
|
||||
plistDict(
|
||||
"Entitlements",
|
||||
[
|
||||
plistString("application-identifier", "FWJYW4S8P8.ai.openclawfoundation.app"),
|
||||
plistString("aps-environment", "production"),
|
||||
plistArray("com.apple.developer.devicecheck.appattest-environment", ["production"]),
|
||||
plistArray("com.apple.security.application-groups", [
|
||||
"group.ai.openclawfoundation.app.shared",
|
||||
]),
|
||||
].join(""),
|
||||
),
|
||||
].join(""),
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const plistBuddy = path.join(binDir, "plistbuddy");
|
||||
writeFakePlistBuddy(plistBuddy);
|
||||
const codesign = path.join(binDir, "codesign");
|
||||
writeExecutable(
|
||||
codesign,
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cat "${entitlementsPath}"
|
||||
`,
|
||||
);
|
||||
const security = path.join(binDir, "security");
|
||||
writeExecutable(
|
||||
security,
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cat "${profilePath}"
|
||||
`,
|
||||
);
|
||||
|
||||
const ipaPath = path.join(root, "OpenClaw.ipa");
|
||||
execFileSync("zip", ["-qry", ipaPath, "Payload"], { cwd: root });
|
||||
return { ipaPath, plistBuddy, codesign, security };
|
||||
}
|
||||
|
||||
function runValidator(fixture: {
|
||||
ipaPath: string;
|
||||
plistBuddy: string;
|
||||
codesign: string;
|
||||
security: string;
|
||||
}): { ok: boolean; stdout: string; stderr: string } {
|
||||
try {
|
||||
const stdout = execFileSync(BASH_BIN, [...bashArgs(SCRIPT), "--ipa", fixture.ipaPath], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
IOS_VALIDATE_PLIST_BUDDY_BIN: fixture.plistBuddy,
|
||||
IOS_VALIDATE_CODESIGN_BIN: fixture.codesign,
|
||||
IOS_VALIDATE_SECURITY_BIN: fixture.security,
|
||||
},
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
return { ok: true, stdout, stderr: "" };
|
||||
} catch (error) {
|
||||
const e = error as { stdout?: unknown; stderr?: unknown };
|
||||
const stdout = Buffer.isBuffer(e.stdout) ? e.stdout.toString("utf8") : String(e.stdout ?? "");
|
||||
const stderr = Buffer.isBuffer(e.stderr) ? e.stderr.toString("utf8") : String(e.stderr ?? "");
|
||||
return { ok: false, stdout, stderr };
|
||||
}
|
||||
}
|
||||
|
||||
describe("scripts/ios-validate-app-store-ipa.sh", () => {
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts an App Store IPA with appStore mode and production entitlements", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-"));
|
||||
tempDirs.push(root);
|
||||
const fixture = writeValidFixture(root);
|
||||
|
||||
const result = runValidator(fixture);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.stdout).toContain("Validated iOS App Store IPA");
|
||||
});
|
||||
|
||||
it("rejects an IPA that was exported with a non-App-Store push mode", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-"));
|
||||
tempDirs.push(root);
|
||||
const fixture = writeValidFixture(root, { pushMode: "localProduction" });
|
||||
|
||||
const result = runValidator(fixture);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.stderr).toContain("push mode mismatch");
|
||||
});
|
||||
|
||||
it("rejects legacy independently selectable production push keys", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-"));
|
||||
tempDirs.push(root);
|
||||
const fixture = writeValidFixture(root, { legacyKey: true });
|
||||
|
||||
const result = runValidator(fixture);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.stderr).toContain("legacy relay profile");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue