pi-remote-control/docs/PHASE-2-ios-mvp.md

186 lines
11 KiB
Markdown

# 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<PiState, Never> { get }
var stream: AnyPublisher<Data, Never> { 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-<task-id>-<slug>`.
- 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.