feat(ios): W3 UI layer + integration CI + ntfy docs

T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM)
T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling
T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field)
T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics
T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed)
T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites)
Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
Yaojia Wang
2026-07-05 00:13:14 +02:00
parent 5c8c87fb7a
commit 5098643355
33 changed files with 5946 additions and 616 deletions

View File

@@ -0,0 +1,57 @@
/// Key-name terminal byte-string table (T-iOS-11; pure data, no I/O).
///
/// Byte-for-byte mirror of the web client's `public/keybar.ts` `KEY_MAP`
/// the SINGLE source of truth for every labelbytes lookup on iOS: the KeyBar
/// buttons AND the hardware `UIKeyCommand` mapping both resolve through here
/// (plan §7 T-iOS-11). Hand-writing an escape sequence anywhere in the App
/// layer is a review finding.
///
/// Gotcha carried over from the web client: Enter is `\r` (0x0D, carriage
/// return), NOT `\n` synthesized input with a linefeed breaks TUIs.
public enum KeyByteMap {
/// One key the bar/hardware mapping can send. Raw values mirror the web
/// `KEY_MAP` property names; declaration order mirrors the web table.
public enum Key: String, Sendable, CaseIterable {
case esc
case escEsc
case shiftTab
case arrowUp
case arrowDown
case arrowLeft
case arrowRight
case enter
case ctrlC
case ctrlR
case ctrlO
case ctrlL
case ctrlT
case ctrlB
case ctrlD
case tab
case slash
}
/// The exact byte string to send as `ClientMessage.input(data:)` for `key`.
/// Values are verbatim from public/keybar.ts KEY_MAP (see file doc).
public static func bytes(for key: Key) -> String {
switch key {
case .esc: return "\u{1B}"
case .escEsc: return "\u{1B}\u{1B}"
case .shiftTab: return "\u{1B}[Z"
case .arrowUp: return "\u{1B}[A"
case .arrowDown: return "\u{1B}[B"
case .arrowLeft: return "\u{1B}[D"
case .arrowRight: return "\u{1B}[C"
case .enter: return "\r" // NOT "\n" (CLAUDE.md gotcha)
case .ctrlC: return "\u{03}"
case .ctrlR: return "\u{12}"
case .ctrlO: return "\u{0F}"
case .ctrlL: return "\u{0C}"
case .ctrlT: return "\u{14}"
case .ctrlB: return "\u{02}"
case .ctrlD: return "\u{04}"
case .tab: return "\t"
case .slash: return "/"
}
}
}

View File

@@ -0,0 +1,77 @@
import Testing
@testable import SessionCore
/// T-iOS-11 · KeyByteMap (plan §7). The key-bar byte table must mirror
/// `public/keybar.ts` `KEY_MAP` byte-for-byte every entry is compared
/// against an independently written expectation (raw scalar values, not the
/// implementation's own literals) so a typo in either side turns the suite red.
@Suite("KeyByteMap")
struct KeyByteMapTests {
/// Expected byte strings, transcribed key-by-key from public/keybar.ts
/// KEY_MAP (the web client's single source of truth). Values are built
/// from Unicode scalars so this table cannot share a typo with the
/// implementation's string literals.
private static let expectedBytesByKey: [KeyByteMap.Key: [UInt8]] = [
.esc: [0x1B], // '\x1b'
.escEsc: [0x1B, 0x1B], // '\x1b\x1b'
.shiftTab: [0x1B, 0x5B, 0x5A], // '\x1b[Z'
.arrowUp: [0x1B, 0x5B, 0x41], // '\x1b[A'
.arrowDown: [0x1B, 0x5B, 0x42], // '\x1b[B'
.arrowLeft: [0x1B, 0x5B, 0x44], // '\x1b[D'
.arrowRight: [0x1B, 0x5B, 0x43], // '\x1b[C'
.enter: [0x0D], // '\r' carriage return, NOT '\n'
.ctrlC: [0x03],
.ctrlR: [0x12],
.ctrlO: [0x0F],
.ctrlL: [0x0C],
.ctrlT: [0x14],
.ctrlB: [0x02],
.ctrlD: [0x04],
.tab: [0x09], // '\t'
.slash: [0x2F], // '/'
]
@Test("every key resolves to the exact bytes of public/keybar.ts KEY_MAP")
func everyEntryMatchesWebKeyMapByteForByte() {
for key in KeyByteMap.Key.allCases {
// Arrange
let expected = Self.expectedBytesByKey[key]
// Act
let actual = Array(KeyByteMap.bytes(for: key).utf8)
// Assert
#expect(actual == expected, "bytes mismatch for \(key.rawValue)")
}
}
@Test("the table covers exactly the 17 web KEY_MAP entries — no more, no less")
func tableCoversExactlyTheWebKeyMapEntries() {
#expect(KeyByteMap.Key.allCases.count == Self.expectedBytesByKey.count)
#expect(Set(KeyByteMap.Key.allCases).count == KeyByteMap.Key.allCases.count)
}
@Test("Enter sends carriage return (0x0D), never linefeed (gotcha: \\r not \\n)")
func enterIsCarriageReturnNotLinefeed() {
#expect(KeyByteMap.bytes(for: .enter) == "\r")
#expect(KeyByteMap.bytes(for: .enter) != "\n")
}
@Test("arrow CSI letters: up=A down=B left=D right=C (left/right are the classic swap)")
func arrowFinalLettersAreNotSwapped() {
#expect(KeyByteMap.bytes(for: .arrowUp).hasSuffix("A"))
#expect(KeyByteMap.bytes(for: .arrowDown).hasSuffix("B"))
#expect(KeyByteMap.bytes(for: .arrowLeft).hasSuffix("D"))
#expect(KeyByteMap.bytes(for: .arrowRight).hasSuffix("C"))
}
@Test("raw values mirror the web KEY_MAP property names (stable lookup identity)")
func rawValuesMirrorWebKeyNames() {
let expectedNames = [
"esc", "escEsc", "shiftTab", "arrowUp", "arrowDown", "arrowLeft",
"arrowRight", "enter", "ctrlC", "ctrlR", "ctrlO", "ctrlL", "ctrlT",
"ctrlB", "ctrlD", "tab", "slash",
]
#expect(KeyByteMap.Key.allCases.map(\.rawValue) == expectedNames)
}
}