|
| 1 | +# Apple Platform Consolidation — `platforms/apple` with an `AppleOS` leaf axis |
| 2 | + |
| 3 | +> The platform-axis half of the [perfect-shape roadmap](./perfect-shape.md): make the Apple plugin own |
| 4 | +> **iOS / iPadOS / tvOS / macOS** today and **visionOS / watchOS** as honest future leaves — a single |
| 5 | +> `apple` platform with an `AppleOS` discriminant. Grounded in a 4-investigator survey of the real code. |
| 6 | +
|
| 7 | +## TL;DR |
| 8 | + |
| 9 | +`src/platforms/ios/` is **~85% an OS-agnostic Apple-XCTest engine that is merely misfiled and misnamed.** |
| 10 | +Of ~14.2k LOC, ~12k is OS-agnostic (the 6,136-LOC runner stack, tool-provider, discovery, snapshot/AX, |
| 11 | +screenshot, perf, debug-symbols), and `apple-runner-platform.ts` already models iOS/tvOS/macOS as |
| 12 | +first-class runner profiles. The XCTest runner **already builds `ios|macos|tvos` from one Xcode project**, |
| 13 | +and one `createAppleInteractor` already serves both `ios` and `macos`. So consolidation is overwhelmingly |
| 14 | +**relocate-and-rename, not rewrite** — for iOS/iPadOS/tvOS/macOS. visionOS is real-but-scoped net-new |
| 15 | +work; watchOS is **externally blocked** by Apple (no XCUITest UI automation). |
| 16 | + |
| 17 | +**The taxonomy decision:** add an **`AppleOS` discriminant under one `apple` Platform** — do **not** promote |
| 18 | +each OS to its own `Platform` literal. Reasons from the code: |
| 19 | +- `DeviceTarget` (`mobile|tv|desktop`) is already **cross-platform** — Android TV uses `target:'tv'`, so a |
| 20 | + `tvos` Platform literal would collide with the form-factor axis. (tvOS is *currently* hacked onto |
| 21 | + `target:'tv'`; the fix is a dedicated `AppleOS` leaf, **not** more overloading of `target`.) |
| 22 | +- Promoting to literals explodes the ~15 `isApplePlatform` and ~52 `platform==='macos'` sites to enumerate |
| 23 | + six literals each, and breaks the single-bucket `apple` capability/selector model that already works. |
| 24 | + |
| 25 | +## Before / after |
| 26 | + |
| 27 | +``` |
| 28 | +BEFORE — Apple support is smeared across an "iOS" folder that is really the Apple engine |
| 29 | +───────────────────────────────────────────────────────────────────────────────────────── |
| 30 | +
|
| 31 | +DeviceInfo (src/utils/device.ts) |
| 32 | + platform: ios | macos | android | linux | web ← macOS is its OWN literal … |
| 33 | + kind: simulator | emulator | device |
| 34 | + target?: mobile | tv | desktop ← … but tvOS = ios + target:'tv' (asymmetric!) |
| 35 | + 'tv'/'desktop' also used by Android (cross-platform) |
| 36 | +
|
| 37 | + resolveApplePlatformName(target) ──► 'iOS' | 'tvOS' | 'macOS' ← OS name INFERRED late & lossily |
| 38 | +
|
| 39 | +core/interactors.ts: ios ─┐ |
| 40 | + macos ─┴─► createAppleInteractor (one Apple owner already exists!) |
| 41 | +
|
| 42 | +┌─ src/platforms/ios/ (~14.2k LOC — the "iOS" name is a lie: ~85% is the Apple engine) ─────────┐ |
| 43 | +│ ░ APPLE-SHARED ENGINE (OS-agnostic, ~12k) ░ │ |
| 44 | +│ runner/ stack ........ 6,136 LOC / 17 files (speaks JSON to a Swift host w/ #if os()) │ |
| 45 | +│ apple-runner-platform.ts ► RUNNER_PROFILES = { iOS, tvOS, macOS } (3 rows) │ |
| 46 | +│ discovery · tool-provider · snapshot/xml · screenshot · perf · debug-symbols │ |
| 47 | +│ ▓ iOS leaf ▓ touch synthesis · status-bar override · xctrace perf │ |
| 48 | +│ ▓ tvOS leaf ▓ XCUIRemote focus / remotePress │ |
| 49 | +│ ▒ macOS leaf — MISLABELED HERE (~797 LOC) ▒ macos-helper · macos-apps · host-provider · scroll │ |
| 50 | +└───────────────────────────────▲─────────────────────────────────────────────────────────────────┘ |
| 51 | + │ imports (dependency arrow points BACKWARD) |
| 52 | + src/platforms/macos/devices.ts = 19-LOC stub |
| 53 | +
|
| 54 | +Discovery: xcrun simctl list ─► filter admits only {ios, tvos} ─► watchOS, visionOS SILENTLY DROPPED |
| 55 | +Capabilities: ONE 'apple' bucket + scattered re-derivation |
| 56 | + isNotMacOs ×5 · isIosMobileSimulator · synthesisGestureUnsupportedHint · dispatch hard-throws ×3 |
| 57 | + ('macos' string in 52 files · 'tv' in 15) |
| 58 | +``` |
| 59 | + |
| 60 | +``` |
| 61 | +AFTER — one 'apple' platform, an AppleOS leaf axis, the engine named for what it actually is |
| 62 | +───────────────────────────────────────────────────────────────────────────────────────────── |
| 63 | +
|
| 64 | +DeviceInfo |
| 65 | + platform: apple | android | linux | web ← ios + macos collapse into 'apple' |
| 66 | + appleOs: ios | ipados | tvos | watchos | visionos | macos ← NEW discriminant, stored at discovery |
| 67 | + kind: simulator | device |
| 68 | + target?: mobile | tv | desktop ← UNCHANGED, stays orthogonal (shared w/ Android) |
| 69 | +
|
| 70 | + resolveAppleOs(device) ──► reads appleOs (fallback: legacy target inference) ← single seam |
| 71 | +
|
| 72 | +Apple plugin = one instance of the PlatformPlugin registry (perfect-shape.md §5.1), |
| 73 | + owning every leaf OS via appleOs |
| 74 | +
|
| 75 | +┌─ src/platforms/apple/ ──────────────────────────────────────────────────────────────────────────┐ |
| 76 | +│ core/ ░ OS-agnostic Apple engine — MOVED VERBATIM from platforms/ios ░ │ |
| 77 | +│ runner/ (6,136 LOC) · tool-provider · discovery (absorbs the old stub, filter widened) │ |
| 78 | +│ snapshot · screenshot · perf · debug-symbols │ |
| 79 | +│ os-profiles.ts ► RUNNER_PROFILES = { iOS, iPadOS, tvOS, macOS, visionOS, watchOS✗ } (3 → 6) │ |
| 80 | +│ interactor.ts (from core/interactors/apple.ts) │ |
| 81 | +│ os/ ← leaf code ONLY (genuinely per-OS) │ |
| 82 | +│ ios/ touch synthesis · status-bar · xctrace │ |
| 83 | +│ ipados/ aliases ios (only if iPad-specific features are modeled) │ |
| 84 | +│ tvos/ XCUIRemote focus — NO coordinate tap (contract differs; NOT flattened) │ |
| 85 | +│ macos/ AppKit: helper binary · host-provider · desktop-scroll · menubar/desktop surfaces │ |
| 86 | +│ visionos/ NEW — feasible: profile + build case + #if os(visionOS) + real spatial-input QA │ |
| 87 | +│ watchos/ ⛔ unsupported sentinel — XCUITest can't drive watchOS (declared, gated at admission) │ |
| 88 | +└────────────────────────────────────────────────────────────────────────────────────────────────────┘ |
| 89 | +
|
| 90 | +Capabilities: per-AppleOS DATA TABLE (mirrors os-profiles + the Swift #if os() guards 1:1) |
| 91 | + { inputModel, multiTouch, gestures{pinch,rotate,transform}, surfaces, keyboard, orientation } |
| 92 | + ⇒ scattered isNotMacOs / target!=='tv' predicates collapse into one lookup |
| 93 | +``` |
| 94 | + |
| 95 | +## Per-OS readiness (honest) |
| 96 | + |
| 97 | +| OS | Status | Reality | |
| 98 | +|---|---|---| |
| 99 | +| **iOS** | works | Reference path. | |
| 100 | +| **iPadOS** | works | Rides iOS *identically* (matched by `/ipad/`). Zero runner work; splitting it is a naming/label concern — only worth it if Stage Manager / pointer / Pencil are actually modeled. | |
| 101 | +| **tvOS** | works | Functional but modeled as `ios + target:'tv'`. Promotion = **rename to a leaf**; XCUIRemote focus + no-coordinate-tap behavior already exists. | |
| 102 | +| **macOS** | works | Same XCUITest project (`build:xcuitest:macos`) **plus** a separate `agent-device-macos-helper` Swift binary for AX surfaces. ~797 LOC already (mis)lives in `platforms/ios`. AppKit, not UIKit — kept as a distinct leaf, not folded into the touch model. | |
| 103 | +| **visionOS** | feasible, net-new | XCUITest *does* support visionOS. Needs `xros` in `SUPPORTED_PLATFORMS`, a profile row, a build case, `#if os(visionOS)`, a widened discovery filter, **and real QA** of spatial input (look+pinch, no flat coordinates) + multi-window snapshot. Good first net-new OS to validate the leaf pattern. | |
| 104 | +| **watchOS** | blocked by Apple | **XCUITest cannot drive watchOS UI** (no `XCUIApplication`). Not a code gap. Model as an explicit *unsupported sentinel* — do not promise it from this runner. | |
| 105 | + |
| 106 | +## On macOS (the one to think about) |
| 107 | + |
| 108 | +macOS is **AppKit**, the odd one out at the UI-framework level — so it's reasonable to ask whether it |
| 109 | +belongs. The code says **include it, as a distinct leaf**, for two reasons: |
| 110 | + |
| 111 | +1. It's already the **same XCUITest project** the iOS/tvOS runner uses (`build:xcuitest:macos`) — it is |
| 112 | + already in the Apple runner, not a separate harness. |
| 113 | +2. **~797 LOC of macOS code already lives *inside* `platforms/ios`** (`macos-helper`, `macos-apps`, |
| 114 | + `macos-host-provider`, `desktop-scroll`), and `platforms/macos/devices.ts` is a 19-LOC stub the iOS |
| 115 | + discovery imports — the dependency already points backwards. |
| 116 | + |
| 117 | +So macOS is *already entangled* in the Apple stack; **excluding it would leave the mislabel in place**, |
| 118 | +which is worse. Consolidation **normalizes** macOS (today it's the only Apple OS with its own `Platform` |
| 119 | +literal while tvOS rides `target`) without **homogenizing** it: its AppKit specifics — the macos-helper |
| 120 | +backend, the menubar/desktop/frontmost-app surface model, coordinate-pinch, no multi-touch — stay in the |
| 121 | +`apple/os/macos/` leaf. The leaf boundary is exactly what protects the AppKit difference. |
| 122 | + |
| 123 | +## Target shape |
| 124 | + |
| 125 | +``` |
| 126 | +src/platforms/apple/ |
| 127 | + core/ ← the ~12k OS-agnostic engine, moved verbatim from platforms/ios |
| 128 | + runner/ (6,136 LOC, 17 files — never needed to know which Apple OS it drives) |
| 129 | + os-profiles.ts (apple-runner-platform.ts RUNNER_PROFILES, 3 → 6 rows) |
| 130 | + discovery.ts tool-provider/ snapshot/ screenshot/ perf/ debug-symbols/ apps.ts |
| 131 | + interactor.ts (from core/interactors/apple.ts) |
| 132 | + os/ |
| 133 | + ios/ ipados/ tvos/ macos/ ← leaf code only (synthesis / focus / AppKit helper / surfaces) |
| 134 | + visionos/ (new, when pursued) watchos/ (unsupported sentinel) |
| 135 | +``` |
| 136 | + |
| 137 | +This is the Apple plugin from the roadmap, now owning *N OS leaves* via `AppleOS`. Capabilities become a |
| 138 | +**per-`AppleOS` data table** mirroring `RUNNER_PROFILES` and the Swift `#if os()` guards 1:1, replacing the |
| 139 | +scattered `target!=='tv'` / `platform!=='macos'` predicates. |
| 140 | + |
| 141 | +## Sequencing (strangler-fig, low-risk first) |
| 142 | + |
| 143 | +1. **Additive `appleOs`** (non-breaking): add `appleOs?: AppleOS` to `DeviceInfo`, populate it at discovery |
| 144 | + (the runtime/productType is already known there), and make `resolveApplePlatformName` / |
| 145 | + `resolveRunnerPlatformName` prefer it with the existing target inference as fallback. Extend |
| 146 | + `RUNNER_PROFILES` 3 → 6. Instantly makes iOS/iPadOS/tvOS/macOS first-class and unambiguous without |
| 147 | + touching the ~50 `macos`/`isApplePlatform` call sites. *(Aligns with AGENTS.md's "Apple-family target |
| 148 | + changes must keep device.ts, capabilities.ts, dispatch-resolve.ts, ios/devices.ts, ios/runner-xctestrun.ts |
| 149 | + in sync" rule — this step is what makes that rule a single seam instead of a five-file checklist.)* |
| 150 | +2. **Relocate macOS** out of `platforms/ios` into `apple/os/macos/` and invert the `macos/devices.ts` stub. |
| 151 | + Self-contained de-scatter; the single biggest mislabel removed. |
| 152 | +3. **Move the OS-agnostic core** (`runner/` stack, tool-provider, discovery, snapshot, screenshot, perf, |
| 153 | + debug-symbols, `os-profiles.ts`) `platforms/ios` → `platforms/apple/core` — pure move + re-export. |
| 154 | + Rename `ios-runner/` → `apple-runner/` (cosmetic). |
| 155 | +4. **Promote tvOS** from `ios + target:'tv'` to an `apple/os/tvos/` leaf (rename; behavior already exists). |
| 156 | +5. **visionOS** as the first net-new OS: profile row + `SUPPORTED_PLATFORMS += xros` + build case + |
| 157 | + `#if os(visionOS)` + widened discovery + **budgeted spatial-input/snapshot QA**. |
| 158 | +6. **watchOS** = explicit unsupported sentinel (declared, gated at admission), no runner work. |
| 159 | +7. **Per-`AppleOS` capability table** replaces the scattered predicates (after the leaves exist). |
| 160 | + |
| 161 | +Steps 1–4 compose with the roadmap's **Phase 3 (platform plugin)** — the Apple plugin is the first real |
| 162 | +`PlatformPlugin`, and it owns the `AppleOS` leaves. Step 1 can land early as additive groundwork. |
| 163 | + |
| 164 | +## Risks / do-not-flatten |
| 165 | + |
| 166 | +- **tvOS has a different interaction contract** (focus-only; `tap(x,y)` returns `UNSUPPORTED` off the |
| 167 | + focused element). A uniform tap across Apple OSes is wrong by design — keep the per-OS capability gates. |
| 168 | +- **macOS is a hybrid backend** (XCTest runner *and* the macos-helper binary). Don't fold the helper into |
| 169 | + the runner. |
| 170 | +- **visionOS is feasible but unvalidated** — spatial windowing, ornaments, multi-window viewport inference, |
| 171 | + and look+pinch synthesis need real device/sim QA. "Just add a profile row" under-counts it. |
| 172 | +- **Snapshot fidelity is uneven** — the deep-RN-tree AX-server fallback is iOS-simulator-only; macOS / tvOS |
| 173 | + / visionOS rely on public XCTest snapshots, so a unified "apple snapshot" has materially different |
| 174 | + reliability per OS. |
| 175 | +- **`watchos` must be gated unsupported**, or it surfaces a selectable device with no runner backend. |
| 176 | +- The relocation touches ~52 `macos` + ~15 `tv` references — mechanical (move + re-export) but high diff; |
| 177 | + stage as create-re-export → move-leaves → flip-the-stub-inversion-last to keep each step shippable. |
0 commit comments