diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 7fd8593063..c5c52b2be6 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -68,6 +68,7 @@ - [visionOS](platforms/visionos.md) - [tvOS](platforms/tvos.md) - [watchOS](platforms/watchos.md) + - [Publishing to the App Store](platforms/watchos-app-store.md) - [Android](platforms/android.md) - [HarmonyOS NEXT](platforms/harmonyos.md) - [Windows](platforms/windows.md) diff --git a/docs/src/platforms/watchos-app-store.md b/docs/src/platforms/watchos-app-store.md new file mode 100644 index 0000000000..6341a76eee --- /dev/null +++ b/docs/src/platforms/watchos-app-store.md @@ -0,0 +1,150 @@ +# Publishing watchOS Apps to the App Store + +Shipping a **watch-only** app (no iPhone app) through App Store Connect has two +non-obvious requirements that aren't enforced until you upload. This page covers +both, plus the architecture/deployment-target rules that decide which watches +your build reaches. + +## Architecture rules + +App Store validation enforces two rules for the watch app binary: + +- **arm64 is required for every watchOS app**, always. +- **arm64_32 is *additionally* required when `MinimumOSVersion < 27.0`.** + +So there are exactly two valid shapes: + +| Build | `MinimumOSVersion` | Reaches | +|---|---|---| +| **Fat: arm64 + arm64_32** | < 27 (e.g. 10.0) | Every watch from Series 4 to the latest | +| arm64-only | ≥ 27.0 | Series 9+ only (watchOS 27+) | + +An arm64_32-only upload is **rejected** ("missing arm64 architecture"). For the +widest reach, ship the fat binary with a low deployment target. + +> **arm64-only builds can stall in processing.** A build whose +> `MinimumOSVersion` is a watchOS version that is not yet generally available +> (e.g. 27.0 during its beta period) may sit in "Processing" indefinitely — +> Apple's pipeline appears unable to finish it until that OS ships. A fat build +> targeting a shipped watchOS (e.g. 10.0) processes normally in minutes. Prefer +> the fat/low-minOS shape unless you specifically need arm64-only. + +## Building the fat binary + +Build each slice (see [Building for Device](watchos.md#building-for-device)), +re-stamp the arm64 slice's load command down to the shared deployment target +(it only ever runs on watchOS 26+ hardware, so the stamp is cosmetic), then +`lipo` them together: + +```bash +# 1. arm64_32 slice at the shared deployment target (e.g. 10.0) +PERRY_WATCHOS_ARM64_32=1 PERRY_WATCHOS_MIN=10.0 \ +PERRY_ENTRY_SYMBOL=_perry_user_main \ +PERRY_RUNTIME_DIR=.../arm64_32-apple-watchos/release \ + perry compile app.ts -o AppA32 --target watchos --features watchos-swift-app + +# 2. arm64 slice (default device target) +PERRY_RUNTIME_DIR=.../aarch64-apple-watchos/release \ + perry compile app.ts -o AppA64 --target watchos --features watchos-swift-app + +# 3. align minos + fuse +xcrun vtool -set-build-version watchos 10.0 26.5 -replace \ + -output AppA64.min10 AppA64.app/AppA64 +lipo -create -output App.fat AppA32.app/AppA32 AppA64.min10 +lipo -info App.fat # => arm64_32 arm64 +``` + +Place `App.fat` as the watch app's executable and set the bundle's +`MinimumOSVersion` to the same value (10.0 here). + +## The iOS stub wrapper + +App Store Connect has no standalone "watchOS" platform — watch software ships +**inside an iOS app record**. Uploading a bare watch `.app` fails in Transporter +with `Unknown platform alias: watchOS`. The watch app must be nested in a minimal +iOS "stub" container: + +``` +Payload/ + .app/ # iOS stub (com.example.app) + # trivial UIKit app, never launched + Info.plist + Watch/ + .app/ # the real watch app (com.example.app.watchkitapp) + + Info.plist + embedded.mobileprovision + embedded.mobileprovision +``` + +Xcode generates this stub automatically for "Watch-Only App" projects; with +Perry you assemble it by hand. The stub is a do-nothing Swift `UIApplicationDelegate` +compiled for `arm64-apple-ios`. + +### Required Info.plist keys + +**Watch app** (`Watch/.app/Info.plist`): + +| Key | Value | +|---|---| +| `WKApplication` | `true` | +| `WKWatchOnly` | `true` | +| `CFBundleIdentifier` | `com.example.app.watchkitapp` | +| `MinimumOSVersion` | matches the fat binary (e.g. `10.0`) | +| `UIDeviceFamily` | `[4]` | + +Do **not** set `WKCompanionAppBundleIdentifier` when `WKWatchOnly` is true. + +**Stub container** (`.app/Info.plist`): + +| Key | Value | +|---|---| +| `ITSWatchOnlyContainer` | `true` | +| `LSApplicationLaunchProhibited` | `true` | +| `CFBundleIdentifier` | `com.example.app` | +| `UISupportedInterfaceOrientations` | all four orientations (iPad multitasking rule with `UIDeviceFamily [1,2]`) | + +## Signing + +The watch app and the stub each need their own distribution provisioning +profile and matching bundle ID, both signed with an **Apple Distribution** +identity. `WKWatchOnly` is rejected on an app record that already distributes an +iOS build, so a watch-only app needs its **own new app record** in App Store +Connect. + +```bash +codesign --force --sign "Apple Distribution: " \ + --entitlements watch.entitlements "Container.app/Watch/WatchApp.app" +codesign --force --sign "Apple Distribution: " \ + --entitlements stub.entitlements "Container.app" +codesign --verify --deep --strict "Container.app" +``` + +## Uploading + +`altool` cannot upload watch apps ("cannot determine platform"). Use Transporter: + +```bash +mkdir Payload && cp -R "Container.app" Payload/ && zip -qr App.ipa Payload +iTMSTransporter -m upload -assetFile App.ipa \ + -apiKey -apiIssuer +``` + +Transporter runs the architecture/plist validation above before accepting the +upload, so its errors are the fastest way to confirm the bundle is well-formed. + +## Development install (no App Store) + +To run a device build on a watch you own without TestFlight, sign it with a +**development** profile that lists the watch's UDID and `get-task-allow=true`, +then install via `devicectl`: + +```bash +xcrun devicectl device install app --device WatchApp.app +``` + +This requires **Developer Mode** enabled on the watch *and* its developer disk +image mounted — open **Xcode → Window → Devices and Simulators** and select the +watch once to mount it (`devicectl` reports `ddiServicesAvailable: false` until +then). Note this needs a watch matching your build's architecture: an arm64_32 +build for a pre-S9 watch, an arm64 build for S9+. diff --git a/docs/src/platforms/watchos.md b/docs/src/platforms/watchos.md index b73120b5fc..ca274152fd 100644 --- a/docs/src/platforms/watchos.md +++ b/docs/src/platforms/watchos.md @@ -8,26 +8,92 @@ Since watchOS does not support UIKit views, Perry uses a **data-driven SwiftUI r - macOS host (cross-compilation from Linux/Windows is not supported) - Xcode (full install) for watchOS SDK and Simulator -- Rust watchOS targets: +- Rust watchOS targets. The simulator target is tier 2 and can be added with + `rustup`; the **device** targets are tier 3 and ship no prebuilt `std`, so + their runtime libraries must be built from source with a nightly toolchain + and `-Z build-std` (see [Building for Device](#building-for-device)): ```bash - rustup target add arm64_32-apple-watchos aarch64-apple-watchos-sim + rustup target add aarch64-apple-watchos-sim # simulator (tier 2) + rustup component add rust-src --toolchain nightly # for device build-std ``` +## Watch architectures + +watchOS spans two CPU architectures, and which one you target decides which +watches your app runs on: + +| Architecture | Watches | watchOS | Perry target | +|---|---|---|---| +| **arm64** (64-bit) | Series 9/10/11, Ultra 2/3, SE 3 (S9 chip+) | 26+ | `--target watchos` (default) | +| **arm64_32** (ILP32, 32-bit pointers) | Series 4–8, SE 1/2 | 9–11 | `--target watchos` + `PERRY_WATCHOS_ARM64_32=1` | +| **arm64** (simulator) | — | — | `--target watchos-simulator` | + +Apple moved S9-and-later watches to full arm64 in watchOS 26. Older watches stay +arm64_32 forever. Perry's NaN-boxed value representation works on both (a 32-bit +pointer fits in the 48-bit NaN payload); the architecture split is purely about +which hardware the binary loads on. The simulator is always arm64 (Apple Silicon +host) and **cannot run an arm64_32 binary** — device-arch builds can only be +tested on real hardware (or shipped via TestFlight). + ## Building for Simulator ```bash perry compile app.ts -o app --target watchos-simulator ``` -This produces an ARM64 binary linked with `swiftc` against the watchOS Simulator SDK, wrapped in a `.app` bundle. +This produces an arm64 binary linked with `swiftc` against the watchOS Simulator +SDK, wrapped in a `.app` bundle. ## Building for Device +Device runtime libraries are tier-3 Rust targets with no prebuilt `std`, so build +`perry-runtime` (and `perry-ui-watchos`, if you use the SwiftUI tree renderer) +from source once, then point `PERRY_RUNTIME_DIR` at them: + +```bash +# arm64 (Series 9+ / watchOS 26+) — the default device target +cargo +nightly build -Z build-std=std,panic_abort --release \ + -p perry-runtime -p perry-ui-watchos --target aarch64-apple-watchos + +PERRY_RUNTIME_DIR=target/aarch64-apple-watchos/release \ + perry compile app.ts -o app --target watchos +``` + ```bash -perry compile app.ts -o app --target watchos +# arm64_32 (Series 4-8 / SE) — opt in with PERRY_WATCHOS_ARM64_32 +cargo +nightly build -Z build-std=std,panic_abort --release \ + -p perry-runtime -p perry-ui-watchos --target arm64_32-apple-watchos + +PERRY_WATCHOS_ARM64_32=1 \ +PERRY_RUNTIME_DIR=target/arm64_32-apple-watchos/release \ + perry compile app.ts -o app --target watchos ``` -This produces an arm64_32 (ILP32) binary for physical Apple Watch hardware. Apple Watch uses 32-bit pointers on 64-bit ARM. +To support every watch from a single App Store upload, build **both** and `lipo` +them into a fat binary — see [Publishing to the App Store](watchos-app-store.md). + +### Build environment variables + +| Variable | Effect | +|---|---| +| `PERRY_WATCHOS_ARM64_32=1` | Switch the `watchos` device target from arm64 to arm64_32 (codegen object arch, runtime/native-lib/Swift/link triples, and the bundle's `MinimumOSVersion` floor all follow). | +| `PERRY_WATCHOS_MIN` | Override `MinimumOSVersion` for arm64_32 device builds (default `11.0`). The engine/SwiftUI you link may impose its own floor — e.g. `onChange(of:initial:)` needs watchOS 10. | +| `PERRY_ENTRY_SYMBOL` | Name the C entry symbol emitted by codegen instead of renaming `_main` afterwards. Needed on arm64_32 because `rust-objcopy --redefine-sym` segfaults on arm64_32 Mach-O (`MachOWriter::writeSections`); see below. | + +> **arm64_32 entry symbol.** With `--features watchos-swift-app`/`watchos-game-loop`, +> Perry normally emits `_main` and renames it to `__perry_user_main` with +> `rust-objcopy`. That tool crashes on arm64_32 objects, so for arm64_32 set +> `PERRY_ENTRY_SYMBOL=_perry_user_main` — codegen then emits the final symbol +> directly (the leading underscore yields Mach-O `__perry_user_main`, which the +> Swift `@main` shell references via `@_silgen_name`) and Perry skips the objcopy +> pass. A fat `lipo` build needs the same symbol in both slices. + +> **Note for runtime contributors.** arm64_32 has 32-bit `usize`. Pointer-range +> guards and size caps in `perry-runtime` must compare in `u64` (e.g. +> `(addr as u64) < 0x8000_0000_0000`) rather than writing bare `usize` literals +> ≥ 2³² — those are a hard "literal out of range" error on arm64_32 (and wasm32). +> Use `usize::try_from(...).unwrap_or(usize::MAX)` to saturate length caps like +> `1usize << 53`. ## Running with `perry run` @@ -122,6 +188,22 @@ perry_ui_*() FFI calls → Node tree stored in memory (Rust) The `PerryWatchApp.swift` file is a fixed runtime (~280 lines) that ships with Perry. It never changes per-app — it's the watchOS equivalent of `libperry_ui_ios.a`. +## App rendering modes + +The data-driven SwiftUI renderer above is the default. Two feature flags switch +to app shells that own their own entry point — used by games and apps that draw +their own frames instead of building a `perry/ui` tree: + +| Feature | Shell | Use case | +|---|---|---| +| *(default)* | Perry's `PerryWatchApp.swift` observes the UI tree | Standard `perry/ui` apps | +| `--features watchos-swift-app` | A native library ships its own `@main struct App: App` | Games / engines with a custom SwiftUI `Canvas` (e.g. Bloom Engine) | +| `--features watchos-game-loop` | `perry-runtime` provides C `main()` + `WKApplicationMain` | Metal/wgpu game loops | + +In both non-default modes the TypeScript entry runs on a background thread the +shell spawns, and the shell references it as `__perry_user_main` (see +`PERRY_ENTRY_SYMBOL` above). + ## Configuration Configure watchOS settings in `perry.toml`: @@ -184,6 +266,7 @@ watchOS apps have inherent platform constraints compared to other Perry targets: ## Next Steps +- [Publishing watchOS apps to the App Store](watchos-app-store.md) — fat binaries, the iOS-stub wrapper, and signing for a watch-only app - [watchOS Complications](../widgets/watchos.md) — WidgetKit complications - [iOS](ios.md) — iOS platform reference - [Platform Overview](overview.md) — All platforms