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

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-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 §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

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.