diff --git a/Sources/Core/Auth/Keychain.swift b/Sources/Core/Auth/Keychain.swift new file mode 100644 index 0000000..3c834c0 --- /dev/null +++ b/Sources/Core/Auth/Keychain.swift @@ -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(_ 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(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, + ] + } +} diff --git a/Sources/Core/Auth/Pairing.swift b/Sources/Core/Auth/Pairing.swift new file mode 100644 index 0000000..3684199 --- /dev/null +++ b/Sources/Core/Auth/Pairing.swift @@ -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://:?pair=&fp=&name= + /// ``` + 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) ?? "" + 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() + ) + } +} diff --git a/Sources/Core/Auth/SidecarCredential.swift b/Sources/Core/Auth/SidecarCredential.swift new file mode 100644 index 0000000..8217cc9 --- /dev/null +++ b/Sources/Core/Auth/SidecarCredential.swift @@ -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 +} diff --git a/Sources/UI/Pairing/PairingFlowView.swift b/Sources/UI/Pairing/PairingFlowView.swift new file mode 100644 index 0000000..e8b8caf --- /dev/null +++ b/Sources/UI/Pairing/PairingFlowView.swift @@ -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() +} diff --git a/Sources/UI/Pairing/QRScannerView.swift b/Sources/UI/Pairing/QRScannerView.swift new file mode 100644 index 0000000..a649568 --- /dev/null +++ b/Sources/UI/Pairing/QRScannerView.swift @@ -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 + } + } +} diff --git a/Sources/UI/Terminal/FontStore.swift b/Sources/UI/Terminal/FontStore.swift new file mode 100644 index 0000000..172e555 --- /dev/null +++ b/Sources/UI/Terminal/FontStore.swift @@ -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) + } +} diff --git a/Sources/UI/Terminal/TerminalFont.swift b/Sources/UI/Terminal/TerminalFont.swift new file mode 100644 index 0000000..676e9cb --- /dev/null +++ b/Sources/UI/Terminal/TerminalFont.swift @@ -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) + }() +} diff --git a/Sources/UI/Terminal/TerminalTheme.swift b/Sources/UI/Terminal/TerminalTheme.swift new file mode 100644 index 0000000..9f23379 --- /dev/null +++ b/Sources/UI/Terminal/TerminalTheme.swift @@ -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), + ] + ) +} diff --git a/Sources/UI/Terminal/TerminalViewController.swift b/Sources/UI/Terminal/TerminalViewController.swift new file mode 100644 index 0000000..d23adad --- /dev/null +++ b/Sources/UI/Terminal/TerminalViewController.swift @@ -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) 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) { + 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) { + // 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 + ) + } +} diff --git a/Sources/UI/Terminal/TerminalViewRepresentable.swift b/Sources/UI/Terminal/TerminalViewRepresentable.swift new file mode 100644 index 0000000..3d4d844 --- /dev/null +++ b/Sources/UI/Terminal/TerminalViewRepresentable.swift @@ -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:). + } +} diff --git a/Sources/UI/Terminal/ThemeStore.swift b/Sources/UI/Terminal/ThemeStore.swift new file mode 100644 index 0000000..fe6955d --- /dev/null +++ b/Sources/UI/Terminal/ThemeStore.swift @@ -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) + } +}