# Phase 2 — iOS App MVP > **Status:** blocked on Phase 1 (sidecar must be reachable). > **Owners:** parallelisable; see task table. > **Repo:** new repository `pi-remote-ios` adjacent to `pi-remote-control`, > at `git.vpsj.de/jay/pi-remote-ios`. Reason: Swift project, separate > tooling, separate release cadence. > **Spec reference:** [`reference/SPEC-ios-app.md`](./reference/SPEC-ios-app.md) §5 > Groups A, B (sans hardware-keyboard), C-01, C-02, D-01 + a/b, E, F. ## Goal A SwiftUI iOS app that: - Pairs with a sidecar via QR scan. - Renders a single pi session 1:1 via SwiftTerm. - Sends keystrokes back via the IC-1 protocol. - Survives backgrounding and reconnect within the < 1s P-3 target. - Switches between multiple sessions with pre-connect cache. - Receives push notifications when pi reaches `awaiting-input`. After Phase 2 the app is usable in the user's daily workflow, replacing the legacy HTML client. Augmentations (slash palette, voice, themes, search, …) come in Phase 3. ## Acceptance Criteria - Apple Developer enrolment complete, App ID with Push capability + APNs Auth Key (`.p8`) generated. - App builds and runs on the user's iPhone via Xcode (sandbox APNs). - App pairs via QR, persists bearer token + cert pinning across launches. - Foreground rendering: SwiftTerm shows pi 1:1, input round-trips. - Background → foreground: < 1s to live stream, no visible empty screen. - Three named sessions, switcher works, pre-connect makes switching feel instant. - Push notification fires when pi state transitions to `awaiting-input` while app is backgrounded. - TestFlight build distributable (production APNs route exercised). - Face-ID gate available as opt-in setting. ## Project Layout New repo `pi-remote-ios`: ``` pi-remote-ios/ ├── README.md ├── Package.swift — SwiftPM (deps: SwiftTerm, Starscream) ├── Apps/ │ └── piRemote/ — main app target │ ├── piRemoteApp.swift — @main entry │ ├── Resources/ │ │ ├── Themes/ — bundled .json theme files │ │ ├── Fonts/ — JetBrains Mono, Hack, etc. │ │ └── Assets.xcassets │ └── Info.plist ├── Sources/ │ ├── Core/ — networking, state, persistence │ │ ├── Network/ │ │ │ ├── WebSocketClient.swift — Starscream wrapper, permessage-deflate │ │ │ ├── FrameCodec.swift — IC-1 encode/decode │ │ │ ├── ResumeCursor.swift — lastSeq tracking per session │ │ │ └── PinnedTrust.swift — TLS pinning from QR fingerprint │ │ ├── Auth/ │ │ │ ├── Keychain.swift │ │ │ └── Pairing.swift — QR parse, exchange │ │ ├── Sessions/ │ │ │ ├── SessionRegistry.swift — list, spawn, kill (talks to /sessions) │ │ │ ├── SessionConnection.swift — one WS per session │ │ │ └── PreConnectPool.swift — D-01a strategy │ │ ├── Push/ │ │ │ ├── NotificationDelegate.swift │ │ │ └── DeviceTokenRegistrar.swift — sends token + env to sidecar │ │ └── Persistence/ │ │ ├── ScrollbackCache.swift — rolling 5MB per session, on disk │ │ └── Preferences.swift │ ├── UI/ │ │ ├── Terminal/ │ │ │ ├── TerminalView.swift — UIViewRepresentable wrapping SwiftTerm │ │ │ ├── ThemeStore.swift — bundled themes, currently selected │ │ │ └── FontStore.swift │ │ ├── Input/ │ │ │ ├── ModifierBar.swift — [Ctrl][Esc][Tab][←↑↓→][⇧↵][🎙][📋] │ │ │ ├── ModifierState.swift — sticky Ctrl + repeat handling │ │ │ └── PasteSheet.swift — confirm-before-paste │ │ ├── Status/ │ │ │ └── StatusBar.swift — connection + pi state │ │ ├── Sessions/ │ │ │ ├── SessionSwitcher.swift — list, spawn, switch │ │ │ └── SessionRow.swift — name + state badge (no thumbnail in MVP) │ │ ├── Pairing/ │ │ │ ├── QRScannerView.swift │ │ │ └── PairingFlowView.swift │ │ └── Settings/ │ │ └── SettingsView.swift — Face-ID toggle, sidecar info │ └── Voice/ — empty placeholder, populated in Phase 3 ├── Tests/ │ └── CoreTests/ │ ├── FrameCodecTests.swift │ └── ResumeCursorTests.swift └── docs/ ├── BUILD.md └── DISTRIBUTION.md — TestFlight steps ``` ## Task Breakdown | ID | Task | Touches | Depends on | Parallel With | |---|---|---|---|---| | T-2.0 | **Repo + Xcode project scaffold + Apple Developer setup.** Create repo on git.vpsj.de, generate App ID + APNs Auth Key, commit `.p8` instructions (key itself stays out of git). Empty SwiftUI shell that boots and shows "Hello pi". | repo root, `Apps/piRemote/` | Phase 1 sidecar reachable | none — must land first | | T-2.1 | **WebSocketClient + FrameCodec.** Starscream, permessage-deflate enabled, encode/decode IC-1 frames, basic ping/pong keepalive. Unit-tested. | `Sources/Core/Network/` | T-2.0 | T-2.2, T-2.3, T-2.4 | | T-2.2 | **Pairing flow + Keychain + TLS pinning.** QR scanner (AVFoundation), parse `pi-remote://`, exchange via Pairing.swift, store bearer + fingerprint in Keychain, install PinnedTrust into URLSession + Starscream. | `Sources/Core/Auth/`, `Sources/Core/Network/PinnedTrust.swift`, `Sources/UI/Pairing/` | T-2.0 | T-2.1, T-2.3 | | T-2.3 | **TerminalView + Theme/Font store.** Wrap SwiftTerm as UIViewRepresentable, render incoming binary chunks, expose Pinch-Zoom gesture (iOS-B-05), Selection/Copy (iOS-B-04). Bundle JetBrains Mono + Hack + default themes. | `Sources/UI/Terminal/`, `Apps/piRemote/Resources/` | T-2.0 | T-2.1, T-2.2, T-2.4 | | T-2.4 | **ModifierBar + Input pipeline.** Layout `[Ctrl][Esc][Tab][←↑↓→][⇧↵][🎙][📋]`, sticky Ctrl, long-press repeat, paste sheet stub (full Smart-Paste in Phase 3). Wires keys into IC-1. | `Sources/UI/Input/` | T-2.1 | T-2.2, T-2.3 | | T-2.5 | **SessionConnection + ResumeCursor + ScrollbackCache.** One WS per session, persist lastSeq, write incoming bytes into a rolling on-disk file per session. Snapshot fallback on gap. | `Sources/Core/Sessions/SessionConnection.swift`, `Sources/Core/Network/ResumeCursor.swift`, `Sources/Core/Persistence/ScrollbackCache.swift` | T-2.1 | T-2.6, T-2.7 | | T-2.6 | **SessionRegistry + SessionSwitcher UI.** Talks to `/sessions`, list/spawn/rename/kill, switcher UI, basic SessionRow. No thumbnails or pre-connect yet. | `Sources/Core/Sessions/SessionRegistry.swift`, `Sources/UI/Sessions/` | T-2.1, T-2.5 | T-2.7 | | T-2.7 | **PreConnectPool + Optimistic Switch + Stale-Frame.** All known sessions hold a hot WS + last frame; switching shows the cached frame instantly with a "syncing…" pill. | `Sources/Core/Sessions/PreConnectPool.swift`, `Sources/UI/Terminal/TerminalView.swift` (cache hooks) | T-2.5, T-2.6 | T-2.8 | | T-2.8 | **StatusBar + side-channel consumption.** Subscribe to `state` frames, render `● thinking` / `▶ awaiting` / `⏸ idle`, session-name display. | `Sources/UI/Status/`, `Sources/Core/Sessions/SessionConnection.swift` (event surface) | T-2.5 | T-2.7 | | T-2.9 | **Push: NotificationDelegate + DeviceTokenRegistrar.** Request user permission, register for remote notifications, ship `{ deviceToken, environment }` to sidecar at pair-time and on every launch. Foreground-handler suppresses banners when relevant session is visible. | `Sources/Core/Push/`, edits in pairing/Settings flow | T-2.2, Phase 1 T-1.10 | T-2.8 | | T-2.10 | **Background lifecycle.** App-foreground triggers reconnect + delta pull, stale-frame freezes during sync, keep-alive ping in foreground only. | `Sources/Core/Sessions/SessionConnection.swift`, app delegate | T-2.5, T-2.7 | parallel with T-2.9 | | T-2.11 | **Face-ID gate + Settings.** Opt-in toggle, gate appears on cold launch and on resume after > N seconds backgrounded. | `Sources/UI/Settings/`, `Sources/Core/Auth/Keychain.swift` | T-2.0 | parallel with most | | T-2.12 | **TestFlight pipeline.** Build script, archive, upload, internal testers list. Verify production APNs path. | `docs/DISTRIBUTION.md`, Fastlane or shell scripts | T-2.0, T-2.9 | parallel with everything once T-2.0 is in | | T-2.13 | **MVP smoke test.** Manual checklist run on the user's iPhone: pair → render → input → backgrounded → push → reopen < 1s → session-switch round-trip. Document any deviations. | `docs/PHASE-2-report.md` | all above | none | ## Interface Contracts iOS consumes the IC-1..IC-4 contracts defined in Phase 1. Any deviation discovered while building is fixed in the sidecar, not the app, and must be communicated via `SYNC.md` (lock change protocol). Additional iOS-internal contract: ### IC-2.1 — SessionConnection surface ```swift protocol SessionConnection { var id: String { get } var state: AnyPublisher { get } var stream: AnyPublisher { get } // ANSI bytes, in order func send(_ frame: ClientToServer) async throws func resume(from lastSeq: UInt64?) async throws func suspend() async // tear down WS but keep state } ``` Owners of `SessionConnection`: T-2.5. Consumers: T-2.6, T-2.7, T-2.8, T-2.10. ## Branching Strategy - All work on `pi-remote-ios`, off `main`. - One branch per T-2.x task, `feat/p2--`. - T-2.0 must land first. - T-2.1, T-2.2, T-2.3, T-2.4, T-2.11, T-2.12 can start in parallel right after T-2.0. - T-2.5..T-2.10 form a dependency cluster but most can interleave. - T-2.13 last. ## Test Strategy - Unit tests for FrameCodec, ResumeCursor, theme parsing. - UI snapshot tests for ModifierBar, SessionRow, StatusBar. - Manual on-device testing via T-2.13 checklist. - No XCUITest in MVP — too brittle for the time invested. ## Risks - **R1.** Apple Developer enrolment delays. Workaround: dev with personal team + free sideloading for the first 1-2 weeks; switch to paid account before T-2.12. - **R2.** Starscream's permessage-deflate compat with our `ws` library needs verification with a smoke test early — block T-2.1 PR until proven. - **R3.** SwiftTerm's alternate-screen handling vs. our scrollback cache. Cache must skip bytes while alternate-screen is active. Spec calls for this; implementation needs care. - **R4.** Push notification permission UX. If user declines, iOS-C-02 degrades to silent. Provide a Settings deep-link to re-enable. ## Exit / Handover - All T-2.x merged. - T-2.13 report green. - App on user's iPhone in daily use. - `docs/PHASE-2-report.md` in this repo, summary mirrored into `SYNC.md`. - Trigger Phase 3.