186 lines
11 KiB
Markdown
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.
|