merge: T-2.2 Pairing/Keychain/QR + T-2.3 TerminalView/Themes/Fonts
This commit is contained in:
commit
27d0a43dbe
|
|
@ -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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue