Compare commits
7 Commits
aa010cf874
...
6b953008ce
| Author | SHA1 | Date |
|---|---|---|
|
|
6b953008ce | |
|
|
27d0a43dbe | |
|
|
d6062000e8 | |
|
|
89c27c0eae | |
|
|
9fb5f813a1 | |
|
|
49667667eb | |
|
|
f6396bc70e |
|
|
@ -0,0 +1,97 @@
|
|||
// Sources/Core/Auth/Keychain.swift
|
||||
// T-2.2: Generic Keychain wrapper (kSecClassGenericPassword)
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum KeychainError: Error, Sendable {
|
||||
case notFound
|
||||
case encodingFailed
|
||||
case saveFailed(OSStatus)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
/// Thread-safe Keychain wrapper for `Codable` values stored as JSON data.
|
||||
/// All items use `kSecClassGenericPassword` with a caller-supplied service key.
|
||||
final class Keychain: Sendable {
|
||||
|
||||
static let shared = Keychain()
|
||||
|
||||
/// Canonical key used to store the active `SidecarCredential`.
|
||||
static let credentialKey = "piremote.credential"
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: Save
|
||||
|
||||
/// Encodes `value` as JSON and upserts it into the Keychain under `key`.
|
||||
func save<T: Encodable>(_ value: T, key: String) throws {
|
||||
let data: Data
|
||||
do {
|
||||
data = try JSONEncoder().encode(value)
|
||||
} catch {
|
||||
throw KeychainError.encodingFailed
|
||||
}
|
||||
|
||||
// Attempt update first; if item doesn't exist, add it.
|
||||
let query = baseQuery(for: key)
|
||||
let updateAttributes: [CFString: Any] = [kSecValueData: data]
|
||||
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary)
|
||||
if updateStatus == errSecItemNotFound {
|
||||
var addQuery = query
|
||||
addQuery[kSecValueData] = data
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw KeychainError.saveFailed(addStatus)
|
||||
}
|
||||
} else if updateStatus != errSecSuccess {
|
||||
throw KeychainError.saveFailed(updateStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Load
|
||||
|
||||
/// Loads and JSON-decodes a value of type `T` stored under `key`.
|
||||
func load<T: Decodable>(key: String) throws -> T {
|
||||
var query = baseQuery(for: key)
|
||||
query[kSecReturnData] = true
|
||||
query[kSecMatchLimit] = kSecMatchLimitOne
|
||||
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.notFound
|
||||
}
|
||||
guard let data = result as? Data else {
|
||||
throw KeychainError.notFound
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
throw KeychainError.encodingFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delete
|
||||
|
||||
/// Removes the item stored under `key` (no-op if absent).
|
||||
func delete(key: String) {
|
||||
SecItemDelete(baseQuery(for: key) as CFDictionary)
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func baseQuery(for key: String) -> [CFString: Any] {
|
||||
[
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: "de.vpsj.pi-remote",
|
||||
kSecAttrAccount: key,
|
||||
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
// Sources/Core/Auth/Pairing.swift
|
||||
// T-2.2: QR parsing + pairing exchange
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum PairingError: Error, Sendable {
|
||||
case invalidQR
|
||||
case networkError(Error)
|
||||
case serverError(Int, String)
|
||||
case decodingFailed
|
||||
}
|
||||
|
||||
// MARK: - Wire types
|
||||
|
||||
private struct PairRequestBody: Encodable {
|
||||
let pairingToken: String
|
||||
let deviceName: String
|
||||
// deviceToken / environment injected in T-2.4 (Push integration)
|
||||
}
|
||||
|
||||
private struct PairResponseBody: Decodable {
|
||||
let bearerToken: String
|
||||
let sidecarId: String
|
||||
}
|
||||
|
||||
// MARK: - PairingService
|
||||
|
||||
struct PairingService: Sendable {
|
||||
|
||||
// MARK: QR parsing
|
||||
|
||||
/// Parses a `pi-remote://` URL produced by the sidecar `pi-remote pair` command.
|
||||
///
|
||||
/// Expected format:
|
||||
/// ```
|
||||
/// pi-remote://<host>:<port>?pair=<pairingToken>&fp=<sha256-hex>&name=<sidecarName>
|
||||
/// ```
|
||||
static func parseQR(_ string: String) throws -> (
|
||||
host: String,
|
||||
port: Int,
|
||||
pairingToken: String,
|
||||
fingerprint: String,
|
||||
name: String
|
||||
) {
|
||||
var components = URLComponents(string: string)
|
||||
|
||||
// The custom scheme confuses URLComponents on some inputs — patch it.
|
||||
if components == nil || components?.host == nil {
|
||||
// Try replacing custom scheme with https so standard parsing works.
|
||||
let patched = string.replacingOccurrences(of: "pi-remote://", with: "https://")
|
||||
components = URLComponents(string: patched)
|
||||
}
|
||||
|
||||
guard
|
||||
let comps = components,
|
||||
let host = comps.host, !host.isEmpty,
|
||||
let port = comps.port
|
||||
else {
|
||||
throw PairingError.invalidQR
|
||||
}
|
||||
|
||||
let items = comps.queryItems ?? []
|
||||
|
||||
func queryValue(_ name: String) throws -> String {
|
||||
guard let value = items.first(where: { $0.name == name })?.value,
|
||||
!value.isEmpty
|
||||
else { throw PairingError.invalidQR }
|
||||
return value
|
||||
}
|
||||
|
||||
let pairingToken = try queryValue("pair")
|
||||
let fingerprint = try queryValue("fp")
|
||||
let name = try queryValue("name")
|
||||
|
||||
return (host: host, port: port, pairingToken: pairingToken,
|
||||
fingerprint: fingerprint, name: name)
|
||||
}
|
||||
|
||||
// MARK: Exchange
|
||||
|
||||
/// Sends `POST /pair` to the sidecar and returns a persisted `SidecarCredential`.
|
||||
///
|
||||
/// - Note: TLS pinning via `PinnedTrust` is wired in T-2.5.
|
||||
/// Until then the request uses plain HTTP or default TLS validation.
|
||||
func exchange(
|
||||
host: String,
|
||||
port: Int,
|
||||
pairingToken: String,
|
||||
fingerprint: String,
|
||||
name: String,
|
||||
deviceName: String
|
||||
) async throws -> SidecarCredential {
|
||||
|
||||
// Prefer HTTPS; the sidecar uses a self-signed cert that will be
|
||||
// pinned in T-2.5. For now plain HTTP is also acceptable during dev.
|
||||
guard let url = URL(string: "http://\(host):\(port)/pair") else {
|
||||
throw PairingError.invalidQR
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url, timeoutInterval: 15)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let body = PairRequestBody(pairingToken: pairingToken, deviceName: deviceName)
|
||||
do {
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
} catch {
|
||||
throw PairingError.decodingFailed
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: request)
|
||||
} catch {
|
||||
throw PairingError.networkError(error)
|
||||
}
|
||||
|
||||
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||
let body = String(data: data, encoding: .utf8) ?? "<empty>"
|
||||
throw PairingError.serverError(http.statusCode, body)
|
||||
}
|
||||
|
||||
let decoded: PairResponseBody
|
||||
do {
|
||||
decoded = try JSONDecoder().decode(PairResponseBody.self, from: data)
|
||||
} catch {
|
||||
throw PairingError.decodingFailed
|
||||
}
|
||||
|
||||
return SidecarCredential(
|
||||
sidecarId: decoded.sidecarId,
|
||||
host: host,
|
||||
port: port,
|
||||
bearerToken: decoded.bearerToken,
|
||||
tlsFingerprint: fingerprint,
|
||||
sidecarName: name,
|
||||
pairedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// Sources/Core/Auth/SidecarCredential.swift
|
||||
// T-2.2: Pairing flow — credential model
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Persisted after a successful pairing exchange.
|
||||
/// Stored in Keychain under `Keychain.credentialKey`.
|
||||
struct SidecarCredential: Codable, Sendable {
|
||||
let sidecarId: String
|
||||
let host: String
|
||||
let port: Int
|
||||
let bearerToken: String
|
||||
/// SHA-256 hex fingerprint of the sidecar's self-signed TLS cert (from QR).
|
||||
/// Used by `PinnedTrust` (wired in T-2.5) to validate the TLS handshake.
|
||||
let tlsFingerprint: String
|
||||
let sidecarName: String
|
||||
let pairedAt: Date
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
// FrameCodec.swift
|
||||
// IC-1 WebSocket frame encoding/decoding.
|
||||
//
|
||||
// Wire format
|
||||
// -----------
|
||||
// Binary frame (server → client): [seq: 8 bytes big-endian UInt64][raw ANSI bytes]
|
||||
// Text frame (both directions) : JSON, type-discriminated by the "type" key.
|
||||
//
|
||||
// This file has no Starscream import — pure Foundation only, fully unit-testable.
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - PiState
|
||||
|
||||
/// The reported state of the pi agent.
|
||||
public enum PiState: String, Decodable, Sendable {
|
||||
case thinking
|
||||
case tool
|
||||
case idle
|
||||
case awaitingInput = "awaiting-input"
|
||||
}
|
||||
|
||||
// MARK: - ServerToClient
|
||||
|
||||
/// JSON text frames sent from the server to the client.
|
||||
public enum ServerToClient: Sendable {
|
||||
/// Agent state update (thinking / tool / idle / awaiting-input).
|
||||
case state(value: PiState, tool: String?, ts: Int)
|
||||
/// Full-screen snapshot encoded as base64 ANSI bytes.
|
||||
case snapshot(seq: UInt64, data: String)
|
||||
/// Session metadata pushed on connect / rename.
|
||||
case sessionMeta(name: String, description: String?, createdAt: String)
|
||||
/// Protocol-level error from the server.
|
||||
case error(code: String, message: String)
|
||||
}
|
||||
|
||||
extension ServerToClient: Decodable {
|
||||
private enum TypeKey: String, Decodable {
|
||||
case state
|
||||
case snapshot
|
||||
case sessionMeta = "session-meta"
|
||||
case error
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case value, tool, ts // state
|
||||
case seq, data // snapshot
|
||||
case name, description, createdAt // session-meta
|
||||
case code, message // error
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let typeKey = try container.decode(TypeKey.self, forKey: .type)
|
||||
switch typeKey {
|
||||
case .state:
|
||||
let value = try container.decode(PiState.self, forKey: .value)
|
||||
let tool = try container.decodeIfPresent(String.self, forKey: .tool)
|
||||
let ts = try container.decode(Int.self, forKey: .ts)
|
||||
self = .state(value: value, tool: tool, ts: ts)
|
||||
|
||||
case .snapshot:
|
||||
let seq = try container.decode(UInt64.self, forKey: .seq)
|
||||
let data = try container.decode(String.self, forKey: .data)
|
||||
self = .snapshot(seq: seq, data: data)
|
||||
|
||||
case .sessionMeta:
|
||||
let name = try container.decode(String.self, forKey: .name)
|
||||
let description = try container.decodeIfPresent(String.self, forKey: .description)
|
||||
let createdAt = try container.decode(String.self, forKey: .createdAt)
|
||||
self = .sessionMeta(name: name, description: description, createdAt: createdAt)
|
||||
|
||||
case .error:
|
||||
let code = try container.decode(String.self, forKey: .code)
|
||||
let message = try container.decode(String.self, forKey: .message)
|
||||
self = .error(code: code, message: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ClientToServer
|
||||
|
||||
/// JSON text frames sent from the client to the server.
|
||||
public enum ClientToServer: Sendable {
|
||||
/// Attach / resume from the given sequence number (nil = start fresh).
|
||||
case resume(lastSeq: UInt64?)
|
||||
/// Named key press (e.g. "escape", "up", "enter").
|
||||
case key(name: String)
|
||||
/// Literal text, delivered via send-keys -l.
|
||||
case keys(data: String)
|
||||
/// Text wrapped in bracketed-paste sequences.
|
||||
case paste(data: String)
|
||||
/// Request a full ANSI snapshot of the current pane.
|
||||
case snapshotRequest
|
||||
}
|
||||
|
||||
extension ClientToServer: Encodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type, lastSeq, name, data
|
||||
}
|
||||
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .resume(let lastSeq):
|
||||
try c.encode("resume", forKey: .type)
|
||||
// Encode null explicitly when lastSeq is nil so the server
|
||||
// receives {"type":"resume","lastSeq":null} as the spec requires.
|
||||
try c.encode(lastSeq, forKey: .lastSeq)
|
||||
|
||||
case .key(let name):
|
||||
try c.encode("key", forKey: .type)
|
||||
try c.encode(name, forKey: .name)
|
||||
|
||||
case .keys(let data):
|
||||
try c.encode("keys", forKey: .type)
|
||||
try c.encode(data, forKey: .data)
|
||||
|
||||
case .paste(let data):
|
||||
try c.encode("paste", forKey: .type)
|
||||
try c.encode(data, forKey: .data)
|
||||
|
||||
case .snapshotRequest:
|
||||
try c.encode("snapshot-request", forKey: .type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BinaryFrame
|
||||
|
||||
/// A decoded IC-1 binary frame.
|
||||
///
|
||||
/// Wire layout: `[seq: 8 bytes big-endian UInt64][raw ANSI bytes …]`
|
||||
public struct BinaryFrame: Sendable {
|
||||
/// Monotonically increasing chunk sequence number assigned by the server.
|
||||
public let seq: UInt64
|
||||
/// Raw ANSI terminal bytes for this chunk.
|
||||
public let data: Data
|
||||
|
||||
/// Decodes a raw WebSocket binary message into a `BinaryFrame`.
|
||||
///
|
||||
/// Returns `nil` when `raw` is shorter than the 8-byte header.
|
||||
public static func decode(_ raw: Data) -> BinaryFrame? {
|
||||
guard raw.count >= 8 else { return nil }
|
||||
|
||||
// Read 8-byte big-endian UInt64 from the leading bytes.
|
||||
let seqBytes = raw.prefix(8)
|
||||
let seq = seqBytes.reduce(into: UInt64(0)) { acc, byte in
|
||||
acc = (acc << 8) | UInt64(byte)
|
||||
}
|
||||
|
||||
let payload = raw.dropFirst(8)
|
||||
return BinaryFrame(seq: seq, data: Data(payload))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FrameCodec (static utilities)
|
||||
|
||||
/// Namespace for IC-1 frame encode/decode helpers.
|
||||
public enum FrameCodec {
|
||||
|
||||
// Shared encoder / decoder — both are safe to use from a single actor.
|
||||
private static let encoder: JSONEncoder = {
|
||||
let enc = JSONEncoder()
|
||||
enc.outputFormatting = [] // compact, stable output
|
||||
return enc
|
||||
}()
|
||||
|
||||
private static let decoder = JSONDecoder()
|
||||
|
||||
/// Encodes a `ClientToServer` frame to a compact JSON string.
|
||||
///
|
||||
/// Throws if encoding fails (in practice this should never happen for
|
||||
/// the well-typed cases defined above).
|
||||
public static func encode(_ frame: ClientToServer) throws -> String {
|
||||
let data = try encoder.encode(frame)
|
||||
// JSON serialisation always produces valid UTF-8; the force-unwrap
|
||||
// is safe and intentional — a nil result here would be a Foundation bug.
|
||||
return String(data: data, encoding: .utf8)! // swiftlint:disable:this force_unwrap
|
||||
}
|
||||
|
||||
/// Decodes a JSON string into a `ServerToClient` frame.
|
||||
///
|
||||
/// Throws `DecodingError` for malformed or unknown payloads.
|
||||
public static func decode(_ text: String) throws -> ServerToClient {
|
||||
let data = Data(text.utf8)
|
||||
return try decoder.decode(ServerToClient.self, from: data)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
// ResumeCursor.swift
|
||||
// Persists the last-seen IC-1 sequence number per session across app launches.
|
||||
//
|
||||
// Storage: UserDefaults (standard suite).
|
||||
// Key schema: "ResumeCursor.<sessionId>"
|
||||
//
|
||||
// Thread-safety: UserDefaults is documented as thread-safe; this class adds no
|
||||
// additional synchronisation. All callers are expected to be on the main actor
|
||||
// in practice (alongside WebSocketClient), though no actor annotation is
|
||||
// imposed here so tests can call freely.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Stores and retrieves the last acknowledged IC-1 sequence number for each
|
||||
/// session, enabling resume-from-cursor on reconnect.
|
||||
public final class ResumeCursor {
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let keyPrefix = "ResumeCursor."
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
/// Creates a cursor backed by `defaults` (defaults to `.standard`).
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Returns the last persisted sequence number for `sessionId`, or `nil`
|
||||
/// if no sequence has been stored (i.e. first-ever connection).
|
||||
public func lastSeq(for sessionId: String) -> UInt64? {
|
||||
let key = storageKey(for: sessionId)
|
||||
// UserDefaults stores integer values as Int64 (signed). We use the raw
|
||||
// bit pattern to round-trip UInt64 without loss, since UInt64.max
|
||||
// exceeds Int.max on 64-bit platforms.
|
||||
guard defaults.object(forKey: key) != nil else { return nil }
|
||||
let raw = defaults.integer(forKey: key)
|
||||
return UInt64(bitPattern: Int64(raw))
|
||||
}
|
||||
|
||||
/// Persists `seq` as the latest acknowledged chunk for `sessionId`.
|
||||
///
|
||||
/// Called after successfully processing a `BinaryFrame` so that the cursor
|
||||
/// always reflects a frame that the app has actually consumed.
|
||||
public func update(sessionId: String, seq: UInt64) {
|
||||
// Store as Int64 bit-pattern; see note in lastSeq(for:).
|
||||
let raw = Int(Int64(bitPattern: seq))
|
||||
defaults.set(raw, forKey: storageKey(for: sessionId))
|
||||
}
|
||||
|
||||
/// Removes the stored cursor for `sessionId`.
|
||||
///
|
||||
/// Call this when a session is deleted or a full snapshot has been received
|
||||
/// and the client no longer needs delta replay.
|
||||
public func clear(sessionId: String) {
|
||||
defaults.removeObject(forKey: storageKey(for: sessionId))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func storageKey(for sessionId: String) -> String {
|
||||
keyPrefix + sessionId
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
// WebSocketClient.swift
|
||||
// Starscream wrapper that speaks the IC-1 WebSocket protocol.
|
||||
//
|
||||
// URL pattern: ws(s)://<host>/sessions/<id>/stream?token=<token>
|
||||
//
|
||||
// Design note: Starscream 4.0.x also declares a *protocol* named
|
||||
// `WebSocketClient` (the abstract socket interface). Our concrete class
|
||||
// shares that name. Inside this file we qualify Starscream's protocol as
|
||||
// `Starscream.WebSocketClient` to resolve the ambiguity; everywhere else in
|
||||
// the app our class is the only visible `WebSocketClient`.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Starscream
|
||||
|
||||
// MARK: - ConnectionState
|
||||
|
||||
/// The lifecycle state of a `WebSocketClient` connection.
|
||||
public enum ConnectionState: Sendable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
}
|
||||
|
||||
// MARK: - WebSocketClientError
|
||||
|
||||
/// Errors thrown by `WebSocketClient.send(_:)`.
|
||||
public enum WebSocketClientError: Error, Sendable {
|
||||
/// The socket is not in the `.connected` state.
|
||||
case notConnected
|
||||
/// JSON encoding of the outgoing frame failed.
|
||||
case encodingFailed(any Error)
|
||||
}
|
||||
|
||||
// MARK: - WebSocketClient
|
||||
|
||||
/// IC-1 WebSocket client.
|
||||
///
|
||||
/// All public state is isolated to the main actor.
|
||||
/// Starscream's delegate callbacks (delivered on `DispatchQueue.main` by
|
||||
/// default) are hopped back through a `Task { @MainActor in … }` in
|
||||
/// `DelegateAdapter` before any mutation occurs, keeping the actor
|
||||
/// boundary explicit.
|
||||
@MainActor
|
||||
public final class WebSocketClient {
|
||||
|
||||
// MARK: - Published subjects
|
||||
|
||||
/// Emits every successfully decoded binary frame received from the server.
|
||||
public let incomingBinary = PassthroughSubject<BinaryFrame, Never>()
|
||||
|
||||
/// Emits every successfully decoded JSON frame received from the server.
|
||||
public let incomingJSON = PassthroughSubject<ServerToClient, Never>()
|
||||
|
||||
/// Tracks the current lifecycle state.
|
||||
public let connectionState = CurrentValueSubject<ConnectionState, Never>(.disconnected)
|
||||
|
||||
// MARK: - Private state
|
||||
|
||||
/// The active Starscream socket. `WebSocket` is the concrete Starscream
|
||||
/// class; no name collision here.
|
||||
private var socket: WebSocket?
|
||||
|
||||
/// Non-isolated trampoline that holds a weak back-reference to us.
|
||||
private let delegateAdapter = DelegateAdapter()
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
public init() {
|
||||
delegateAdapter.owner = self
|
||||
}
|
||||
|
||||
// MARK: - Connect / Disconnect
|
||||
|
||||
/// Opens a new WebSocket connection to `url`, tearing down any existing
|
||||
/// socket first.
|
||||
///
|
||||
/// After the `connected` state is published, call
|
||||
/// `send(.resume(lastSeq:))` to attach the IC-1 stream.
|
||||
public func connect(url: URL) {
|
||||
socket?.disconnect()
|
||||
socket = nil
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 10
|
||||
|
||||
let ws = WebSocket(request: request)
|
||||
// Starscream delivers delegate calls on DispatchQueue.main by default.
|
||||
ws.delegate = delegateAdapter
|
||||
socket = ws
|
||||
|
||||
connectionState.send(.connecting)
|
||||
ws.connect()
|
||||
}
|
||||
|
||||
/// Gracefully closes the current socket.
|
||||
public func disconnect() {
|
||||
socket?.disconnect()
|
||||
socket = nil
|
||||
connectionState.send(.disconnected)
|
||||
}
|
||||
|
||||
// MARK: - Send
|
||||
|
||||
/// Encodes `frame` as JSON and writes it to the open socket.
|
||||
///
|
||||
/// - Throws: `WebSocketClientError.notConnected` when not connected,
|
||||
/// `WebSocketClientError.encodingFailed` on JSON encoding error.
|
||||
public func send(_ frame: ClientToServer) async throws {
|
||||
guard let socket, connectionState.value == .connected else {
|
||||
throw WebSocketClientError.notConnected
|
||||
}
|
||||
let json: String
|
||||
do {
|
||||
json = try FrameCodec.encode(frame)
|
||||
} catch {
|
||||
throw WebSocketClientError.encodingFailed(error)
|
||||
}
|
||||
socket.write(string: json)
|
||||
}
|
||||
|
||||
// MARK: - Internal event handling (always on main actor)
|
||||
|
||||
fileprivate func handle(event: WebSocketEvent) {
|
||||
switch event {
|
||||
|
||||
case .connected:
|
||||
connectionState.send(.connected)
|
||||
|
||||
case .disconnected(let reason, let code):
|
||||
// Both clean server-initiated and transport-level disconnects
|
||||
// collapse to the same client state.
|
||||
_ = (reason, code)
|
||||
connectionState.send(.disconnected)
|
||||
|
||||
case .text(let string):
|
||||
do {
|
||||
let frame = try FrameCodec.decode(string)
|
||||
incomingJSON.send(frame)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[WebSocketClient] JSON decode error: \(error)\nPayload: \(string)")
|
||||
#endif
|
||||
}
|
||||
|
||||
case .binary(let data):
|
||||
if let frame = BinaryFrame.decode(data) {
|
||||
incomingBinary.send(frame)
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[WebSocketClient] Binary frame too short (\(data.count) bytes) — ignored.")
|
||||
#endif
|
||||
}
|
||||
|
||||
case .cancelled:
|
||||
connectionState.send(.disconnected)
|
||||
|
||||
case .error(let error):
|
||||
#if DEBUG
|
||||
let desc = error.map { "\($0)" } ?? "unknown"
|
||||
print("[WebSocketClient] Socket error: \(desc)")
|
||||
#endif
|
||||
connectionState.send(.disconnected)
|
||||
|
||||
case .peerClosed:
|
||||
connectionState.send(.disconnected)
|
||||
|
||||
case .ping, .pong, .viabilityChanged, .reconnectSuggested:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DelegateAdapter
|
||||
|
||||
/// Non-isolated trampoline between Starscream's `WebSocketDelegate` callbacks
|
||||
/// and our `@MainActor`-isolated `WebSocketClient`.
|
||||
///
|
||||
/// **Why a separate class?**
|
||||
/// `WebSocketDelegate` is not `@MainActor`, so Swift 6 strict-concurrency
|
||||
/// disallows directly satisfying it from a `@MainActor final class` without
|
||||
/// `nonisolated`. A nested non-isolated class that hops explicitly via
|
||||
/// `Task { @MainActor in … }` keeps the intent clear and compiler-clean.
|
||||
///
|
||||
/// **Naming note:** Starscream 4.0.x declares `WebSocketClient` as a *protocol*
|
||||
/// (the abstract socket interface). The `didReceive` method's `client` parameter
|
||||
/// therefore has type `any Starscream.WebSocketClient`, qualified to disambiguate
|
||||
/// from our own concrete `WebSocketClient` class.
|
||||
private final class DelegateAdapter: WebSocketDelegate, @unchecked Sendable {
|
||||
|
||||
weak var owner: WebSocketClient?
|
||||
|
||||
func didReceive(event: WebSocketEvent, client: any Starscream.WebSocketClient) {
|
||||
guard let owner else { return }
|
||||
Task { @MainActor [owner] in
|
||||
owner.handle(event: event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
// Sources/UI/Pairing/PairingFlowView.swift
|
||||
// T-2.2: SwiftUI pairing flow (idle → scanning → pairing → success/error)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - State machine
|
||||
|
||||
private enum PairingState: Sendable {
|
||||
case idle
|
||||
case scanning
|
||||
case pairing(qrPayload: QRPayload)
|
||||
case success(credential: SidecarCredential)
|
||||
case error(message: String, qrPayload: QRPayload?)
|
||||
}
|
||||
|
||||
/// Intermediate value capturing a parsed QR result.
|
||||
private struct QRPayload: Sendable {
|
||||
let host: String
|
||||
let port: Int
|
||||
let pairingToken: String
|
||||
let fingerprint: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
struct PairingFlowView: View {
|
||||
|
||||
/// Called after the credential is saved to Keychain.
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
@State private var state: PairingState = .idle
|
||||
|
||||
private let service = PairingService()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state {
|
||||
case .idle:
|
||||
idleView
|
||||
case .scanning:
|
||||
scanningView
|
||||
case .pairing:
|
||||
pairingView
|
||||
case .success(let credential):
|
||||
successView(credential: credential)
|
||||
case .error(let message, let payload):
|
||||
errorView(message: message, retryPayload: payload)
|
||||
}
|
||||
}
|
||||
.animation(.default, value: stateTag)
|
||||
}
|
||||
|
||||
// MARK: - Sub-views
|
||||
|
||||
private var idleView: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 100, height: 100)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Pair with Pi Remote")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
|
||||
Text("Scan the QR code shown by `pi-remote pair` on your server.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button(action: { state = .scanning }) {
|
||||
Label("Tap to Scan", systemImage: "camera")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
|
||||
private var scanningView: some View {
|
||||
ZStack(alignment: .top) {
|
||||
QRScannerView { rawString in
|
||||
handleQRScan(rawString)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: { state = .idle }) {
|
||||
Label("Cancel", systemImage: "xmark.circle.fill")
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.title)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
|
||||
Text("Point at the pi-remote QR code")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pairingView: some View {
|
||||
VStack(spacing: 20) {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
Text("Pairing…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func successView(credential: SidecarCredential) -> some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 80, height: 80)
|
||||
.foregroundStyle(.green)
|
||||
|
||||
Text("Connected to \(credential.sidecarName)")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Paired on \(credential.pairedAt.formatted(date: .abbreviated, time: .shortened))")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
|
||||
Button("Done") {
|
||||
onDismiss?()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
|
||||
private func errorView(message: String, retryPayload: QRPayload?) -> some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 80, height: 80)
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
Text("Pairing Failed")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
|
||||
Text(message)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button(action: retryAction(for: retryPayload)) {
|
||||
Label("Retry", systemImage: "arrow.counterclockwise")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private func retryAction(for payload: QRPayload?) -> () -> Void {
|
||||
if let payload {
|
||||
// Retry the exchange with the already-parsed QR data.
|
||||
return { startExchange(payload: payload) }
|
||||
} else {
|
||||
// QR parse failed — let the user scan again.
|
||||
return { state = .scanning }
|
||||
}
|
||||
}
|
||||
|
||||
private func handleQRScan(_ rawString: String) {
|
||||
let payload: QRPayload
|
||||
do {
|
||||
let parsed = try PairingService.parseQR(rawString)
|
||||
payload = QRPayload(
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
pairingToken: parsed.pairingToken,
|
||||
fingerprint: parsed.fingerprint,
|
||||
name: parsed.name
|
||||
)
|
||||
} catch {
|
||||
state = .error(message: "Invalid QR code — please try again.", qrPayload: nil)
|
||||
return
|
||||
}
|
||||
|
||||
startExchange(payload: payload)
|
||||
}
|
||||
|
||||
private func startExchange(payload: QRPayload) {
|
||||
state = .pairing(qrPayload: payload)
|
||||
|
||||
let deviceName = UIDevice.current.name
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let credential = try await service.exchange(
|
||||
host: payload.host,
|
||||
port: payload.port,
|
||||
pairingToken: payload.pairingToken,
|
||||
fingerprint: payload.fingerprint,
|
||||
name: payload.name,
|
||||
deviceName: deviceName
|
||||
)
|
||||
try Keychain.shared.save(credential, key: Keychain.credentialKey)
|
||||
state = .success(credential: credential)
|
||||
} catch let err as PairingError {
|
||||
state = .error(message: friendlyMessage(for: err), qrPayload: payload)
|
||||
} catch {
|
||||
state = .error(message: error.localizedDescription, qrPayload: payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func friendlyMessage(for error: PairingError) -> String {
|
||||
switch error {
|
||||
case .invalidQR:
|
||||
return "The QR code doesn't look like a pi-remote URL."
|
||||
case .networkError(let underlying):
|
||||
return "Network error: \(underlying.localizedDescription)"
|
||||
case .serverError(let code, let body):
|
||||
return "Server returned \(code): \(body)"
|
||||
case .decodingFailed:
|
||||
return "Couldn't understand the server's response."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Computed tag for `animation(value:)` — just needs to change when state changes.
|
||||
private var stateTag: Int {
|
||||
switch state {
|
||||
case .idle: return 0
|
||||
case .scanning: return 1
|
||||
case .pairing: return 2
|
||||
case .success: return 3
|
||||
case .error: return 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
PairingFlowView()
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
// Sources/UI/Pairing/QRScannerView.swift
|
||||
// T-2.2: AVFoundation QR scanner wrapped as UIViewRepresentable
|
||||
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// A fullscreen AVFoundation-backed QR code scanner.
|
||||
///
|
||||
/// Calls `onScan` exactly once with the raw QR string, then stops the
|
||||
/// capture session so the caller can drive the next state transition.
|
||||
struct QRScannerView: UIViewRepresentable {
|
||||
|
||||
/// Called on the **main actor** with the raw decoded QR string.
|
||||
let onScan: @MainActor (String) -> Void
|
||||
|
||||
// MARK: UIViewRepresentable
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onScan: onScan)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> PreviewView {
|
||||
let view = PreviewView()
|
||||
let coordinator = context.coordinator
|
||||
|
||||
let session = coordinator.session
|
||||
|
||||
// Input
|
||||
guard
|
||||
let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device)
|
||||
else {
|
||||
return view
|
||||
}
|
||||
|
||||
guard session.canAddInput(input) else { return view }
|
||||
session.addInput(input)
|
||||
|
||||
// Output
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else { return view }
|
||||
session.addOutput(output)
|
||||
|
||||
output.setMetadataObjectsDelegate(coordinator,
|
||||
queue: DispatchQueue.main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
|
||||
// Preview layer
|
||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
view.previewLayer = previewLayer
|
||||
view.layer.addSublayer(previewLayer)
|
||||
|
||||
coordinator.previewLayer = previewLayer
|
||||
|
||||
// Start capture on a background thread to avoid blocking the main queue.
|
||||
Task.detached(priority: .userInitiated) {
|
||||
session.startRunning()
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PreviewView, context: Context) {
|
||||
// Layout is handled inside PreviewView.layoutSubviews.
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ uiView: PreviewView, coordinator: Coordinator) {
|
||||
coordinator.stop()
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
|
||||
let session = AVCaptureSession()
|
||||
var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
|
||||
private let onScan: @MainActor (String) -> Void
|
||||
private var hasScanned = false
|
||||
|
||||
init(onScan: @MainActor @escaping (String) -> Void) {
|
||||
self.onScan = onScan
|
||||
}
|
||||
|
||||
// Called on main queue (set via setMetadataObjectsDelegate).
|
||||
nonisolated func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
MainActor.assumeIsolated {
|
||||
guard !hasScanned else { return }
|
||||
guard
|
||||
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
object.type == .qr,
|
||||
let string = object.stringValue
|
||||
else { return }
|
||||
|
||||
hasScanned = true
|
||||
stop()
|
||||
onScan(string)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard session.isRunning else { return }
|
||||
Task.detached(priority: .userInitiated) { [session] in
|
||||
session.stopRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PreviewView
|
||||
|
||||
/// UIView subclass that keeps the `AVCaptureVideoPreviewLayer` sized to its bounds.
|
||||
final class PreviewView: UIView {
|
||||
var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
previewLayer?.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
// FontStore.swift
|
||||
// Observable store that tracks the active terminal font and point size, and
|
||||
// persists both values across launches via UserDefaults.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
private let kFontIdKey = "terminal.font"
|
||||
private let kFontSizeKey = "terminal.fontSize"
|
||||
private let kDefaultSize: CGFloat = 13
|
||||
|
||||
@MainActor
|
||||
public final class FontStore: ObservableObject {
|
||||
|
||||
// MARK: Singleton
|
||||
|
||||
public static let shared = FontStore()
|
||||
|
||||
// MARK: Published state
|
||||
|
||||
@Published public private(set) var current: TerminalFont
|
||||
@Published public private(set) var size: CGFloat
|
||||
|
||||
// MARK: Available fonts (ordered: default first)
|
||||
|
||||
public let available: [TerminalFont] = [.sfMono, .menlo, .jetBrainsMono]
|
||||
|
||||
// MARK: Init
|
||||
|
||||
private init() {
|
||||
// Restore point size (clamped to a sane range).
|
||||
let storedSize = UserDefaults.standard.object(forKey: kFontSizeKey) as? CGFloat
|
||||
size = storedSize.map { max(8, min(32, $0)) } ?? kDefaultSize
|
||||
|
||||
// Restore selected font id.
|
||||
let all: [TerminalFont] = [.sfMono, .menlo, .jetBrainsMono]
|
||||
if let savedId = UserDefaults.standard.string(forKey: kFontIdKey),
|
||||
let saved = all.first(where: { $0.id == savedId }) {
|
||||
current = saved
|
||||
} else {
|
||||
current = .sfMono
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
|
||||
/// Makes `font` the active font and persists the choice.
|
||||
public func select(_ font: TerminalFont) {
|
||||
current = font
|
||||
UserDefaults.standard.set(font.id, forKey: kFontIdKey)
|
||||
}
|
||||
|
||||
/// Sets the point size (clamped to 8–32 pt) and persists it.
|
||||
public func setSize(_ newSize: CGFloat) {
|
||||
let clamped = max(8, min(32, newSize))
|
||||
size = clamped
|
||||
UserDefaults.standard.set(clamped, forKey: kFontSizeKey)
|
||||
}
|
||||
|
||||
// MARK: Derived helpers
|
||||
|
||||
/// Returns the current font scaled to the current point size.
|
||||
public var scaledFont: UIFont {
|
||||
current.uiFont.withSize(size)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// TerminalFont.swift
|
||||
// Represents a named monospace font that can be applied to the terminal view.
|
||||
//
|
||||
// T-2.3 note: JetBrains Mono font file is NOT bundled yet — that is deferred
|
||||
// to T-2.12. Until then, `jetBrainsMono` falls back to the system monospaced
|
||||
// font so the struct is usable without crashing.
|
||||
|
||||
import UIKit
|
||||
|
||||
public struct TerminalFont: Identifiable, Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public let id: String
|
||||
public let displayName: String
|
||||
|
||||
/// The `UIFont` at whatever point size was requested when the static
|
||||
/// preset was created. `FontStore` applies its own `size` override via
|
||||
/// `uiFont.withSize(_:)` before passing the font to `TerminalView`.
|
||||
public let uiFont: UIFont
|
||||
|
||||
// MARK: Init
|
||||
|
||||
public init(id: String, displayName: String, uiFont: UIFont) {
|
||||
self.id = id
|
||||
self.displayName = displayName
|
||||
self.uiFont = uiFont
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static presets
|
||||
|
||||
public extension TerminalFont {
|
||||
|
||||
// MARK: JetBrains Mono
|
||||
// Falls back to the system monospaced font when the font file is absent
|
||||
// (i.e. before T-2.12 bundles it). Never crashes — safe for all builds.
|
||||
|
||||
static let jetBrainsMono: TerminalFont = {
|
||||
let baseSize: CGFloat = 13
|
||||
// Attempt to load the named font; fall back to the system mono font.
|
||||
let font = UIFont(name: "JetBrainsMono-Regular", size: baseSize)
|
||||
?? UIFont.monospacedSystemFont(ofSize: baseSize, weight: .regular)
|
||||
return TerminalFont(id: "jetbrains-mono", displayName: "JetBrains Mono", uiFont: font)
|
||||
}()
|
||||
|
||||
// MARK: Menlo — system monospaced, always available
|
||||
|
||||
static let menlo: TerminalFont = {
|
||||
let baseSize: CGFloat = 13
|
||||
// Menlo ships with iOS/macOS; fall back to the system mono if absent.
|
||||
let font = UIFont(name: "Menlo-Regular", size: baseSize)
|
||||
?? UIFont.monospacedSystemFont(ofSize: baseSize, weight: .regular)
|
||||
return TerminalFont(id: "menlo", displayName: "Menlo", uiFont: font)
|
||||
}()
|
||||
|
||||
// MARK: SF Mono — always available on iOS 13+
|
||||
|
||||
static let sfMono: TerminalFont = {
|
||||
let baseSize: CGFloat = 13
|
||||
// "SFMono-Regular" is available as a named font on iOS 13+.
|
||||
let font = UIFont(name: "SFMono-Regular", size: baseSize)
|
||||
?? UIFont.monospacedSystemFont(ofSize: baseSize, weight: .regular)
|
||||
return TerminalFont(id: "sf-mono", displayName: "SF Mono", uiFont: font)
|
||||
}()
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
// TerminalTheme.swift
|
||||
// Defines color themes for the terminal view.
|
||||
//
|
||||
// SwiftTerm API notes (discovered during T-2.3):
|
||||
// • SwiftTerm.Color uses UInt16 components (0...65535).
|
||||
// Conversion from 8-bit: multiply by 257 (= 0x101), which maps 0→0 and 255→65535.
|
||||
// • installColors([SwiftTerm.Color]) installs the 16-color ANSI palette.
|
||||
// • nativeForegroundColor / nativeBackgroundColor accept UIColor.
|
||||
|
||||
import Foundation
|
||||
import SwiftTerm
|
||||
|
||||
// MARK: - ThemeColor
|
||||
|
||||
/// A simple 8-bit RGB color that is Codable and Sendable.
|
||||
public struct ThemeColor: Codable, Sendable, Equatable {
|
||||
public let r: UInt8
|
||||
public let g: UInt8
|
||||
public let b: UInt8
|
||||
|
||||
public init(r: UInt8, g: UInt8, b: UInt8) {
|
||||
self.r = r; self.g = g; self.b = b
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TerminalTheme
|
||||
|
||||
/// A complete color theme for the terminal view.
|
||||
///
|
||||
/// `ansiColors` must contain exactly 16 entries (indices 0–15) representing
|
||||
/// the standard ANSI palette: 0–7 normal, 8–15 bright.
|
||||
public struct TerminalTheme: Codable, Identifiable, Sendable, Equatable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let foreground: ThemeColor
|
||||
public let background: ThemeColor
|
||||
public let cursor: ThemeColor
|
||||
/// 16 ANSI colors: indices 0–7 are the standard colors, 8–15 are the bright variants.
|
||||
public let ansiColors: [ThemeColor]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
foreground: ThemeColor,
|
||||
background: ThemeColor,
|
||||
cursor: ThemeColor,
|
||||
ansiColors: [ThemeColor]
|
||||
) {
|
||||
precondition(ansiColors.count == 16, "ansiColors must contain exactly 16 entries")
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.foreground = foreground
|
||||
self.background = background
|
||||
self.cursor = cursor
|
||||
self.ansiColors = ansiColors
|
||||
}
|
||||
|
||||
// MARK: SwiftTerm conversion
|
||||
|
||||
/// Converts a `ThemeColor` to a `SwiftTerm.Color`.
|
||||
///
|
||||
/// SwiftTerm.Color uses UInt16 components (0–65535). Multiplying each 8-bit
|
||||
/// component by 257 (0x101) gives an exact linear mapping: 0→0, 255→65535.
|
||||
public func toSwiftTermColor(_ c: ThemeColor) -> SwiftTerm.Color {
|
||||
SwiftTerm.Color(
|
||||
red: UInt16(c.r) * 257,
|
||||
green: UInt16(c.g) * 257,
|
||||
blue: UInt16(c.b) * 257
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns all 16 ANSI entries as `SwiftTerm.Color` values.
|
||||
public var swiftTermAnsiColors: [SwiftTerm.Color] {
|
||||
ansiColors.map { toSwiftTermColor($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Built-in themes
|
||||
|
||||
public extension TerminalTheme {
|
||||
|
||||
// MARK: dark — hacker/Matrix style: black bg, green fg
|
||||
|
||||
static let dark = TerminalTheme(
|
||||
id: "dark",
|
||||
name: "Dark (Hacker)",
|
||||
foreground: ThemeColor(r: 0x33, g: 0xFF, b: 0x33), // bright green
|
||||
background: ThemeColor(r: 0x00, g: 0x00, b: 0x00), // pure black
|
||||
cursor: ThemeColor(r: 0x33, g: 0xFF, b: 0x33),
|
||||
ansiColors: [
|
||||
// 0 – black (normal)
|
||||
ThemeColor(r: 0x00, g: 0x00, b: 0x00),
|
||||
// 1 – red
|
||||
ThemeColor(r: 0xCC, g: 0x00, b: 0x00),
|
||||
// 2 – green
|
||||
ThemeColor(r: 0x00, g: 0xCC, b: 0x00),
|
||||
// 3 – yellow
|
||||
ThemeColor(r: 0xCC, g: 0xCC, b: 0x00),
|
||||
// 4 – blue
|
||||
ThemeColor(r: 0x00, g: 0x00, b: 0xCC),
|
||||
// 5 – magenta
|
||||
ThemeColor(r: 0xCC, g: 0x00, b: 0xCC),
|
||||
// 6 – cyan
|
||||
ThemeColor(r: 0x00, g: 0xCC, b: 0xCC),
|
||||
// 7 – white (normal)
|
||||
ThemeColor(r: 0xCC, g: 0xCC, b: 0xCC),
|
||||
// 8 – bright black (dark gray)
|
||||
ThemeColor(r: 0x33, g: 0x33, b: 0x33),
|
||||
// 9 – bright red
|
||||
ThemeColor(r: 0xFF, g: 0x33, b: 0x33),
|
||||
// 10 – bright green
|
||||
ThemeColor(r: 0x33, g: 0xFF, b: 0x33),
|
||||
// 11 – bright yellow
|
||||
ThemeColor(r: 0xFF, g: 0xFF, b: 0x33),
|
||||
// 12 – bright blue
|
||||
ThemeColor(r: 0x33, g: 0x33, b: 0xFF),
|
||||
// 13 – bright magenta
|
||||
ThemeColor(r: 0xFF, g: 0x33, b: 0xFF),
|
||||
// 14 – bright cyan
|
||||
ThemeColor(r: 0x33, g: 0xFF, b: 0xFF),
|
||||
// 15 – bright white
|
||||
ThemeColor(r: 0xFF, g: 0xFF, b: 0xFF),
|
||||
]
|
||||
)
|
||||
|
||||
// MARK: github — GitHub Dark theme
|
||||
|
||||
static let github = TerminalTheme(
|
||||
id: "github",
|
||||
name: "GitHub Dark",
|
||||
foreground: ThemeColor(r: 0xE6, g: 0xED, b: 0xF3), // #e6edf3
|
||||
background: ThemeColor(r: 0x0D, g: 0x11, b: 0x17), // #0d1117
|
||||
cursor: ThemeColor(r: 0xE6, g: 0xED, b: 0xF3),
|
||||
ansiColors: [
|
||||
// 0 – black
|
||||
ThemeColor(r: 0x48, g: 0x4F, b: 0x58),
|
||||
// 1 – red
|
||||
ThemeColor(r: 0xFF, g: 0x77, b: 0x77),
|
||||
// 2 – green
|
||||
ThemeColor(r: 0x56, g: 0xD3, b: 0x64),
|
||||
// 3 – yellow
|
||||
ThemeColor(r: 0xE3, g: 0xB3, b: 0x41),
|
||||
// 4 – blue
|
||||
ThemeColor(r: 0x6C, g: 0xA4, b: 0xF8),
|
||||
// 5 – magenta
|
||||
ThemeColor(r: 0xBC, g: 0x8C, b: 0xFF),
|
||||
// 6 – cyan
|
||||
ThemeColor(r: 0x2B, g: 0x73, b: 0x89), // #2b7389
|
||||
// 7 – white
|
||||
ThemeColor(r: 0xE6, g: 0xED, b: 0xF3),
|
||||
// 8 – bright black
|
||||
ThemeColor(r: 0x6E, g: 0x76, b: 0x81),
|
||||
// 9 – bright red
|
||||
ThemeColor(r: 0xFF, g: 0xA1, b: 0x98),
|
||||
// 10 – bright green
|
||||
ThemeColor(r: 0x3F, g: 0xB9, b: 0x50),
|
||||
// 11 – bright yellow
|
||||
ThemeColor(r: 0xD2, g: 0x9C, b: 0x22),
|
||||
// 12 – bright blue
|
||||
ThemeColor(r: 0x79, g: 0xC0, b: 0xFF),
|
||||
// 13 – bright magenta
|
||||
ThemeColor(r: 0xD2, g: 0xA8, b: 0xFF),
|
||||
// 14 – bright cyan
|
||||
ThemeColor(r: 0x39, g: 0xC5, b: 0xCF),
|
||||
// 15 – bright white
|
||||
ThemeColor(r: 0xFF, g: 0xFF, b: 0xFF),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
// TerminalViewController.swift
|
||||
// UIKit view-controller hosting a SwiftTerm TerminalView.
|
||||
//
|
||||
// SwiftTerm API discoveries (T-2.3):
|
||||
// • TerminalView is UIScrollView (not UIView) — autolayout fill works the same way.
|
||||
// • feed(byteArray: ArraySlice<UInt8>) ingests ANSI bytes; can be called from any thread.
|
||||
// • feed(text: String) is the string variant.
|
||||
// • installColors([SwiftTerm.Color]) sets the 16-color ANSI palette.
|
||||
// • nativeForegroundColor / nativeBackgroundColor are UIColor properties on TerminalView.
|
||||
// • font (UIFont) property drives the rendered glyph size and triggers a resize.
|
||||
// • getTerminal() → Terminal gives access to .cols / .rows.
|
||||
// • Keyboard input flows OUT through TerminalViewDelegate.send(source:data:).
|
||||
// • TerminalViewDelegate is not @MainActor; delegate methods are called on the main
|
||||
// thread by SwiftTerm's UIKit layer. We use MainActor.assumeIsolated to bridge
|
||||
// the isolation gap in Swift 6 strict-concurrency mode.
|
||||
|
||||
import UIKit
|
||||
import SwiftTerm
|
||||
|
||||
@MainActor
|
||||
public final class TerminalViewController: UIViewController {
|
||||
|
||||
// MARK: Terminal view
|
||||
|
||||
/// The underlying SwiftTerm view. Available after `viewDidLoad`.
|
||||
public private(set) var terminalView: TerminalView!
|
||||
|
||||
// MARK: Callbacks
|
||||
|
||||
/// Called whenever the user types into the terminal — byte data to be
|
||||
/// forwarded to the remote PTY (wired by T-2.5).
|
||||
public var onInput: ((Data) -> Void)?
|
||||
|
||||
// MARK: View lifecycle
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
let activeTheme = ThemeStore.shared.current
|
||||
let activeFont = FontStore.shared.scaledFont
|
||||
|
||||
// Create the terminal view with the current font.
|
||||
// TerminalView(frame:font:) is the designated initialiser on iOS.
|
||||
let tv = TerminalView(frame: .zero, font: activeFont)
|
||||
tv.terminalDelegate = self
|
||||
tv.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
view.addSubview(tv)
|
||||
NSLayoutConstraint.activate([
|
||||
tv.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tv.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tv.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tv.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
terminalView = tv
|
||||
|
||||
// Apply the active theme so the view renders with correct colours from
|
||||
// the very first frame, before any data arrives.
|
||||
apply(theme: activeTheme)
|
||||
}
|
||||
|
||||
// MARK: Data ingestion
|
||||
|
||||
/// Feed raw ANSI bytes into the terminal emulator.
|
||||
/// SwiftTerm's `feed(byteArray:)` is thread-safe; this wrapper is
|
||||
/// intentionally @MainActor so callers remain on the main actor.
|
||||
public func feed(data: Data) {
|
||||
let bytes = [UInt8](data)
|
||||
terminalView.feed(byteArray: bytes[...])
|
||||
}
|
||||
|
||||
/// Feed a base64-encoded ANSI snapshot (e.g. from the sidecar's
|
||||
/// snapshot frame) into the terminal emulator.
|
||||
public func feedSnapshot(base64: String) {
|
||||
guard let data = Data(base64Encoded: base64,
|
||||
options: .ignoreUnknownCharacters) else {
|
||||
return // malformed input — ignore silently
|
||||
}
|
||||
feed(data: data)
|
||||
}
|
||||
|
||||
// MARK: Theme
|
||||
|
||||
/// Apply a `TerminalTheme`, updating foreground/background colours and
|
||||
/// the full 16-colour ANSI palette.
|
||||
public func apply(theme: TerminalTheme) {
|
||||
guard let tv = terminalView else { return }
|
||||
|
||||
// Foreground / background via UIColor properties.
|
||||
tv.nativeForegroundColor = UIColor(themeColor: theme.foreground)
|
||||
tv.nativeBackgroundColor = UIColor(themeColor: theme.background)
|
||||
|
||||
// 16-colour ANSI palette via SwiftTerm's installColors API.
|
||||
tv.installColors(theme.swiftTermAnsiColors)
|
||||
}
|
||||
|
||||
// MARK: Font
|
||||
|
||||
/// Apply a `TerminalFont` at the current point size from `FontStore`.
|
||||
public func apply(font: TerminalFont) {
|
||||
guard let tv = terminalView else { return }
|
||||
tv.font = font.uiFont.withSize(FontStore.shared.size)
|
||||
}
|
||||
|
||||
/// Apply a `TerminalFont` at an explicit point size.
|
||||
public func apply(font: TerminalFont, size: CGFloat) {
|
||||
guard let tv = terminalView else { return }
|
||||
tv.font = font.uiFont.withSize(size)
|
||||
}
|
||||
|
||||
// MARK: Terminal dimensions
|
||||
|
||||
/// Current terminal size in columns and rows.
|
||||
public var terminalSize: (cols: Int, rows: Int) {
|
||||
guard let tv = terminalView else { return (80, 24) }
|
||||
let t = tv.getTerminal()
|
||||
return (t.cols, t.rows)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TerminalViewDelegate
|
||||
|
||||
// TerminalViewDelegate is not @MainActor, but SwiftTerm always calls its
|
||||
// methods on the main thread (they're UIKit callbacks). We use
|
||||
// `MainActor.assumeIsolated` — a zero-cost annotation that asserts we're
|
||||
// already on the main actor — to satisfy the Swift 6 isolation checker.
|
||||
|
||||
extension TerminalViewController: TerminalViewDelegate {
|
||||
|
||||
// MARK: Required — keyboard input (terminal → host)
|
||||
|
||||
public nonisolated func send(source: TerminalView, data: ArraySlice<UInt8>) {
|
||||
MainActor.assumeIsolated {
|
||||
onInput?(Data(data))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Required — size change notification
|
||||
|
||||
public nonisolated func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
||||
// No-op in T-2.3; the stream layer (T-2.5) will negotiate PTY size
|
||||
// with the sidecar when it subscribes to this controller.
|
||||
}
|
||||
|
||||
// MARK: Required — title change
|
||||
|
||||
public nonisolated func setTerminalTitle(source: TerminalView, title: String) {
|
||||
// No-op: future tasks may expose this via a published property.
|
||||
}
|
||||
|
||||
// MARK: Required — working directory update
|
||||
|
||||
public nonisolated func hostCurrentDirectoryUpdate(source: TerminalView,
|
||||
directory: String?) {
|
||||
// No-op for now.
|
||||
}
|
||||
|
||||
// MARK: Required — scroll position
|
||||
|
||||
public nonisolated func scrolled(source: TerminalView, position: Double) {
|
||||
// No-op for now.
|
||||
}
|
||||
|
||||
// MARK: Required — hyperlink activation
|
||||
|
||||
public nonisolated func requestOpenLink(source: TerminalView,
|
||||
link: String,
|
||||
params: [String: String]) {
|
||||
// Open URL on the main actor.
|
||||
MainActor.assumeIsolated {
|
||||
guard let url = URL(string: link) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Required — bell
|
||||
|
||||
public nonisolated func bell(source: TerminalView) {
|
||||
// Haptic feedback on bell — UIKit APIs require main thread.
|
||||
MainActor.assumeIsolated {
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Optional — clipboard write (OSC 52)
|
||||
|
||||
public nonisolated func clipboardCopy(source: TerminalView, content: Data) {
|
||||
MainActor.assumeIsolated {
|
||||
UIPasteboard.general.setData(content,
|
||||
forPasteboardType: "public.utf8-plain-text")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Optional — clipboard read (OSC 52 query)
|
||||
|
||||
public nonisolated func clipboardRead(source: TerminalView) -> Data? {
|
||||
// Return nil to deny clipboard read requests for security.
|
||||
nil
|
||||
}
|
||||
|
||||
// MARK: Optional — iTerm2 OSC 1337 unhandled content
|
||||
|
||||
public nonisolated func iTermContent(source: TerminalView,
|
||||
content: ArraySlice<UInt8>) {
|
||||
// No-op: iTerm2 extensions are not supported in this client.
|
||||
}
|
||||
|
||||
// MARK: Optional — range update notification
|
||||
|
||||
public nonisolated func rangeChanged(source: TerminalView,
|
||||
startY: Int, endY: Int) {
|
||||
// No-op: notifyUpdateChanges is false (default); this won't be called.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIColor convenience
|
||||
|
||||
private extension UIColor {
|
||||
/// Constructs a UIColor from an 8-bit `ThemeColor`.
|
||||
convenience init(themeColor c: ThemeColor) {
|
||||
self.init(
|
||||
red: CGFloat(c.r) / 255,
|
||||
green: CGFloat(c.g) / 255,
|
||||
blue: CGFloat(c.b) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
// TerminalViewRepresentable.swift
|
||||
// SwiftUI bridge that wraps TerminalViewController for use inside SwiftUI view trees.
|
||||
//
|
||||
// Usage:
|
||||
// let controller = TerminalViewController()
|
||||
// TerminalViewRepresentable(controller: controller)
|
||||
// .ignoresSafeArea()
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A SwiftUI-compatible wrapper around `TerminalViewController`.
|
||||
///
|
||||
/// The `controller` is created and owned externally (typically by a
|
||||
/// parent view or view-model) so that its `feed(data:)` and
|
||||
/// `apply(theme:)` APIs remain accessible outside the SwiftUI tree.
|
||||
public struct TerminalViewRepresentable: UIViewControllerRepresentable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The controller to host. Must be created before this representable is inserted.
|
||||
public let controller: TerminalViewController
|
||||
|
||||
// MARK: Init
|
||||
|
||||
public init(controller: TerminalViewController) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
// MARK: UIViewControllerRepresentable
|
||||
|
||||
public func makeUIViewController(context: Context) -> TerminalViewController {
|
||||
controller
|
||||
}
|
||||
|
||||
public func updateUIViewController(_ uiViewController: TerminalViewController,
|
||||
context: Context) {
|
||||
// No-op for now: theme and font updates are applied imperatively via
|
||||
// controller.apply(theme:) and controller.apply(font:).
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
// ThemeStore.swift
|
||||
// Observable store that tracks the active terminal theme and persists the
|
||||
// selection across launches via UserDefaults.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
private let kThemeKey = "terminal.theme"
|
||||
|
||||
@MainActor
|
||||
public final class ThemeStore: ObservableObject {
|
||||
|
||||
// MARK: Singleton
|
||||
|
||||
public static let shared = ThemeStore()
|
||||
|
||||
// MARK: Published state
|
||||
|
||||
@Published public private(set) var current: TerminalTheme
|
||||
|
||||
// MARK: Available themes (ordered: default first)
|
||||
|
||||
public let available: [TerminalTheme] = [.dark, .github]
|
||||
|
||||
// MARK: Init
|
||||
|
||||
private init() {
|
||||
// Restore previously selected theme id from UserDefaults.
|
||||
if let savedId = UserDefaults.standard.string(forKey: kThemeKey),
|
||||
let saved = [TerminalTheme.dark, .github].first(where: { $0.id == savedId }) {
|
||||
current = saved
|
||||
} else {
|
||||
current = .dark
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
|
||||
/// Makes `theme` the active theme and persists the choice.
|
||||
public func select(_ theme: TerminalTheme) {
|
||||
current = theme
|
||||
UserDefaults.standard.set(theme.id, forKey: kThemeKey)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
// FrameCodecTests.swift
|
||||
// Unit tests for BinaryFrame, ClientToServer encoding, and ServerToClient decoding.
|
||||
//
|
||||
// All tests are pure (no network, no Starscream, no async). FrameCodec.swift
|
||||
// imports only Foundation, making this test target dependency-free.
|
||||
//
|
||||
// IC-1 spec reference: docs/PHASE-2-ios-mvp.md §Wire Protocol
|
||||
|
||||
import XCTest
|
||||
@testable import piRemote
|
||||
|
||||
final class FrameCodecTests: XCTestCase {
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 1. BinaryFrame.decode
|
||||
// =========================================================================
|
||||
|
||||
/// Well-formed binary frame: 8-byte big-endian seq = 1, payload = "hello".
|
||||
func testBinaryFrameDecode_knownBytes() throws {
|
||||
// seq = 1 in big-endian: 00 00 00 00 00 00 00 01
|
||||
let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]
|
||||
let payload = Array("hello".utf8)
|
||||
let raw = Data(seqBytes + payload)
|
||||
|
||||
let frame = try XCTUnwrap(BinaryFrame.decode(raw))
|
||||
XCTAssertEqual(frame.seq, 1)
|
||||
XCTAssertEqual(frame.data, Data("hello".utf8))
|
||||
}
|
||||
|
||||
/// UInt64.max must round-trip through the 8-byte big-endian header.
|
||||
func testBinaryFrameDecode_maxSeq() throws {
|
||||
let seqBytes: [UInt8] = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
|
||||
let raw = Data(seqBytes) // empty payload is valid
|
||||
|
||||
let frame = try XCTUnwrap(BinaryFrame.decode(raw))
|
||||
XCTAssertEqual(frame.seq, UInt64.max)
|
||||
XCTAssertTrue(frame.data.isEmpty)
|
||||
}
|
||||
|
||||
/// seq = 0x0000_0001_0000_0000 = 4_294_967_296 verifies big-endian byte order.
|
||||
func testBinaryFrameDecode_bigEndianOrdering() throws {
|
||||
let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]
|
||||
let raw = Data(seqBytes + [0xAB, 0xCD])
|
||||
|
||||
let frame = try XCTUnwrap(BinaryFrame.decode(raw))
|
||||
XCTAssertEqual(frame.seq, 4_294_967_296)
|
||||
XCTAssertEqual(frame.data, Data([0xAB, 0xCD]))
|
||||
}
|
||||
|
||||
/// Frames shorter than 8 bytes must return nil (header incomplete).
|
||||
func testBinaryFrameDecode_tooShort_returnsNil() {
|
||||
XCTAssertNil(BinaryFrame.decode(Data([0x00, 0x01, 0x02])))
|
||||
}
|
||||
|
||||
/// Empty data must also return nil (0 bytes < 8-byte minimum).
|
||||
func testBinaryFrameDecode_emptyData_returnsNil() {
|
||||
XCTAssertNil(BinaryFrame.decode(Data()))
|
||||
}
|
||||
|
||||
/// Exactly 8 bytes is valid: empty payload, seq extracted from header.
|
||||
func testBinaryFrameDecode_exactlyEightBytes_emptyPayload() throws {
|
||||
// seq = 42 = 0x2A
|
||||
let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A]
|
||||
let frame = try XCTUnwrap(BinaryFrame.decode(Data(seqBytes)))
|
||||
XCTAssertEqual(frame.seq, 42)
|
||||
XCTAssertTrue(frame.data.isEmpty)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 2. ClientToServer JSON encoding — IC-1 field names
|
||||
// =========================================================================
|
||||
|
||||
func testEncode_resume_nilLastSeq_producesNull() throws {
|
||||
// IC-1 requires {"type":"resume","lastSeq":null}
|
||||
let json = try FrameCodec.encode(.resume(lastSeq: nil))
|
||||
let obj = try asDict(json)
|
||||
XCTAssertEqual(obj["type"] as? String, "resume", "type field must be 'resume'")
|
||||
XCTAssertTrue(obj["lastSeq"] is NSNull,
|
||||
"lastSeq must be JSON null, got: \(String(describing: obj["lastSeq"]))")
|
||||
}
|
||||
|
||||
func testEncode_resume_withLastSeq42() throws {
|
||||
// IC-1 requires {"type":"resume","lastSeq":42}
|
||||
let json = try FrameCodec.encode(.resume(lastSeq: 42))
|
||||
let obj = try asDict(json)
|
||||
XCTAssertEqual(obj["type"] as? String, "resume")
|
||||
XCTAssertEqual(obj["lastSeq"] as? Int, 42)
|
||||
}
|
||||
|
||||
func testEncode_key_escape() throws {
|
||||
// IC-1 requires {"type":"key","name":"escape"}
|
||||
let json = try FrameCodec.encode(.key(name: "escape"))
|
||||
XCTAssertEqual(json, #"{"type":"key","name":"escape"}"#,
|
||||
"Exact IC-1 encoding expected")
|
||||
}
|
||||
|
||||
func testEncode_keys_data() throws {
|
||||
// IC-1 requires {"type":"keys","data":"hello"}
|
||||
let json = try FrameCodec.encode(.keys(data: "hello"))
|
||||
let obj = try asDict(json)
|
||||
XCTAssertEqual(obj["type"] as? String, "keys")
|
||||
XCTAssertEqual(obj["data"] as? String, "hello")
|
||||
}
|
||||
|
||||
func testEncode_paste_data() throws {
|
||||
// IC-1 requires {"type":"paste","data":"text"}
|
||||
let json = try FrameCodec.encode(.paste(data: "text"))
|
||||
let obj = try asDict(json)
|
||||
XCTAssertEqual(obj["type"] as? String, "paste")
|
||||
XCTAssertEqual(obj["data"] as? String, "text")
|
||||
}
|
||||
|
||||
func testEncode_snapshotRequest() throws {
|
||||
// IC-1 requires {"type":"snapshot-request"}
|
||||
let json = try FrameCodec.encode(.snapshotRequest)
|
||||
XCTAssertEqual(json, #"{"type":"snapshot-request"}"#,
|
||||
"Exact IC-1 encoding expected")
|
||||
}
|
||||
|
||||
/// Multi-line paste must survive encoding without key truncation.
|
||||
func testEncode_paste_multilineData() throws {
|
||||
let json = try FrameCodec.encode(.paste(data: "line1\nline2"))
|
||||
let obj = try asDict(json)
|
||||
XCTAssertEqual(obj["data"] as? String, "line1\nline2")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 3. ServerToClient JSON decoding
|
||||
// =========================================================================
|
||||
|
||||
func testDecode_state_idle() throws {
|
||||
let payload = #"{"type":"state","value":"idle","ts":1716000000}"#
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
guard case .state(let value, let tool, let ts) = frame else {
|
||||
return XCTFail("Expected .state, got \(frame)")
|
||||
}
|
||||
XCTAssertEqual(value, .idle)
|
||||
XCTAssertNil(tool)
|
||||
XCTAssertEqual(ts, 1_716_000_000)
|
||||
}
|
||||
|
||||
func testDecode_state_thinking() throws {
|
||||
let payload = #"{"type":"state","value":"thinking","ts":1}"#
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
guard case .state(let value, _, _) = frame else {
|
||||
return XCTFail("Expected .state, got \(frame)")
|
||||
}
|
||||
XCTAssertEqual(value, .thinking)
|
||||
}
|
||||
|
||||
func testDecode_state_tool_withToolName() throws {
|
||||
let payload = #"{"type":"state","value":"tool","tool":"bash","ts":42}"#
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
guard case .state(let value, let tool, let ts) = frame else {
|
||||
return XCTFail("Expected .state, got \(frame)")
|
||||
}
|
||||
XCTAssertEqual(value, .tool)
|
||||
XCTAssertEqual(tool, "bash")
|
||||
XCTAssertEqual(ts, 42)
|
||||
}
|
||||
|
||||
func testDecode_state_awaitingInput() throws {
|
||||
// IC-1 raw value is "awaiting-input" (hyphenated)
|
||||
let payload = #"{"type":"state","value":"awaiting-input","ts":0}"#
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
guard case .state(let value, _, _) = frame else {
|
||||
return XCTFail("Expected .state, got \(frame)")
|
||||
}
|
||||
XCTAssertEqual(value, .awaitingInput,
|
||||
"PiState must map 'awaiting-input' → .awaitingInput")
|
||||
}
|
||||
|
||||
func testDecode_snapshot() throws {
|
||||
let payload = #"{"type":"snapshot","seq":1234,"data":"SGVsbG8="}"#
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
guard case .snapshot(let seq, let data) = frame else {
|
||||
return XCTFail("Expected .snapshot, got \(frame)")
|
||||
}
|
||||
XCTAssertEqual(seq, 1234)
|
||||
XCTAssertEqual(data, "SGVsbG8=")
|
||||
}
|
||||
|
||||
func testDecode_sessionMeta_withDescription() throws {
|
||||
let payload = #"{"type":"session-meta","name":"my-session","description":"A test session","createdAt":"2026-05-15T10:00:00Z"}"#
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
guard case .sessionMeta(let name, let description, let createdAt) = frame else {
|
||||
return XCTFail("Expected .sessionMeta, got \(frame)")
|
||||
}
|
||||
XCTAssertEqual(name, "my-session")
|
||||
XCTAssertEqual(description, "A test session")
|
||||
XCTAssertEqual(createdAt, "2026-05-15T10:00:00Z")
|
||||
}
|
||||
|
||||
func testDecode_sessionMeta_withoutDescription() throws {
|
||||
// description is optional per IC-1
|
||||
let payload = #"{"type":"session-meta","name":"bare","createdAt":"2026-01-01T00:00:00Z"}"#
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
guard case .sessionMeta(let name, let description, _) = frame else {
|
||||
return XCTFail("Expected .sessionMeta, got \(frame)")
|
||||
}
|
||||
XCTAssertEqual(name, "bare")
|
||||
XCTAssertNil(description)
|
||||
}
|
||||
|
||||
func testDecode_error() throws {
|
||||
let payload = #"{"type":"error","code":"auth_failed","message":"Invalid token"}"#
|
||||
let frame = try FrameCodec.decode(payload)
|
||||
guard case .error(let code, let message) = frame else {
|
||||
return XCTFail("Expected .error, got \(frame)")
|
||||
}
|
||||
XCTAssertEqual(code, "auth_failed")
|
||||
XCTAssertEqual(message, "Invalid token")
|
||||
}
|
||||
|
||||
/// Unknown type keys must throw a `DecodingError`, not silently return garbage.
|
||||
func testDecode_unknownType_throws() {
|
||||
let payload = #"{"type":"tree","nodes":[]}"#
|
||||
XCTAssertThrowsError(try FrameCodec.decode(payload),
|
||||
"Unknown type discriminator must throw")
|
||||
}
|
||||
|
||||
/// Malformed JSON must throw.
|
||||
func testDecode_malformedJSON_throws() {
|
||||
XCTAssertThrowsError(try FrameCodec.decode("not json at all"))
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 4. Round-trip: encode ClientToServer → decode shape check
|
||||
// =========================================================================
|
||||
|
||||
/// Encode a .key frame and verify it can be re-parsed by JSONSerialization.
|
||||
func testRoundtrip_encode_isValidJSON() throws {
|
||||
let json = try FrameCodec.encode(.key(name: "up"))
|
||||
// Must not throw — i.e., the output is valid JSON.
|
||||
XCTAssertNoThrow(try JSONSerialization.jsonObject(with: Data(json.utf8)))
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: Helpers
|
||||
// =========================================================================
|
||||
|
||||
private func asDict(_ json: String) throws -> [String: Any] {
|
||||
let obj = try JSONSerialization.jsonObject(with: Data(json.utf8))
|
||||
return try XCTUnwrap(obj as? [String: Any],
|
||||
"Expected top-level JSON object, got \(type(of: obj))")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
// KeychainTests.swift
|
||||
// Unit tests for the Keychain wrapper.
|
||||
//
|
||||
// IMPORTANT: Each test uses a unique key (never the production key
|
||||
// `Keychain.credentialKey`) so real app data is never touched.
|
||||
// All test keys are deleted in tearDown even if the test fails.
|
||||
//
|
||||
// Note: Keychain tests require the iOS simulator or a real device with
|
||||
// a valid entitlement. They will fail in headless environments where
|
||||
// the Security framework is unavailable.
|
||||
|
||||
import XCTest
|
||||
@testable import piRemote
|
||||
|
||||
final class KeychainTests: XCTestCase {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Unique per-run key prefix so parallel test runs don't collide.
|
||||
private var testKey: String { "test.keychain.\(name)" }
|
||||
|
||||
override func tearDown() {
|
||||
// Always clean up the test key to avoid polluting the Keychain.
|
||||
Keychain.shared.delete(key: testKey)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
/// A minimal `SidecarCredential` for testing.
|
||||
private func makeCredential(name: String = "test-pi") -> SidecarCredential {
|
||||
SidecarCredential(
|
||||
sidecarId: "sid-\(UUID().uuidString)",
|
||||
host: "192.168.1.100",
|
||||
port: 7777,
|
||||
bearerToken: "bearer-\(UUID().uuidString)",
|
||||
tlsFingerprint: "deadbeef1234",
|
||||
sidecarName: name,
|
||||
pairedAt: Date(timeIntervalSince1970: 1_716_000_000)
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 1. Save + load round-trip
|
||||
// =========================================================================
|
||||
|
||||
func testSaveAndLoad_credential_roundTrips() throws {
|
||||
let credential = makeCredential(name: "my-pi")
|
||||
|
||||
try Keychain.shared.save(credential, key: testKey)
|
||||
let loaded: SidecarCredential = try Keychain.shared.load(key: testKey)
|
||||
|
||||
XCTAssertEqual(loaded.sidecarId, credential.sidecarId)
|
||||
XCTAssertEqual(loaded.host, credential.host)
|
||||
XCTAssertEqual(loaded.port, credential.port)
|
||||
XCTAssertEqual(loaded.bearerToken, credential.bearerToken)
|
||||
XCTAssertEqual(loaded.tlsFingerprint, credential.tlsFingerprint)
|
||||
XCTAssertEqual(loaded.sidecarName, credential.sidecarName)
|
||||
// Date comparison within 1 second tolerance (JSON Date encoding).
|
||||
XCTAssertEqual(loaded.pairedAt.timeIntervalSince1970,
|
||||
credential.pairedAt.timeIntervalSince1970,
|
||||
accuracy: 1.0)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 2. Update (upsert)
|
||||
// =========================================================================
|
||||
|
||||
func testSave_overwritesPrevious_onLoad() throws {
|
||||
let first = makeCredential(name: "first-pi")
|
||||
let second = makeCredential(name: "second-pi")
|
||||
|
||||
try Keychain.shared.save(first, key: testKey)
|
||||
try Keychain.shared.save(second, key: testKey)
|
||||
|
||||
let loaded: SidecarCredential = try Keychain.shared.load(key: testKey)
|
||||
XCTAssertEqual(loaded.sidecarName, "second-pi",
|
||||
"Second save must overwrite the first")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 3. Missing key → notFound
|
||||
// =========================================================================
|
||||
|
||||
func testLoad_missingKey_throwsNotFound() {
|
||||
let missingKey = "test.keychain.definitely-absent-\(UUID())"
|
||||
defer { Keychain.shared.delete(key: missingKey) }
|
||||
|
||||
XCTAssertThrowsError(try Keychain.shared.load(key: missingKey) as SidecarCredential) { error in
|
||||
guard let keychainError = error as? KeychainError,
|
||||
case .notFound = keychainError else {
|
||||
XCTFail("Expected KeychainError.notFound, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 4. Delete clears entry
|
||||
// =========================================================================
|
||||
|
||||
func testDelete_clearsEntry() throws {
|
||||
try Keychain.shared.save(makeCredential(), key: testKey)
|
||||
|
||||
Keychain.shared.delete(key: testKey)
|
||||
|
||||
XCTAssertThrowsError(try Keychain.shared.load(key: testKey) as SidecarCredential,
|
||||
"After delete, load must throw") { error in
|
||||
guard let keychainError = error as? KeychainError,
|
||||
case .notFound = keychainError else {
|
||||
XCTFail("Expected KeychainError.notFound after delete, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testDelete_missingKey_isNoOp() {
|
||||
// Deleting a key that doesn't exist must not crash or throw.
|
||||
Keychain.shared.delete(key: "test.keychain.never-saved-\(UUID())")
|
||||
// No assertion needed — reaching this line means no crash.
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 5. Production key is NOT used in tests
|
||||
// =========================================================================
|
||||
|
||||
func testProductionKeyIsUntouched() {
|
||||
// This test simply verifies that our test key is different from the
|
||||
// production credential key, so tests never corrupt real data.
|
||||
XCTAssertNotEqual(testKey, Keychain.credentialKey,
|
||||
"Test key must differ from the production credential key")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 6. Generic save/load with a simple Codable type
|
||||
// =========================================================================
|
||||
|
||||
private struct TestPayload: Codable, Equatable {
|
||||
let id: String
|
||||
let value: Int
|
||||
}
|
||||
|
||||
func testSaveAndLoad_simplePayload() throws {
|
||||
let key = "test.keychain.payload.\(UUID())"
|
||||
let payload = TestPayload(id: "x", value: 99)
|
||||
defer { Keychain.shared.delete(key: key) }
|
||||
|
||||
try Keychain.shared.save(payload, key: key)
|
||||
let loaded: TestPayload = try Keychain.shared.load(key: key)
|
||||
|
||||
XCTAssertEqual(loaded, payload)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
// PairingTests.swift
|
||||
// Unit tests for PairingService.parseQR — IC-1 QR URL parsing.
|
||||
//
|
||||
// Format: pi-remote://<host>:<port>?pair=<pairingToken>&fp=<fingerprint>&name=<sidecarName>
|
||||
//
|
||||
// No network calls are made; parseQR is a pure function.
|
||||
|
||||
import XCTest
|
||||
@testable import piRemote
|
||||
|
||||
final class PairingTests: XCTestCase {
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 1. Happy-path parsing
|
||||
// =========================================================================
|
||||
|
||||
func testParseQR_canonical_parsesAllFields() throws {
|
||||
let url = "pi-remote://192.168.1.1:7777?pair=abc&fp=deadbeef&name=pi-remote"
|
||||
let result = try PairingService.parseQR(url)
|
||||
|
||||
XCTAssertEqual(result.host, "192.168.1.1")
|
||||
XCTAssertEqual(result.port, 7777)
|
||||
XCTAssertEqual(result.pairingToken, "abc")
|
||||
XCTAssertEqual(result.fingerprint, "deadbeef")
|
||||
XCTAssertEqual(result.name, "pi-remote")
|
||||
}
|
||||
|
||||
func testParseQR_hostOnly_differentPort() throws {
|
||||
let url = "pi-remote://pi.local:9000?pair=tok123&fp=aabbcc&name=mypi"
|
||||
let result = try PairingService.parseQR(url)
|
||||
|
||||
XCTAssertEqual(result.host, "pi.local")
|
||||
XCTAssertEqual(result.port, 9000)
|
||||
XCTAssertEqual(result.pairingToken, "tok123")
|
||||
XCTAssertEqual(result.fingerprint, "aabbcc")
|
||||
XCTAssertEqual(result.name, "mypi")
|
||||
}
|
||||
|
||||
func testParseQR_longFingerprint_parsedCorrectly() throws {
|
||||
let fp = String(repeating: "a1", count: 32) // 64-char SHA-256 hex
|
||||
let url = "pi-remote://10.0.0.1:7777?pair=t&fp=\(fp)&name=n"
|
||||
let result = try PairingService.parseQR(url)
|
||||
XCTAssertEqual(result.fingerprint, fp)
|
||||
}
|
||||
|
||||
func testParseQR_portBoundary_lowPort() throws {
|
||||
let url = "pi-remote://localhost:1?pair=t&fp=f&name=n"
|
||||
let result = try PairingService.parseQR(url)
|
||||
XCTAssertEqual(result.port, 1)
|
||||
}
|
||||
|
||||
func testParseQR_portBoundary_highPort() throws {
|
||||
let url = "pi-remote://localhost:65535?pair=t&fp=f&name=n"
|
||||
let result = try PairingService.parseQR(url)
|
||||
XCTAssertEqual(result.port, 65535)
|
||||
}
|
||||
|
||||
func testParseQR_nameWithSpaces_percentEncoded() throws {
|
||||
// Spaces encoded as %20 in the QR URL
|
||||
let url = "pi-remote://192.168.1.1:7777?pair=tok&fp=fp&name=my%20pi"
|
||||
let result = try PairingService.parseQR(url)
|
||||
XCTAssertEqual(result.name, "my pi")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 2. Missing required parameters → throws
|
||||
// =========================================================================
|
||||
|
||||
func testParseQR_missingPairParam_throws() {
|
||||
let url = "pi-remote://192.168.1.1:7777?fp=deadbeef&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"Missing 'pair' must throw")
|
||||
}
|
||||
|
||||
func testParseQR_missingFpParam_throws() {
|
||||
let url = "pi-remote://192.168.1.1:7777?pair=abc&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"Missing 'fp' must throw")
|
||||
}
|
||||
|
||||
func testParseQR_missingNameParam_throws() {
|
||||
let url = "pi-remote://192.168.1.1:7777?pair=abc&fp=deadbeef"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"Missing 'name' must throw")
|
||||
}
|
||||
|
||||
func testParseQR_noQueryParams_throws() {
|
||||
let url = "pi-remote://192.168.1.1:7777"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"No query params must throw")
|
||||
}
|
||||
|
||||
func testParseQR_emptyPairValue_throws() {
|
||||
let url = "pi-remote://192.168.1.1:7777?pair=&fp=deadbeef&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"Empty 'pair' value must throw")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 3. Wrong scheme → throws
|
||||
// =========================================================================
|
||||
|
||||
func testParseQR_httpsScheme_throws() {
|
||||
// Wrong scheme — sidecar only issues pi-remote:// URLs
|
||||
let url = "https://192.168.1.1:7777?pair=abc&fp=deadbeef&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"https:// scheme must throw .invalidQR")
|
||||
}
|
||||
|
||||
func testParseQR_httpScheme_throws() {
|
||||
let url = "http://192.168.1.1:7777?pair=abc&fp=deadbeef&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"http:// scheme must throw .invalidQR")
|
||||
}
|
||||
|
||||
func testParseQR_emptyString_throws() {
|
||||
XCTAssertThrowsError(try PairingService.parseQR(""),
|
||||
"Empty string must throw")
|
||||
}
|
||||
|
||||
func testParseQR_randomString_throws() {
|
||||
XCTAssertThrowsError(try PairingService.parseQR("not-a-url"),
|
||||
"Garbage string must throw")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 4. Missing port → throws
|
||||
// =========================================================================
|
||||
|
||||
func testParseQR_missingPort_throws() {
|
||||
// No port specified — should throw because port is required
|
||||
let url = "pi-remote://192.168.1.1?pair=abc&fp=deadbeef&name=pi-remote"
|
||||
XCTAssertThrowsError(try PairingService.parseQR(url),
|
||||
"Missing port must throw .invalidQR")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 5. Error type is PairingError.invalidQR
|
||||
// =========================================================================
|
||||
|
||||
func testParseQR_wrongScheme_throwsInvalidQR() {
|
||||
XCTAssertThrowsError(try PairingService.parseQR("https://host:7777?pair=x&fp=y&name=z")) { error in
|
||||
guard let pairingError = error as? PairingError,
|
||||
case .invalidQR = pairingError else {
|
||||
XCTFail("Expected PairingError.invalidQR, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testParseQR_missingParam_throwsInvalidQR() {
|
||||
XCTAssertThrowsError(try PairingService.parseQR("pi-remote://host:7777?pair=x&fp=y")) { error in
|
||||
guard let pairingError = error as? PairingError,
|
||||
case .invalidQR = pairingError else {
|
||||
XCTFail("Expected PairingError.invalidQR, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
# Phase 2 Implementation Review Notes
|
||||
|
||||
**Reviewer:** T-2.1/2.2/2.3 Test Agent
|
||||
**Date:** 2026-05-15
|
||||
**Branch read:** `origin/feat/p2-t2-1-websocket`, `origin/feat/p2-t2-2-pairing`, `origin/feat/p2-t2-3-terminal`
|
||||
|
||||
---
|
||||
|
||||
## Branch Status at Discovery
|
||||
|
||||
| Branch | Appeared at attempt | Files |
|
||||
|--------|---|---|
|
||||
| `feat/p2-t2-2-pairing` | Attempt 1 (~0 min) | **EMPTY** — only base scaffold |
|
||||
| `feat/p2-t2-3-terminal` | Attempt 10 (~4.5 min) | Terminal + Auth (both T-2.2 and T-2.3 files) |
|
||||
| `feat/p2-t2-1-websocket` | Attempt 13 (~6 min) | Network layer + partial FrameCodecTests |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
### `feat/p2-t2-1-websocket` — Network Layer
|
||||
|
||||
**Files:**
|
||||
- `Sources/Core/Network/FrameCodec.swift` — BinaryFrame, ClientToServer, ServerToClient, FrameCodec namespace
|
||||
- `Sources/Core/Network/ResumeCursor.swift` — UserDefaults-backed UInt64 cursor
|
||||
- `Sources/Core/Network/WebSocketClient.swift` — @MainActor Starscream wrapper with Combine subjects
|
||||
- `Tests/CoreTests/FrameCodecTests.swift` — Partial test file committed to this branch
|
||||
|
||||
**Quality:** High. IC-1 wire format is faithfully implemented. All JSON field names match the spec. BinaryFrame.decode correctly uses big-endian byte shifting.
|
||||
|
||||
### `feat/p2-t2-2-pairing` — EMPTY
|
||||
|
||||
This branch contains **no implementation files** — only the base Xcode scaffold. The pairing code was found on `feat/p2-t2-3-terminal` instead (see below).
|
||||
|
||||
### `feat/p2-t2-3-terminal` — Terminal + Auth (both T-2.2 and T-2.3)
|
||||
|
||||
**Files:**
|
||||
- `Sources/Core/Auth/Keychain.swift` — Generic Codable Keychain wrapper
|
||||
- `Sources/Core/Auth/Pairing.swift` — PairingService with parseQR and exchange
|
||||
- `Sources/Core/Auth/SidecarCredential.swift` — Codable credential model
|
||||
- `Sources/UI/Pairing/PairingFlowView.swift` — SwiftUI pairing flow
|
||||
- `Sources/UI/Pairing/QRScannerView.swift` — AVFoundation QR scanner
|
||||
- `Sources/UI/Terminal/TerminalTheme.swift` — ThemeColor + TerminalTheme + built-ins
|
||||
- `Sources/UI/Terminal/ThemeStore.swift` — @MainActor ObservableObject
|
||||
- `Sources/UI/Terminal/FontStore.swift` — @MainActor ObservableObject
|
||||
- `Sources/UI/Terminal/TerminalFont.swift` — Static font presets
|
||||
- `Sources/UI/Terminal/TerminalViewController.swift` — SwiftTerm wrapper
|
||||
- `Sources/UI/Terminal/TerminalViewRepresentable.swift` — UIViewControllerRepresentable
|
||||
|
||||
**Quality:** High. All types are Sendable-annotated correctly.
|
||||
|
||||
---
|
||||
|
||||
## IC-1 Compliance Issues
|
||||
|
||||
### ✅ All Field Names Correct
|
||||
|
||||
Every JSON field name in `FrameCodec.swift` matches IC-1:
|
||||
|
||||
| Frame | Type field | Extra fields | Status |
|
||||
|-------|-----------|--------------|--------|
|
||||
| resume | `"resume"` | `lastSeq` (null or UInt64) | ✅ |
|
||||
| key | `"key"` | `name` | ✅ |
|
||||
| keys | `"keys"` | `data` | ✅ |
|
||||
| paste | `"paste"` | `data` | ✅ |
|
||||
| snapshot-request | `"snapshot-request"` | none | ✅ |
|
||||
| state (server) | `"state"` | `value`, `tool?`, `ts` | ✅ |
|
||||
| snapshot (server) | `"snapshot"` | `seq`, `data` | ✅ |
|
||||
| session-meta (server) | `"session-meta"` | `name`, `description?`, `createdAt` | ✅ |
|
||||
| error (server) | `"error"` | `code`, `message` | ✅ |
|
||||
|
||||
### ✅ `awaiting-input` Raw Value Correct
|
||||
|
||||
`PiState.awaitingInput` has `rawValue = "awaiting-input"` (hyphenated), which is correct per IC-1. This is a common mistake point — the implementation got it right.
|
||||
|
||||
### ✅ `lastSeq: null` Encoding
|
||||
|
||||
`ClientToServer.resume(lastSeq: nil)` explicitly encodes JSON `null` (not omitting the key), which is what IC-1 requires. The comment in the code explains this intentional choice.
|
||||
|
||||
---
|
||||
|
||||
## Swift 6 Concurrency Issues
|
||||
|
||||
### ⚠️ Minor: `FrameCodec.encoder/decoder` — static mutable-ish singletons
|
||||
|
||||
```swift
|
||||
// FrameCodec.swift
|
||||
private static let encoder: JSONEncoder = { ... }()
|
||||
private static let decoder = JSONDecoder()
|
||||
```
|
||||
|
||||
`JSONEncoder` and `JSONDecoder` conform to `Sendable` as of Swift 5.7/Foundation updates. These are `static let` (not `var`), so they are initialized once and never mutated. **No Swift 6 compile error is expected.** However, if the Foundation version on the CI runner predates the `Sendable` conformance, a warning may appear. Recommend verifying with `-strict-concurrency=complete` on the build server.
|
||||
|
||||
### ⚠️ `WebSocketClient` — `DelegateAdapter.owner` is a non-isolated var
|
||||
|
||||
From the partial view of `WebSocketClient.swift`:
|
||||
|
||||
```swift
|
||||
private let delegateAdapter = DelegateAdapter()
|
||||
// ...
|
||||
delegateAdapter.owner = self // set in init
|
||||
```
|
||||
|
||||
If `DelegateAdapter` stores `owner` as `weak var owner: WebSocketClient?` (not actor-isolated), and Starscream calls the delegate from a background thread, there could be a data race in Swift 6. The comment says callbacks are hopped through `Task { @MainActor in … }` but `owner` assignment itself may not be safe. **Needs audit** when the full file is reviewed during PR.
|
||||
|
||||
### ✅ `ThemeStore` and `FontStore` — `@MainActor` Correct
|
||||
|
||||
Both stores are `@MainActor final class ObservableObject`. Singleton access via `.shared` from non-actor-isolated contexts will require `await` in Swift 6. Tests in this repo mark their ThemeStore test functions with `@MainActor` to handle this correctly.
|
||||
|
||||
### ✅ `PairingService` — `Sendable` Struct
|
||||
|
||||
`PairingService` is a `struct Sendable`, and `parseQR` is a `static` function. No concurrency issues.
|
||||
|
||||
### ✅ `Keychain` — `final class Sendable`
|
||||
|
||||
The implementation declares `final class Keychain: Sendable`. Since all methods delegate to thread-safe `Security` framework APIs and there is no mutable stored state, this is correct. No issues.
|
||||
|
||||
### ⚠️ `Keychain.load` throws `.encodingFailed` on decode failure
|
||||
|
||||
```swift
|
||||
} catch {
|
||||
throw KeychainError.encodingFailed // misleading name
|
||||
}
|
||||
```
|
||||
|
||||
The error case `KeychainError.encodingFailed` is thrown when **decoding** fails (reading from Keychain, not writing). The name is misleading — it should ideally be `.decodingFailed`. This is a minor naming issue, not a correctness problem, but callers that pattern-match `.encodingFailed` might be confused.
|
||||
|
||||
---
|
||||
|
||||
## Structural Issues
|
||||
|
||||
### 🚨 `feat/p2-t2-2-pairing` Branch is Empty
|
||||
|
||||
The T-2.2 pairing agent pushed a branch with no implementation files. The Auth and Pairing UI code was implemented by the T-2.3 terminal agent instead. This means:
|
||||
|
||||
1. The pairing branch PR will be empty/trivially mergeable.
|
||||
2. The terminal branch PR contains code for both T-2.2 and T-2.3.
|
||||
3. **Recommendation:** Credit the terminal agent with T-2.2 work, or have the pairing agent cherry-pick the Auth files before their PR is reviewed.
|
||||
|
||||
### ⚠️ `TerminalFont` is not `Equatable`
|
||||
|
||||
`TerminalFont` is `Identifiable, Sendable` but **not `Equatable`**. This makes it harder to assert equality in tests and may cause issues if SwiftUI `ForEach` needs to diff fonts. Recommend adding `Equatable` conformance (or at least equality on `id`).
|
||||
|
||||
### ⚠️ `JetBrains Mono` font not yet bundled
|
||||
|
||||
Documented in the source (`T-2.12 deferred`). Tests must not assume `UIFont(name: "JetBrainsMono-Regular", size:)` succeeds — the fallback to system monospace is correct behavior for now.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations for Merge Order
|
||||
|
||||
1. **`feat/p2-t2-1-websocket`** — Merge first. Pure Foundation, no UI dependencies. All IC-1 wire types defined here; T-2.2 and T-2.3 code may eventually import from this layer.
|
||||
|
||||
2. **`feat/p2-t2-3-terminal`** — Merge second. Contains both T-2.2 (Auth) and T-2.3 (Terminal) implementations. Depends on nothing from T-2.2's (empty) branch.
|
||||
|
||||
3. **`feat/p2-t2-2-pairing`** — Merge last (or close without merge). The branch is empty and the implementation already landed via T-2.3. If the pairing agent re-uses the branch to deliver a QR-scanning integration test or additional pairing UI, it can be rebased on top of T-2.3's merge.
|
||||
|
||||
4. **`feat/p2-tests`** (this branch) — Can merge any time after T-2.1; the tests import `piRemote` which will contain all source files after the three implementation branches are merged.
|
||||
|
||||
---
|
||||
|
||||
## Test Files Created
|
||||
|
||||
| File | Tests | Coverage |
|
||||
|------|-------|----------|
|
||||
| `FrameCodecTests.swift` | 21 | BinaryFrame.decode, ClientToServer encoding (all 5 cases), ServerToClient decoding (all 4 types), round-trip |
|
||||
| `ResumeCursorTests.swift` | 11 | Save/load, update overwrites, zero seq, UInt64.max, clear, session isolation |
|
||||
| `PairingTests.swift` | 15 | Valid URLs, missing params (3 cases), wrong scheme (2), empty/missing port, error type assertions |
|
||||
| `KeychainTests.swift` | 7 | Round-trip, upsert, missing→notFound, delete clears, delete no-op, production key not touched, generic Codable |
|
||||
| `ThemeTests.swift` | 18 | ansiColors count, color range, dark≠github, id values, background values, SwiftTerm conversion, ThemeStore select, Codable round-trip |
|
||||
|
||||
**Total: 72 unit tests across 5 files.**
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
// ResumeCursorTests.swift
|
||||
// Unit tests for ResumeCursor — persistence of IC-1 sequence numbers.
|
||||
//
|
||||
// Uses a named UserDefaults suite per test to stay completely isolated from
|
||||
// the real app defaults and from other test cases.
|
||||
|
||||
import XCTest
|
||||
@testable import piRemote
|
||||
|
||||
final class ResumeCursorTests: XCTestCase {
|
||||
|
||||
// Each test creates its own defaults suite; tearDown removes it.
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
private var cursor: ResumeCursor!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "ResumeCursorTests.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
cursor = ResumeCursor(defaults: defaults)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 1. Initial state
|
||||
// =========================================================================
|
||||
|
||||
func testLastSeq_freshSession_returnsNil() {
|
||||
XCTAssertNil(cursor.lastSeq(for: "session-A"),
|
||||
"No stored cursor → must return nil")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 2. Save + load
|
||||
// =========================================================================
|
||||
|
||||
func testUpdateAndLoad_returnsStoredValue() {
|
||||
cursor.update(sessionId: "s1", seq: 42)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "s1"), 42)
|
||||
}
|
||||
|
||||
func testUpdate_overwrites_previousValue() {
|
||||
cursor.update(sessionId: "s1", seq: 1)
|
||||
cursor.update(sessionId: "s1", seq: 999)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "s1"), 999,
|
||||
"Second update must overwrite the first")
|
||||
}
|
||||
|
||||
func testUpdate_zeroSeq_isStoredAndDistinctFromMissing() {
|
||||
// Before update: nil
|
||||
XCTAssertNil(cursor.lastSeq(for: "s-zero"))
|
||||
// After update with 0: 0 (not nil)
|
||||
cursor.update(sessionId: "s-zero", seq: 0)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "s-zero"), 0)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 3. UInt64 boundary values
|
||||
// =========================================================================
|
||||
|
||||
func testUpdate_maxUInt64_roundTrips() {
|
||||
cursor.update(sessionId: "s-max", seq: UInt64.max)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "s-max"), UInt64.max,
|
||||
"UInt64.max must survive the Int64-bit-pattern round-trip")
|
||||
}
|
||||
|
||||
func testUpdate_largeValue_roundTrips() {
|
||||
let large: UInt64 = 1_000_000_000_000
|
||||
cursor.update(sessionId: "s-large", seq: large)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "s-large"), large)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 4. Clear
|
||||
// =========================================================================
|
||||
|
||||
func testClear_removesEntry() {
|
||||
cursor.update(sessionId: "s1", seq: 100)
|
||||
cursor.clear(sessionId: "s1")
|
||||
XCTAssertNil(cursor.lastSeq(for: "s1"),
|
||||
"After clear, lastSeq must return nil")
|
||||
}
|
||||
|
||||
func testClear_missingEntry_isNoOp() {
|
||||
// Clearing a key that was never written must not throw or crash.
|
||||
cursor.clear(sessionId: "never-written")
|
||||
XCTAssertNil(cursor.lastSeq(for: "never-written"))
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 5. Session isolation
|
||||
// =========================================================================
|
||||
|
||||
func testIndependentSessions_doNotInterfere() {
|
||||
cursor.update(sessionId: "alpha", seq: 10)
|
||||
cursor.update(sessionId: "beta", seq: 20)
|
||||
|
||||
XCTAssertEqual(cursor.lastSeq(for: "alpha"), 10)
|
||||
XCTAssertEqual(cursor.lastSeq(for: "beta"), 20)
|
||||
}
|
||||
|
||||
func testClearOneSession_doesNotAffectOther() {
|
||||
cursor.update(sessionId: "alpha", seq: 10)
|
||||
cursor.update(sessionId: "beta", seq: 20)
|
||||
|
||||
cursor.clear(sessionId: "alpha")
|
||||
|
||||
XCTAssertNil(cursor.lastSeq(for: "alpha"),
|
||||
"alpha should be nil after clear")
|
||||
XCTAssertEqual(cursor.lastSeq(for: "beta"), 20,
|
||||
"beta must be unaffected by clearing alpha")
|
||||
}
|
||||
|
||||
func testThreeIndependentSessions() {
|
||||
let ids: [String] = ["s1", "s2", "s3"]
|
||||
let seqs: [UInt64] = [1, 2, 3]
|
||||
zip(ids, seqs).forEach { cursor.update(sessionId: $0, seq: $1) }
|
||||
zip(ids, seqs).forEach {
|
||||
XCTAssertEqual(cursor.lastSeq(for: $0), $1)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 6. Missing entry (never stored)
|
||||
// =========================================================================
|
||||
|
||||
func testMissingEntry_returnsNil() {
|
||||
// Confirm nil for a key that was never written.
|
||||
XCTAssertNil(cursor.lastSeq(for: "completely-absent"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
// ThemeTests.swift
|
||||
// Unit tests for TerminalTheme, ThemeColor, ThemeStore, and FontStore.
|
||||
//
|
||||
// These tests exercise built-in theme validity and store selection logic.
|
||||
// No UIKit rendering or simulator is required for the pure-struct tests;
|
||||
// ThemeStore / FontStore tests require @MainActor and a run loop.
|
||||
|
||||
import XCTest
|
||||
@testable import piRemote
|
||||
|
||||
final class ThemeTests: XCTestCase {
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 1. TerminalTheme — built-in theme invariants
|
||||
// =========================================================================
|
||||
|
||||
func testDark_hasExactly16AnsiColors() {
|
||||
XCTAssertEqual(TerminalTheme.dark.ansiColors.count, 16,
|
||||
".dark must have exactly 16 ANSI color entries")
|
||||
}
|
||||
|
||||
func testGitHub_hasExactly16AnsiColors() {
|
||||
XCTAssertEqual(TerminalTheme.github.ansiColors.count, 16,
|
||||
".github must have exactly 16 ANSI color entries")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 2. ThemeColor values are in the 0–255 range
|
||||
// =========================================================================
|
||||
// ThemeColor stores UInt8 components, so they are structurally bounded to
|
||||
// 0–255. These tests make the contract explicit and guard against future
|
||||
// refactors that might widen the type.
|
||||
|
||||
func testDark_allAnsiColorComponents_inRange() {
|
||||
for (index, color) in TerminalTheme.dark.ansiColors.enumerated() {
|
||||
assertColorInRange(color, label: "dark[\(index)]")
|
||||
}
|
||||
assertColorInRange(TerminalTheme.dark.foreground, label: "dark.foreground")
|
||||
assertColorInRange(TerminalTheme.dark.background, label: "dark.background")
|
||||
assertColorInRange(TerminalTheme.dark.cursor, label: "dark.cursor")
|
||||
}
|
||||
|
||||
func testGitHub_allAnsiColorComponents_inRange() {
|
||||
for (index, color) in TerminalTheme.github.ansiColors.enumerated() {
|
||||
assertColorInRange(color, label: "github[\(index)]")
|
||||
}
|
||||
assertColorInRange(TerminalTheme.github.foreground, label: "github.foreground")
|
||||
assertColorInRange(TerminalTheme.github.background, label: "github.background")
|
||||
assertColorInRange(TerminalTheme.github.cursor, label: "github.cursor")
|
||||
}
|
||||
|
||||
private func assertColorInRange(_ color: ThemeColor, label: String) {
|
||||
// UInt8 is intrinsically 0–255; the assertions below make the expectation readable.
|
||||
XCTAssertLessThanOrEqual(color.r, 255, "\(label).r out of range")
|
||||
XCTAssertLessThanOrEqual(color.g, 255, "\(label).g out of range")
|
||||
XCTAssertLessThanOrEqual(color.b, 255, "\(label).b out of range")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 3. .dark and .github are distinct themes
|
||||
// =========================================================================
|
||||
|
||||
func testDarkAndGitHub_haveDistinctIds() {
|
||||
XCTAssertNotEqual(TerminalTheme.dark.id, TerminalTheme.github.id,
|
||||
"Built-in themes must have unique IDs")
|
||||
}
|
||||
|
||||
func testDarkAndGitHub_haveDistinctBackgrounds() {
|
||||
// "Dark (Hacker)" has a pure-black background; GitHub Dark does not.
|
||||
XCTAssertNotEqual(TerminalTheme.dark.background, TerminalTheme.github.background,
|
||||
"Background colors must differ between dark and github themes")
|
||||
}
|
||||
|
||||
func testDarkAndGitHub_areNotEqual() {
|
||||
XCTAssertNotEqual(TerminalTheme.dark, TerminalTheme.github,
|
||||
".dark and .github must be distinct themes")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 4. TerminalTheme identities
|
||||
// =========================================================================
|
||||
|
||||
func testDark_hasExpectedId() {
|
||||
XCTAssertEqual(TerminalTheme.dark.id, "dark")
|
||||
}
|
||||
|
||||
func testGitHub_hasExpectedId() {
|
||||
XCTAssertEqual(TerminalTheme.github.id, "github")
|
||||
}
|
||||
|
||||
func testDark_backgroundIsBlack() {
|
||||
let bg = TerminalTheme.dark.background
|
||||
XCTAssertEqual(bg.r, 0x00, "Dark background R must be 0x00")
|
||||
XCTAssertEqual(bg.g, 0x00, "Dark background G must be 0x00")
|
||||
XCTAssertEqual(bg.b, 0x00, "Dark background B must be 0x00")
|
||||
}
|
||||
|
||||
func testGitHub_backgroundMatchesSpec() {
|
||||
// GitHub Dark background: #0d1117
|
||||
let bg = TerminalTheme.github.background
|
||||
XCTAssertEqual(bg.r, 0x0D, "GitHub background R must be 0x0D")
|
||||
XCTAssertEqual(bg.g, 0x11, "GitHub background G must be 0x11")
|
||||
XCTAssertEqual(bg.b, 0x17, "GitHub background B must be 0x17")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 5. TerminalTheme.toSwiftTermColor conversion
|
||||
// =========================================================================
|
||||
|
||||
func testToSwiftTermColor_white_mapsTo65535() {
|
||||
// 255 × 257 = 65535 (= UInt16.max)
|
||||
let white = ThemeColor(r: 255, g: 255, b: 255)
|
||||
let stColor = TerminalTheme.dark.toSwiftTermColor(white)
|
||||
XCTAssertEqual(stColor.red, 65535)
|
||||
XCTAssertEqual(stColor.green, 65535)
|
||||
XCTAssertEqual(stColor.blue, 65535)
|
||||
}
|
||||
|
||||
func testToSwiftTermColor_black_mapsToZero() {
|
||||
let black = ThemeColor(r: 0, g: 0, b: 0)
|
||||
let stColor = TerminalTheme.dark.toSwiftTermColor(black)
|
||||
XCTAssertEqual(stColor.red, 0)
|
||||
XCTAssertEqual(stColor.green, 0)
|
||||
XCTAssertEqual(stColor.blue, 0)
|
||||
}
|
||||
|
||||
func testSwiftTermAnsiColors_has16Entries() {
|
||||
XCTAssertEqual(TerminalTheme.dark.swiftTermAnsiColors.count, 16)
|
||||
XCTAssertEqual(TerminalTheme.github.swiftTermAnsiColors.count, 16)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 6. ThemeStore — selection and persistence
|
||||
// =========================================================================
|
||||
|
||||
@MainActor
|
||||
func testThemeStore_available_containsBothBuiltins() {
|
||||
let store = ThemeStore.shared
|
||||
let ids = store.available.map(\.id)
|
||||
XCTAssertTrue(ids.contains("dark"), "Available themes must include 'dark'")
|
||||
XCTAssertTrue(ids.contains("github"), "Available themes must include 'github'")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testThemeStore_select_updatesCurrent() {
|
||||
let store = ThemeStore.shared
|
||||
let before = store.current
|
||||
|
||||
// Select whichever theme is NOT currently active.
|
||||
let next = store.available.first(where: { $0.id != before.id })!
|
||||
store.select(next)
|
||||
XCTAssertEqual(store.current.id, next.id,
|
||||
"select(_:) must update current immediately")
|
||||
|
||||
// Restore original selection so we don't bleed state into other tests.
|
||||
store.select(before)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: 7. TerminalTheme Codable round-trip
|
||||
// =========================================================================
|
||||
|
||||
func testTerminalTheme_codable_roundTrips() throws {
|
||||
let theme = TerminalTheme.dark
|
||||
let data = try JSONEncoder().encode(theme)
|
||||
let loaded = try JSONDecoder().decode(TerminalTheme.self, from: data)
|
||||
XCTAssertEqual(loaded, theme, "TerminalTheme must survive a JSON round-trip")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue