11 KiB
Phase 2 — iOS App MVP
Status: blocked on Phase 1 (sidecar must be reachable). Owners: parallelisable; see task table. Repo: new repository
pi-remote-iosadjacent topi-remote-control, atgit.vpsj.de/jay/pi-remote-ios. Reason: Swift project, separate tooling, separate release cadence. Spec reference: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-inputwhile 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
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, offmain. - 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
wslibrary 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.mdin this repo, summary mirrored intoSYNC.md.- Trigger Phase 3.