Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
150 changes: 150 additions & 0 deletions docs/src/platforms/watchos-app-store.md
Original file line number Diff line number Diff line change
@@ -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/
<Container>.app/ # iOS stub (com.example.app)
<stub binary> # trivial UIKit app, never launched
Info.plist
Watch/
<WatchApp>.app/ # the real watch app (com.example.app.watchkitapp)
<fat binary>
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/<WatchApp>.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** (`<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: <Team>" \
--entitlements watch.entitlements "Container.app/Watch/WatchApp.app"
codesign --force --sign "Apple Distribution: <Team>" \
--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 <KEY_ID> -apiIssuer <ISSUER_ID>
```

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 <watch-udid> 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+.
93 changes: 88 additions & 5 deletions docs/src/platforms/watchos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -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
Expand Down