diff --git a/apps/apple/.gitignore b/apps/apple/.gitignore new file mode 100644 index 00000000..2d9f16e2 --- /dev/null +++ b/apps/apple/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/apps/apple/Apps/T4IOSApp/Info.plist b/apps/apple/Apps/T4IOSApp/Info.plist new file mode 100644 index 00000000..47ef031b --- /dev/null +++ b/apps/apple/Apps/T4IOSApp/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/apps/apple/Apps/T4IOSApp/T4IOSApp.swift b/apps/apple/Apps/T4IOSApp/T4IOSApp.swift new file mode 100644 index 00000000..5fb259e3 --- /dev/null +++ b/apps/apple/Apps/T4IOSApp/T4IOSApp.swift @@ -0,0 +1,11 @@ +import SwiftUI +import T4UI + +@main +struct T4IOSApp: App { + var body: some Scene { + WindowGroup { + T4RootView(composition: T4Composition.live()) + } + } +} diff --git a/apps/apple/Apps/T4MacApp/Info.plist b/apps/apple/Apps/T4MacApp/Info.plist new file mode 100644 index 00000000..23413815 --- /dev/null +++ b/apps/apple/Apps/T4MacApp/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + T4 Code + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSPrincipalClass + NSApplication + + diff --git a/apps/apple/Package.swift b/apps/apple/Package.swift new file mode 100644 index 00000000..8484c330 --- /dev/null +++ b/apps/apple/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "T4Apple", + platforms: [ + .macOS(.v14), + .iOS(.v17), + ], + products: [ + .library(name: "T4Protocol", targets: ["T4Protocol"]), + .library(name: "T4Client", targets: ["T4Client"]), + .library(name: "T4Platform", targets: ["T4Platform"]), + .library(name: "T4UI", targets: ["T4UI"]), + .executable(name: "T4MacApp", targets: ["T4MacApp"]), + ], + targets: [ + .target( + name: "T4Protocol" + ), + .target( + name: "T4Client", + dependencies: ["T4Protocol"] + ), + .target( + name: "T4Platform", + dependencies: ["T4Protocol"] + ), + .target( + name: "T4UI", + dependencies: ["T4Protocol", "T4Client", "T4Platform"] + ), + .executableTarget( + name: "T4MacApp", + dependencies: ["T4UI", "T4Client", "T4Platform"] + ), + .testTarget( + name: "T4ProtocolTests", + dependencies: ["T4Protocol"] + ), + .testTarget( + name: "T4ClientTests", + dependencies: ["T4Client", "T4Platform"] + ), + ] +) diff --git a/apps/apple/Sources/T4Client/AppState.swift b/apps/apple/Sources/T4Client/AppState.swift new file mode 100644 index 00000000..bfaef6ea --- /dev/null +++ b/apps/apple/Sources/T4Client/AppState.swift @@ -0,0 +1,160 @@ +import Foundation +import Observation +import T4Protocol + +/// Connection lifecycle exposed to native surfaces. +public enum T4ConnectionState: String, Sendable, Equatable { + case disconnected + case connecting + case connected + case reconnecting + case failed +} + +public enum T4AuthenticationState: String, Sendable, Equatable { + case unknown + case local + case pairingRequired + case paired + case failed +} + +public struct T4Profile: Identifiable, Sendable, Equatable { + public let id: String + public var label: String + public var targetID: String + public var isEnabled: Bool + public var isSelected: Bool + + public init(id: String, label: String, targetID: String = "local", isEnabled: Bool = true, isSelected: Bool = false) { + self.id = id + self.label = label + self.targetID = targetID + self.isEnabled = isEnabled + self.isSelected = isSelected + } +} + +public struct T4Session: Identifiable, Sendable, Equatable { + public let id: String + public var hostID: String + public var title: String + public var status: String + public var updatedAt: Date? + public var isSelected: Bool + + public init(id: String, hostID: String, title: String = "", status: String = "", updatedAt: Date? = nil, isSelected: Bool = false) { + self.id = id + self.hostID = hostID + self.title = title + self.status = status + self.updatedAt = updatedAt + self.isSelected = isSelected + } +} + +public struct T4TranscriptItem: Identifiable, Sendable, Equatable { + public let id: String + public var role: String + public var text: String + public var cursor: TranscriptCursor? + public var revision: String? + + public init(id: String, role: String, text: String, cursor: TranscriptCursor? = nil, revision: String? = nil) { + self.id = id + self.role = role + self.text = text + self.cursor = cursor + self.revision = revision + } +} + +public struct T4AttentionItem: Identifiable, Sendable, Equatable { + public let id: String + public var kind: String + public var title: String + public var detail: String + public var commandID: String? + + public init(id: String, kind: String, title: String, detail: String = "", commandID: String? = nil) { + self.id = id + self.kind = kind + self.title = title + self.detail = detail + self.commandID = commandID + } +} + +public struct T4ComposerState: Sendable, Equatable { + public var text = "" + public var isSending = false + public var queuedCount = 0 + public var error: String? + + public init() {} +} + +public struct T4DeveloperState: Sendable, Equatable { + public var isEnabled = false + public var lastRequestID: String? + public var lastCommandID: String? + public var messages: [String] = [] + + public init() {} +} + +public struct T4SettingsState: Sendable, Equatable { + public var values: [String: String] = [:] + + public init(values: [String: String] = [:]) { + self.values = values + } +} + +/// The disposable native projection. It intentionally keeps host-index and live +/// transcript cursors separate: they have independent ordering domains. +@Observable @MainActor +public final class AppState { + public var connection: T4ConnectionState = .disconnected + public var authentication: T4AuthenticationState = .unknown + public var profiles: [T4Profile] = [] + public var selectedProfileID: String? + public var sessions: [T4Session] = [] + public var selectedSessionID: String? + public var transcript: [T4TranscriptItem] = [] + public var transcriptCursor: TranscriptCursor? + public var sessionIndexCursor: SessionIndexCursor? + public var composer = T4ComposerState() + public var attention: [T4AttentionItem] = [] + public var developer = T4DeveloperState() + public var settings = T4SettingsState() + public var errorMessage: String? + public private(set) var generation: UInt64 = 0 + + public init() {} + + func beginGeneration() -> UInt64 { + generation &+= 1 + return generation + } + + func resetConnection(retainSelection: Bool = true) { + connection = .disconnected + authentication = .unknown + errorMessage = nil + composer.isSending = false + if !retainSelection { + sessions.removeAll(keepingCapacity: true) + transcript.removeAll(keepingCapacity: true) + transcriptCursor = nil + sessionIndexCursor = nil + composer.queuedCount = 0 + attention.removeAll(keepingCapacity: true) + } + if !retainSelection { + selectedProfileID = nil + selectedSessionID = nil + profiles = profiles.map { T4Profile(id: $0.id, label: $0.label, targetID: $0.targetID, isEnabled: $0.isEnabled) } + } + } +} diff --git a/apps/apple/Sources/T4Client/T4ClientController.swift b/apps/apple/Sources/T4Client/T4ClientController.swift new file mode 100644 index 00000000..3efb4621 --- /dev/null +++ b/apps/apple/Sources/T4Client/T4ClientController.swift @@ -0,0 +1,596 @@ +import Foundation +import Observation +@_exported import T4Protocol + +public struct T4CommandResponse: Sendable, Equatable { + public let requestID: String + public let commandID: String? + public let command: String? + public let hostID: String + public let sessionID: String? + public let result: [String: JSONValue]? + public let errorCode: String? + public let errorMessage: String? + + public var isSuccess: Bool { errorCode == nil } + + public init(requestID: String, commandID: String? = nil, command: String? = nil, hostID: String, sessionID: String? = nil, result: [String: JSONValue]? = nil, errorCode: String? = nil, errorMessage: String? = nil) { + self.requestID = requestID + self.commandID = commandID + self.command = command + self.hostID = hostID + self.sessionID = sessionID + self.result = result + self.errorCode = errorCode + self.errorMessage = errorMessage + } +} + +public struct T4ReconnectPolicy: Sendable, Equatable { + public var baseDelay: Duration + public var maximumDelay: Duration + public var maximumAttempts: Int? + + public init(baseDelay: Duration = .milliseconds(250), maximumDelay: Duration = .seconds(8), maximumAttempts: Int? = nil) { + self.baseDelay = baseDelay + self.maximumDelay = maximumDelay + self.maximumAttempts = maximumAttempts + } +} + +public enum T4ClientControllerError: Error, Sendable, Equatable { + case disconnected + case staleGeneration + case invalidFrame + case remote(code: String, message: String) + case transport(String) +} + +/// Main-actor supervisor for the native app. The controller owns no durable +/// data: state can be discarded on disconnect while selected profile/session +/// identity and cursors are retained for reconnect recovery. +@Observable +@MainActor +public final class T4ClientController { + public let state: AppState + public let transport: any T4ClientTransport + public private(set) var hostID: String? + public private(set) var generation: UInt64 = 0 + + private let reconnectPolicy: T4ReconnectPolicy + private var listenTask: Task? + private var reconnectTask: Task? + private var connectTask: Task? + private var userDisconnected = false + private var reconnectAttempt = 0 + private var pending: [String: PendingRequest] = [:] + private var commandIDs: [String: String] = [:] + private var welcomeWaiter: WelcomeWaiter? + private var grantedFeatures: Set = [] + + private struct PendingRequest { + let requestID: String + let commandID: String? + let generation: UInt64 + let continuation: CheckedContinuation + } + + private struct WelcomeWaiter { + let generation: UInt64 + let continuation: CheckedContinuation + } + + public init(transport: any T4ClientTransport, state: AppState = AppState(), reconnectPolicy: T4ReconnectPolicy = T4ReconnectPolicy()) { + self.transport = transport + self.state = state + self.reconnectPolicy = reconnectPolicy + } + + + public func connect() async { + if let existing = connectTask { + existing.cancel() + await transport.disconnect() + await existing.value + connectTask = nil + } + userDisconnected = false + reconnectTask?.cancel() + reconnectTask = nil + reconnectAttempt = 0 + let currentGeneration = nextGeneration() + state.connection = .connecting + let task = Task { @MainActor [weak self] in + guard let self else { return } + await self.performConnect(generation: currentGeneration) + } + connectTask = task + await withTaskCancellationHandler(operation: { + await task.value + }, onCancel: { + task.cancel() + }) + } + + private func performConnect(generation currentGeneration: UInt64) async { + defer { + if generation == currentGeneration { + connectTask = nil + } + } + do { + try Task.checkCancellation() + try await transport.connect() + try Task.checkCancellation() + guard currentGeneration == generation, !userDisconnected else { return } + startListening(for: currentGeneration) + _ = try await negotiateWelcome(generation: currentGeneration) + try await bootstrap(generation: currentGeneration) + } catch { + guard currentGeneration == generation, !userDisconnected else { return } + if error is CancellationError { return } + failWelcomeWaiter(for: currentGeneration, with: error) + failPending(for: currentGeneration, with: error) + state.connection = .failed + state.errorMessage = message(for: error) + scheduleReconnect() + } + } + + public func disconnect() async { + userDisconnected = true + let reconnect = reconnectTask + reconnectTask?.cancel() + reconnectTask = nil + let listener = listenTask + listenTask?.cancel() + listenTask = nil + let connector = connectTask + connectTask?.cancel() + connectTask = nil + failWelcomeWaiter(with: T4ClientControllerError.disconnected) + _ = nextGeneration() + failPending(with: T4ClientControllerError.disconnected) + await transport.disconnect() + await reconnect?.value + await connector?.value + await listener?.value + state.resetConnection(retainSelection: true) + } + + + public func selectProfile(_ profileID: String?) { + selectedProfileID = profileID + for index in state.profiles.indices { + state.profiles[index].isSelected = state.profiles[index].id == profileID + } + } + + public func selectSession(_ sessionID: String?) async { + selectedSessionID = sessionID + for index in state.sessions.indices { + state.sessions[index].isSelected = state.sessions[index].id == sessionID + } + state.transcript.removeAll(keepingCapacity: true) + state.transcriptCursor = nil + guard let sessionID, let hostID, state.connection == .connected else { return } + do { + _ = try await command("session.attach", hostID: hostID, sessionID: sessionID, args: attachArguments()) + } catch { + state.errorMessage = message(for: error) + } + } + + @discardableResult + public func prompt(_ text: String) async throws -> T4CommandResponse { + guard let sessionID = selectedSessionID else { throw T4ClientControllerError.disconnected } + state.composer.isSending = true + defer { state.composer.isSending = false } + do { + let response = try await command("session.prompt", sessionID: sessionID, args: ["message": .string(text)]) + state.composer.text = "" + state.composer.error = nil + return response + } catch { + state.composer.error = message(for: error) + throw error + } + } + + @discardableResult + public func queue(_ text: String) async throws -> T4CommandResponse { + guard let sessionID = selectedSessionID else { throw T4ClientControllerError.disconnected } + let response = try await command("session.queue", sessionID: sessionID, args: ["message": .string(text)]) + state.composer.queuedCount += 1 + return response + } + @discardableResult + public func cancel(commandID: String? = nil) async throws -> T4CommandResponse { + guard let sessionID = selectedSessionID else { throw T4ClientControllerError.disconnected } + var args: [String: JSONValue] = [:] + if let commandID { args["commandId"] = .string(commandID) } + return try await command("session.cancel", sessionID: sessionID, args: args) + } + + @discardableResult + public func command(_ name: String, hostID explicitHostID: String? = nil, sessionID explicitSessionID: String? = nil, expectedRevision: String? = nil, args: [String: JSONValue] = [:]) async throws -> T4CommandResponse { + guard state.connection == .connected, let hostID = explicitHostID ?? hostID else { throw T4ClientControllerError.disconnected } + let sessionID = explicitSessionID + let requestID = UUID().uuidString.lowercased() + let commandID = UUID().uuidString.lowercased() + var object: [String: JSONValue] = [ + "v": .string(WireLimits.protocolVersion), "type": .string("command"), "requestId": .string(requestID), + "commandId": .string(commandID), "hostId": .string(hostID), "command": .string(name), "args": .object(args) + ] + if let sessionID { object["sessionId"] = .string(sessionID) } + if let expectedRevision { object["expectedRevision"] = .string(expectedRevision) } + let frame = try WireDecoder.decode(try JSONValue.object(object).encodedData()) + let currentGeneration = generation + let response = try await withCheckedThrowingContinuation { continuation in + pending[requestID] = PendingRequest(requestID: requestID, commandID: commandID, generation: currentGeneration, continuation: continuation) + commandIDs[requestID] = commandID + Task { @MainActor [weak self] in + guard let self else { return } + do { try await self.transport.send(frame) } + catch { + guard let pending = self.pending.removeValue(forKey: requestID) else { return } + self.commandIDs.removeValue(forKey: requestID) + pending.continuation.resume(throwing: T4ClientControllerError.transport(self.message(for: error))) + } + } + } + guard currentGeneration == generation else { throw T4ClientControllerError.staleGeneration } + return response + } + + /// Convenience spelling for mutations that must carry an optimistic + /// revision. It does not retry stale revisions or unknown outcomes. + @discardableResult + public func revisionedCommand(_ name: String, expectedRevision: String, args: [String: JSONValue] = [:]) async throws -> T4CommandResponse { + try await command(name, sessionID: selectedSessionID, expectedRevision: expectedRevision, args: args) + } + + private var selectedProfileID: String? { + get { state.selectedProfileID } + set { selectProfileWithoutSideEffects(newValue: newValue) } + } + + private var selectedSessionID: String? { + get { state.selectedSessionID } + set { state.selectedSessionID = newValue } + } + + private func selectProfileWithoutSideEffects(newValue: String?) { + state.selectedProfileID = newValue + } + + private func nextGeneration() -> UInt64 { + failWelcomeWaiter(with: T4ClientControllerError.staleGeneration) + failPending(with: T4ClientControllerError.staleGeneration) + generation &+= 1 + _ = state.beginGeneration() + return generation + } + + private func startListening(for currentGeneration: UInt64) { + listenTask?.cancel() + listenTask = Task { @MainActor [weak self, transport] in + defer { + if let self, self.generation == currentGeneration { + self.listenTask = nil + } + } + do { + for try await frame in transport.incoming { + guard let self, self.generation == currentGeneration, !self.userDisconnected else { return } + self.handle(frame, generation: currentGeneration) + } + guard let self, self.generation == currentGeneration, !self.userDisconnected else { return } + let error = T4ClientControllerError.transport("Transport closed.") + self.failWelcomeWaiter(for: currentGeneration, with: error) + self.failPending(for: currentGeneration, with: error) + self.scheduleReconnect() + } catch { + guard let self, self.generation == currentGeneration, !self.userDisconnected else { return } + if error is CancellationError { return } + let transportError = T4ClientControllerError.transport(self.message(for: error)) + self.failWelcomeWaiter(for: currentGeneration, with: transportError) + self.failPending(for: currentGeneration, with: transportError) + self.state.connection = .reconnecting + self.scheduleReconnect() + } + } + } + + + private func sendHello(generation currentGeneration: UInt64) async throws { + var saved: [SavedCursor] = [] + if let hostID, let sessionID = selectedSessionID, let cursor = state.transcriptCursor { + saved.append(SavedCursor(hostId: hostID, sessionId: sessionID, cursor: cursor)) + } + let client = ClientIdentity(name: "t4-apple", version: "1", build: "native", platform: "apple") + guard generation == currentGeneration else { throw T4ClientControllerError.staleGeneration } + let data = try WireEncoder.hello(client: client, requestedFeatures: ["resume", "host.watch", "session.watch"], savedCursors: saved) + try await transport.send(try WireDecoder.decode(data)) + } + + private func negotiateWelcome(generation currentGeneration: UInt64) async throws -> String { + try await withTaskCancellationHandler(operation: { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard generation == currentGeneration, !userDisconnected else { + continuation.resume(throwing: userDisconnected ? T4ClientControllerError.disconnected : T4ClientControllerError.staleGeneration) + return + } + welcomeWaiter = WelcomeWaiter(generation: currentGeneration, continuation: continuation) + Task { @MainActor in + do { + try await self.sendHello(generation: currentGeneration) + } catch { + self.failWelcomeWaiter(for: currentGeneration, with: error) + } + } + } + }, onCancel: { + Task { @MainActor [weak self] in + self?.failWelcomeWaiter(for: currentGeneration, with: CancellationError()) + } + }) + } + + private func bootstrap(generation currentGeneration: UInt64) async throws { + guard generation == currentGeneration else { throw T4ClientControllerError.staleGeneration } + guard let hostID else { throw T4ClientControllerError.disconnected } + let list = try await command("session.list", hostID: hostID, sessionID: nil, args: [:]) + guard generation == currentGeneration else { throw T4ClientControllerError.staleGeneration } + applySessionList(list.result) + if grantedFeatures.contains("host.watch"), let cursor = state.sessionIndexCursor { + _ = try await command("host.watch", hostID: hostID, sessionID: nil, args: ["cursor": cursorJSON(cursor)]) + } + guard generation == currentGeneration else { throw T4ClientControllerError.staleGeneration } + if let selectedSessionID { + _ = try await command("session.attach", hostID: hostID, sessionID: selectedSessionID, args: attachArguments()) + } + } + + private func attachArguments() -> [String: JSONValue] { + guard let cursor = state.transcriptCursor else { return [:] } + return ["cursor": cursorJSON(cursor)] + } + + private func scheduleReconnect() { + guard !userDisconnected, reconnectTask == nil else { return } + if let maximumAttempts = reconnectPolicy.maximumAttempts, reconnectAttempt >= maximumAttempts { + state.connection = .failed + return + } + state.connection = .reconnecting + let attempt = reconnectAttempt + reconnectAttempt += 1 + let delay = reconnectDelay(attempt: attempt) + reconnectTask = Task { @MainActor [weak self] in + do { try await Task.sleep(for: delay) } catch { return } + guard let self, !self.userDisconnected else { return } + self.reconnectTask = nil + await self.connect() + } + } + + private func reconnectDelay(attempt: Int) -> Duration { + var delay = reconnectPolicy.baseDelay + if attempt > 0 { + for _ in 0.. Duration { lhs < rhs ? lhs : rhs } + + private func handle(_ frame: WireFrame, generation currentGeneration: UInt64) { + guard currentGeneration == generation else { return } + switch frame { + case let .welcome(welcome): + guard welcomeWaiter?.generation == currentGeneration else { return } + hostID = welcome.hostId + state.authentication = T4AuthenticationState(rawValue: welcome.authentication.camelized) ?? .unknown + grantedFeatures = Set(welcome.grantedFeatures) + state.connection = .connected + resumeWelcomeWaiter(hostID: welcome.hostId, generation: currentGeneration) + case let .response(response): + handleResponse(response, generation: currentGeneration) + case let .sessions(sessions): + applySessions(sessions.raw.mapValues { $0.toFoundation() }) + case let .snapshot(snapshot): + applySnapshot(snapshot.raw.mapValues { $0.toFoundation() }) + case let .entry(entry): + applyEntry(entry.raw.mapValues { $0.toFoundation() }) + case let .event(event): + applyEvent(event.event.mapValues { $0.toFoundation() }, cursor: event.cursor) + case let .confirmation(confirmation): + applyAttention(confirmation.raw.mapValues { $0.toFoundation() }) + case let .additive(additive) where additive.type == "settings": + applySettings(additive.raw.mapValues { $0.toFoundation() }) + case let .error(error): + if let requestID = error.requestId { + settleError(error, requestID: requestID) + } else { + failWelcomeWaiter(for: currentGeneration, with: T4ClientControllerError.remote(code: error.code, message: error.message)) + } + default: + break + } + } + + private func handleResponse(_ frame: ResponseFrame, generation currentGeneration: UInt64) { + guard let pendingRequest = pending[frame.requestId], pendingRequest.generation == currentGeneration else { return } + if let expectedCommandID = pendingRequest.commandID { + guard let actual = frame.commandId, expectedCommandID == actual else { return } + } + pending.removeValue(forKey: frame.requestId) + commandIDs.removeValue(forKey: frame.requestId) + let host = frame.hostId ?? hostID ?? "" + if frame.ok { + let result = frame.result?.objectValue + let response = T4CommandResponse( + requestID: frame.requestId, + commandID: frame.commandId, + command: frame.command, + hostID: host, + sessionID: frame.sessionId, + result: result + ) + pendingRequest.continuation.resume(returning: response) + return + } + let code = frame.error?["code"]?.stringValue ?? "remote_error" + let message = frame.error?["message"]?.stringValue ?? "The host rejected the command." + pendingRequest.continuation.resume(throwing: T4ClientControllerError.remote(code: code, message: message)) + } + + private func settleError(_ frame: ErrorFrame, requestID: String) { + guard let pendingRequest = pending.removeValue(forKey: requestID) else { return } + commandIDs.removeValue(forKey: requestID) + pendingRequest.continuation.resume(throwing: T4ClientControllerError.remote(code: frame.code, message: frame.message)) + } + + private func failWelcomeWaiter(with error: Error) { + guard let waiter = welcomeWaiter else { return } + welcomeWaiter = nil + waiter.continuation.resume(throwing: error) + } + + private func failWelcomeWaiter(for currentGeneration: UInt64, with error: Error) { + guard welcomeWaiter?.generation == currentGeneration else { return } + failWelcomeWaiter(with: error) + } + + private func resumeWelcomeWaiter(hostID: String, generation currentGeneration: UInt64) { + guard let waiter = welcomeWaiter, waiter.generation == currentGeneration else { return } + welcomeWaiter = nil + waiter.continuation.resume(returning: hostID) + } + + private func failPending(with error: Error) { + let requests = pending.values + pending.removeAll(keepingCapacity: true) + commandIDs.removeAll(keepingCapacity: true) + for request in requests { request.continuation.resume(throwing: error) } + } + + private func failPending(for currentGeneration: UInt64, with error: Error) { + let requests = pending.values.filter { $0.generation == currentGeneration } + for request in requests { + pending.removeValue(forKey: request.requestID) + commandIDs.removeValue(forKey: request.requestID) + request.continuation.resume(throwing: error) + } + } + + + + private func applySessionList(_ result: [String: JSONValue]?) { + guard let result else { return } + let object = result.compactMapValues(AnyJSONValue.value) + applySessions(object) + if let cursor = makeSessionIndexCursor(object["cursor"]) { state.sessionIndexCursor = cursor } + } + + private func applySessions(_ object: [String: Any]) { + guard let values = object["sessions"] as? [[String: Any]] else { return } + state.sessions = values.compactMap { value in + guard let id = (value["sessionId"] ?? value["id"]) as? String else { return nil } + let host = value["hostId"] as? String ?? hostID ?? "" + return T4Session(id: id, hostID: host, title: value["title"] as? String ?? value["name"] as? String ?? "", status: value["status"] as? String ?? "", isSelected: id == state.selectedSessionID) + } + } + + private func applySnapshot(_ object: [String: Any]) { + guard let entries = object["entries"] as? [[String: Any]] else { return } + state.transcript = entries.compactMap(makeTranscriptItem) + state.transcriptCursor = makeTranscriptCursor(object["cursor"]) + } + + private func applyEntry(_ object: [String: Any]) { + guard let item = makeTranscriptItem(object["entry"] as? [String: Any] ?? object) else { return } + if !state.transcript.contains(where: { $0.id == item.id }) { state.transcript.append(item) } + state.transcriptCursor = makeTranscriptCursor(object["cursor"]) ?? state.transcriptCursor + } + + private func applyEvent(_ object: [String: Any], cursor: TranscriptCursor? = nil) { + let id = object["eventId"] as? String ?? object["id"] as? String ?? UUID().uuidString + let text = object["message"] as? String ?? object["text"] as? String ?? "" + guard !text.isEmpty else { return } + let itemCursor = cursor ?? makeTranscriptCursor(object["cursor"]) + state.transcript.append(T4TranscriptItem(id: id, role: object["role"] as? String ?? object["author"] as? String ?? "event", text: text, cursor: itemCursor)) + state.transcriptCursor = itemCursor ?? state.transcriptCursor + } + + private func makeTranscriptItem(_ object: [String: Any]?) -> T4TranscriptItem? { + guard let object, let id = (object["id"] ?? object["entryId"]) as? String else { return nil } + let text = object["text"] as? String ?? object["message"] as? String ?? "" + return T4TranscriptItem(id: id, role: object["role"] as? String ?? object["author"] as? String ?? "assistant", text: text, cursor: makeTranscriptCursor(object["cursor"]), revision: object["revision"] as? String) + } + + private func applyAttention(_ object: [String: Any]) { + guard let id = (object["requestId"] ?? object["confirmationId"] ?? object["id"]) as? String else { return } + state.attention.removeAll { $0.id == id } + state.attention.append(T4AttentionItem(id: id, kind: object["kind"] as? String ?? "confirmation", title: object["title"] as? String ?? "Action required", detail: object["summary"] as? String ?? object["message"] as? String ?? "", commandID: object["commandId"] as? String)) + } + + private func applySettings(_ object: [String: Any]) { + let values = (object["values"] as? [String: Any] ?? object["settings"] as? [String: Any] ?? [:]).compactMapValues { value in + if let string = value as? String { return string } + if let bool = value as? Bool { return bool ? "true" : "false" } + if let number = value as? NSNumber { return number.stringValue } + return nil + } + state.settings = T4SettingsState(values: values) + } + + private func makeTranscriptCursor(_ value: Any?) -> TranscriptCursor? { + guard let object = value as? [String: Any], let epoch = object["epoch"] as? String, let seq = integer(object["seq"]) else { return nil } + return TranscriptCursor(epoch: epoch, seq: seq) + } + + private func makeSessionIndexCursor(_ value: Any?) -> SessionIndexCursor? { + guard let object = value as? [String: Any], let epoch = object["epoch"] as? String, let seq = integer(object["seq"]) else { return nil } + return SessionIndexCursor(epoch: epoch, seq: seq) + } + + private func integer(_ value: Any?) -> Int? { + if let value = value as? Int { return value } + guard let value = value as? Double, value.isFinite, value.rounded() == value, + value >= Double(Int.min), value <= Double(Int.max) else { return nil } + return Int(value) + } + + private func cursorJSON(_ cursor: C) -> JSONValue { + if let transcript = cursor as? TranscriptCursor { return .object(["epoch": .string(transcript.epoch), "seq": .number(Double(transcript.seq))]) } + if let index = cursor as? SessionIndexCursor { return .object(["epoch": .string(index.epoch), "seq": .number(Double(index.seq))]) } + return .null + } + + private func message(for error: Error) -> String { String(describing: error) } +} + +private enum AnyJSONValue { + static func value(_ value: JSONValue) -> Any? { + switch value { + case .null: return NSNull() + case .bool(let value): return value + case .number(let value): return value + case .string(let value): return value + case .array(let values): return values.compactMap { Self.value($0) } + case .object(let values): return values.compactMapValues { Self.value($0) } + } + } +} + + +private extension String { + var camelized: String { + switch self { + case "pairing-required": return "pairingRequired" + default: return self + } + } +} diff --git a/apps/apple/Sources/T4Client/UnixWebSocketTransport.swift b/apps/apple/Sources/T4Client/UnixWebSocketTransport.swift new file mode 100644 index 00000000..d947e470 --- /dev/null +++ b/apps/apple/Sources/T4Client/UnixWebSocketTransport.swift @@ -0,0 +1,509 @@ +import Foundation +import CryptoKit +import T4Protocol +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +/// Errors raised by the native AF_UNIX WebSocket transport. +public enum UnixWebSocketTransportError: Error, Sendable, Equatable, CustomStringConvertible { + case invalidSocketPath(String) + case connectionFailed(Int32) + case alreadyConnected + case notConnected + case handshakeTimeout + case invalidHandshake(String) + case protocolViolation(String) + case messageTooLarge + case invalidText + case closed + + public var description: String { + switch self { + case .invalidSocketPath(let reason): return "invalid Unix socket path: \(reason)" + case .connectionFailed(let code): return "Unix socket connection failed (errno \(code))" + case .alreadyConnected: return "already connected" + case .notConnected: return "not connected" + case .handshakeTimeout: return "WebSocket handshake timed out" + case .invalidHandshake(let reason): return "invalid WebSocket handshake: \(reason)" + case .protocolViolation(let reason): return "WebSocket protocol violation: \(reason)" + case .messageTooLarge: return "WebSocket message exceeds 1 MiB" + case .invalidText: return "invalid UTF-8 WebSocket text" + case .closed: return "WebSocket is closed" + } + } +} + +private final class UnixIncomingStreamBox: @unchecked Sendable { + private let lock = NSLock() + private var stream: AsyncThrowingStream + private var continuation: AsyncThrowingStream.Continuation + + init() { + var continuation: AsyncThrowingStream.Continuation! + stream = AsyncThrowingStream { continuation = $0 } + self.continuation = continuation + } + + var current: AsyncThrowingStream { + lock.lock(); defer { lock.unlock() } + return stream + } + + func replace() { + lock.lock() + let old = continuation + var next: AsyncThrowingStream.Continuation! + stream = AsyncThrowingStream { next = $0 } + continuation = next + lock.unlock() + old.finish(throwing: UnixWebSocketTransportError.closed) + } + + func yield(_ frame: WireFrame) { + lock.lock(); let continuation = self.continuation; lock.unlock() + continuation.yield(frame) + } + + func finish(throwing error: Error? = nil) { + lock.lock(); let continuation = self.continuation; lock.unlock() + if let error { continuation.finish(throwing: error) } else { continuation.finish() } + } +} + +private final class UnixSocket: @unchecked Sendable { + let fd: Int32 + private let lock = NSLock() + private var didClose = false + + init(fd: Int32) { self.fd = fd } + + func close() { + lock.lock() + guard !didClose else { lock.unlock(); return } + didClose = true + lock.unlock() + _ = shutdown(fd, Int32(SHUT_RDWR)) + _ = Darwin.close(fd) + } + + func writeAll(_ data: Data) throws { + lock.lock() + defer { lock.unlock() } + guard !didClose else { throw UnixWebSocketTransportError.closed } + try data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.baseAddress else { return } + var sent = 0 + while sent < rawBuffer.count { + let result = Darwin.send(fd, base.advanced(by: sent), rawBuffer.count - sent, 0) + if result > 0 { sent += result; continue } + if result < 0 && errno == EINTR { continue } + throw UnixWebSocketTransportError.connectionFailed(errno) + } + } + } +} + +private enum UnixSocketEvent: Sendable { + case frame(opcode: UInt8, fin: Bool, payload: Data) + case closed +} + +/// A concurrency-safe RFC 6455 client connected to T4's local Unix-domain +/// WebSocket endpoint. The public path initializer accepts only absolute, +/// NUL-free, sockaddr-compatible paths; tests and embedders may inject an +/// already-connected descriptor with `init(fileDescriptor:)`. +public actor UnixWebSocketTransport: T4ClientTransport { + public static let maximumMessageBytes = 1 * 1024 * 1024 + public static let handshakeTimeout: Duration = .seconds(10) + + public static var defaultSocketPath: String { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".omp/run/appserver.sock").path + } + + public nonisolated var incoming: AsyncThrowingStream { streamBox.current } + private nonisolated let streamBox: UnixIncomingStreamBox + private let socketPath: String? + private let keyProvider: @Sendable () -> Data + private var injectedFileDescriptor: Int32? + private var socket: UnixSocket? + private var receiveTask: Task? + private var generation: UInt64 = 0 + private var fragmentOpcode: UInt8? + private var fragmentData = Data() + + /// Connect to a validated filesystem Unix socket path. + public init(socketPath: String = UnixWebSocketTransport.defaultSocketPath, + keyProvider: @escaping @Sendable () -> Data = UnixWebSocketTransport.randomKey) throws { + try Self.validate(socketPath: socketPath) + self.socketPath = socketPath + self.keyProvider = keyProvider + self.injectedFileDescriptor = nil + self.streamBox = UnixIncomingStreamBox() + } + + /// Inject an already-connected descriptor (normally one end of a + /// `socketpair`) while retaining the normal WebSocket handshake. + public init(fileDescriptor: Int32, + keyProvider: @escaping @Sendable () -> Data = UnixWebSocketTransport.randomKey) { + self.socketPath = nil + self.keyProvider = keyProvider + self.injectedFileDescriptor = fileDescriptor + self.streamBox = UnixIncomingStreamBox() + } + + public nonisolated static func randomKey() -> Data { + Data((0..<16).map { _ in UInt8.random(in: UInt8.min...UInt8.max) }) + } + + public func connect() async throws { + guard socket == nil else { throw UnixWebSocketTransportError.alreadyConnected } + generation &+= 1 + let currentGeneration = generation + fragmentOpcode = nil + fragmentData.removeAll(keepingCapacity: false) + streamBox.replace() + + let fd: Int32 + if let injected = injectedFileDescriptor { + injectedFileDescriptor = nil + fd = injected + } else if let socketPath { + fd = try await Task.detached(priority: .userInitiated) { + try Self.openUnixSocket(path: socketPath) + }.value + } else { + throw UnixWebSocketTransportError.invalidSocketPath("missing socket path") + } + + let socket = UnixSocket(fd: fd) + self.socket = socket + do { + let key = keyProvider() + guard key.count == 16 else { + throw UnixWebSocketTransportError.invalidHandshake("Sec-WebSocket-Key must be 16 bytes") + } + try await Task.detached(priority: .userInitiated) { + try Self.performHandshake(socket: socket, key: key) + }.value + guard currentGeneration == generation, self.socket === socket else { + socket.close() + throw UnixWebSocketTransportError.closed + } + receiveTask = Task.detached(priority: .userInitiated) { [weak self, socket] in + do { + while true { + let event = try Self.readEvent(socket: socket) + guard let self else { return } + await self.handle(event, socket: socket, generation: currentGeneration) + if case .closed = event { return } + } + } catch { + guard let self else { return } + await self.receiveFailed(error, socket: socket, generation: currentGeneration) + } + } + } catch { + if self.socket === socket { self.socket = nil } + socket.close() + streamBox.finish(throwing: error) + throw error + } + } + + public func send(_ frame: WireFrame) async throws { + let data = try WireCodec.encode(frame) + guard String(data: data, encoding: .utf8) != nil else { + throw UnixWebSocketTransportError.invalidText + } + try sendPayload(data, opcode: 0x1) + } + + /// Sends an arbitrary WebSocket data message. `send(_:)` remains the + /// protocol-facing text path used by the client controller. + public func send(data: Data, binary: Bool = false) async throws { + try sendPayload(data, opcode: binary ? 0x2 : 0x1) + } + + public func disconnect() async { + generation &+= 1 + let socket = self.socket + self.socket = nil + receiveTask?.cancel() + receiveTask = nil + fragmentOpcode = nil + fragmentData.removeAll(keepingCapacity: false) + if let socket { + do { try socket.writeAll(Self.makeFrame(payload: Data([0x03, 0xE8]), opcode: 0x8)) } catch { } + socket.close() + } + streamBox.finish() + } + + private func sendPayload(_ payload: Data, opcode: UInt8) throws { + guard let socket else { throw UnixWebSocketTransportError.notConnected } + try socket.writeAll(Self.makeFrame(payload: payload, opcode: opcode)) + } + + private func handle(_ event: UnixSocketEvent, socket: UnixSocket, generation currentGeneration: UInt64) { + guard currentGeneration == generation, self.socket === socket else { return } + switch event { + case .closed: + receiveFailed(UnixWebSocketTransportError.closed, socket: socket, generation: currentGeneration) + case .frame(let opcode, let fin, let payload): + do { + switch opcode { + case 0x0: + guard let firstOpcode = fragmentOpcode else { + throw UnixWebSocketTransportError.protocolViolation("unexpected continuation") + } + guard fragmentData.count + payload.count <= Self.maximumMessageBytes else { + throw UnixWebSocketTransportError.messageTooLarge + } + fragmentData.append(payload) + if fin { + let completed = fragmentData + fragmentOpcode = nil + fragmentData.removeAll(keepingCapacity: false) + try publish(completed, opcode: firstOpcode) + } + case 0x1, 0x2: + guard fragmentOpcode == nil else { + throw UnixWebSocketTransportError.protocolViolation("new data frame while fragmented message is open") + } + if fin { + try publish(payload, opcode: opcode) + } else { + guard payload.count <= Self.maximumMessageBytes else { + throw UnixWebSocketTransportError.messageTooLarge + } + fragmentOpcode = opcode + fragmentData = payload + } + case 0x8: + guard fin, payload.count != 1 else { + throw UnixWebSocketTransportError.protocolViolation("invalid close frame") + } + try? socket.writeAll(Self.makeFrame(payload: payload, opcode: 0x8)) + closeAfterPeer(socket: socket, generation: currentGeneration) + case 0x9: + guard fin, payload.count <= 125 else { + throw UnixWebSocketTransportError.protocolViolation("invalid ping frame") + } + try socket.writeAll(Self.makeFrame(payload: payload, opcode: 0xA)) + case 0xA: + guard fin, payload.count <= 125 else { + throw UnixWebSocketTransportError.protocolViolation("invalid pong frame") + } + default: + throw UnixWebSocketTransportError.protocolViolation("unsupported opcode") + } + } catch { + closeAfterError(error, socket: socket, generation: currentGeneration) + } + } + } + + private func publish(_ payload: Data, opcode: UInt8) throws { + guard payload.count <= Self.maximumMessageBytes else { + throw UnixWebSocketTransportError.messageTooLarge + } + if opcode == 0x1, String(data: payload, encoding: .utf8) == nil { + throw UnixWebSocketTransportError.invalidText + } + do { + streamBox.yield(try WireDecoder.decode(payload)) + } catch { + throw error + } + } + + private func receiveFailed(_ error: Error, socket: UnixSocket, generation currentGeneration: UInt64) { + guard currentGeneration == generation, self.socket === socket else { return } + self.socket = nil + receiveTask = nil + socket.close() + streamBox.finish(throwing: error) + } + + private func closeAfterPeer(socket: UnixSocket, generation currentGeneration: UInt64) { + guard currentGeneration == generation, self.socket === socket else { return } + generation &+= 1 + self.socket = nil + receiveTask = nil + fragmentOpcode = nil + fragmentData.removeAll(keepingCapacity: false) + socket.close() + streamBox.finish() + } + + private func closeAfterError(_ error: Error, socket: UnixSocket, generation currentGeneration: UInt64) { + guard currentGeneration == generation, self.socket === socket else { return } + generation &+= 1 + self.socket = nil + receiveTask = nil + socket.close() + streamBox.finish(throwing: error) + } +} + +private extension UnixWebSocketTransport { + nonisolated static func validate(socketPath: String) throws { + let bytes = Array(socketPath.utf8) + guard !bytes.isEmpty else { throw UnixWebSocketTransportError.invalidSocketPath("path is empty") } + guard !bytes.contains(0) else { throw UnixWebSocketTransportError.invalidSocketPath("path contains NUL") } + guard socketPath.first == "/" else { throw UnixWebSocketTransportError.invalidSocketPath("path must be absolute") } + guard !socketPath.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." }) else { + throw UnixWebSocketTransportError.invalidSocketPath("path traversal is not allowed") + } + let capacity = MemoryLayout.size - MemoryLayout.size + guard bytes.count + 1 <= capacity else { + throw UnixWebSocketTransportError.invalidSocketPath("path exceeds sockaddr_un capacity") + } + } + + nonisolated static func openUnixSocket(path: String) throws -> Int32 { + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw UnixWebSocketTransportError.connectionFailed(errno) } + do { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(path.utf8) + [0] + withUnsafeMutableBytes(of: &address.sun_path) { raw in + raw.copyBytes(from: pathBytes) + } + let pathOffset = MemoryLayout.offset(of: \.sun_path)! + let length = socklen_t(pathOffset + pathBytes.count) + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, length) + } + } + guard result == 0 else { throw UnixWebSocketTransportError.connectionFailed(errno) } + return fd + } catch { + _ = Darwin.close(fd) + throw error + } + } + + nonisolated static func performHandshake(socket: UnixSocket, key: Data) throws { + let keyString = key.base64EncodedString() + let request = "GET /ws HTTP/1.1\r\nHost: omp.local\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: \(keyString)\r\n\r\n" + try socket.writeAll(Data(request.utf8)) + var response = Data() + let deadline = ContinuousClock.now.advanced(by: UnixWebSocketTransport.handshakeTimeout) + while !(response.count >= 4 && response.suffix(4).elementsEqual([13, 10, 13, 10])) { + if ContinuousClock.now >= deadline { throw UnixWebSocketTransportError.handshakeTimeout } + var descriptor = pollfd(fd: socket.fd, events: Int16(POLLIN), revents: 0) + let polled = poll(&descriptor, 1, 1_000) + if polled == 0 { continue } + if polled < 0 { if errno == EINTR { continue }; throw UnixWebSocketTransportError.connectionFailed(errno) } + var byte: UInt8 = 0 + let count = Darwin.recv(socket.fd, &byte, 1, 0) + if count == 1 { response.append(byte); if response.count > 16 * 1024 { throw UnixWebSocketTransportError.invalidHandshake("response headers too large") } } + else if count == 0 { throw UnixWebSocketTransportError.closed } + else if errno != EINTR { throw UnixWebSocketTransportError.connectionFailed(errno) } + } + guard let text = String(data: response, encoding: .utf8) else { + throw UnixWebSocketTransportError.invalidHandshake("response is not UTF-8") + } + let lines = text.components(separatedBy: "\r\n") + guard let status = lines.first?.split(separator: " "), status.count >= 2, status[0] == "HTTP/1.1", status[1] == "101" else { + throw UnixWebSocketTransportError.invalidHandshake("expected HTTP/1.1 101") + } + var headers: [String: String] = [:] + for line in lines.dropFirst() { + guard !line.isEmpty else { continue } + guard let separator = line.firstIndex(of: ":") else { + throw UnixWebSocketTransportError.invalidHandshake("malformed header") + } + let name = String(line[.. UnixSocketEvent { + let header = try readBytes(socket: socket, count: 2) + let first = header[0] + let second = header[1] + guard first & 0x70 == 0 else { throw UnixWebSocketTransportError.protocolViolation("reserved bits are set") } + let fin = first & 0x80 != 0 + let opcode = first & 0x0F + let masked = second & 0x80 != 0 + guard !masked else { throw UnixWebSocketTransportError.protocolViolation("server frame is masked") } + let indicator = second & 0x7F + var length = UInt64(indicator) + if indicator == 126 { + let bytes = try readBytes(socket: socket, count: 2) + length = UInt64(bytes[0]) << 8 | UInt64(bytes[1]) + } else if indicator == 127 { + let bytes = try readBytes(socket: socket, count: 8) + guard bytes[0] & 0x80 == 0 else { throw UnixWebSocketTransportError.protocolViolation("invalid 64-bit length") } + length = bytes.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } + } + if opcode >= 0x8 { + guard fin, length <= 125 else { throw UnixWebSocketTransportError.protocolViolation("invalid control frame") } + } else if length > UInt64(UnixWebSocketTransport.maximumMessageBytes) { + throw UnixWebSocketTransportError.messageTooLarge + } + guard length <= UInt64(Int.max) else { throw UnixWebSocketTransportError.messageTooLarge } + let payload = try readBytes(socket: socket, count: Int(length)) + return .frame(opcode: opcode, fin: fin, payload: payload) + } + + nonisolated static func readBytes(socket: UnixSocket, count: Int) throws -> Data { + var result = Data(capacity: count) + var remaining = count + while remaining > 0 { + var buffer = [UInt8](repeating: 0, count: min(remaining, 64 * 1024)) + let received = Darwin.recv(socket.fd, &buffer, buffer.count, 0) + if received > 0 { + result.append(contentsOf: buffer[0.. Data { + var frame = Data() + frame.reserveCapacity(payload.count + 14) + frame.append(UInt8(0x80) | opcode) + if payload.count <= 125 { + frame.append(UInt8(0x80) | UInt8(payload.count)) + } else if payload.count <= 65_535 { + frame.append(UInt8(0x80 | 126)) + frame.append(UInt8((payload.count >> 8) & 0xFF)) + frame.append(UInt8(payload.count & 0xFF)) + } else { + frame.append(UInt8(0x80 | 127)) + let value = UInt64(payload.count) + for shift in stride(from: 56, through: 0, by: -8) { + frame.append(UInt8((value >> UInt64(shift)) & 0xFF)) + } + } + let mask = [UInt8](randomKey().prefix(4)) + frame.append(contentsOf: mask) + frame.append(contentsOf: payload.enumerated().map { $0.element ^ mask[$0.offset & 3] }) + return frame + } +} diff --git a/apps/apple/Sources/T4Client/WebSocketTransport.swift b/apps/apple/Sources/T4Client/WebSocketTransport.swift new file mode 100644 index 00000000..0d897054 --- /dev/null +++ b/apps/apple/Sources/T4Client/WebSocketTransport.swift @@ -0,0 +1,154 @@ +import Foundation +import T4Protocol +/// A transport frame is kept as UTF-8 JSON bytes at the client boundary. This +/// keeps the transport independent from UI isolation and lets tests inject a +/// deterministic stream without opening a socket. +public struct TransportMessage: Sendable, Equatable { + public let data: Data + + public init(data: Data) { self.data = data } +} + +public protocol T4ClientTransport: Sendable { + var incoming: AsyncThrowingStream { get } + func connect() async throws + func send(_ frame: WireFrame) async throws + func disconnect() async +} + +public enum WebSocketTransportError: Error, Sendable, Equatable { + case alreadyConnected + case notConnected + case unsupportedMessage + case invalidText + case closed +} + +private final class IncomingStreamBox: @unchecked Sendable { + private let lock = NSLock() + private var stream: AsyncThrowingStream + private var continuation: AsyncThrowingStream.Continuation + + init() { + var continuation: AsyncThrowingStream.Continuation! + stream = AsyncThrowingStream { continuation = $0 } + self.continuation = continuation + } + + var current: AsyncThrowingStream { + lock.lock() + defer { lock.unlock() } + return stream + } + + func replace() { + let oldContinuation: AsyncThrowingStream.Continuation + lock.lock() + oldContinuation = continuation + var nextContinuation: AsyncThrowingStream.Continuation! + stream = AsyncThrowingStream { nextContinuation = $0 } + continuation = nextContinuation + lock.unlock() + oldContinuation.finish(throwing: WebSocketTransportError.closed) + } + + func yield(_ frame: WireFrame) { + lock.lock() + let continuation = self.continuation + lock.unlock() + continuation.yield(frame) + } + + func finish(throwing error: Error? = nil) { + lock.lock() + let continuation = self.continuation + lock.unlock() + if let error { + continuation.finish(throwing: error) + } else { + continuation.finish() + } + } +} + +/// Actor-isolated URLSession WebSocket implementation. A receive loop is +/// associated with a monotonically increasing generation, so callbacks from a +/// task that was replaced by reconnect cannot publish old frames. +public actor WebSocketTransport: T4ClientTransport { + public nonisolated var incoming: AsyncThrowingStream { streamBox.current } + private nonisolated let streamBox: IncomingStreamBox + private let url: URL + private let session: URLSession + private var task: URLSessionWebSocketTask? + private var generation: UInt64 = 0 + private var closedByUser = false + + public init(url: URL, session: URLSession = .shared) { + self.url = url + self.session = session + self.streamBox = IncomingStreamBox() + } + + + public func connect() async throws { + guard task == nil else { throw WebSocketTransportError.alreadyConnected } + closedByUser = false + streamBox.replace() + generation &+= 1 + let currentGeneration = generation + let socket = session.webSocketTask(with: url) + task = socket + socket.resume() + receive(on: socket, generation: currentGeneration) + } + + public func send(_ frame: WireFrame) async throws { + guard let task else { throw WebSocketTransportError.notConnected } + let data = try WireCodec.encode(frame) + guard let text = String(data: data, encoding: .utf8) else { throw WebSocketTransportError.invalidText } + try await task.send(.string(text)) + } + + public func disconnect() async { + closedByUser = true + generation &+= 1 + task?.cancel(with: .normalClosure, reason: nil) + task = nil + streamBox.finish() + } + + private func receive(on socket: URLSessionWebSocketTask, generation currentGeneration: UInt64) { + socket.receive { [weak self] result in + guard let self else { return } + Task { await self.received(result, from: socket, generation: currentGeneration) } + } + } + + private func received(_ result: Result, from socket: URLSessionWebSocketTask, generation currentGeneration: UInt64) { + guard currentGeneration == generation, task === socket, !closedByUser else { return } + switch result { + case .success(let message): + switch message { + case .string(let text): + guard let data = text.data(using: .utf8) else { + streamBox.finish(throwing: WebSocketTransportError.invalidText) + task = nil + return + } + do { streamBox.yield(try WireDecoder.decode(data)) } + catch { streamBox.finish(throwing: error); task = nil; return } + case .data(let data): + do { streamBox.yield(try WireDecoder.decode(data)) } + catch { streamBox.finish(throwing: error); task = nil; return } + @unknown default: + streamBox.finish(throwing: WebSocketTransportError.unsupportedMessage) + task = nil + return + } + receive(on: socket, generation: currentGeneration) + case .failure(let error): + task = nil + if !closedByUser { streamBox.finish(throwing: error) } + } + } +} diff --git a/apps/apple/Sources/T4MacApp/T4MacApp.swift b/apps/apple/Sources/T4MacApp/T4MacApp.swift new file mode 100644 index 00000000..614bada6 --- /dev/null +++ b/apps/apple/Sources/T4MacApp/T4MacApp.swift @@ -0,0 +1,13 @@ +import SwiftUI +import T4UI + +@main +struct T4MacApp: App { + private let composition = T4Composition.local(bundle: .main) + + var body: some Scene { + WindowGroup { + T4RootView(composition: composition) + } + } +} diff --git a/apps/apple/Sources/T4Platform/CredentialStore.swift b/apps/apple/Sources/T4Platform/CredentialStore.swift new file mode 100644 index 00000000..ed82ff59 --- /dev/null +++ b/apps/apple/Sources/T4Platform/CredentialStore.swift @@ -0,0 +1,169 @@ +import Foundation +import Security + +public let credentialStoragePrefix = "t4-code:device-credentials:v1:" +public let credentialKeychainService = "com.lycaonsolutions.t4code.credentials" + +public struct DeviceCredentials: Codable, Equatable, Sendable { + public let deviceID: String + public let deviceToken: String + public var deviceId: String { deviceID } + + public init(deviceID: String, deviceToken: String) throws { + guard Self.valid(deviceID, maximum: 256), Self.valid(deviceToken, maximum: 512) else { throw CredentialStoreError.invalidCredentials } + self.deviceID = deviceID + self.deviceToken = deviceToken + } + public init(deviceId: String, deviceToken: String) throws { try self.init(deviceID: deviceId, deviceToken: deviceToken) } + private static func valid(_ value: String, maximum: Int) -> Bool { + !value.isEmpty && value.utf8.count <= maximum && !value.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7f } + } +} + +public enum CredentialStoreError: Error, Equatable, Sendable { + case invalidCredentials + case invalidRecord + case keychainFailure + case migrationFailed +} + +public protocol KeychainStore: Sendable { + func read(service: String, account: String) throws -> Data? + func write(_ data: Data, service: String, account: String) throws + func delete(service: String, account: String) throws +} + +public protocol CredentialStore: Sendable { + func read(for profile: HostProfile) async throws -> DeviceCredentials? + func write(_ credentials: DeviceCredentials, for profile: HostProfile) async throws + func delete(for profile: HostProfile) async throws +} + +public actor KeychainCredentialStore: CredentialStore { + private let keychain: any KeychainStore + private let service: String + + public init(keychain: any KeychainStore = SecurityKeychainStore(), service: String = credentialKeychainService) { + self.keychain = keychain + self.service = service + } + + public func read(for profile: HostProfile) async throws -> DeviceCredentials? { + let current = try keychain.read(service: service, account: Self.account(for: profile.endpointKey)) + if let current { return try decode(current) } + guard profile.profileID == "default" else { return nil } + let originAccount = Self.account(for: profile.origin) + guard let origin = try keychain.read(service: service, account: originAccount) else { return nil } + let credentials = try decode(origin) + do { + try keychain.write(encode(credentials), service: service, account: Self.account(for: profile.endpointKey)) + do { + try keychain.delete(service: service, account: originAccount) + } catch { + try? keychain.delete(service: service, account: Self.account(for: profile.endpointKey)) + throw CredentialStoreError.migrationFailed + } + } catch let error as CredentialStoreError { throw error } + catch { throw CredentialStoreError.migrationFailed } + return credentials + } + + public func write(_ credentials: DeviceCredentials, for profile: HostProfile) async throws { + let currentAccount = Self.account(for: profile.endpointKey) + do { + try keychain.write(encode(credentials), service: service, account: currentAccount) + if profile.profileID == "default" { + do { try keychain.delete(service: service, account: Self.account(for: profile.origin)) } + catch { + try? keychain.delete(service: service, account: currentAccount) + throw CredentialStoreError.migrationFailed + } + } + } catch let error as CredentialStoreError { throw error } + catch { throw CredentialStoreError.keychainFailure } + } + + public func delete(for profile: HostProfile) async throws { + do { + try keychain.delete(service: service, account: Self.account(for: profile.endpointKey)) + if profile.profileID == "default" { try keychain.delete(service: service, account: Self.account(for: profile.origin)) } + } catch { throw CredentialStoreError.keychainFailure } + } + + public static func account(for endpointKey: String) -> String { + let encoded = Data(endpointKey.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return credentialStoragePrefix + encoded + } + + private struct CredentialRecord: Codable { + let version: Int + let deviceID: String + let deviceToken: String + enum CodingKeys: String, CodingKey { case version, deviceID = "deviceId", deviceToken } + private struct AnyCodingKey: CodingKey { + let stringValue: String + init?(stringValue: String) { self.stringValue = stringValue } + let intValue: Int? = nil + init?(intValue: Int) { return nil } + } + init(version: Int, deviceID: String, deviceToken: String) { + self.version = version; self.deviceID = deviceID; self.deviceToken = deviceToken + } + init(from decoder: Decoder) throws { + let rawKeys = try decoder.container(keyedBy: AnyCodingKey.self).allKeys.map(\.stringValue) + guard Set(rawKeys) == Set(["version", "deviceId", "deviceToken"]) else { throw CredentialStoreError.invalidRecord } + let c = try decoder.container(keyedBy: CodingKeys.self) + self.version = try c.decode(Int.self, forKey: .version) + self.deviceID = try c.decode(String.self, forKey: .deviceID) + self.deviceToken = try c.decode(String.self, forKey: .deviceToken) + } + } + + private func encode(_ credentials: DeviceCredentials) throws -> Data { + do { return try JSONEncoder().encode(CredentialRecord(version: 1, deviceID: credentials.deviceID, deviceToken: credentials.deviceToken)) } + catch { throw CredentialStoreError.invalidRecord } + } + + private func decode(_ data: Data) throws -> DeviceCredentials { + do { + let record = try JSONDecoder().decode(CredentialRecord.self, from: data) + guard record.version == 1 else { throw CredentialStoreError.invalidRecord } + return try DeviceCredentials(deviceID: record.deviceID, deviceToken: record.deviceToken) + } catch let error as CredentialStoreError { throw error } + catch { throw CredentialStoreError.invalidRecord } + } +} + +public struct SecurityKeychainStore: KeychainStore { + public init() {} + + public func read(service: String, account: String) throws -> Data? { + let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: account, kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { throw CredentialStoreError.keychainFailure } + return data + } + + public func write(_ data: Data, service: String, account: String) throws { + let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: account] + let attributes: [CFString: Any] = [kSecValueData: data, kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var insert = query + insert[kSecValueData] = data + insert[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + guard SecItemAdd(insert as CFDictionary, nil) == errSecSuccess else { throw CredentialStoreError.keychainFailure } + } else if status != errSecSuccess { throw CredentialStoreError.keychainFailure } + } + + public func delete(service: String, account: String) throws { + let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: account] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw CredentialStoreError.keychainFailure } + } +} diff --git a/apps/apple/Sources/T4Platform/HostProfileStore.swift b/apps/apple/Sources/T4Platform/HostProfileStore.swift new file mode 100644 index 00000000..9af742de --- /dev/null +++ b/apps/apple/Sources/T4Platform/HostProfileStore.swift @@ -0,0 +1,194 @@ +import Foundation + +public let hostProfileSchemaVersion = 3 +public let hostDirectoryStorageKey = "t4-code:mobile-backends:v3" +public let maximumSavedHosts = 16 +public let maximumHostURLLength = 2_048 +public let maximumHostLabelLength = 128 +public let maximumProfileIDLength = 64 + +public enum HostProfileStoreError: Error, Equatable, Sendable { + case invalidSavedData + case unsupportedVersion + case emptyDirectory + case tooManyProfiles + case duplicateProfile + case invalidProfile +} + +public struct HostProfile: Codable, Equatable, Hashable, Sendable { + public let endpointKey: String + public let origin: String + public let profileID: String + public let webSocketURL: URL + public let label: String + + public var profileId: String { profileID } + public var wsURL: URL { webSocketURL } + + public init(endpointKey: String, origin: String, profileID: String, webSocketURL: URL, label: String) throws { + self.endpointKey = endpointKey + self.origin = origin + self.profileID = profileID + self.webSocketURL = webSocketURL + self.label = label + try validate() + } + + public init(endpointKey: String, origin: String, profileId: String, webSocketURL: URL, label: String) throws { + try self.init(endpointKey: endpointKey, origin: origin, profileID: profileId, webSocketURL: webSocketURL, label: label) + } + + public static func parseTailnetAddress(_ value: String, profileID: String = "default") throws -> HostProfile { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.utf8.count <= maximumHostURLLength else { throw HostProfileStoreError.invalidProfile } + let normalizedID = try normalizeProfileID(profileID) + let candidate = trimmed.contains("://") ? trimmed : "https://\(trimmed)" + guard let components = URLComponents(string: candidate), components.scheme?.lowercased() == "https", + let host = components.host?.lowercased(), host != "ts.net", host.hasSuffix(".ts.net"), !host.isEmpty, + components.user == nil, components.password == nil, + (components.path.isEmpty || components.path == "/"), components.query == nil, components.fragment == nil else { + throw HostProfileStoreError.invalidProfile + } + let port = components.port.map { $0 == 443 ? "" : ":\($0)" } ?? "" + let canonicalOrigin = "https://\(host)\(port)" + let path = normalizedID == "default" ? "/v1/ws" : "/v1/profiles/\(normalizedID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? normalizedID)/ws" + guard let wsURL = URL(string: "wss://\(host)\(port)\(path)") else { throw HostProfileStoreError.invalidProfile } + let firstLabel = host.split(separator: ".", maxSplits: 1).first.map(String.init) ?? host + let profile = try HostProfile(endpointKey: "\(canonicalOrigin)#profile=\(normalizedID)", origin: canonicalOrigin, profileID: normalizedID, webSocketURL: wsURL, label: "T4 on \(firstLabel)") + return profile + } + + public static func normalizeProfileID(_ value: String) throws -> String { + let value = value.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { return "default" } + guard value.utf8.count <= maximumProfileIDLength, + value.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", options: .regularExpression) != nil else { + throw HostProfileStoreError.invalidProfile + } + return value + } + + private enum CodingKeys: String, CodingKey { case version, endpointKey, origin, profileId, wsUrl, label } + private struct AnyCodingKey: CodingKey { + let stringValue: String + init?(stringValue: String) { self.stringValue = stringValue } + let intValue: Int? = nil + init?(intValue: Int) { return nil } + } + + public init(from decoder: Decoder) throws { + let rawKeys = try decoder.container(keyedBy: AnyCodingKey.self).allKeys.map(\.stringValue) + guard Set(rawKeys) == Set(["version", "endpointKey", "origin", "profileId", "wsUrl", "label"]) else { throw HostProfileStoreError.invalidSavedData } + let container = try decoder.container(keyedBy: CodingKeys.self) + guard try container.decode(Int.self, forKey: .version) == hostProfileSchemaVersion else { throw HostProfileStoreError.unsupportedVersion } + self.endpointKey = try container.decode(String.self, forKey: .endpointKey) + self.origin = try container.decode(String.self, forKey: .origin) + self.profileID = try container.decode(String.self, forKey: .profileId) + self.webSocketURL = try container.decode(URL.self, forKey: .wsUrl) + self.label = try container.decode(String.self, forKey: .label) + try validate() + let canonical = try Self.parseTailnetAddress(origin, profileID: profileID) + guard canonical.endpointKey == endpointKey, canonical.webSocketURL == webSocketURL, canonical.label == label else { throw HostProfileStoreError.invalidSavedData } + } + + public func encode(to encoder: Encoder) throws { + try validate() + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(hostProfileSchemaVersion, forKey: .version) + try container.encode(endpointKey, forKey: .endpointKey) + try container.encode(origin, forKey: .origin) + try container.encode(profileID, forKey: .profileId) + try container.encode(webSocketURL, forKey: .wsUrl) + try container.encode(label, forKey: .label) + } + + private func validate() throws { + guard !endpointKey.isEmpty, endpointKey.utf8.count <= maximumHostURLLength, + !origin.isEmpty, origin.utf8.count <= maximumHostURLLength, + !label.isEmpty, label.utf8.count <= maximumHostLabelLength, + !profileID.isEmpty, profileID.utf8.count <= maximumProfileIDLength, + webSocketURL.scheme?.lowercased() == "wss" else { throw HostProfileStoreError.invalidProfile } + for text in [endpointKey, origin, profileID, label] where text.unicodeScalars.contains(where: { $0.value < 0x20 || $0.value == 0x7f }) { throw HostProfileStoreError.invalidProfile } + } +} + +public struct HostDirectory: Codable, Equatable, Sendable { + public let profiles: [HostProfile] + public let activeEndpointKey: String? + + public init(profiles: [HostProfile] = [], activeEndpointKey: String? = nil) throws { + guard profiles.count <= maximumSavedHosts else { throw HostProfileStoreError.tooManyProfiles } + let keys = profiles.map(\.endpointKey) + guard Set(keys).count == keys.count else { throw HostProfileStoreError.duplicateProfile } + guard profiles.isEmpty ? activeEndpointKey == nil : (activeEndpointKey.map { keys.contains($0) } ?? false) else { throw HostProfileStoreError.invalidSavedData } + self.profiles = profiles + self.activeEndpointKey = activeEndpointKey + } + + public static let empty = try! HostDirectory() + + public var activeProfile: HostProfile? { profiles.first { $0.endpointKey == activeEndpointKey } } + + public func upserting(_ profile: HostProfile) throws -> HostDirectory { + var next = profiles.filter { $0.endpointKey != profile.endpointKey } + if next.count >= maximumSavedHosts { throw HostProfileStoreError.tooManyProfiles } + next.append(profile) + return try HostDirectory(profiles: next, activeEndpointKey: profile.endpointKey) + } + + public func activating(endpointKey: String) throws -> HostDirectory { guard profiles.contains(where: { $0.endpointKey == endpointKey }) else { throw HostProfileStoreError.invalidSavedData }; return try HostDirectory(profiles: profiles, activeEndpointKey: endpointKey) } + + public func removing(endpointKey: String) throws -> HostDirectory { + let next = profiles.filter { $0.endpointKey != endpointKey } + guard next.count != profiles.count else { return self } + let active = activeEndpointKey == endpointKey ? next.first?.endpointKey : activeEndpointKey + return try HostDirectory(profiles: next, activeEndpointKey: active) + } + + private enum CodingKeys: String, CodingKey { case version, activeEndpointKey, backends } + private struct AnyCodingKey: CodingKey { + let stringValue: String + init?(stringValue: String) { self.stringValue = stringValue } + let intValue: Int? = nil + init?(intValue: Int) { return nil } + } + public init(from decoder: Decoder) throws { + let rawKeys = try decoder.container(keyedBy: AnyCodingKey.self).allKeys.map(\.stringValue) + guard Set(rawKeys) == Set(["version", "activeEndpointKey", "backends"]) else { throw HostProfileStoreError.invalidSavedData } + let c = try decoder.container(keyedBy: CodingKeys.self) + guard try c.decode(Int.self, forKey: .version) == hostProfileSchemaVersion else { throw HostProfileStoreError.unsupportedVersion } + let active = try c.decodeIfPresent(String.self, forKey: .activeEndpointKey) + let profiles = try c.decode([HostProfile].self, forKey: .backends) + guard !profiles.isEmpty else { throw HostProfileStoreError.emptyDirectory } + try self.init(profiles: profiles, activeEndpointKey: active) + } + public func encode(to encoder: Encoder) throws { + guard !profiles.isEmpty, let activeEndpointKey else { throw HostProfileStoreError.emptyDirectory } + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(hostProfileSchemaVersion, forKey: .version) + try c.encode(activeEndpointKey, forKey: .activeEndpointKey) + try c.encode(profiles, forKey: .backends) + } +} + +public protocol HostProfileStore: Sendable { + func load() async throws -> HostDirectory + func save(_ directory: HostDirectory) async throws +} + +public actor UserDefaultsHostProfileStore: HostProfileStore { + private let defaults: UserDefaults + private let key: String + public init(defaults: UserDefaults = .standard, key: String = hostDirectoryStorageKey) { self.defaults = defaults; self.key = key } + + public func load() async throws -> HostDirectory { + guard let data = defaults.data(forKey: key) else { return .empty } + do { return try JSONDecoder().decode(HostDirectory.self, from: data) } catch { throw HostProfileStoreError.invalidSavedData } + } + + public func save(_ directory: HostDirectory) async throws { + if directory.profiles.isEmpty { defaults.removeObject(forKey: key); return } + do { defaults.set(try JSONEncoder().encode(directory), forKey: key) } catch { throw HostProfileStoreError.invalidSavedData } + } +} diff --git a/apps/apple/Sources/T4Platform/LocalHostSupervisor.swift b/apps/apple/Sources/T4Platform/LocalHostSupervisor.swift new file mode 100644 index 00000000..320faa04 --- /dev/null +++ b/apps/apple/Sources/T4Platform/LocalHostSupervisor.swift @@ -0,0 +1,412 @@ +import Foundation + +public enum LocalHostSupervisorError: Error, Equatable, Sendable { + case unsupported + case invalidResource(String) + case invalidPath(String) + case launchFailed(String) + case processExited(Int32, String) + case timedOut(String) +} + +public enum LocalHostPhase: String, Equatable, Sendable { + case unsupported + case stopped + case starting + case running + case failed +} + +public struct LocalHostSupervisorStatus: Equatable, Sendable { + public let supported: Bool + public let phase: LocalHostPhase + public let socketURL: URL + public let ownsProcess: Bool + public let diagnostics: String + + public init(supported: Bool, phase: LocalHostPhase, socketURL: URL, ownsProcess: Bool, diagnostics: String = "") { + self.supported = supported + self.phase = phase + self.socketURL = socketURL + self.ownsProcess = ownsProcess + self.diagnostics = LocalHostSupervisorDiagnostics.redacted(diagnostics) + } + + public static func unsupported(socketURL: URL) -> Self { + Self(supported: false, phase: .unsupported, socketURL: socketURL, ownsProcess: false, diagnostics: "Local host management is available on macOS only.") + } +} + +public protocol LocalHostFileSystem: Sendable { + func homeDirectoryURL() -> URL + func isRegularFile(at url: URL) -> Bool + func isExecutableFile(at url: URL) -> Bool + func isSymbolicLink(at url: URL) -> Bool +} + +public protocol LocalHostProcessHandle: AnyObject, Sendable { + var isRunning: Bool { get } + var terminationStatus: Int32 { get } + var diagnostics: String { get } + func launch() throws + func terminate() +} + +public protocol LocalHostProcessFactory: Sendable { + func makeProcess(executableURL: URL, arguments: [String]) -> any LocalHostProcessHandle +} + +public protocol LocalHostSocketProbe: Sendable { + func isHealthy(at socketURL: URL) -> Bool +} + +public actor LocalHostSupervisor { + public static let defaultProfile = "default" + public static let defaultWaitNanoseconds: UInt64 = 15_000_000_000 + public static let defaultPollNanoseconds: UInt64 = 100_000_000 + + private let t4HostURL: URL? + private let ompURL: URL? + private let profile: String + private let stateRootURL: URL + private let socketURL: URL + private let fileSystem: any LocalHostFileSystem + private let processFactory: any LocalHostProcessFactory + private let socketProbe: any LocalHostSocketProbe + private let waitNanoseconds: UInt64 + private let pollNanoseconds: UInt64 + private var ownedProcess: (any LocalHostProcessHandle)? + private var lastDiagnostics = "" + + public init( + bundle: Bundle = .main, + profile: String = LocalHostSupervisor.defaultProfile, + fileSystem: any LocalHostFileSystem = DefaultLocalHostFileSystem(), + processFactory: any LocalHostProcessFactory = DefaultLocalHostProcessFactory(), + socketProbe: any LocalHostSocketProbe = DefaultLocalHostSocketProbe(), + waitNanoseconds: UInt64 = LocalHostSupervisor.defaultWaitNanoseconds, + pollNanoseconds: UInt64 = LocalHostSupervisor.defaultPollNanoseconds + ) { + self.init( + t4HostURL: bundle.url(forResource: "t4-host", withExtension: nil, subdirectory: "T4Runtime"), + ompURL: bundle.url(forResource: "omp", withExtension: nil, subdirectory: "T4Runtime"), + profile: profile, + fileSystem: fileSystem, + processFactory: processFactory, + socketProbe: socketProbe, + waitNanoseconds: waitNanoseconds, + pollNanoseconds: pollNanoseconds + ) + } + + public init( + t4HostURL: URL?, + ompURL: URL?, + profile: String = LocalHostSupervisor.defaultProfile, + stateRootURL: URL? = nil, + socketURL: URL? = nil, + homeDirectoryURL: URL? = nil, + fileSystem: any LocalHostFileSystem = DefaultLocalHostFileSystem(), + processFactory: any LocalHostProcessFactory = DefaultLocalHostProcessFactory(), + socketProbe: any LocalHostSocketProbe = DefaultLocalHostSocketProbe(), + waitNanoseconds: UInt64 = LocalHostSupervisor.defaultWaitNanoseconds, + pollNanoseconds: UInt64 = LocalHostSupervisor.defaultPollNanoseconds + ) { + let home = homeDirectoryURL ?? fileSystem.homeDirectoryURL() + self.t4HostURL = t4HostURL + self.ompURL = ompURL + self.profile = profile + self.stateRootURL = stateRootURL ?? home.appendingPathComponent(".t4-code/host", isDirectory: true) + self.socketURL = socketURL ?? home.appendingPathComponent(".omp/run/appserver.sock", isDirectory: false) + self.fileSystem = fileSystem + self.processFactory = processFactory + self.socketProbe = socketProbe + self.waitNanoseconds = min(waitNanoseconds, Self.defaultWaitNanoseconds) + self.pollNanoseconds = pollNanoseconds + } + + public func status() -> LocalHostSupervisorStatus { +#if os(macOS) + let healthy = socketProbe.isHealthy(at: socketURL) + if let process = ownedProcess { + if process.isRunning { + return status(phase: healthy ? .running : .starting, ownsProcess: true) + } + ownedProcess = nil + if healthy { return status(phase: .running, ownsProcess: false) } + } + return status(phase: healthy ? .running : .stopped, ownsProcess: false) +#else + return .unsupported(socketURL: socketURL) +#endif + } + + public func start() async throws -> LocalHostSupervisorStatus { +#if os(macOS) + if let process = ownedProcess { + if !process.isRunning { + ownedProcess = nil + } else { + do { return try await waitUntilReady(process: process) } + catch { throw error } + } + } + + // A healthy socket is deliberately never claimed. It may belong to a + // service started by launchd, another T4 instance, or an administrator. + if socketProbe.isHealthy(at: socketURL) { + lastDiagnostics = "" + return status(phase: .running, ownsProcess: false) + } + + guard let t4HostURL else { throw invalidResource("Missing bundled t4-host executable.") } + guard let ompURL else { throw invalidResource("Missing bundled omp executable.") } + try validateExecutable(t4HostURL, name: "t4-host") + try validateExecutable(ompURL, name: "omp") + guard !profile.isEmpty, !profile.contains("\0"), !profile.contains("/") else { + throw LocalHostSupervisorError.invalidPath("Invalid host profile.") + } + try validatePath(stateRootURL, name: "state root") + try validatePath(socketURL, name: "socket") + + let arguments = [ + "serve", "--omp", ompURL.path, + "--profile", profile, + "--state-root", stateRootURL.path, + ] + let process = processFactory.makeProcess(executableURL: t4HostURL, arguments: arguments) + do { + try process.launch() + } catch { + throw launchFailed(process.diagnostics.isEmpty ? String(describing: error) : process.diagnostics) + } + // Assign ownership only after launch succeeds. From this point onward + // every stop/restart operation can identify the exact Process object. + ownedProcess = process + do { + return try await waitUntilReady(process: process) + } catch { + if ownedProcess === process { + process.terminate() + ownedProcess = nil + } + throw error + } +#else + throw LocalHostSupervisorError.unsupported +#endif + } + + public func stop() async throws -> LocalHostSupervisorStatus { +#if os(macOS) + if let process = ownedProcess { + if process.isRunning { process.terminate() } + ownedProcess = nil + lastDiagnostics = "" + } + // Never terminate or claim a process solely because this socket is live. + return status() +#else + throw LocalHostSupervisorError.unsupported +#endif + } + + public func restart() async throws -> LocalHostSupervisorStatus { +#if os(macOS) + if let process = ownedProcess { + if process.isRunning { process.terminate() } + ownedProcess = nil + } + return try await start() +#else + throw LocalHostSupervisorError.unsupported +#endif + } + +#if os(macOS) + private func waitUntilReady(process: any LocalHostProcessHandle) async throws -> LocalHostSupervisorStatus { + let started = DispatchTime.now().uptimeNanoseconds + let deadline = started.addingReportingOverflow(waitNanoseconds).partialValue + while true { + guard ownedProcess === process else { + throw LocalHostSupervisorError.launchFailed("Host ownership was lost while starting.") + } + if socketProbe.isHealthy(at: socketURL) { + lastDiagnostics = "" + return status(phase: .running, ownsProcess: true) + } + if !process.isRunning { + let detail = process.diagnostics + let message = LocalHostSupervisorDiagnostics.redacted(detail.isEmpty ? "The bundled host exited before its socket became ready." : detail) + ownedProcess = nil + throw LocalHostSupervisorError.processExited(process.terminationStatus, message) + } + let now = DispatchTime.now().uptimeNanoseconds + if now >= deadline { + let detail = process.diagnostics + let message = LocalHostSupervisorDiagnostics.redacted(detail.isEmpty ? "The local host did not become ready before the timeout." : detail) + throw LocalHostSupervisorError.timedOut(message) + } + let remaining = deadline - now + let delay = min(pollNanoseconds, remaining) + if delay == 0 { await Task.yield() } + else { try await Task.sleep(nanoseconds: delay) } + } + } + + private func validateExecutable(_ url: URL, name: String) throws { + try validatePath(url, name: name) + guard fileSystem.isRegularFile(at: url), !fileSystem.isSymbolicLink(at: url), fileSystem.isExecutableFile(at: url) else { + throw invalidResource("Bundled \(name) is not a regular executable file.") + } + // Reject symlinked ancestors as well; a bundle resource must not be + // redirected to an executable outside the signed app bundle. + guard url.resolvingSymlinksInPath().standardizedFileURL.path == url.standardizedFileURL.path else { + throw invalidResource("Bundled \(name) uses an unsafe symbolic-link path.") + } + } + + private func validatePath(_ url: URL, name: String) throws { + guard url.isFileURL, !url.path.isEmpty, url.path.hasPrefix("/"), !url.path.contains("\0"), !url.pathComponents.contains("..") else { + throw LocalHostSupervisorError.invalidPath("Invalid \(name) path.") + } + } + + private func invalidResource(_ message: String) -> LocalHostSupervisorError { + .invalidResource(LocalHostSupervisorDiagnostics.redacted(message)) + } + + private func launchFailed(_ message: String) -> LocalHostSupervisorError { + .launchFailed(LocalHostSupervisorDiagnostics.redacted(message)) + } + + private func status(phase: LocalHostPhase, ownsProcess: Bool) -> LocalHostSupervisorStatus { + LocalHostSupervisorStatus(supported: true, phase: phase, socketURL: socketURL, ownsProcess: ownsProcess, diagnostics: lastDiagnostics) + } +#endif +} + +public enum LocalHostSupervisorDiagnostics { + public static let maximumLength = 4096 + + public static func redacted(_ value: String) -> String { + var result = String(value.prefix(maximumLength)) + result = result.replacingOccurrences( + of: "(?i)(authorization|cookie|token|password|secret|api[_-]?key)[=:][^\\s,;]+", + with: "$1=", + options: .regularExpression + ) + result = result.replacingOccurrences(of: "(?i)https?://[^\\s/?#]+[^\\s]*", with: "", options: .regularExpression) + return String(result.prefix(maximumLength)) + } +} + +public struct DefaultLocalHostFileSystem: LocalHostFileSystem { + public init() {} + public func homeDirectoryURL() -> URL { FileManager.default.homeDirectoryForCurrentUser } + public func isRegularFile(at url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true + } + public func isExecutableFile(at url: URL) -> Bool { FileManager.default.isExecutableFile(atPath: url.path) } + public func isSymbolicLink(at url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true + } +} + +#if os(macOS) +public struct DefaultLocalHostProcessFactory: LocalHostProcessFactory { + public init() {} + public func makeProcess(executableURL: URL, arguments: [String]) -> any LocalHostProcessHandle { + FoundationLocalHostProcess(executableURL: executableURL, arguments: arguments) + } +} + +private final class FoundationLocalHostProcess: LocalHostProcessHandle, @unchecked Sendable { + private let process: Process + private let output: Pipe + private let errors: Pipe + private let lock = NSLock() + private var outputText = "" + private var errorText = "" + + init(executableURL: URL, arguments: [String]) { + process = Process() + output = Pipe() + errors = Pipe() + process.executableURL = executableURL + process.arguments = arguments + process.standardOutput = output + process.standardError = errors + output.fileHandleForReading.readabilityHandler = { [weak self] handle in self?.append(handle.availableData, toErrors: false) } + errors.fileHandleForReading.readabilityHandler = { [weak self] handle in self?.append(handle.availableData, toErrors: true) } + } + + var isRunning: Bool { process.isRunning } + var terminationStatus: Int32 { process.terminationStatus } + var diagnostics: String { + lock.lock(); defer { lock.unlock() } + return LocalHostSupervisorDiagnostics.redacted([outputText, errorText].filter { !$0.isEmpty }.joined(separator: "\n")) + } + func launch() throws { + do { try process.run() } + catch { throw LocalHostSupervisorError.launchFailed("Unable to launch the bundled local host.") } + } + func terminate() { + if process.isRunning { process.terminate() } + } + private func append(_ data: Data, toErrors: Bool) { + guard !data.isEmpty else { return } + let text = String(decoding: data, as: UTF8.self) + lock.lock(); defer { lock.unlock() } + if toErrors { errorText = String((errorText + text).suffix(LocalHostSupervisorDiagnostics.maximumLength)) } + else { outputText = String((outputText + text).suffix(LocalHostSupervisorDiagnostics.maximumLength)) } + } +} + +public struct DefaultLocalHostSocketProbe: LocalHostSocketProbe { + public init() {} + public func isHealthy(at socketURL: URL) -> Bool { + guard socketURL.isFileURL, socketURL.path.utf8.count < 104 else { return false } + let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return false } + defer { Darwin.close(descriptor) } + var address = sockaddr_un() + address.sun_family = UInt8(AF_UNIX) + let pathLength = socketURL.path.withCString { pointer in + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + let length = min(strlen(pointer), buffer.count - 1) + buffer.copyBytes(from: UnsafeRawBufferPointer(start: pointer, count: length)) + buffer[length] = 0 + return length + } + } + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + _ = pathLength + return result == 0 + } +} +#else +public struct DefaultLocalHostProcessFactory: LocalHostProcessFactory { + public init() {} + public func makeProcess(executableURL: URL, arguments: [String]) -> any LocalHostProcessHandle { + UnsupportedLocalHostProcess() + } +} + +private final class UnsupportedLocalHostProcess: LocalHostProcessHandle, @unchecked Sendable { + var isRunning: Bool { false } + var terminationStatus: Int32 { -1 } + var diagnostics: String { "Local host management is available on macOS only." } + func launch() throws { throw LocalHostSupervisorError.unsupported } + func terminate() {} +} + +public struct DefaultLocalHostSocketProbe: LocalHostSocketProbe { + public init() {} + public func isHealthy(at socketURL: URL) -> Bool { false } +} +#endif diff --git a/apps/apple/Sources/T4Platform/PlatformLifecycle.swift b/apps/apple/Sources/T4Platform/PlatformLifecycle.swift new file mode 100644 index 00000000..c49a182c --- /dev/null +++ b/apps/apple/Sources/T4Platform/PlatformLifecycle.swift @@ -0,0 +1,197 @@ +import Foundation +#if os(macOS) +import Darwin +#endif + +public struct PlatformProcessResult: Equatable, Sendable { + public let status: Int32 + public let stdout: String + public let stderr: String + public init(status: Int32, stdout: String = "", stderr: String = "") { self.status = status; self.stdout = stdout; self.stderr = stderr } +} + +public protocol PlatformProcessFacade: Sendable { + func run(executable: String, arguments: [String]) throws -> PlatformProcessResult +} + +public enum PlatformLifecycleError: Error, Equatable, Sendable { + case unsupported + case invalidConfiguration + case processFailed(String) +} + +public enum RuntimeServicePhase: String, Codable, Sendable { case stopped, starting, running, failed, unknown } +public enum RuntimeDefinitionState: String, Codable, Sendable { case missing, current, drifted } + +public struct RuntimeServiceStatus: Equatable, Sendable { + public let supported: Bool + public let available: Bool + public let definition: RuntimeDefinitionState + public let service: RuntimeServicePhase + public let diagnostics: String + public let executable: String? + public let issueCode: String? + public let message: String? + + public init(supported: Bool, available: Bool, definition: RuntimeDefinitionState, service: RuntimeServicePhase, diagnostics: String, executable: String? = nil, issueCode: String? = nil, message: String? = nil) { + self.supported = supported; self.available = available; self.definition = definition; self.service = service; self.diagnostics = Self.bounded(diagnostics, maximum: 4096); self.executable = executable.map { Self.bounded($0, maximum: 1024) }; self.issueCode = issueCode.map { Self.bounded($0, maximum: 128) }; self.message = message.map { Self.bounded($0, maximum: 512) } + } + public static var unsupported: RuntimeServiceStatus { RuntimeServiceStatus(supported: false, available: false, definition: .missing, service: .unknown, diagnostics: "Local OMP service management is available on macOS only.", issueCode: "unsupported_platform", message: "Unsupported on this platform.") } + private static func bounded(_ value: String, maximum: Int) -> String { String(value.prefix(maximum)) } +} + +public protocol PlatformLifecycle: Sendable { + func status() async -> RuntimeServiceStatus + func install() async throws -> RuntimeServiceStatus + func start() async throws -> RuntimeServiceStatus + + func stop() async throws -> RuntimeServiceStatus + func restart() async throws -> RuntimeServiceStatus + func uninstall() async throws -> RuntimeServiceStatus +} +public typealias ProcessFacade = any PlatformProcessFacade + +public actor PlatformLifecycleService: PlatformLifecycle { + public static let defaultServiceLabel = "dev.oh-my-pi.appserver" + private let process: any PlatformProcessFacade + private let serviceLabel: String + private let executableURL: URL? + private let definitionURL: URL? + private var cachedStatus: RuntimeServiceStatus = .unsupported + +#if os(macOS) + private var launchDomain: String { "gui/\(getuid())" } + private var launchTarget: String { "\(launchDomain)/\(serviceLabel)" } +#endif + public init(process: (any PlatformProcessFacade)? = nil, serviceLabel: String = PlatformLifecycleService.defaultServiceLabel, executableURL: URL? = nil, definitionURL: URL? = nil) { + self.process = process ?? PlatformLifecycleService.defaultFacade + self.serviceLabel = serviceLabel + self.executableURL = executableURL + self.definitionURL = definitionURL +#if os(macOS) + self.cachedStatus = RuntimeServiceStatus(supported: true, available: false, definition: .missing, service: .unknown, diagnostics: "The local T4 host service has not been inspected.", executable: executableURL?.path) +#endif + } + + private static var defaultFacade: any PlatformProcessFacade { +#if os(macOS) + MacPlatformProcessFacade() +#else + UnsupportedPlatformProcessFacade() +#endif + } + + public func status() async -> RuntimeServiceStatus { +#if os(macOS) + do { + let result = try process.run(executable: "/bin/launchctl", arguments: ["print", launchTarget]) + let running = result.status == 0 + let definition: RuntimeDefinitionState = definitionURL.map { FileManager.default.fileExists(atPath: $0.path) ? .current : .missing } ?? .missing + cachedStatus = RuntimeServiceStatus(supported: true, available: running, definition: definition, service: running ? .running : .stopped, diagnostics: sanitize(result.stderr.isEmpty ? result.stdout : result.stderr), executable: executableURL?.path, issueCode: running ? nil : "service_stopped") + } catch { + cachedStatus = RuntimeServiceStatus(supported: true, available: false, definition: .missing, service: .unknown, diagnostics: sanitize(String(describing: error)), executable: executableURL?.path, issueCode: "inspection_failed", message: "Unable to inspect the local host service.") + } + return cachedStatus +#else + return .unsupported +#endif + } + + public func inspectRuntime() async -> RuntimeServiceStatus { await status() } + public func installRuntime() async throws -> RuntimeServiceStatus { try await install() } + public func startRuntime() async throws -> RuntimeServiceStatus { try await start() } + public func stopRuntime() async throws -> RuntimeServiceStatus { try await stop() } + public func restartRuntime() async throws -> RuntimeServiceStatus { try await restart() } + public func uninstallRuntime() async throws -> RuntimeServiceStatus { try await uninstall() } + + public func install() async throws -> RuntimeServiceStatus { +#if os(macOS) + guard let definitionURL else { throw PlatformLifecycleError.invalidConfiguration } + try runChecked("bootstrap", [launchDomain, definitionURL.path]) + return await status() +#else + throw PlatformLifecycleError.unsupported +#endif + } + + public func start() async throws -> RuntimeServiceStatus { +#if os(macOS) + try runChecked("kickstart", ["-k", launchTarget]) + return await status() +#else + throw PlatformLifecycleError.unsupported +#endif + } + + public func stop() async throws -> RuntimeServiceStatus { +#if os(macOS) + try runChecked("kill", ["SIGTERM", launchTarget]) + return await status() +#else + throw PlatformLifecycleError.unsupported +#endif + } + + public func restart() async throws -> RuntimeServiceStatus { +#if os(macOS) + try runChecked("kickstart", ["-k", launchTarget]) + return await status() +#else + throw PlatformLifecycleError.unsupported +#endif + } + + public func uninstall() async throws -> RuntimeServiceStatus { +#if os(macOS) + try runChecked("bootout", [launchTarget]) + return await status() +#else + throw PlatformLifecycleError.unsupported +#endif + } + +#if os(macOS) + private func runChecked(_ command: String, _ arguments: [String]) throws { + do { + let result = try process.run(executable: "/bin/launchctl", arguments: [command] + arguments) + guard result.status == 0 else { throw PlatformLifecycleError.processFailed(Self.sanitize(result.stderr.isEmpty ? result.stdout : result.stderr)) } + } catch let error as PlatformLifecycleError { throw error } + catch { throw PlatformLifecycleError.processFailed(Self.sanitize(String(describing: error))) } + } +#endif + + private static func sanitize(_ value: String) -> String { + var result = String(value.prefix(512)) + result = result.replacingOccurrences(of: "(?i)(authorization|cookie|token|password|secret)[=:][^\\s,;]+", with: "$1=", options: .regularExpression) + result = result.replacingOccurrences(of: "(?i)(https?://[^\\s/?#]+)[^\\s]*", with: "$1", options: .regularExpression) + return result + } + private func sanitize(_ value: String) -> String { Self.sanitize(value) } +} + +public struct UnsupportedPlatformProcessFacade: PlatformProcessFacade { + public init() {} + public func run(executable: String, arguments: [String]) throws -> PlatformProcessResult { throw PlatformLifecycleError.unsupported } +} + +#if os(macOS) +public struct MacPlatformProcessFacade: PlatformProcessFacade { + public init() {} + public func run(executable: String, arguments: [String]) throws -> PlatformProcessResult { + let process = Process() + let output = Pipe(); let errors = Pipe() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.standardOutput = output; process.standardError = errors + do { try process.run(); process.waitUntilExit() } catch { throw PlatformLifecycleError.processFailed("Unable to launch the service manager.") } + let stdout = String(data: output.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: errors.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return PlatformProcessResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) + } +} +#endif + +public typealias UnsupportedProcessFacade = UnsupportedPlatformProcessFacade +#if os(macOS) +public typealias MacProcessFacade = MacPlatformProcessFacade +#endif diff --git a/apps/apple/Sources/T4Protocol/JSONValue.swift b/apps/apple/Sources/T4Protocol/JSONValue.swift new file mode 100644 index 00000000..7ea49fc2 --- /dev/null +++ b/apps/apple/Sources/T4Protocol/JSONValue.swift @@ -0,0 +1,169 @@ +import Foundation + +/// A recursively Sendable JSON value used at the omp-app/1 boundary. +public indirect enum JSONValue: Sendable, Equatable, Hashable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public static func integer(_ value: Int) -> JSONValue { + .number(Double(value)) + } + + public var objectValue: [String: JSONValue]? { + guard case let .object(value) = self else { return nil } + return value + } + + public var stringValue: String? { + guard case let .string(value) = self else { return nil } + return value + } + + public func toFoundation() -> Any { + switch self { + case .null: NSNull() + case let .bool(value): value + case let .number(value): value + case let .string(value): value + case let .array(value): value.map { $0.toFoundation() } + case let .object(value): value.mapValues { $0.toFoundation() } + } + } + + public func encodedData() throws -> Data { + guard JSONSerialization.isValidJSONObject(["value": toFoundation()]) else { + throw JSONValueError.invalidValue + } + return try JSONSerialization.data(withJSONObject: toFoundation(), options: [.fragmentsAllowed]) + } +} + +public enum JSONValueError: Error, Equatable, Sendable { + case invalidValue + case invalidJSON + case inputTooLarge + case depthExceeded + case nodeLimitExceeded + case mapLimitExceeded + case arrayLimitExceeded + case unsafeNumber +} + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Double.self) { + guard value.isFinite, abs(value) <= 9_007_199_254_740_991 else { + throw JSONValueError.unsafeNumber + } + self = .number(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw JSONValueError.invalidJSON + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case let .bool(value): try container.encode(value) + case let .number(value): + guard value.isFinite, abs(value) <= 9_007_199_254_740_991 else { + throw JSONValueError.unsafeNumber + } + try container.encode(value) + case let .string(value): try container.encode(value) + case let .array(value): try container.encode(value) + case let .object(value): try container.encode(value) + } + } +} + +extension JSONValue { + /// Parses and validates one bounded JSON value. This is intentionally the + /// only untrusted-input entry point used by `WireCodec`. + func validateBounded() throws { + var nodes = 0 + try validate(depth: 0, nodes: &nodes) + } + + private func validate(depth: Int, nodes: inout Int) throws { + guard depth <= WireLimits.maxDepth else { throw JSONValueError.depthExceeded } + nodes += 1 + guard nodes <= WireLimits.maxNodes else { throw JSONValueError.nodeLimitExceeded } + switch self { + case .null, .bool, .string: + return + case .number(let value): + guard value.isFinite, abs(value) <= WireLimits.maxSafeInteger else { throw JSONValueError.unsafeNumber } + case .array(let values): + guard values.count <= WireLimits.maxArrayItems else { throw JSONValueError.arrayLimitExceeded } + for value in values { try value.validate(depth: depth + 1, nodes: &nodes) } + case .object(let values): + guard values.count <= WireLimits.maxMapKeys else { throw JSONValueError.mapLimitExceeded } + for value in values.values { try value.validate(depth: depth + 1, nodes: &nodes) } + } + } + + static func parseBounded(_ source: String) throws -> JSONValue { + try parseBounded(Data(source.utf8)) + } + + static func parseBounded(_ data: Data) throws -> JSONValue { + guard data.count <= WireLimits.maxFrameBytes else { + throw JSONValueError.inputTooLarge + } + let value: JSONValue + do { + value = try JSONDecoder().decode(JSONValue.self, from: data) + } catch { + throw JSONValueError.invalidJSON + } + try value.validateBounded() + return value + } + + private static func convert(_ value: Any, depth: Int, nodes: inout Int) throws -> JSONValue { + guard depth <= WireLimits.maxDepth else { throw JSONValueError.depthExceeded } + nodes += 1 + guard nodes <= WireLimits.maxNodes else { throw JSONValueError.nodeLimitExceeded } + if value is NSNull { return .null } + if let value = value as? NSNumber { + if CFGetTypeID(value) == CFBooleanGetTypeID() { + return .bool(value.boolValue) + } + let number = value.doubleValue + guard number.isFinite, abs(number) <= WireLimits.maxSafeInteger else { + throw JSONValueError.unsafeNumber + } + return .number(number) + } + if let value = value as? [Any] { + guard value.count <= WireLimits.maxArrayItems else { throw JSONValueError.arrayLimitExceeded } + return .array(try value.map { try convert($0, depth: depth + 1, nodes: &nodes) }) + } + if let value = value as? [String: Any] { + guard value.count <= WireLimits.maxMapKeys else { throw JSONValueError.mapLimitExceeded } + var result = [String: JSONValue](minimumCapacity: value.count) + for (key, child) in value { + result[key] = try convert(child, depth: depth + 1, nodes: &nodes) + } + return .object(result) + } + throw JSONValueError.invalidJSON + } +} diff --git a/apps/apple/Sources/T4Protocol/WireCodec.swift b/apps/apple/Sources/T4Protocol/WireCodec.swift new file mode 100644 index 00000000..8c4de7c0 --- /dev/null +++ b/apps/apple/Sources/T4Protocol/WireCodec.swift @@ -0,0 +1,287 @@ +import Foundation + +public enum WireDecoder { + public static func decode(_ data: Data) throws -> WireFrame { + guard data.count <= WireLimits.maxFrameBytes else { + throw WireFormatError("inbound frame exceeds the 4 MiB UTF-8 limit") + } + guard String(data: data, encoding: .utf8) != nil else { + throw WireFormatError("inbound frame is not valid UTF-8") + } + do { + let value = try JSONValue.parseBounded(data) + return try decode(value) + } catch let error as WireFormatError { throw error } + catch let error as JSONValueError { throw map(error) } + catch { throw WireFormatError("invalid JSON") } + } + + public static func decode(_ source: String) throws -> WireFrame { + try decode(Data(source.utf8)) + } + + private static func decode(_ value: JSONValue) throws -> WireFrame { + let raw = try object(value, "frame") + guard try string(raw["v"], "v") == WireLimits.protocolVersion else { + throw WireFormatError("protocol version must be exactly omp-app/1", path: "v") + } + let type = try string(raw["type"], "type") + switch type { + case "hello": return .hello(HelloFrame(raw: raw)) + case "command": return .command(try command(raw)) + case "confirm": return .confirm(try confirm(raw)) + case "pair.start": return .pairStart(try pairStart(raw)) + case "terminal.input": return .terminalInput(try terminalInput(raw)) + case "terminal.resize": return .terminalResize(try terminalResize(raw)) + case "terminal.close": return .terminalClose(try terminalClose(raw)) + case "ping": return .ping(try ping(raw)) + case "welcome": return .welcome(try welcome(raw)) + case "sessions": return .sessions(try sessions(raw)) + case "snapshot": return .snapshot(try snapshot(raw)) + case "entry": return .entry(try entry(raw)) + case "event": return .event(try event(raw)) + case "response": return .response(try response(raw)) + case "error": return .error(try errorFrame(raw)) + case "pong": return .pong(try pong(raw)) + case "gap": return .gap(try gap(raw)) + case "confirmation": return .confirmation(try confirmation(raw)) + case "pair.ok": return .pairOK(try pairOK(raw)) + case "pair.error": return .pairError(try pairError(raw)) + case "agent", "terminal", "files", "review", "audit", "bye", + "host.watch", "session.watch", "session.state", "session.delta", + "lease", "prompt.lease", "agent.state", "agent.lifecycle", "agent.progress", + "agent.event", "agent.transcript", "terminal.output", "terminal.exit", + "files.list", "files.read", "files.write", "files.patch", "files.diff", + "audit.tail", "audit.event", "catalog", "settings", "preview.launch", + "preview.state", "preview.navigation", "preview.capture", "preview.error": + return .additive(AdditiveServerFrame(type: type, raw: raw)) + default: + throw WireFormatError("unknown top-level frame family", path: "type") + } + } + + private static func welcome(_ raw: [String: JSONValue]) throws -> WelcomeFrame { + let selected = try string(raw["selectedProtocol"], "selectedProtocol") + guard selected == WireLimits.protocolVersion else { + throw WireFormatError("selected protocol must be omp-app/1", path: "selectedProtocol") + } + let host = try id(raw["hostId"], "hostId") + let epoch = try string(raw["epoch"], "epoch") + let auth = try string(raw["authentication"], "authentication") + guard ["local", "pairing-required", "paired"].contains(auth) else { + throw WireFormatError("invalid authentication state", path: "authentication") + } + let capabilities = try strings(raw["grantedCapabilities"], "grantedCapabilities") + if auth == "pairing-required", !capabilities.isEmpty { + throw WireFormatError("pairing-required welcome cannot grant capabilities", path: "grantedCapabilities") + } + return WelcomeFrame(hostId: host, selectedProtocol: selected, epoch: epoch, authentication: auth, + resumed: try bool(raw["resumed"], "resumed"), + grantedCapabilities: capabilities, + grantedFeatures: try strings(raw["grantedFeatures"], "grantedFeatures"), + negotiatedLimits: try object(raw["negotiatedLimits"], "negotiatedLimits"), raw: raw) + } + + private static func sessions(_ raw: [String: JSONValue]) throws -> SessionsFrame { + let values = try array(raw["sessions"], "sessions") + let sessions = try values.enumerated().map { try sessionReference($0.element, "sessions[\($0.offset)]") } + let total = raw["totalCount"] == nil ? sessions.count : try integer(raw["totalCount"], "totalCount") + guard total >= sessions.count else { throw WireFormatError("totalCount cannot be less than sessions length", path: "totalCount") } + let truncated = raw["truncated"] == nil ? total > sessions.count : try bool(raw["truncated"], "truncated") + guard truncated == (total > sessions.count) else { throw WireFormatError("truncated does not match totalCount", path: "truncated") } + return SessionsFrame(hostId: raw["hostId"] == nil ? nil : try id(raw["hostId"], "hostId"), + cursor: try sessionCursor(raw["cursor"], "cursor"), sessions: sessions, + totalCount: total, truncated: truncated, raw: raw) + } + + private static func sessionReference(_ value: JSONValue, _ path: String) throws -> SessionReference { + let raw = try object(value, path) + return SessionReference(hostId: try id(raw["hostId"], "\(path).hostId"), sessionId: try id(raw["sessionId"], "\(path).sessionId"), + title: try string(raw["title"], "\(path).title"), revision: try id(raw["revision"], "\(path).revision"), + status: try string(raw["status"], "\(path).status"), updatedAt: try string(raw["updatedAt"], "\(path).updatedAt"), + project: try object(raw["project"], "\(path).project"), raw: raw) + } + + private static func snapshot(_ raw: [String: JSONValue]) throws -> SnapshotFrame { + let values = try array(raw["entries"], "entries") + return SnapshotFrame(hostId: try id(raw["hostId"], "hostId"), sessionId: try id(raw["sessionId"], "sessionId"), + cursor: try transcriptCursor(raw["cursor"], "cursor"), revision: try id(raw["revision"], "revision"), + entries: try values.enumerated().map { try object($0.element, "entries[\($0.offset)]") }, raw: raw) + } + + private static func entry(_ raw: [String: JSONValue]) throws -> EntryFrame { + let host = try id(raw["hostId"], "hostId"); let session = try id(raw["sessionId"], "sessionId") + let item = try object(raw["entry"], "entry") + guard try id(item["hostId"], "entry.hostId") == host, try id(item["sessionId"], "entry.sessionId") == session else { + throw WireFormatError("entry belongs to another session", path: "entry") + } + return EntryFrame(hostId: host, sessionId: session, cursor: try transcriptCursor(raw["cursor"], "cursor"), + revision: try id(raw["revision"], "revision"), entry: item, raw: raw) + } + + private static func event(_ raw: [String: JSONValue]) throws -> EventFrame { + let payload = try object(raw["event"], "event") + _ = try string(payload["type"], "event.type") + return EventFrame(hostId: try id(raw["hostId"], "hostId"), sessionId: try id(raw["sessionId"], "sessionId"), + cursor: try transcriptCursor(raw["cursor"], "cursor"), event: payload, raw: raw) + } + + private static func response(_ raw: [String: JSONValue]) throws -> ResponseFrame { + ResponseFrame(requestId: try id(raw["requestId"], "requestId"), commandId: raw["commandId"] == nil ? nil : try id(raw["commandId"], "commandId"), + hostId: raw["hostId"] == nil ? nil : try id(raw["hostId"], "hostId"), sessionId: raw["sessionId"] == nil ? nil : try id(raw["sessionId"], "sessionId"), + command: raw["command"] == nil ? nil : try string(raw["command"], "command"), ok: try bool(raw["ok"], "ok"), + result: raw["result"], error: raw["error"] == nil ? nil : try object(raw["error"], "error"), raw: raw) + } + + private static func command(_ raw: [String: JSONValue]) throws -> CommandFrame { + CommandFrame(requestId: try id(raw["requestId"], "requestId"), commandId: try id(raw["commandId"], "commandId"), + hostId: try id(raw["hostId"], "hostId"), sessionId: raw["sessionId"] == nil ? nil : try id(raw["sessionId"], "sessionId"), + command: try string(raw["command"], "command"), expectedRevision: raw["expectedRevision"] == nil ? nil : try id(raw["expectedRevision"], "expectedRevision"), + confirmationId: raw["confirmationId"] == nil ? nil : try id(raw["confirmationId"], "confirmationId"), args: raw["args"] ?? .object([:]), raw: raw) + } + + private static func confirm(_ raw: [String: JSONValue]) throws -> ConfirmFrame { + let decision = try string(raw["decision"], "decision") + guard decision == "approve" || decision == "deny" else { throw WireFormatError("decision must be approve or deny", path: "decision") } + return ConfirmFrame(requestId: try id(raw["requestId"], "requestId"), confirmationId: try id(raw["confirmationId"], "confirmationId"), commandId: try id(raw["commandId"], "commandId"), hostId: try id(raw["hostId"], "hostId"), decision: decision, sessionId: raw["sessionId"] == nil ? nil : try id(raw["sessionId"], "sessionId"), raw: raw) + } + + private static func pairStart(_ raw: [String: JSONValue]) throws -> PairStartFrame { + let code = try string(raw["code"], "code") + guard code.utf8.count == 6, code.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) else { throw WireFormatError("code must be exactly six digits", path: "code") } + return PairStartFrame(requestId: try id(raw["requestId"], "requestId"), code: code, deviceId: try id(raw["deviceId"], "deviceId"), deviceName: try string(raw["deviceName"], "deviceName"), platform: try string(raw["platform"], "platform"), requestedCapabilities: try strings(raw["requestedCapabilities"], "requestedCapabilities"), raw: raw) + } + + private static func terminalInput(_ raw: [String: JSONValue]) throws -> TerminalInputFrame { + TerminalInputFrame(hostId: try id(raw["hostId"], "hostId"), sessionId: try id(raw["sessionId"], "sessionId"), terminalId: try id(raw["terminalId"], "terminalId"), data: try string(raw["data"], "data"), encoding: raw["encoding"] == nil ? nil : try string(raw["encoding"], "encoding"), raw: raw) + } + + private static func terminalResize(_ raw: [String: JSONValue]) throws -> TerminalResizeFrame { + TerminalResizeFrame(hostId: try id(raw["hostId"], "hostId"), sessionId: try id(raw["sessionId"], "sessionId"), terminalId: try id(raw["terminalId"], "terminalId"), cols: try integer(raw["cols"], "cols"), rows: try integer(raw["rows"], "rows"), raw: raw) + } + + private static func terminalClose(_ raw: [String: JSONValue]) throws -> TerminalCloseFrame { + TerminalCloseFrame(hostId: try id(raw["hostId"], "hostId"), sessionId: try id(raw["sessionId"], "sessionId"), terminalId: try id(raw["terminalId"], "terminalId"), reason: raw["reason"] == nil ? nil : try string(raw["reason"], "reason"), raw: raw) + } + + private static func ping(_ raw: [String: JSONValue]) throws -> PingFrame { PingFrame(nonce: try string(raw["nonce"], "nonce"), timestamp: try string(raw["timestamp"], "timestamp"), raw: raw) } + private static func errorFrame(_ raw: [String: JSONValue]) throws -> ErrorFrame { ErrorFrame(code: try string(raw["code"], "code"), message: try string(raw["message"], "message"), requestId: raw["requestId"] == nil ? nil : try id(raw["requestId"], "requestId"), raw: raw) } + private static func pong(_ raw: [String: JSONValue]) throws -> PongFrame { PongFrame(nonce: try string(raw["nonce"], "nonce"), timestamp: raw["timestamp"] == nil ? nil : try string(raw["timestamp"], "timestamp"), raw: raw) } + private static func confirmation(_ raw: [String: JSONValue]) throws -> ConfirmationFrame { ConfirmationFrame(confirmationId: try id(raw["confirmationId"], "confirmationId"), raw: raw) } + private static func pairOK(_ raw: [String: JSONValue]) throws -> PairOKFrame { PairOKFrame(pairingId: try id(raw["pairingId"], "pairingId"), raw: raw) } + private static func pairError(_ raw: [String: JSONValue]) throws -> PairErrorFrame { PairErrorFrame(message: try string(raw["message"], "message"), raw: raw) } + private static func gap(_ raw: [String: JSONValue]) throws -> GapFrame { + let from = try transcriptCursor(raw["from"], "from") + let to = raw["to"] == nil ? nil : try transcriptCursor(raw["to"], "to") + if let to, from.epoch != to.epoch || to.seq < from.seq { + throw WireFormatError("invalid gap cursor range", path: "to") + } + return GapFrame(hostId: try id(raw["hostId"], "hostId"), + sessionId: try id(raw["sessionId"], "sessionId"), + from: from, to: to, + reason: raw["reason"] == nil ? nil : try string(raw["reason"], "reason"), + raw: raw) + } + + private static func transcriptCursor(_ value: JSONValue?, _ path: String) throws -> TranscriptCursor { + let object = try object(value, path) + let seq = try integer(object["seq"], "\(path).seq") + guard seq >= 0 else { throw WireFormatError("cursor sequence must be nonnegative", path: "\(path).seq") } + return TranscriptCursor(epoch: try string(object["epoch"], "\(path).epoch"), seq: seq) + } + private static func sessionCursor(_ value: JSONValue?, _ path: String) throws -> SessionIndexCursor { + let object = try object(value, path) + let seq = try integer(object["seq"], "\(path).seq") + guard seq >= 0 else { throw WireFormatError("cursor sequence must be nonnegative", path: "\(path).seq") } + return SessionIndexCursor(epoch: try string(object["epoch"], "\(path).epoch"), seq: seq) + } + private static func object(_ value: JSONValue?, _ path: String) throws -> [String: JSONValue] { guard case let .object(value) = value else { throw WireFormatError("expected object", path: path) }; return value } + private static func array(_ value: JSONValue?, _ path: String) throws -> [JSONValue] { guard case let .array(value) = value else { throw WireFormatError("expected array", path: path) }; return value } + private static func string(_ value: JSONValue?, _ path: String) throws -> String { guard case let .string(value) = value else { throw WireFormatError("expected string", path: path) }; return value } + private static func bool(_ value: JSONValue?, _ path: String) throws -> Bool { guard case let .bool(value) = value else { throw WireFormatError("expected boolean", path: path) }; return value } + private static func integer(_ value: JSONValue?, _ path: String) throws -> Int { guard case let .number(value) = value, value.isFinite, value.rounded() == value, abs(value) <= WireLimits.maxSafeInteger, value >= Double(Int.min), value <= Double(Int.max) else { throw WireFormatError("expected safe integer", path: path) }; return Int(value) } + private static func id(_ value: JSONValue?, _ path: String) throws -> String { let value = try string(value, path); guard !value.isEmpty, value.utf8.count <= WireLimits.maxIdBytes, !value.unicodeScalars.contains(where: { $0.value < 0x20 }) else { throw WireFormatError("invalid identifier", path: path) }; return value } + private static func strings(_ value: JSONValue?, _ path: String) throws -> [String] { try array(value, path).enumerated().map { try string($0.element, "\(path)[\($0.offset)]") } } + private static func map(_ error: JSONValueError) -> WireFormatError { + switch error { case .inputTooLarge: return WireFormatError("inbound frame exceeds the 4 MiB UTF-8 limit"); case .depthExceeded: return WireFormatError("JSON nesting exceeds the depth limit"); case .nodeLimitExceeded: return WireFormatError("JSON node count exceeds the limit"); case .mapLimitExceeded: return WireFormatError("JSON object exceeds the key limit"); case .arrayLimitExceeded: return WireFormatError("JSON array exceeds the item limit"); case .unsafeNumber: return WireFormatError("number is outside the safe integer range"); default: return WireFormatError("invalid JSON") } + } +} + +public enum WireEncoder { + public static func hello(client: ClientIdentity, requestedFeatures: [String] = [], savedCursors: [SavedCursor] = [], capabilities: [String] = [], authentication: DeviceAuthentication? = nil) throws -> Data { + guard savedCursors.count <= WireLimits.maxSavedCursors else { throw WireFormatError("savedCursors exceeds the limit", path: "savedCursors") } + let clientValue: [String: JSONValue] = ["name": .string(client.name), "version": .string(client.version), "build": .string(client.build), "platform": .string(client.platform)] + var frame: [String: JSONValue] = ["v": .string(WireLimits.protocolVersion), "type": .string("hello"), "protocol": .object(["min": .string(WireLimits.protocolVersion), "max": .string(WireLimits.protocolVersion)]), "client": .object(clientValue), "requestedFeatures": .array(requestedFeatures.map(JSONValue.string)), "savedCursors": .array(savedCursors.map { .object(["hostId": .string($0.hostId), "sessionId": .string($0.sessionId), "cursor": transcriptCursorValue($0.cursor)]) })] + if !capabilities.isEmpty { frame["capabilities"] = .object(["client": .array(capabilities.map(JSONValue.string))]) } + if let authentication { frame["authentication"] = .object(["deviceId": .string(authentication.deviceId), "deviceToken": .string(authentication.deviceToken)]) } + return try encode(frame) + } + + public static func sessionList(requestId: String, commandId: String, hostId: String) throws -> Data { try command(requestId: requestId, commandId: commandId, hostId: hostId, command: "session.list", args: .object([:])) } + public static func list(requestId: String, commandId: String, hostId: String) throws -> Data { try sessionList(requestId: requestId, commandId: commandId, hostId: hostId) } + public static func hostWatch(requestId: String, commandId: String, hostId: String, cursor: SessionIndexCursor) throws -> Data { try command(requestId: requestId, commandId: commandId, hostId: hostId, command: "host.watch", args: .object(["cursor": cursorValue(cursor)])) } + public static func watch(requestId: String, commandId: String, hostId: String, cursor: SessionIndexCursor) throws -> Data { try hostWatch(requestId: requestId, commandId: commandId, hostId: hostId, cursor: cursor) } + public static func sessionAttach(requestId: String, commandId: String, hostId: String, sessionId: String, cursor: TranscriptCursor? = nil) throws -> Data { try command(requestId: requestId, commandId: commandId, hostId: hostId, command: "session.attach", args: .object(cursor.map { ["cursor": transcriptCursorValue($0)] } ?? [:]), sessionId: sessionId) } + public static func attach(requestId: String, commandId: String, hostId: String, sessionId: String, cursor: TranscriptCursor? = nil) throws -> Data { try sessionAttach(requestId: requestId, commandId: commandId, hostId: hostId, sessionId: sessionId, cursor: cursor) } + public static func sessionPrompt(requestId: String, commandId: String, hostId: String, sessionId: String, expectedRevision: String, text: String, imageIds: [String] = []) throws -> Data { + guard !text.isEmpty || !imageIds.isEmpty else { throw WireFormatError("text must not be empty without images", path: "text") } + guard imageIds.count <= 8 else { throw WireFormatError("imageIds exceeds the limit", path: "imageIds") } + let images = imageIds.map { JSONValue.object(["imageId": .string($0)]) } + return try command(requestId: requestId, commandId: commandId, hostId: hostId, command: "session.prompt", args: .object(["message": .string(text), "images": .array(images)]), sessionId: sessionId, expectedRevision: expectedRevision) + } + public static func prompt(requestId: String, commandId: String, hostId: String, sessionId: String, expectedRevision: String, text: String, imageIds: [String] = []) throws -> Data { try sessionPrompt(requestId: requestId, commandId: commandId, hostId: hostId, sessionId: sessionId, expectedRevision: expectedRevision, text: text, imageIds: imageIds) } + public static func command(requestId: String, commandId: String, hostId: String, command commandName: String, args: JSONValue, sessionId: String? = nil, expectedRevision: String? = nil, confirmationId: String? = nil) throws -> Data { + guard knownCommands.contains(commandName) else { throw WireFormatError("command is not a pinned command", path: "command") } + var frame: [String: JSONValue] = ["v": .string(WireLimits.protocolVersion), "type": .string("command"), "requestId": .string(requestId), "commandId": .string(commandId), "hostId": .string(hostId), "command": .string(commandName), "args": args] + if let sessionId { frame["sessionId"] = .string(sessionId) }; if let expectedRevision { frame["expectedRevision"] = .string(expectedRevision) }; if let confirmationId { frame["confirmationId"] = .string(confirmationId) } + return try encode(frame) + } + public static func command(requestId: String, commandId: String, hostId: String, command commandName: String, args: [String: JSONValue], sessionId: String? = nil, expectedRevision: String? = nil, confirmationId: String? = nil) throws -> Data { + try Self.command(requestId: requestId, commandId: commandId, hostId: hostId, command: commandName, args: .object(args), sessionId: sessionId, expectedRevision: expectedRevision, confirmationId: confirmationId) + } + public static func terminal(hostId: String, sessionId: String, terminalId: String, data: String, encoding: String? = nil) throws -> Data { + try terminalInput(hostId: hostId, sessionId: sessionId, terminalId: terminalId, data: data, encoding: encoding) + } + public static func confirm(requestId: String, confirmationId: String, commandId: String, hostId: String, decision: String, sessionId: String? = nil) throws -> Data { + guard decision == "approve" || decision == "deny" else { throw WireFormatError("decision must be approve or deny", path: "decision") } + var frame: [String: JSONValue] = ["v": .string(WireLimits.protocolVersion), "type": .string("confirm"), "requestId": .string(requestId), "confirmationId": .string(confirmationId), "commandId": .string(commandId), "hostId": .string(hostId), "decision": .string(decision)] + if let sessionId { frame["sessionId"] = .string(sessionId) }; return try encode(frame) + } + public static func pairStart(requestId: String, code: String, deviceId: String, deviceName: String, platform: String, requestedCapabilities: [String] = []) throws -> Data { + guard code.utf8.count == 6, code.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) else { throw WireFormatError("code must be exactly six digits", path: "code") } + return try encode(["v": .string(WireLimits.protocolVersion), "type": .string("pair.start"), "requestId": .string(requestId), "code": .string(code), "deviceId": .string(deviceId), "deviceName": .string(deviceName), "platform": .string(platform), "requestedCapabilities": .array(requestedCapabilities.map(JSONValue.string))]) + } + public static func pair(requestId: String, code: String, deviceId: String, deviceName: String, platform: String, requestedCapabilities: [String] = []) throws -> Data { try pairStart(requestId: requestId, code: code, deviceId: deviceId, deviceName: deviceName, platform: platform, requestedCapabilities: requestedCapabilities) } + public static func terminalInput(hostId: String, sessionId: String, terminalId: String, data: String, encoding: String? = nil) throws -> Data { try encode(["v": .string(WireLimits.protocolVersion), "type": .string("terminal.input"), "hostId": .string(hostId), "sessionId": .string(sessionId), "terminalId": .string(terminalId), "data": .string(data), "encoding": encoding.map(JSONValue.string) ?? .null].filterNulls()) } + public static func terminalResize(hostId: String, sessionId: String, terminalId: String, cols: Int, rows: Int) throws -> Data { guard (1...1000).contains(cols), (1...500).contains(rows) else { throw WireFormatError("terminal dimensions are out of range") }; return try encode(["v": .string(WireLimits.protocolVersion), "type": .string("terminal.resize"), "hostId": .string(hostId), "sessionId": .string(sessionId), "terminalId": .string(terminalId), "cols": .number(Double(cols)), "rows": .number(Double(rows))]) } + public static func terminalClose(hostId: String, sessionId: String, terminalId: String, reason: String? = nil) throws -> Data { try encode(["v": .string(WireLimits.protocolVersion), "type": .string("terminal.close"), "hostId": .string(hostId), "sessionId": .string(sessionId), "terminalId": .string(terminalId), "reason": reason.map(JSONValue.string) ?? .null].filterNulls()) } + public static func ping(nonce: String, timestamp: String) throws -> Data { try encode(["v": .string(WireLimits.protocolVersion), "type": .string("ping"), "nonce": .string(nonce), "timestamp": .string(timestamp)]) } + + private static func transcriptCursorValue(_ value: TranscriptCursor) -> JSONValue { .object(["epoch": .string(value.epoch), "seq": .number(Double(value.seq))]) } + private static func cursorValue(_ value: SessionIndexCursor) -> JSONValue { .object(["epoch": .string(value.epoch), "seq": .number(Double(value.seq))]) } + private static func encode(_ object: [String: JSONValue]) throws -> Data { + do { + try JSONValue.object(object).validateBounded() + } catch { + throw WireFormatError("outbound JSON exceeds protocol bounds: \(String(describing: error))") + } + let data = try JSONSerialization.data(withJSONObject: object.mapValues { $0.toFoundation() }, options: [.withoutEscapingSlashes]) + guard data.count <= WireLimits.maxFrameBytes else { throw WireFormatError("outbound frame exceeds the 4 MiB UTF-8 limit") } + return data + } + private static let knownCommands: Set = ["host.list", "session.list", "transcript.search", "transcript.context", "transcript.page", "session.create", "session.attach", "session.prompt", "session.state.get", "session.watch", "host.watch", "session.cancel", "session.close", "files.read", "files.write", "files.patch", "files.list", "files.diff", "audit.read", "audit.tail", "settings.read", "settings.write", "catalog.get", "usage.read", "term.open", "bash.run", "agent.cancel", "preview.launch", "preview.state", "preview.navigate", "preview.capture"] +} + +private extension Dictionary where Key == String, Value == JSONValue { + func filterNulls() -> [String: JSONValue] { filter { if case .null = $0.value { return false }; return true } } +} + +/// Compatibility facade for clients that prefer one codec namespace. +public enum WireCodec { + public static func decode(_ data: Data) throws -> WireFrame { try WireDecoder.decode(data) } + public static func encode(_ frame: WireFrame) throws -> Data { + let data = try JSONSerialization.data(withJSONObject: frame.raw.mapValues { $0.toFoundation() }, options: [.withoutEscapingSlashes]) + guard data.count <= WireLimits.maxFrameBytes else { throw WireFormatError("outbound frame exceeds the 4 MiB UTF-8 limit") } + return data + } +} diff --git a/apps/apple/Sources/T4Protocol/WireModels.swift b/apps/apple/Sources/T4Protocol/WireModels.swift new file mode 100644 index 00000000..412e119d --- /dev/null +++ b/apps/apple/Sources/T4Protocol/WireModels.swift @@ -0,0 +1,309 @@ +import Foundation + +public enum WireLimits { + public static let protocolVersion = "omp-app/1" + public static let maxFrameBytes = 4 * 1024 * 1024 + public static let maxDepth = 32 + public static let maxNodes = 20_000 + public static let maxMapKeys = 512 + public static let maxArrayItems = 1_000 + public static let maxSafeInteger = 9_007_199_254_740_991.0 + public static let maxSavedCursors = 128 + public static let maxStringBytes = 65_536 + public static let maxIdBytes = 256 + public static let maxTerminalBytes = 256_000 +} + +public struct WireFormatError: Error, Sendable, Equatable, CustomStringConvertible { + public let message: String + public let path: String? + + public init(_ message: String, path: String? = nil) { + self.message = message + self.path = path + } + + public var description: String { + if let path { return "WireFormatError at \(path): \(message)" } + return "WireFormatError: \(message)" + } +} +public typealias WireCodecError = WireFormatError +public typealias WireFormatException = WireFormatError + +public struct TranscriptCursor: Sendable, Equatable, Hashable { + public let epoch: String + public let seq: Int + public init(epoch: String, seq: Int) { self.epoch = epoch; self.seq = seq } +} + +public struct SessionIndexCursor: Sendable, Equatable, Hashable { + public let epoch: String + public let seq: Int + public init(epoch: String, seq: Int) { self.epoch = epoch; self.seq = seq } +} + +public struct SavedCursor: Sendable, Equatable { + public let hostId: String + public let sessionId: String + public let cursor: TranscriptCursor + public init(hostId: String, sessionId: String, cursor: TranscriptCursor) { + self.hostId = hostId; self.sessionId = sessionId; self.cursor = cursor + } +} + +public struct ClientIdentity: Sendable, Equatable { + public let name: String + public let version: String + public let build: String + public let platform: String + public init(name: String, version: String, build: String, platform: String) { + self.name = name; self.version = version; self.build = build; self.platform = platform + } +} + +public struct DeviceAuthentication: Sendable, Equatable { + public let deviceId: String + public let deviceToken: String + public init(deviceId: String, deviceToken: String) { + self.deviceId = deviceId; self.deviceToken = deviceToken + } +} + +public struct HelloFrame: Sendable, Equatable { + public let raw: [String: JSONValue] + public init(raw: [String: JSONValue]) { self.raw = raw } +} + +public struct CommandFrame: Sendable, Equatable { + public let requestId: String + public let commandId: String + public let hostId: String + public let sessionId: String? + public let command: String + public let expectedRevision: String? + public let confirmationId: String? + public let args: JSONValue + public let raw: [String: JSONValue] + public init(requestId: String, commandId: String, hostId: String, sessionId: String? = nil, + command: String, expectedRevision: String? = nil, confirmationId: String? = nil, + args: JSONValue = .object([:]), raw: [String: JSONValue] = [:]) { + self.requestId = requestId; self.commandId = commandId; self.hostId = hostId + self.sessionId = sessionId; self.command = command; self.expectedRevision = expectedRevision + self.confirmationId = confirmationId; self.args = args; self.raw = raw + } +} + +public struct ConfirmFrame: Sendable, Equatable { + public let requestId: String; public let confirmationId: String; public let commandId: String + public let hostId: String; public let sessionId: String?; public let decision: String + public let raw: [String: JSONValue] + public init(requestId: String, confirmationId: String, commandId: String, hostId: String, + decision: String, sessionId: String? = nil, raw: [String: JSONValue] = [:]) { + self.requestId = requestId; self.confirmationId = confirmationId; self.commandId = commandId + self.hostId = hostId; self.decision = decision; self.sessionId = sessionId; self.raw = raw + } +} + +public struct PairStartFrame: Sendable, Equatable { + public let requestId: String; public let code: String; public let deviceId: String + public let deviceName: String; public let platform: String; public let requestedCapabilities: [String] + public let raw: [String: JSONValue] + public init(requestId: String, code: String, deviceId: String, deviceName: String, + platform: String, requestedCapabilities: [String] = [], raw: [String: JSONValue] = [:]) { + self.requestId = requestId; self.code = code; self.deviceId = deviceId; self.deviceName = deviceName + self.platform = platform; self.requestedCapabilities = requestedCapabilities; self.raw = raw + } +} + +public struct TerminalInputFrame: Sendable, Equatable { + public let hostId: String; public let sessionId: String; public let terminalId: String + public let data: String; public let encoding: String?; public let raw: [String: JSONValue] + public init(hostId: String, sessionId: String, terminalId: String, data: String, + encoding: String? = nil, raw: [String: JSONValue] = [:]) { + self.hostId = hostId; self.sessionId = sessionId; self.terminalId = terminalId + self.data = data; self.encoding = encoding; self.raw = raw + } +} + +public struct TerminalResizeFrame: Sendable, Equatable { + public let hostId: String; public let sessionId: String; public let terminalId: String + public let cols: Int; public let rows: Int; public let raw: [String: JSONValue] + public init(hostId: String, sessionId: String, terminalId: String, cols: Int, rows: Int, + raw: [String: JSONValue] = [:]) { + self.hostId = hostId; self.sessionId = sessionId; self.terminalId = terminalId + self.cols = cols; self.rows = rows; self.raw = raw + } +} + +public struct TerminalCloseFrame: Sendable, Equatable { + public let hostId: String; public let sessionId: String; public let terminalId: String + public let reason: String?; public let raw: [String: JSONValue] + public init(hostId: String, sessionId: String, terminalId: String, reason: String? = nil, + raw: [String: JSONValue] = [:]) { + self.hostId = hostId; self.sessionId = sessionId; self.terminalId = terminalId + self.reason = reason; self.raw = raw + } +} + +public struct PingFrame: Sendable, Equatable { + public let nonce: String; public let timestamp: String; public let raw: [String: JSONValue] + public init(nonce: String, timestamp: String, raw: [String: JSONValue] = [:]) { + self.nonce = nonce; self.timestamp = timestamp; self.raw = raw + } +} + +public struct WelcomeFrame: Sendable, Equatable { + public let hostId: String; public let selectedProtocol: String; public let epoch: String + public let authentication: String; public let resumed: Bool + public let grantedCapabilities: [String]; public let grantedFeatures: [String] + public let negotiatedLimits: [String: JSONValue]; public let raw: [String: JSONValue] + public init(hostId: String, selectedProtocol: String, epoch: String, authentication: String, resumed: Bool, grantedCapabilities: [String], grantedFeatures: [String], negotiatedLimits: [String: JSONValue], raw: [String: JSONValue]) { + self.hostId = hostId; self.selectedProtocol = selectedProtocol; self.epoch = epoch; self.authentication = authentication; self.resumed = resumed + self.grantedCapabilities = grantedCapabilities; self.grantedFeatures = grantedFeatures; self.negotiatedLimits = negotiatedLimits; self.raw = raw + } +} + +public struct SessionReference: Sendable, Equatable { + public let hostId: String; public let sessionId: String; public let title: String + public let revision: String; public let status: String; public let updatedAt: String + public let project: [String: JSONValue]; public let raw: [String: JSONValue] + public init(hostId: String, sessionId: String, title: String, revision: String, status: String, updatedAt: String, project: [String: JSONValue], raw: [String: JSONValue]) { + self.hostId = hostId; self.sessionId = sessionId; self.title = title; self.revision = revision; self.status = status; self.updatedAt = updatedAt; self.project = project; self.raw = raw + } +} + +public struct SessionsFrame: Sendable, Equatable { + public let hostId: String?; public let cursor: SessionIndexCursor; public let sessions: [SessionReference] + public let totalCount: Int; public let truncated: Bool; public let raw: [String: JSONValue] + public init(hostId: String?, cursor: SessionIndexCursor, sessions: [SessionReference], totalCount: Int, truncated: Bool, raw: [String: JSONValue]) { + self.hostId = hostId; self.cursor = cursor; self.sessions = sessions; self.totalCount = totalCount; self.truncated = truncated; self.raw = raw + } +} + +public struct SnapshotFrame: Sendable, Equatable { + public let hostId: String; public let sessionId: String; public let cursor: TranscriptCursor + public let revision: String; public let entries: [[String: JSONValue]]; public let raw: [String: JSONValue] + public init(hostId: String, sessionId: String, cursor: TranscriptCursor, revision: String, entries: [[String: JSONValue]], raw: [String: JSONValue]) { + self.hostId = hostId; self.sessionId = sessionId; self.cursor = cursor; self.revision = revision; self.entries = entries; self.raw = raw + } +} + +public struct EntryFrame: Sendable, Equatable { + public let hostId: String; public let sessionId: String; public let cursor: TranscriptCursor + public let revision: String; public let entry: [String: JSONValue]; public let raw: [String: JSONValue] + public init(hostId: String, sessionId: String, cursor: TranscriptCursor, revision: String, entry: [String: JSONValue], raw: [String: JSONValue]) { + self.hostId = hostId; self.sessionId = sessionId; self.cursor = cursor; self.revision = revision; self.entry = entry; self.raw = raw + } +} + +public struct EventFrame: Sendable, Equatable { + public let hostId: String; public let sessionId: String; public let cursor: TranscriptCursor + /// Complete event payload, including fields unknown to this client version. + public let event: [String: JSONValue]; public let raw: [String: JSONValue] + public init(hostId: String, sessionId: String, cursor: TranscriptCursor, event: [String: JSONValue], raw: [String: JSONValue]) { + self.hostId = hostId; self.sessionId = sessionId; self.cursor = cursor; self.event = event; self.raw = raw + } +} +public struct ResponseFrame: Sendable, Equatable { + public let requestId: String; public let commandId: String?; public let hostId: String? + public let sessionId: String?; public let command: String?; public let ok: Bool + public let result: JSONValue?; public let error: [String: JSONValue]?; public let raw: [String: JSONValue] + public init(requestId: String, commandId: String?, hostId: String?, sessionId: String?, command: String?, ok: Bool, result: JSONValue?, error: [String: JSONValue]?, raw: [String: JSONValue]) { + self.requestId = requestId; self.commandId = commandId; self.hostId = hostId; self.sessionId = sessionId; self.command = command; self.ok = ok; self.result = result; self.error = error; self.raw = raw + } +} +public struct ErrorFrame: Sendable, Equatable { + public let code: String; public let message: String; public let requestId: String? + public let raw: [String: JSONValue] + public init(code: String, message: String, requestId: String?, raw: [String: JSONValue]) { + self.code = code; self.message = message; self.requestId = requestId; self.raw = raw + } +} + +public struct PongFrame: Sendable, Equatable { + public let nonce: String; public let timestamp: String?; public let raw: [String: JSONValue] + public init(nonce: String, timestamp: String?, raw: [String: JSONValue]) { + self.nonce = nonce; self.timestamp = timestamp; self.raw = raw + } +} + +public struct GapFrame: Sendable, Equatable { + public let hostId: String; public let sessionId: String; public let from: TranscriptCursor + public let to: TranscriptCursor?; public let reason: String?; public let raw: [String: JSONValue] + public init(hostId: String, sessionId: String, from: TranscriptCursor, to: TranscriptCursor?, reason: String?, raw: [String: JSONValue]) { + self.hostId = hostId; self.sessionId = sessionId; self.from = from; self.to = to; self.reason = reason; self.raw = raw + } +} + +public struct ConfirmationFrame: Sendable, Equatable { + public let confirmationId: String; public let raw: [String: JSONValue] + public init(confirmationId: String, raw: [String: JSONValue]) { self.confirmationId = confirmationId; self.raw = raw } +} + +public struct PairOKFrame: Sendable, Equatable { + public let pairingId: String; public let raw: [String: JSONValue] + public init(pairingId: String, raw: [String: JSONValue]) { self.pairingId = pairingId; self.raw = raw } +} + +public struct PairErrorFrame: Sendable, Equatable { + public let message: String; public let raw: [String: JSONValue] + public init(message: String, raw: [String: JSONValue]) { self.message = message; self.raw = raw } +} + +/// A known additive server family whose detailed schema is intentionally +/// carried losslessly until a later protocol revision models it. +public struct AdditiveServerFrame: Sendable, Equatable { + public let type: String; public let raw: [String: JSONValue] + public init(type: String, raw: [String: JSONValue]) { self.type = type; self.raw = raw } +} + +public enum WireFrame: Sendable, Equatable { + case hello(HelloFrame) + case command(CommandFrame) + case confirm(ConfirmFrame) + case pairStart(PairStartFrame) + case terminalInput(TerminalInputFrame) + case terminalResize(TerminalResizeFrame) + case terminalClose(TerminalCloseFrame) + case ping(PingFrame) + case welcome(WelcomeFrame) + case sessions(SessionsFrame) + case snapshot(SnapshotFrame) + case entry(EntryFrame) + case event(EventFrame) + case response(ResponseFrame) + case error(ErrorFrame) + case pong(PongFrame) + case gap(GapFrame) + case confirmation(ConfirmationFrame) + case pairOK(PairOKFrame) + case pairError(PairErrorFrame) + case additive(AdditiveServerFrame) + + public var type: String { + switch self { + case .hello: "hello"; case .command: "command"; case .confirm: "confirm" + case .pairStart: "pair.start"; case .terminalInput: "terminal.input" + case .terminalResize: "terminal.resize"; case .terminalClose: "terminal.close" + case .ping: "ping"; case .welcome: "welcome"; case .sessions: "sessions" + case .snapshot: "snapshot"; case .entry: "entry"; case .event: "event" + case .response: "response"; case .error: "error"; case .pong: "pong" + case .gap: "gap"; case .confirmation: "confirmation"; case .pairOK: "pair.ok" + case .pairError: "pair.error"; case let .additive(frame): frame.type + } + } + + public var raw: [String: JSONValue] { + switch self { + case let .hello(frame): frame.raw; case let .command(frame): frame.raw; case let .confirm(frame): frame.raw + case let .pairStart(frame): frame.raw; case let .terminalInput(frame): frame.raw + case let .terminalResize(frame): frame.raw; case let .terminalClose(frame): frame.raw + case let .ping(frame): frame.raw; case let .welcome(frame): frame.raw; case let .sessions(frame): frame.raw + case let .snapshot(frame): frame.raw; case let .entry(frame): frame.raw; case let .event(frame): frame.raw + case let .response(frame): frame.raw; case let .error(frame): frame.raw; case let .pong(frame): frame.raw + case let .gap(frame): frame.raw; case let .confirmation(frame): frame.raw; case let .pairOK(frame): frame.raw + case let .pairError(frame): frame.raw; case let .additive(frame): frame.raw + } + } +} diff --git a/apps/apple/Sources/T4UI/AdaptiveShell.swift b/apps/apple/Sources/T4UI/AdaptiveShell.swift new file mode 100644 index 00000000..99803da7 --- /dev/null +++ b/apps/apple/Sources/T4UI/AdaptiveShell.swift @@ -0,0 +1,727 @@ +import SwiftUI +import T4Client +import T4Platform + +public enum T4Destination: String, CaseIterable, Identifiable, Hashable, Sendable { + case conversation + case hosts + case attention + case developer + case settings + case search + case usage + + public var id: String { rawValue } + + public var title: String { + switch self { + case .conversation: "Sessions" + case .hosts: "Remote hosts" + case .attention: "Inbox" + case .developer: "Developer" + case .settings: "Settings" + case .search: "Search" + case .usage: "Usage" + } + } + + public var systemImage: String { + switch self { + case .conversation: "bubble.left.and.bubble.right" + case .hosts: "network" + case .attention: "tray" + case .developer: "hammer" + case .settings: "gearshape" + case .search: "magnifyingglass" + case .usage: "chart.bar.xaxis" + } + } +} + +@MainActor +public struct AdaptiveShell: View { + private let controller: T4ClientController + private let state: AppState + + @State private var destination: T4Destination = .conversation + @State private var isDrawerPresented = false + @State private var isQuickOpenPresented = false + @State private var opensQuickOpenAfterDrawer = false + + public init(controller: T4ClientController) { + self.controller = controller + self.state = controller.state + } + + public var body: some View { + GeometryReader { geometry in + if geometry.size.width >= T4Layout.wideBreakpoint { + wideLayout + } else { + compactLayout + } + } + .background(T4Color.background) + .foregroundStyle(T4Color.foreground) + .sheet(isPresented: $isQuickOpenPresented) { + QuickOpenView( + controller: controller, + destination: $destination, + isPresented: $isQuickOpenPresented + ) + } + } + + private var wideLayout: some View { + NavigationSplitView { + ShellRail( + controller: controller, + destination: $destination, + onQuickOpen: { isQuickOpenPresented = true } + ) + .navigationSplitViewColumnWidth( + min: ShellLayout.minimumRailWidth, + ideal: ShellLayout.idealRailWidth, + max: ShellLayout.maximumRailWidth + ) + } detail: { + shellDetail + } + .navigationSplitViewStyle(.balanced) + } + + private var compactLayout: some View { + NavigationStack { + shellDetail + .navigationTitle(destination.title) + .toolbar { + ToolbarItem(placement: .navigation) { + Button { + isDrawerPresented = true + } label: { + Image(systemName: "line.3.horizontal") + } + .accessibilityLabel("Open navigation drawer") + } + ToolbarItemGroup(placement: .primaryAction) { + Button { + isQuickOpenPresented = true + } label: { + Image(systemName: "magnifyingglass") + } + .accessibilityLabel("Quick Open") + .accessibilityHint("Search sessions and destinations") + .keyboardShortcut("k", modifiers: .command) + + Button { + destination = .developer + } label: { + Image(systemName: T4Destination.developer.systemImage) + } + .accessibilityLabel("Open Developer workspace") + .keyboardShortcut("d", modifiers: [.command, .shift]) + } + } + } + .sheet(isPresented: $isDrawerPresented, onDismiss: { + guard opensQuickOpenAfterDrawer else { return } + opensQuickOpenAfterDrawer = false + isQuickOpenPresented = true + }) { + NavigationStack { + CompactDrawer( + controller: controller, + destination: $destination, + isPresented: $isDrawerPresented, + onQuickOpen: { + opensQuickOpenAfterDrawer = true + isDrawerPresented = false + } + ) + .navigationTitle("T4 Code") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { isDrawerPresented = false } + } + } + } +#if os(iOS) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) +#endif + } + } + + @ViewBuilder + private var shellDetail: some View { + VStack(spacing: 0) { + if let error = state.errorMessage { + ShellErrorBanner( + message: error, + canRetry: state.connection == .failed, + onRetry: { Task { await controller.connect() } }, + onDismiss: { state.errorMessage = nil } + ) + } + destinationContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(T4Color.background) + } + + @ViewBuilder + private var destinationContent: some View { + switch destination { + case .conversation: + ConversationView(controller: controller) + case .hosts: + HostManagerView(controller: controller) + case .attention: + AttentionView(controller: controller) + case .developer: + DeveloperWorkspaceView(controller: controller) + case .search: + SearchUsageView(controller: controller, mode: .search) + case .usage: + SearchUsageView(controller: controller, mode: .usage) + case .settings: + SettingsView(controller: controller, lifecycle: PlatformLifecycleService()) + } + } +} + +@MainActor +private struct ShellRail: View { + private let controller: T4ClientController + private let state: AppState + @Binding private var destination: T4Destination + private let onQuickOpen: () -> Void + + init( + controller: T4ClientController, + destination: Binding, + onQuickOpen: @escaping () -> Void + ) { + self.controller = controller + self.state = controller.state + self._destination = destination + self.onQuickOpen = onQuickOpen + } + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: T4Spacing.md) { + HStack(spacing: T4Spacing.sm) { + Image(systemName: "command.square.fill") + .font(.title2) + .foregroundStyle(T4Color.accent) + .accessibilityHidden(true) + Text("T4 Code") + .font(T4Typography.heading(.title3, weight: .bold)) + Spacer(minLength: 0) + ConnectionIndicator(connection: state.connection) + } + ConnectionControl(controller: controller) + } + .padding(T4Spacing.md) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: T4Spacing.md) { + VStack(spacing: T4Spacing.xxs) { + navigationButton(.conversation) + navigationButton(.attention) + navigationButton(.search) + } + + Divider() + + SessionNavigationView(controller: controller) { _ in + destination = .conversation + } + .frame(maxWidth: .infinity, alignment: .leading) + + Divider() + + VStack(spacing: T4Spacing.xxs) { + navigationButton(.hosts) + navigationButton(.usage) + navigationButton(.settings) + } + } + .padding(T4Spacing.sm) + } + + Divider() + + VStack(spacing: T4Spacing.xxs) { + Button(action: onQuickOpen) { + Label("Quick Open", systemImage: "magnifyingglass") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(ShellNavigationButtonStyle(isSelected: false)) + .keyboardShortcut("k", modifiers: .command) + .accessibilityHint("Search sessions and destinations") + + Button { + destination = .developer + } label: { + Label("Developer", systemImage: T4Destination.developer.systemImage) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(ShellNavigationButtonStyle(isSelected: destination == .developer)) + .keyboardShortcut("d", modifiers: [.command, .shift]) + } + .padding(T4Spacing.sm) + } + .background(T4Color.surface) + .accessibilityElement(children: .contain) + .accessibilityLabel("Application navigation") + } + + private func navigationButton(_ target: T4Destination) -> some View { + Button { + destination = target + } label: { + HStack(spacing: T4Spacing.sm) { + Image(systemName: target.systemImage) + .frame(width: ShellLayout.navigationIconWidth) + .accessibilityHidden(true) + Text(target.title) + Spacer(minLength: 0) + if target == .attention, !state.attention.isEmpty { + InboxBadge(count: state.attention.count) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(ShellNavigationButtonStyle(isSelected: destination == target)) + .accessibilityValue(destination == target ? "Selected" : "") + } +} + +@MainActor +private struct CompactDrawer: View { + private let controller: T4ClientController + private let state: AppState + @Binding private var destination: T4Destination + @Binding private var isPresented: Bool + private let onQuickOpen: () -> Void + + init( + controller: T4ClientController, + destination: Binding, + isPresented: Binding, + onQuickOpen: @escaping () -> Void + ) { + self.controller = controller + self.state = controller.state + self._destination = destination + self._isPresented = isPresented + self.onQuickOpen = onQuickOpen + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: T4Spacing.lg) { + HStack { + ConnectionIndicator(connection: state.connection, showsLabel: true) + Spacer(minLength: 0) + ConnectionControl(controller: controller, compact: true) + } + .padding(.horizontal, T4Spacing.md) + + VStack(spacing: T4Spacing.xxs) { + drawerButton(.conversation) + drawerButton(.attention) + drawerButton(.search) + } + .padding(.horizontal, T4Spacing.sm) + + Divider() + + VStack(alignment: .leading, spacing: T4Spacing.sm) { + Text("Sessions") + .font(T4Typography.body(.caption, weight: .semibold)) + .foregroundStyle(T4Color.mutedText) + .textCase(.uppercase) + .padding(.horizontal, T4Spacing.md) + SessionNavigationView(controller: controller) { _ in + destination = .conversation + isPresented = false + } + } + + Divider() + + VStack(spacing: T4Spacing.xxs) { + drawerButton(.hosts) + drawerButton(.usage) + drawerButton(.developer) + drawerButton(.settings) + Button(action: onQuickOpen) { + Label("Quick Open", systemImage: "magnifyingglass") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(ShellNavigationButtonStyle(isSelected: false)) + .accessibilityHint("Search sessions and destinations") + } + .padding(.horizontal, T4Spacing.sm) + } + .padding(.vertical, T4Spacing.md) + } + .background(T4Color.background) + .accessibilityElement(children: .contain) + .accessibilityLabel("Navigation drawer") + } + + private func drawerButton(_ target: T4Destination) -> some View { + Button { + destination = target + isPresented = false + } label: { + HStack(spacing: T4Spacing.sm) { + Image(systemName: target.systemImage) + .frame(width: ShellLayout.navigationIconWidth) + .accessibilityHidden(true) + Text(target.title) + Spacer(minLength: 0) + if target == .attention, !state.attention.isEmpty { + InboxBadge(count: state.attention.count) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(ShellNavigationButtonStyle(isSelected: destination == target)) + .accessibilityValue(destination == target ? "Selected" : "") + } +} + +private struct ConnectionIndicator: View { + let connection: T4ConnectionState + var showsLabel = false + + var body: some View { + HStack(spacing: T4Spacing.xs) { + Circle() + .fill(color) + .frame(width: T4Spacing.xs, height: T4Spacing.xs) + .accessibilityHidden(true) + if showsLabel { + Text(label) + .font(T4Typography.body(.caption, weight: .medium)) + .foregroundStyle(T4Color.secondaryText) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Connection status") + .accessibilityValue(label) + } + + private var label: String { + switch connection { + case .disconnected: "Disconnected" + case .connecting: "Connecting" + case .connected: "Connected" + case .reconnecting: "Reconnecting" + case .failed: "Connection failed" + } + } + + private var color: Color { + switch connection { + case .disconnected: T4Color.mutedText + case .connecting: T4Color.info + case .connected: T4Color.success + case .reconnecting: T4Color.warning + case .failed: T4Color.destructive + } + } +} + +@MainActor +private struct ConnectionControl: View { + private let controller: T4ClientController + private let state: AppState + @State private var isPerforming = false + private let compact: Bool + + init(controller: T4ClientController, compact: Bool = false) { + self.controller = controller + self.state = controller.state + self.compact = compact + } + + var body: some View { + Button(role: disconnects ? .destructive : nil) { + performAction() + } label: { + HStack(spacing: T4Spacing.xs) { + if isPerforming || state.connection == .connecting || state.connection == .reconnecting { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + } else { + Image(systemName: actionIcon) + .accessibilityHidden(true) + } + Text(actionLabel) + } + .frame(maxWidth: compact ? nil : .infinity) + } + .buttonStyle(.bordered) + .controlSize(compact ? .regular : .large) + .disabled(isPerforming) + .accessibilityLabel(actionLabel) + .accessibilityHint(actionHint) + } + + private var disconnects: Bool { + switch state.connection { + case .connected, .connecting, .reconnecting: true + case .disconnected, .failed: false + } + } + + private var actionLabel: String { + switch state.connection { + case .disconnected: "Connect" + case .connecting: "Cancel" + case .connected: "Disconnect" + case .reconnecting: "Stop retrying" + case .failed: "Retry" + } + } + + private var actionIcon: String { + disconnects ? "stop.circle" : state.connection == .failed ? "arrow.clockwise" : "bolt.horizontal.circle" + } + + private var actionHint: String { + switch state.connection { + case .disconnected: "Connects to the selected host profile" + case .connecting: "Cancels the current connection attempt" + case .connected: "Disconnects from the live host" + case .reconnecting: "Stops automatic reconnection" + case .failed: "Tries the selected host again" + } + } + + private func performAction() { + guard !isPerforming else { return } + let shouldDisconnect = disconnects + isPerforming = true + Task { @MainActor in + if shouldDisconnect { + await controller.disconnect() + } else { + await controller.connect() + } + isPerforming = false + } + } +} + +private struct InboxBadge: View { + let count: Int + + var body: some View { + Text(count > ShellLayout.maximumBadgeCount ? "\(ShellLayout.maximumBadgeCount)+" : "\(count)") + .font(T4Typography.body(.caption2, weight: .bold)) + .foregroundStyle(T4Color.accentForeground) + .padding(.horizontal, T4Spacing.xs) + .padding(.vertical, T4Spacing.xxs) + .background(T4Color.accent, in: Capsule()) + .accessibilityLabel("\(count) items need attention") + } +} + +private struct ShellErrorBanner: View { + let message: String + let canRetry: Bool + let onRetry: () -> Void + let onDismiss: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: T4Spacing.sm) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(T4Color.destructive) + .accessibilityHidden(true) + Text(T4Privacy.redacted(message)) + .font(T4Typography.body(.subheadline)) + .lineLimit(3) + Spacer(minLength: 0) + if canRetry { + Button("Retry", action: onRetry) + .buttonStyle(.bordered) + } + Button(action: onDismiss) { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + .accessibilityLabel("Dismiss connection error") + } + .padding(T4Spacing.sm) + .background(T4Color.destructiveSoft) + .accessibilityElement(children: .contain) + } +} + +private struct ShellNavigationButtonStyle: ButtonStyle { + let isSelected: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(T4Typography.body(.subheadline, weight: isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? T4Color.accent : T4Color.foreground) + .padding(.horizontal, T4Spacing.sm) + .padding(.vertical, T4Spacing.xs) + .frame(minHeight: T4Layout.minimumControlHeight) + .background( + isSelected ? T4Color.accentSoft : configuration.isPressed ? T4Color.raised : Color.clear, + in: RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + ) + .contentShape(Rectangle()) + } +} + +private struct QuickOpenEntry: Identifiable { + enum Action { + case destination(T4Destination) + case session(String) + } + + let id: String + let title: String + let subtitle: String + let systemImage: String + let action: Action +} + +@MainActor +private struct QuickOpenView: View { + private let controller: T4ClientController + private let state: AppState + @Binding private var destination: T4Destination + @Binding private var isPresented: Bool + @State private var query = "" + + init( + controller: T4ClientController, + destination: Binding, + isPresented: Binding + ) { + self.controller = controller + self.state = controller.state + self._destination = destination + self._isPresented = isPresented + } + + var body: some View { + NavigationStack { + Group { + if filteredEntries.isEmpty { + T4EmptyState( + icon: "magnifyingglass", + title: "No matches", + message: "Try a session title, host, or destination such as Inbox or Settings." + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(filteredEntries) { entry in + Button { + select(entry) + } label: { + HStack(spacing: T4Spacing.sm) { + Image(systemName: entry.systemImage) + .foregroundStyle(T4Color.mutedText) + .frame(width: ShellLayout.quickOpenIconWidth) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(entry.title) + .font(T4Typography.body(.body, weight: .medium)) + Text(entry.subtitle) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(entry.title), \(entry.subtitle)") + } + .scrollContentBackground(.hidden) + } + } + .background(T4Color.background) + .navigationTitle("Quick Open") + .searchable(text: $query, prompt: "Sessions and destinations") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { isPresented = false } + } + } + } +#if os(iOS) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) +#endif + } + + private var entries: [QuickOpenEntry] { + let destinations = T4Destination.allCases.map { destination in + QuickOpenEntry( + id: "destination:\(destination.rawValue)", + title: destination.title, + subtitle: destination == .developer ? "Inspect terminal, files, review, audit, and preview" : "Open \(destination.title)", + systemImage: destination.systemImage, + action: .destination(destination) + ) + } + let sessions = state.sessions.map { session in + QuickOpenEntry( + id: "session:\(session.id)", + title: session.title.isEmpty ? "Untitled session" : session.title, + subtitle: [session.hostID, session.status].filter { !$0.isEmpty }.joined(separator: " · "), + systemImage: "bubble.left", + action: .session(session.id) + ) + } + return destinations + sessions + } + + private var filteredEntries: [QuickOpenEntry] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return entries } + return entries.filter { + $0.title.localizedCaseInsensitiveContains(trimmed) || + $0.subtitle.localizedCaseInsensitiveContains(trimmed) + } + } + + private func select(_ entry: QuickOpenEntry) { + switch entry.action { + case let .destination(target): + destination = target + isPresented = false + case let .session(sessionID): + Task { @MainActor in + await controller.selectSession(sessionID) + destination = .conversation + isPresented = false + } + } + } +} + +private enum ShellLayout { + static let minimumRailWidth: CGFloat = 252 + static let idealRailWidth: CGFloat = 288 + static let maximumRailWidth: CGFloat = 340 + static let navigationIconWidth: CGFloat = 20 + static let quickOpenIconWidth: CGFloat = 24 + static let maximumBadgeCount = 99 +} diff --git a/apps/apple/Sources/T4UI/AttentionView.swift b/apps/apple/Sources/T4UI/AttentionView.swift new file mode 100644 index 00000000..d225445f --- /dev/null +++ b/apps/apple/Sources/T4UI/AttentionView.swift @@ -0,0 +1,489 @@ +import SwiftUI +import T4Client +import T4Protocol + +/// Sends the protocol-level response used by destructive confirmation +/// challenges. A confirmation is a distinct omp-app frame, not a command. +@MainActor +func sendT4ConfirmationDecision( + controller: T4ClientController, + item: T4AttentionItem, + decision: String +) async throws { + guard decision == "approve" || decision == "deny", + let hostID = controller.hostID, + let commandID = item.commandID + else { + throw T4ClientControllerError.disconnected + } + + let data = try WireEncoder.confirm( + requestId: UUID().uuidString.lowercased(), + confirmationId: item.id, + commandId: commandID, + hostId: hostID, + decision: decision, + sessionId: controller.state.selectedSessionID + ) + try await controller.transport.send(try WireDecoder.decode(data)) +} + +@MainActor +public struct AttentionView: View { + private let controller: T4ClientController + @State private var pendingItemIDs: Set = [] + @State private var answers: [String: String] = [:] + @State private var itemErrors: [String: String] = [:] + @State private var announcement = "" + + public init(controller: T4ClientController) { + self.controller = controller + } + + public var body: some View { + GeometryReader { geometry in + let isWide = geometry.size.width >= T4Layout.wideBreakpoint + ScrollView { + HStack(alignment: .top, spacing: T4Spacing.xl) { + if isWide { + summary + .frame(width: T4Layout.settingsRailWidth, alignment: .leading) + } + inbox + .frame(maxWidth: T4Layout.readableMeasure, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .top) + .padding(isWide ? T4Spacing.xl : T4Spacing.md) + } + .background(T4Color.background) + } + .navigationTitle("Attention") + .accessibilityElement(children: .contain) + .overlay(alignment: .topLeading) { + Text(announcement) + .frame(width: 1, height: 1) + .opacity(0.001) + .accessibilityHidden(announcement.isEmpty) + } + } + + private var items: [T4AttentionItem] { controller.state.attention } + + private var blockingItems: [T4AttentionItem] { + items.filter { ["approval", "confirmation", "input", "plan", "question"].contains($0.kind.lowercased()) } + } + + private var problemItems: [T4AttentionItem] { + items.filter { ["cancelled", "error", "failed"].contains($0.kind.lowercased()) } + } + + private var backgroundItems: [T4AttentionItem] { + items.filter { item in + !blockingItems.contains(where: { $0.id == item.id }) && + !problemItems.contains(where: { $0.id == item.id }) + } + } + + private var summary: some View { + VStack(alignment: .leading, spacing: T4Spacing.lg) { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text("Attention inbox") + .font(T4Typography.heading(.title2)) + .foregroundStyle(T4Color.foreground) + Text("Decisions and updates from the active OMP host, ordered by what needs you first.") + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: T4Spacing.sm) { + summaryRow("Needs you", count: blockingItems.count, tone: .approval) + summaryRow("Problems", count: problemItems.count, tone: .error) + summaryRow("Background", count: backgroundItems.count, tone: .working) + } + + connectionStatus + } + } + + private func summaryRow(_ label: String, count: Int, tone: T4StatusTone) -> some View { + HStack(spacing: T4Spacing.xs) { + T4StatusPill("\(count)", tone: tone) + Text(label) + .font(T4Typography.body(.subheadline, weight: .medium)) + .foregroundStyle(T4Color.secondaryText) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(count) \(label)") + } + + @ViewBuilder + private var connectionStatus: some View { + switch controller.state.connection { + case .connected: + T4StatusPill("Live", tone: .success) + case .connecting, .reconnecting: + T4StatusPill("Updating", tone: .working, isPulsing: true) + case .disconnected: + T4StatusPill("Offline", tone: .neutral) + case .failed: + T4StatusPill("Connection failed", tone: .error) + } + } + + @ViewBuilder + private var inbox: some View { + VStack(alignment: .leading, spacing: T4Spacing.lg) { + if !isWideHeaderSuppressed { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + HStack(alignment: .firstTextBaseline, spacing: T4Spacing.xs) { + Text("Attention inbox") + .font(T4Typography.heading(.title2)) + Spacer(minLength: T4Spacing.sm) + connectionStatus + } + Text("Decisions and updates from the active OMP host.") + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + } + } + + if let errorMessage = controller.state.errorMessage { + T4ErrorState( + title: "Host update failed", + message: T4Privacy.redacted(errorMessage), + retry: { Task { await controller.connect() } } + ) + } + + if items.isEmpty { + emptyOrLoading + } else { + if !blockingItems.isEmpty { + section( + title: "Needs you", + detail: "Work paused for a decision or answer", + items: blockingItems + ) + } + if !problemItems.isEmpty { + section( + title: "Problems", + detail: "Failed or cancelled work ready to review", + items: problemItems + ) + } + if !backgroundItems.isEmpty { + section( + title: "Background", + detail: "Recent plan and agent status", + items: backgroundItems + ) + } + } + } + } + + /// The width decision is made by the parent GeometryReader. A compact + /// header remains useful at all sizes and the wide summary adds detail; + /// duplicate visible copy is avoided with this environment-independent + /// check supplied by the layout itself. + private var isWideHeaderSuppressed: Bool { false } + + @ViewBuilder + private var emptyOrLoading: some View { + switch controller.state.connection { + case .connecting, .reconnecting: + VStack(alignment: .leading, spacing: T4Spacing.sm) { + ProgressView() + .controlSize(.small) + Text("Checking for decisions and updates…") + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + } + .padding(T4Spacing.lg) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("Loading attention inbox") + case .failed: + T4EmptyState( + icon: "bolt.horizontal.circle", + title: "The inbox could not update", + message: "Reconnect to OMP to check whether any work needs your attention.", + actionTitle: "Reconnect", + action: { Task { await controller.connect() } } + ) + case .connected: + T4EmptyState( + icon: "checkmark.circle", + title: "Nothing needs you", + message: "OMP will place approvals, questions, plans, errors, and background updates here." + ) + case .disconnected: + T4EmptyState( + icon: "network.slash", + title: "Inbox is offline", + message: "Connect to an OMP host to load current decisions and status.", + actionTitle: "Connect", + action: { Task { await controller.connect() } } + ) + } + } + + private func section(title: String, detail: String, items: [T4AttentionItem]) -> some View { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + HStack(alignment: .firstTextBaseline, spacing: T4Spacing.xs) { + Text(title) + .font(T4Typography.heading(.headline)) + Text("\(items.count)") + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.mutedText) + Spacer(minLength: T4Spacing.sm) + Text(detail) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.mutedText) + .lineLimit(1) + } + + LazyVStack(spacing: T4Spacing.xs) { + ForEach(items) { item in + itemRow(item) + } + } + } + } + + private func itemRow(_ item: T4AttentionItem) -> some View { + let pending = pendingItemIDs.contains(item.id) + let kind = item.kind.lowercased() + return VStack(alignment: .leading, spacing: T4Spacing.sm) { + HStack(alignment: .top, spacing: T4Spacing.sm) { + Image(systemName: icon(for: kind)) + .foregroundStyle(tone(for: kind).colorForAttention) + .frame(width: T4Spacing.lg) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + HStack(alignment: .firstTextBaseline, spacing: T4Spacing.xs) { + Text(item.title) + .font(T4Typography.heading(.subheadline)) + .foregroundStyle(T4Color.foreground) + Spacer(minLength: T4Spacing.sm) + T4StatusPill(statusLabel(for: kind), tone: tone(for: kind), isPulsing: pending) + } + if !item.detail.isEmpty { + Text(T4Privacy.redacted(item.detail)) + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + actionControls(for: item, kind: kind, pending: pending) + + if let error = itemErrors[item.id] { + T4ErrorState(title: "Action not sent", message: error) + } + } + .padding(T4Spacing.md) + .background(T4Color.raised, in: RoundedRectangle(cornerRadius: T4Radius.lg)) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.lg) + .stroke(T4Color.border) + } + .accessibilityElement(children: .contain) + } + + @ViewBuilder + private func actionControls(for item: T4AttentionItem, kind: String, pending: Bool) -> some View { + let connected = controller.state.connection == .connected + switch kind { + case "approval", "confirmation", "plan": + HStack(spacing: T4Spacing.xs) { + Button(kind == "plan" ? "Approve plan" : "Approve") { + Task { await resolve(item, decision: "approve") } + } + .buttonStyle(.borderedProminent) + .tint(T4Color.accent) + Button(kind == "plan" ? "Deny plan" : "Deny") { + Task { await resolve(item, decision: "deny") } + } + .buttonStyle(.bordered) + .tint(T4Color.destructive) + } + .disabled(pending || !connected || (kind != "confirmation" && sessionRevision == nil)) + case "input", "question": + HStack(alignment: .center, spacing: T4Spacing.xs) { + TextField("Answer", text: answerBinding(for: item.id)) + .textFieldStyle(.roundedBorder) + .accessibilityLabel("Answer \(item.title)") + .onSubmit { Task { await answer(item) } } + Button("Answer") { + Task { await answer(item) } + } + .buttonStyle(.borderedProminent) + .tint(T4Color.accent) + .disabled(answerText(for: item.id).isEmpty || pending || !connected || sessionRevision == nil) + } + case "error", "failed": + if sessionRevision != nil { + Button("Retry") { + Task { await retry(item) } + } + .buttonStyle(.bordered) + .tint(T4Color.accent) + .disabled(pending || !connected) + } else { + Text("Open or refresh the session before retrying this turn.") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.mutedText) + } + default: + EmptyView() + } + + if !connected && ["approval", "confirmation", "input", "plan", "question"].contains(kind) { + Text("Reconnect before responding. This item stays pending.") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.warning) + } + } + + private var sessionRevision: String? { + controller.state.transcript.reversed().compactMap(\.revision).first + } + + private func answerBinding(for itemID: String) -> Binding { + Binding( + get: { answers[itemID, default: ""] }, + set: { answers[itemID] = $0 } + ) + } + + private func answerText(for itemID: String) -> String { + answers[itemID, default: ""].trimmingCharacters(in: .whitespacesAndNewlines) + } + + @MainActor + private func answer(_ item: T4AttentionItem) async { + let answer = answerText(for: item.id) + guard !answer.isEmpty else { return } + await perform(item, success: "Answer sent") { + guard let revision = sessionRevision else { throw T4ClientControllerError.staleGeneration } + _ = try await controller.command( + "session.ui.respond", + sessionID: controller.state.selectedSessionID, + expectedRevision: revision, + args: ["requestId": .string(item.id), "value": .string(answer)] + ) + } + } + + @MainActor + private func resolve(_ item: T4AttentionItem, decision: String) async { + await perform(item, success: decision == "approve" ? "Approved" : "Denied") { + if item.kind.lowercased() == "confirmation" { + try await sendT4ConfirmationDecision(controller: controller, item: item, decision: decision) + return + } + guard let revision = sessionRevision else { throw T4ClientControllerError.staleGeneration } + _ = try await controller.command( + "session.ui.respond", + sessionID: controller.state.selectedSessionID, + expectedRevision: revision, + args: [ + "requestId": .string(item.id), + "confirmed": .bool(decision == "approve"), + ] + ) + } + } + + @MainActor + private func retry(_ item: T4AttentionItem) async { + await perform(item, success: "Retry started") { + guard let revision = sessionRevision else { throw T4ClientControllerError.staleGeneration } + _ = try await controller.command( + "session.retry", + sessionID: controller.state.selectedSessionID, + expectedRevision: revision + ) + } + } + + @MainActor + private func perform( + _ item: T4AttentionItem, + success: String, + operation: @MainActor () async throws -> Void + ) async { + guard !pendingItemIDs.contains(item.id) else { return } + pendingItemIDs.insert(item.id) + itemErrors[item.id] = nil + defer { pendingItemIDs.remove(item.id) } + do { + try await operation() + controller.state.attention.removeAll { $0.id == item.id } + answers[item.id] = nil + announcement = "\(success): \(item.title)" + } catch { + let message = T4Privacy.redacted(String(describing: error)) + itemErrors[item.id] = message + announcement = "Action failed: \(message)" + } + } + + private func tone(for kind: String) -> T4StatusTone { + switch kind { + case "approval", "confirmation": .approval + case "input", "question": .input + case "plan": .plan + case "error", "failed": .error + case "completed", "done": .success + case "cancelled": .warning + case "background", "working": .working + default: .neutral + } + } + + private func statusLabel(for kind: String) -> String { + switch kind { + case "approval": "Approval" + case "confirmation": "Confirm" + case "input", "question": "Question" + case "plan": "Plan" + case "error", "failed": "Failed" + case "completed", "done": "Done" + case "cancelled": "Cancelled" + case "background", "working": "Working" + default: kind.isEmpty ? "Update" : kind.capitalized + } + } + + private func icon(for kind: String) -> String { + switch kind { + case "approval", "confirmation": "checkmark.shield" + case "input", "question": "questionmark.bubble" + case "plan": "list.bullet.clipboard" + case "error", "failed": "exclamationmark.triangle" + case "completed", "done": "checkmark.circle" + case "cancelled": "xmark.circle" + default: "waveform.path.ecg" + } + } +} + +private extension T4StatusTone { + var colorForAttention: Color { + switch self { + case .neutral: T4Color.mutedText + case .working: T4Color.statusWorking + case .approval: T4Color.statusApproval + case .input: T4Color.statusInput + case .plan: T4Color.statusPlan + case .success, .done: T4Color.statusDone + case .warning: T4Color.warning + case .error: T4Color.statusError + } + } +} diff --git a/apps/apple/Sources/T4UI/ComposerView.swift b/apps/apple/Sources/T4UI/ComposerView.swift new file mode 100644 index 00000000..7348e380 --- /dev/null +++ b/apps/apple/Sources/T4UI/ComposerView.swift @@ -0,0 +1,881 @@ +import CryptoKit +import Foundation +import SwiftUI +import T4Client +import T4Protocol +import UniformTypeIdentifiers + +#if os(macOS) +import AppKit +#elseif os(iOS) +import UIKit +#endif + +@MainActor +public struct ComposerView: View { + private let controller: T4ClientController + + @State private var activeSessionID: String? + @State private var draft = "" + @State private var drafts: [String: String] = [:] + @State private var attachments: [String: [ComposerImageAttachment]] = [:] + @State private var busyAction: ComposerAction? + @State private var localError: String? + @State private var showingImageImporter = false + @State private var showingModelEditor = false + @State private var modelInput = "" + @FocusState private var composerFocused: Bool + + public init(controller: T4ClientController) { + self.controller = controller + } + + public var body: some View { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + if let localError { + composerError(localError) + } else if let error = controller.state.composer.error { + composerError(error) + } + + if !currentAttachments.isEmpty { + attachmentStrip + } + + if selectedSession != nil { + controlStrip + } + + HStack(alignment: .bottom, spacing: T4Spacing.xs) { + Button { + showingImageImporter = true + } label: { + Image(systemName: "paperclip") + .frame(minWidth: T4Spacing.xl, minHeight: T4Spacing.xl) + } + .buttonStyle(.borderless) + .disabled(attachmentDisabledReason != nil) + .help(attachmentDisabledReason ?? "Attach images") + .accessibilityLabel("Attach images") + .accessibilityHint(attachmentDisabledReason ?? "Choose up to eight PNG, JPEG, GIF, or WebP images") + + TextField(composerPlaceholder, text: draftBinding, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(ComposerLimits.minimumLines...ComposerLimits.maximumLines) + .submitLabel(.send) + .focused($composerFocused) + .disabled(inputDisabledReason != nil) + .accessibilityLabel("Prompt message") + .accessibilityHint(inputDisabledReason ?? "Press Return to send") + .onSubmit { + Task { await submit() } + } + + Button { + Task { await submit() } + } label: { + if isSending { + ProgressView() + .controlSize(.small) + .frame(minWidth: T4Spacing.xl, minHeight: T4Spacing.xl) + .accessibilityLabel("Sending prompt") + } else { + Text(turnIsActive ? "Steer" : "Send") + } + } + .buttonStyle(.borderedProminent) + .disabled(!canSubmit) + .keyboardShortcut(.return, modifiers: .command) + .accessibilityHint(submitDisabledReason ?? "Sends this prompt to the selected session") + } + } + .frame(maxWidth: T4Layout.readableMeasure) + .padding(.horizontal, T4Spacing.md) + .padding(.vertical, T4Spacing.sm) + .frame(maxWidth: .infinity) + .background(T4Color.surface) + .overlay(alignment: .top) { + Rectangle() + .fill(T4Color.border) + .frame(height: T4Spacing.xxs / 2) + } + .onAppear { + activateSession(controller.state.selectedSessionID) + } + .onChange(of: controller.state.selectedSessionID) { _, nextSessionID in + activateSession(nextSessionID) + } + .fileImporter( + isPresented: $showingImageImporter, + allowedContentTypes: [.png, .jpeg, .gif, .webP], + allowsMultipleSelection: true + ) { result in + switch result { + case let .success(urls): + Task { await importImages(urls) } + case let .failure(error): + localError = "Could not open the selected image: \(error.localizedDescription)" + } + } + .sheet(isPresented: $showingModelEditor) { + ModelSelectionSheet( + selection: $modelInput, + suggestedModels: modelOptions, + isPending: busyAction != nil, + onCancel: { showingModelEditor = false }, + onSelect: { selector in + showingModelEditor = false + Task { await setModel(selector) } + } + ) + } + } + + private var selectedSession: T4Session? { + guard let selectedSessionID = controller.state.selectedSessionID else { return nil } + return controller.state.sessions.first { $0.id == selectedSessionID } + } + + private var currentAttachments: [ComposerImageAttachment] { + guard let activeSessionID else { return [] } + return attachments[activeSessionID] ?? [] + } + + private var isSending: Bool { + controller.state.composer.isSending || busyAction == .sending + } + + private var turnIsActive: Bool { + selectedSession.map { SessionRuntime.isActive($0.status) } ?? false + } + + private var sessionIsPaused: Bool { + selectedSession.map { SessionRuntime.isPaused($0.status) } ?? false + } + + private var modelOptions: [String] { + SessionSettings.modelOptions(state: controller.state) + } + + private var currentModelLabel: String { + guard let sessionID = activeSessionID else { return "Model" } + return SessionSettings.currentModel(sessionID: sessionID, state: controller.state) ?? "Model" + } + + private var draftBinding: Binding { + Binding( + get: { draft }, + set: { value in + draft = value + if let activeSessionID { + drafts[activeSessionID] = value + } + controller.state.composer.text = value + controller.state.composer.error = nil + localError = nil + } + ) + } + + private var canSubmit: Bool { + submitDisabledReason == nil + } + + private var submitDisabledReason: String? { + if let inputDisabledReason { return inputDisabledReason } + guard !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !currentAttachments.isEmpty else { + return "Enter a message or attach an image." + } + if turnIsActive && !currentAttachments.isEmpty { + return "Images cannot be sent while a turn is active. Queue text or stop the turn first." + } + if !currentAttachments.isEmpty && currentRevision == nil { + return "Wait for the session revision before sending images." + } + return nil + } + + private var inputDisabledReason: String? { + guard controller.state.connection == .connected else { return "Reconnect before sending a prompt." } + guard selectedSession != nil else { return "Choose a session before sending a prompt." } + guard SessionCapabilities.allows("sessions.prompt", state: controller.state) else { + return "The paired host did not grant sessions.prompt access." + } + guard !sessionIsPaused else { return "Resume the session before sending a prompt." } + guard !isSending else { return "Wait for the current prompt to finish sending." } + return nil + } + + private var attachmentDisabledReason: String? { + if let inputDisabledReason { return inputDisabledReason } + guard currentAttachments.count < ComposerLimits.maximumImages else { + return "A prompt can contain at most eight images." + } + guard !turnIsActive else { return "Images cannot be attached while a turn is active." } + return nil + } + + private var currentRevision: String? { + SessionRuntime.revision(for: activeSessionID, state: controller.state) + } + + private var controlDisabledReason: String? { + guard controller.state.connection == .connected else { return "Reconnect to control this session." } + guard SessionCapabilities.allows("sessions.control", state: controller.state) else { + return "The paired host did not grant sessions.control access." + } + guard currentRevision != nil else { return "Wait for the current session revision." } + guard busyAction == nil && !controller.state.composer.isSending else { return "A session action is already in progress." } + return nil + } + + private var modelDisabledReason: String? { + guard controller.state.connection == .connected else { return "Reconnect to change the model." } + guard SessionCapabilities.allows("sessions.manage", state: controller.state) else { + return "The paired host did not grant sessions.manage access." + } + guard currentRevision != nil else { return "Wait for the current session revision." } + guard busyAction == nil else { return "A session action is already in progress." } + return nil + } + + private var composerPlaceholder: String { + if selectedSession == nil { return "Choose a session to begin" } + if sessionIsPaused { return "Resume the session to continue" } + if turnIsActive { return "Steer the active turn" } + return "Message T4" + } + + private var controlStrip: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: T4Spacing.xs) { + Menu { + ForEach(modelOptions, id: \.self) { selector in + Button(selector) { + Task { await setModel(selector) } + } + } + if !modelOptions.isEmpty { + Divider() + } + Button("Choose model…") { + modelInput = currentModelLabel == "Model" ? "" : currentModelLabel + showingModelEditor = true + } + } label: { + Label(currentModelLabel, systemImage: "cpu") + .lineLimit(1) + } + .disabled(modelDisabledReason != nil) + .help(modelDisabledReason ?? "Choose a model for this session") + .accessibilityLabel("Session model: \(currentModelLabel)") + + if sessionIsPaused { + controlButton("Resume", systemImage: "play.fill", action: .resume) + } else if turnIsActive { + controlButton("Pause", systemImage: "pause.fill", action: .pause) + } else { + controlButton("Compact", systemImage: "rectangle.compress.vertical", action: .compact) + } + + if turnIsActive { + Button { + Task { await cancelTurn() } + } label: { + Label("Stop", systemImage: "stop.fill") + } + .buttonStyle(.bordered) + .disabled(controlDisabledReason != nil) + .help(controlDisabledReason ?? "Cancel the active turn") + + Button { + Task { await queue() } + } label: { + Label(queueLabel, systemImage: "text.badge.plus") + } + .buttonStyle(.bordered) + .disabled(!canQueue) + .help(queueDisabledReason ?? "Queue this message after the active turn") + } + } + .controlSize(.small) + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Session controls") + } + + private var queueLabel: String { + let count = controller.state.composer.queuedCount + return count == 0 ? "Queue" : "Queue (\(count))" + } + + private var canQueue: Bool { + queueDisabledReason == nil + } + + private var queueDisabledReason: String? { + guard turnIsActive else { return "Messages can be queued while a turn is active." } + guard inputDisabledReason == nil else { return inputDisabledReason } + guard !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "Enter a follow-up message to queue." } + return nil + } + + private var attachmentStrip: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: T4Spacing.xs) { + ForEach(currentAttachments) { attachment in + HStack(spacing: T4Spacing.xs) { + ComposerImageThumbnail(data: attachment.data) + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(attachment.name) + .font(T4Typography.body(.caption, weight: .medium)) + .lineLimit(1) + Text(ByteCountFormatter.string(fromByteCount: Int64(attachment.data.count), countStyle: .file)) + .font(T4Typography.body(.caption2)) + .foregroundStyle(T4Color.secondaryText) + } + Button { + removeAttachment(attachment.id) + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.borderless) + .disabled(isSending) + .accessibilityLabel("Remove \(attachment.name)") + } + .padding(T4Spacing.xs) + .background(T4Color.raised, in: RoundedRectangle(cornerRadius: T4Radius.sm, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.sm, style: .continuous) + .stroke(T4Color.border) + } + } + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Attached images") + } + + private func controlButton(_ title: String, systemImage: String, action: ComposerAction) -> some View { + Button { + Task { await runControl(action) } + } label: { + Label(title, systemImage: systemImage) + } + .buttonStyle(.bordered) + .disabled(controlDisabledReason != nil) + .help(controlDisabledReason ?? title) + } + + private func composerError(_ message: String) -> some View { + HStack(alignment: .top, spacing: T4Spacing.xs) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(T4Color.destructive) + .accessibilityHidden(true) + Text(message) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.foreground) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + localError = nil + controller.state.composer.error = nil + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + .accessibilityLabel("Dismiss composer error") + } + .padding(T4Spacing.xs) + .background(T4Color.destructiveSoft, in: RoundedRectangle(cornerRadius: T4Radius.sm, style: .continuous)) + .accessibilityElement(children: .contain) + } + + private func activateSession(_ nextSessionID: String?) { + if let activeSessionID { + drafts[activeSessionID] = draft + } + activeSessionID = nextSessionID + draft = nextSessionID.flatMap { drafts[$0] } ?? "" + controller.state.composer.text = draft + localError = nil + } + + private func submit() async { + guard canSubmit, let sessionID = activeSessionID else { return } + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + let images = currentAttachments + let submittedDraft = draft + busyAction = .sending + controller.state.composer.error = nil + localError = nil + defer { busyAction = nil } + + do { + if images.isEmpty { + _ = try await controller.prompt(text) + } else { + guard let revision = currentRevision else { return } + try await submit(text: text, images: images, sessionID: sessionID, revision: revision) + } + guard activeSessionID == sessionID else { return } + if draft == submittedDraft { + draft = "" + drafts[sessionID] = "" + controller.state.composer.text = "" + } + attachments[sessionID] = [] + composerFocused = true + } catch { + let message = "Could not send the prompt: \(error.localizedDescription)" + localError = message + controller.state.composer.error = message + } + } + + private func queue() async { + guard canQueue, let sessionID = activeSessionID else { return } + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + let submittedDraft = draft + busyAction = .queueing + localError = nil + defer { busyAction = nil } + + do { + _ = try await controller.queue(text) + guard activeSessionID == sessionID else { return } + if draft == submittedDraft { + draft = "" + drafts[sessionID] = "" + controller.state.composer.text = "" + } + composerFocused = true + } catch { + localError = "Could not queue the follow-up: \(error.localizedDescription)" + } + } + + private func cancelTurn() async { + guard controlDisabledReason == nil else { return } + busyAction = .cancel + localError = nil + defer { busyAction = nil } + do { + _ = try await controller.cancel() + } catch { + localError = "Could not stop the active turn: \(error.localizedDescription)" + } + } + + private func runControl(_ action: ComposerAction) async { + guard + controlDisabledReason == nil, + let sessionID = activeSessionID, + let revision = currentRevision + else { return } + + busyAction = action + localError = nil + defer { busyAction = nil } + + let command: String + switch action { + case .pause: command = "session.pause" + case .resume: command = "session.resume" + case .compact: command = "session.compact" + case .sending, .queueing, .cancel, .model: return + } + + do { + _ = try await controller.command(command, sessionID: sessionID, expectedRevision: revision) + } catch { + localError = "Could not update the session: \(error.localizedDescription)" + } + } + + private func setModel(_ rawSelector: String) async { + let selector = rawSelector.trimmingCharacters(in: .whitespacesAndNewlines) + guard + !selector.isEmpty, + modelDisabledReason == nil, + let sessionID = activeSessionID, + let revision = currentRevision + else { return } + + busyAction = .model + localError = nil + defer { busyAction = nil } + do { + _ = try await controller.command( + "session.model.set", + sessionID: sessionID, + expectedRevision: revision, + args: [ + "selector": .string(selector), + "persistence": .string("session") + ] + ) + } catch { + localError = "Could not change the session model: \(error.localizedDescription)" + } + } + + private func importImages(_ urls: [URL]) async { + guard let sessionID = activeSessionID, inputDisabledReason == nil else { return } + let available = ComposerLimits.maximumImages - currentAttachments.count + guard available > 0 else { + localError = "A prompt can contain at most eight images." + return + } + if urls.count > available { + localError = "Only the first \(available) selected image\(available == 1 ? "" : "s") could be attached. A prompt can contain at most eight." + } else { + localError = nil + } + + var imported: [ComposerImageAttachment] = [] + for url in urls.prefix(available) { + let accessed = url.startAccessingSecurityScopedResource() + defer { + if accessed { url.stopAccessingSecurityScopedResource() } + } + do { + let values = try url.resourceValues(forKeys: [.fileSizeKey, .nameKey]) + guard let byteCount = values.fileSize, byteCount > 0 else { + localError = "\(values.name ?? url.lastPathComponent) is empty." + continue + } + guard byteCount <= ComposerLimits.maximumImageBytes else { + localError = "\(values.name ?? url.lastPathComponent) is larger than 20 MB." + continue + } + guard let mimeType = ComposerImageAttachment.mimeType(for: url) else { + localError = "\(values.name ?? url.lastPathComponent) is not a supported image type." + continue + } + let data = try Data(contentsOf: url, options: .mappedIfSafe) + guard !data.isEmpty, data.count <= ComposerLimits.maximumImageBytes else { + localError = "\(values.name ?? url.lastPathComponent) must be between 1 byte and 20 MB." + continue + } + imported.append( + ComposerImageAttachment( + id: UUID().uuidString.lowercased(), + name: values.name ?? url.lastPathComponent, + mimeType: mimeType, + data: data + ) + ) + } catch { + localError = "Could not open \(url.lastPathComponent): \(error.localizedDescription)" + } + } + + guard activeSessionID == sessionID, !imported.isEmpty else { return } + attachments[sessionID, default: []].append(contentsOf: imported) + } + + private func removeAttachment(_ id: String) { + guard let sessionID = activeSessionID else { return } + attachments[sessionID]?.removeAll { $0.id == id } + localError = nil + } + + private func submit( + text: String, + images: [ComposerImageAttachment], + sessionID: String, + revision: String + ) async throws { + controller.state.composer.isSending = true + defer { controller.state.composer.isSending = false } + + var imageIDs: [String] = [] + do { + for image in images { + imageIDs.append(try await upload(image, sessionID: sessionID)) + } + let response = try await controller.command( + "session.prompt", + sessionID: sessionID, + expectedRevision: revision, + args: [ + "message": .string(text), + "images": .array(imageIDs.map { .object(["imageId": .string($0)]) }) + ] + ) + if case let .bool(accepted)? = response.result?["accepted"], !accepted { + throw ComposerError.promptRejected + } + } catch { + await discard(imageIDs, sessionID: sessionID) + throw error + } + } + + private func upload(_ image: ComposerImageAttachment, sessionID: String) async throws -> String { + guard image.data.count <= ComposerLimits.maximumImageBytes else { throw ComposerError.imageTooLarge } + let begin = try await controller.command( + "session.image.begin", + sessionID: sessionID, + args: [ + "mimeType": .string(image.mimeType), + "size": .integer(image.data.count), + "sha256": .string(Self.sha256Hex(image.data)) + ] + ) + guard + let imageID = begin.result?["imageId"]?.stringValue, + case let .number(rawChunkBytes)? = begin.result?["chunkBytes"], + rawChunkBytes.rounded(.towardZero) == rawChunkBytes, + rawChunkBytes > 0, + rawChunkBytes <= Double(Int.max) + else { throw ComposerError.invalidImageResponse } + + let requestedChunkBytes = Int(rawChunkBytes) + let chunkBytes = min(requestedChunkBytes, ComposerLimits.maximumUploadChunkBytes) + var offset = 0 + while offset < image.data.count { + let end = min(offset + chunkBytes, image.data.count) + let content = image.data[offset.. String { + let alphabet = Array("0123456789abcdef".utf8) + var result: [UInt8] = [] + result.reserveCapacity(SHA256.Digest.byteCount * 2) + for byte in SHA256.hash(data: data) { + result.append(alphabet[Int(byte >> 4)]) + result.append(alphabet[Int(byte & 0x0f)]) + } + return String(decoding: result, as: UTF8.self) + } +} + +private enum ComposerLimits { + static let maximumImages = 8 + static let maximumImageBytes = 20 * 1024 * 1024 + static let maximumUploadChunkBytes = 512 * 1024 + static let minimumLines = 1 + static let maximumLines = 6 +} + +private enum ComposerAction: Equatable { + case sending + case queueing + case cancel + case pause + case resume + case compact + case model +} + +private enum ComposerError: LocalizedError { + case imageTooLarge + case invalidImageResponse + case promptRejected + + var errorDescription: String? { + switch self { + case .imageTooLarge: "An image is larger than 20 MB." + case .invalidImageResponse: "The host returned an invalid image-upload response." + case .promptRejected: "The host did not accept the prompt." + } + } +} + +private struct ComposerImageAttachment: Identifiable { + let id: String + let name: String + let mimeType: String + let data: Data + + static func mimeType(for url: URL) -> String? { + switch url.pathExtension.lowercased() { + case "png": "image/png" + case "jpg", "jpeg": "image/jpeg" + case "gif": "image/gif" + case "webp": "image/webp" + default: nil + } + } +} + +private struct ComposerImageThumbnail: View { + let data: Data + + var body: some View { +#if os(macOS) + if let image = NSImage(data: data) { + Image(nsImage: image) + .resizable() + .scaledToFill() + .frame(width: T4Spacing.xxl, height: T4Spacing.xxl) + .clipShape(RoundedRectangle(cornerRadius: T4Radius.sm, style: .continuous)) + .accessibilityHidden(true) + } else { + fallback + } +#elseif os(iOS) + if let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: T4Spacing.xxl, height: T4Spacing.xxl) + .clipShape(RoundedRectangle(cornerRadius: T4Radius.sm, style: .continuous)) + .accessibilityHidden(true) + } else { + fallback + } +#endif + } + + private var fallback: some View { + Image(systemName: "photo") + .frame(width: T4Spacing.xxl, height: T4Spacing.xxl) + .background(T4Color.surface, in: RoundedRectangle(cornerRadius: T4Radius.sm, style: .continuous)) + .foregroundStyle(T4Color.mutedText) + .accessibilityHidden(true) + } +} + +private struct ModelSelectionSheet: View { + @Binding var selection: String + let suggestedModels: [String] + let isPending: Bool + let onCancel: () -> Void + let onSelect: (String) -> Void + + var body: some View { + NavigationStack { + Form { + Section("Model selector") { + TextField("provider/model", text: $selection) + .font(T4Typography.monospaced()) + .autocorrectionDisabled() + .onSubmit(select) + Text("Enter the exact model selector advertised by the host.") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + } + if !suggestedModels.isEmpty { + Section("Available models") { + ForEach(suggestedModels, id: \.self) { model in + Button(model) { + selection = model + } + } + } + } + } + .navigationTitle("Choose model") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: onCancel) + .disabled(isPending) + } + ToolbarItem(placement: .confirmationAction) { + Button("Use Model", action: select) + .disabled(normalizedSelection.isEmpty || isPending) + } + } + } + } + + private var normalizedSelection: String { + selection.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func select() { + guard !normalizedSelection.isEmpty, !isPending else { return } + onSelect(normalizedSelection) + } +} + +@MainActor +enum SessionCapabilities { + static func allows(_ capability: String, state: AppState) -> Bool { + guard let advertised = state.settings.values["client.grantedCapabilities"] + ?? state.settings.values["grantedCapabilities"] + else { return true } + let values = Set( + advertised + .split { !$0.isLetter && !$0.isNumber && $0 != "." && $0 != "-" } + .map(String.init) + ) + return values.contains(capability) + } +} + +@MainActor +enum SessionRuntime { + static func isActive(_ status: String) -> Bool { + let normalized = status.lowercased() + return ["working", "running", "streaming", "busy", "thinking"].contains { normalized.contains($0) } + && !isPaused(status) + } + + static func isPaused(_ status: String) -> Bool { + let normalized = status.lowercased() + return normalized.contains("paused") || normalized.contains("suspended") + } + + static func revision(for sessionID: String?, state: AppState) -> String? { + guard sessionID != nil, sessionID == state.selectedSessionID else { return nil } + return state.transcript.reversed().compactMap(\.revision).first { !$0.isEmpty } + } +} + +@MainActor +private enum SessionSettings { + static func modelOptions(state: AppState) -> [String] { + let raw = state.settings.values["session.model.options"] + ?? state.settings.values["client.models"] + ?? state.settings.values["models"] + guard let raw else { return [] } + var seen: Set = [] + return raw + .split { $0 == "," || $0 == ";" || $0 == "\n" } + .map { + $0.trimmingCharacters(in: CharacterSet(charactersIn: " \t\r\n[]\"") ) + } + .filter { !$0.isEmpty && seen.insert($0).inserted } + } + + static func currentModel(sessionID: String, state: AppState) -> String? { + let value = state.settings.values["session.\(sessionID).model"] + ?? state.settings.values["session.model"] + ?? state.settings.values["model"] + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.flatMap { $0.isEmpty ? nil : $0 } + } +} diff --git a/apps/apple/Sources/T4UI/ConversationView.swift b/apps/apple/Sources/T4UI/ConversationView.swift new file mode 100644 index 00000000..0592fa39 --- /dev/null +++ b/apps/apple/Sources/T4UI/ConversationView.swift @@ -0,0 +1,915 @@ +import Foundation +import SwiftUI +import T4Client +import T4Protocol + +@MainActor +public struct ConversationView: View { + private let controller: T4ClientController + + public init(controller: T4ClientController) { + self.controller = controller + } + + public var body: some View { + let state = controller.state + + VStack(spacing: 0) { + ConversationHeader(state: state) + + if let error = visibleError(state: state) { + ConnectionErrorBanner( + message: error, + canRetry: state.connection == .failed || state.connection == .disconnected, + onRetry: { + Task { @MainActor in await controller.connect() } + } + ) + } + + if state.connection != .connected && !state.transcript.isEmpty { + CachedTranscriptNotice(connection: state.connection) + } + + TranscriptTimelineView(controller: controller) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + ComposerView(controller: controller) + } + .background(T4Color.background) + } + + private func visibleError(state: AppState) -> String? { + let message = state.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) + if let message, !message.isEmpty { return T4Privacy.redacted(message) } + return state.connection == .failed ? "The connection could not be established." : nil + } +} + +@MainActor +private struct ConversationHeader: View { + let state: AppState + + var body: some View { + let session = state.sessions.first { $0.id == state.selectedSessionID } + let streaming = session.map { isWorking($0.status) } ?? false + + ViewThatFits(in: .horizontal) { + HStack(spacing: T4Spacing.md) { + titleBlock(session: session) + Spacer(minLength: T4Spacing.md) + statusBlock(session: session, streaming: streaming) + } + + VStack(alignment: .leading, spacing: T4Spacing.sm) { + titleBlock(session: session) + statusBlock(session: session, streaming: streaming) + } + } + .padding(.horizontal, T4Spacing.lg) + .padding(.vertical, T4Spacing.md) + .background(T4Color.surface) + .overlay(alignment: .bottom) { + Rectangle() + .fill(T4Color.border) + .frame(height: T4Spacing.xxs / 2) + } + .accessibilityElement(children: .contain) + } + + private func titleBlock(session: T4Session?) -> some View { + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(displayTitle(session)) + .font(T4Typography.heading(.title2)) + .foregroundStyle(T4Color.foreground) + .lineLimit(1) + + if let session { + Text(session.status.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Idle" : session.status) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + .lineLimit(1) + } else { + Text("Choose a session to begin") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + } + } + } + + private func statusBlock(session: T4Session?, streaming: Bool) -> some View { + HStack(spacing: T4Spacing.sm) { + if !state.attention.isEmpty { + Label("\(state.attention.count) pending", systemImage: "tray.full") + .font(T4Typography.body(.caption, weight: .semibold)) + .foregroundStyle(T4Color.warning) + .accessibilityLabel("\(state.attention.count) pending attention items") + } + + if streaming { + HStack(spacing: T4Spacing.xs) { + ProgressView() + .controlSize(.small) + Text("Streaming") + .font(T4Typography.body(.caption, weight: .semibold)) + } + .foregroundStyle(T4Color.statusWorking) + .accessibilityElement(children: .combine) + .accessibilityLabel("Assistant is streaming a response") + .accessibilityAddTraits(.updatesFrequently) + } else if let session { + T4StatusPill(text: statusLabel(session.status), tone: statusTone(session.status)) + } + } + } + + private func displayTitle(_ session: T4Session?) -> String { + guard let session else { return "No session selected" } + let title = session.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? "Untitled session" : title + } + + private func statusLabel(_ status: String) -> String { + let normalized = status.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? "Idle" : normalized + } + + private func statusTone(_ status: String) -> T4StatusTone { + let normalized = status.lowercased() + if normalized.contains("error") || normalized.contains("failed") { return .error } + if normalized.contains("approval") { return .approval } + if normalized.contains("input") || normalized.contains("question") { return .input } + if normalized.contains("done") || normalized.contains("complete") || normalized.contains("closed") { return .done } + if isWorking(status) { return .working } + return .neutral + } + + private func isWorking(_ status: String) -> Bool { + SessionRuntime.isActive(status) + } +} + +private struct ConnectionErrorBanner: View { + let message: String + let canRetry: Bool + let onRetry: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: T4Spacing.sm) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(T4Color.destructive) + .accessibilityHidden(true) + + Text(message) + .font(T4Typography.body(.callout)) + .foregroundStyle(T4Color.foreground) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + + if canRetry { + Button("Retry", action: onRetry) + .buttonStyle(.borderless) + } + } + .padding(.horizontal, T4Spacing.md) + .padding(.vertical, T4Spacing.sm) + .background(T4Color.destructiveSoft) + .accessibilityElement(children: .contain) + .accessibilityLabel("Connection error: \(message)") + } +} + +private struct CachedTranscriptNotice: View { + let connection: T4ConnectionState + + var body: some View { + HStack(spacing: T4Spacing.xs) { + Image(systemName: "lock.doc") + .accessibilityHidden(true) + Text(message) + .font(T4Typography.body(.caption)) + } + .foregroundStyle(T4Color.secondaryText) + .frame(maxWidth: .infinity) + .padding(.horizontal, T4Spacing.md) + .padding(.vertical, T4Spacing.xs) + .background(T4Color.raised) + .accessibilityElement(children: .combine) + .accessibilityLabel(message) + .accessibilityAddTraits(.updatesFrequently) + } + + private var message: String { + switch connection { + case .connecting, .reconnecting: + "Showing cached messages while the live transcript reconnects." + case .failed: + "Showing cached messages. Live updates are unavailable." + case .disconnected: + "Showing cached messages while offline." + case .connected: + "Showing live messages." + } + } +} + +@MainActor +private struct TranscriptTimelineView: View { + let controller: T4ClientController + + @State private var followsTail = true + @State private var historyLoading = false + @State private var historyError: String? + @State private var historyCursor: TranscriptCursor? + @State private var historyExhausted = false + + private let tailID = "t4-transcript-tail" + + var body: some View { + let state = controller.state + + if state.selectedSessionID == nil { + T4EmptyState( + title: "Choose a session", + message: "Select a session from the session list to view its conversation.", + systemImage: "bubble.left.and.bubble.right" + ) + } else if state.transcript.isEmpty { + emptyTranscript(state: state) + } else { + GeometryReader { viewport in + ScrollViewReader { proxy in + ZStack(alignment: .bottomTrailing) { + ScrollView { + LazyVStack(alignment: .leading, spacing: T4Spacing.lg) { + historyControl(state: state, proxy: proxy) + + ForEach(state.transcript) { item in + TranscriptMessageView( + item: item, + streaming: item.id == state.transcript.last?.id + && isStreamableRole(item.role) + && selectedSessionIsWorking(state: state), + onRetryImage: { + Task { @MainActor in + guard let sessionID = state.selectedSessionID else { return } + await controller.selectSession(sessionID) + } + } + ) + .id(item.id) + } + + Color.clear + .frame(height: T4Spacing.xxs) + .id(tailID) + .background { + GeometryReader { tail in + Color.clear.preference( + key: TranscriptTailVisibilityKey.self, + value: tail.frame(in: .named("transcript-scroll")).minY + <= viewport.size.height + T4Spacing.xl + ) + } + } + } + .padding(.horizontal, T4Spacing.md) + .padding(.top, T4Spacing.lg) + .padding(.bottom, T4Spacing.xl) + } + .coordinateSpace(name: "transcript-scroll") + .scrollDismissesKeyboard(.interactively) + .onPreferenceChange(TranscriptTailVisibilityKey.self) { visible in + followsTail = visible + } + .onChange(of: state.selectedSessionID) { _, _ in + followsTail = true + historyCursor = nil + historyExhausted = false + historyError = nil + scrollToTail(proxy, animated: false) + } + .onChange(of: state.transcript.count) { oldCount, newCount in + guard newCount >= oldCount, followsTail else { return } + scrollToTail(proxy, animated: oldCount > 0) + } + .onChange(of: state.transcript.last?.text) { _, _ in + guard followsTail else { return } + scrollToTail(proxy, animated: false) + } + .onAppear { + scrollToTail(proxy, animated: false) + } + + if !followsTail { + Button { + followsTail = true + scrollToTail(proxy, animated: true) + } label: { + Label("Jump to latest", systemImage: "arrow.down") + } + .buttonStyle(.borderedProminent) + .padding(T4Spacing.md) + .accessibilityHint("Scrolls to the newest message and resumes automatic scrolling") + } + } + } + } + } + } + + private func emptyTranscript(state: AppState) -> some View { + let loading = state.connection == .connecting || state.connection == .reconnecting + return VStack(spacing: T4Spacing.md) { + if loading { + ProgressView() + .accessibilityLabel("Loading conversation") + } + T4EmptyState( + title: loading ? "Loading conversation" : "Start the conversation", + message: state.connection == .connected + ? "Send a prompt when you are ready." + : "Messages and the composer will be available when the host reconnects.", + systemImage: "bubble.left" + ) + } + } + + private func historyControl(state: AppState, proxy: ScrollViewProxy) -> some View { + VStack(spacing: T4Spacing.xs) { + if let historyError { + Text(historyError) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.destructive) + .textSelection(.enabled) + .accessibilityLabel("Earlier message error: \(historyError)") + } + + Button { + Task { @MainActor in await loadEarlier(state: state, proxy: proxy) } + } label: { + if historyLoading { + HStack(spacing: T4Spacing.xs) { + ProgressView() + .controlSize(.small) + Text("Loading earlier messages") + } + } else { + Label("Load earlier messages", systemImage: "clock.arrow.circlepath") + } + } + .buttonStyle(.bordered) + .disabled(historyDisabledReason(state: state) != nil || historyLoading) + .help(historyDisabledReason(state: state) ?? "Load the previous transcript page") + .accessibilityHint(historyDisabledReason(state: state) ?? "Keeps the current reading position stable") + } + .frame(maxWidth: .infinity) + .padding(.bottom, T4Spacing.sm) + } + + private func historyDisabledReason(state: AppState) -> String? { + guard state.connection == .connected else { return "Reconnect to load earlier messages." } + guard SessionCapabilities.allows("sessions.read", state: state) else { + return "The paired host did not grant sessions.read access." + } + guard effectiveHistoryCursor(state: state) != nil else { return "No earlier history cursor is available." } + return nil + } + + private func loadEarlier(state: AppState, proxy: ScrollViewProxy) async { + guard + !historyLoading, + let sessionID = state.selectedSessionID, + let cursor = effectiveHistoryCursor(state: state) + else { return } + + historyLoading = true + historyError = nil + let readingAnchor = state.transcript.first?.id + defer { historyLoading = false } + + do { + let response = try await controller.command( + "transcript.page", + sessionID: sessionID, + args: [ + "before": .object([ + "epoch": .string(cursor.epoch), + "seq": .number(Double(cursor.seq)) + ]), + "limit": .integer(ConversationLimits.historyPageEntries), + "maxBytes": .integer(ConversationLimits.historyPageBytes) + ] + ) + guard case let .array(values)? = response.result?["entries"] else { + throw ConversationError.invalidHistoryResponse + } + + let nextCursor = decodeCursor(response.result?["nextCursor"] ?? response.result?["cursor"]) + if case let .bool(hasMore)? = response.result?["hasMore"] { + historyCursor = hasMore ? nextCursor : nil + historyExhausted = !hasMore || nextCursor == nil + } else if let nextCursor { + historyCursor = nextCursor + } else if values.isEmpty { + historyCursor = nil + historyExhausted = true + } + + let knownIDs = Set(state.transcript.map(\.id)) + let earlier = values.compactMap(decodeTranscriptItem).filter { !knownIDs.contains($0.id) } + guard !earlier.isEmpty else { return } + state.transcript.insert(contentsOf: earlier, at: 0) + + if let readingAnchor { + await Task.yield() + proxy.scrollTo(readingAnchor, anchor: .top) + } + } catch { + historyError = error.localizedDescription + } + } + + private func effectiveHistoryCursor(state: AppState) -> TranscriptCursor? { + guard !historyExhausted else { return nil } + return historyCursor ?? state.transcript.first?.cursor ?? state.transcriptCursor + } + + private func decodeTranscriptItem(_ value: JSONValue) -> T4TranscriptItem? { + guard + let object = value.objectValue, + let id = (object["id"] ?? object["entryId"])?.stringValue + else { return nil } + + return T4TranscriptItem( + id: id, + role: (object["role"] ?? object["author"])?.stringValue ?? "assistant", + text: (object["text"] ?? object["message"])?.stringValue ?? "", + cursor: decodeCursor(object["cursor"]), + revision: object["revision"]?.stringValue + ) + } + + private func decodeCursor(_ value: JSONValue?) -> TranscriptCursor? { + guard + let object = value?.objectValue, + let epoch = object["epoch"]?.stringValue, + case let .number(sequence)? = object["seq"], + sequence.rounded(.towardZero) == sequence, + sequence >= 0, + sequence <= Double(Int.max) + else { return nil } + return TranscriptCursor(epoch: epoch, seq: Int(sequence)) + } + + private func isStreamableRole(_ role: String) -> Bool { + let normalized = role.lowercased() + return normalized != "user" + && normalized != "human" + && normalized != "system" + && normalized != "event" + && !normalized.contains("image") + } + + private func selectedSessionIsWorking(state: AppState) -> Bool { + guard let session = state.sessions.first(where: { $0.id == state.selectedSessionID }) else { return false } + return SessionRuntime.isActive(session.status) + } + + private func scrollToTail(_ proxy: ScrollViewProxy, animated: Bool) { + Task { @MainActor in + await Task.yield() + if animated { + withAnimation(.easeOut) { + proxy.scrollTo(tailID, anchor: .bottom) + } + } else { + proxy.scrollTo(tailID, anchor: .bottom) + } + } + } +} + +private struct TranscriptMessageView: View { + let item: T4TranscriptItem + let streaming: Bool + let onRetryImage: () -> Void + + var body: some View { + switch messageKind { + case .tool: + toolMessage + case .reasoning: + reasoningMessage + case .image: + TranscriptRepresentedImage(item: item, onRetry: onRetryImage) + case .user, .assistant, .system: + standardMessage + } + } + + private var standardMessage: some View { + HStack { + if messageKind == .user { Spacer(minLength: T4Spacing.xl) } + + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text(roleLabel.uppercased()) + .font(T4Typography.body(.caption2, weight: .semibold)) + .foregroundStyle(T4Color.secondaryText) + + if item.text.isEmpty && streaming { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Assistant is preparing a response") + } else { + TranscriptMarkdownView(text: item.text) + } + + if streaming { + HStack(spacing: T4Spacing.xs) { + ProgressView() + .controlSize(.small) + Text("Streaming") + .font(T4Typography.body(.caption, weight: .semibold)) + } + .foregroundStyle(T4Color.statusWorking) + .accessibilityElement(children: .combine) + .accessibilityLabel("Assistant response is streaming") + .accessibilityAddTraits(.updatesFrequently) + } + } + .padding(messageKind == .assistant ? T4Spacing.xs : T4Spacing.md) + .background(messageBackground) + .clipShape(RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous)) + .overlay { + if messageKind == .system { + RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + .stroke(T4Color.border) + } + } + .frame(maxWidth: .infinity, alignment: messageKind == .user ? .trailing : .leading) + + if messageKind != .user { Spacer(minLength: T4Spacing.xl) } + } + .accessibilityElement(children: .contain) + .accessibilityLabel("\(roleLabel) message\(streaming ? ", streaming" : "")") + } + + private var toolMessage: some View { + DisclosureGroup { + Text(item.text.isEmpty ? "No tool output was provided." : item.text) + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.foreground) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, T4Spacing.sm) + } label: { + HStack(spacing: T4Spacing.sm) { + Image(systemName: streaming ? "arrow.triangle.2.circlepath" : "wrench.and.screwdriver") + .foregroundStyle(streaming ? T4Color.statusWorking : T4Color.secondaryText) + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text("Tool") + .font(T4Typography.heading(.callout)) + Text(streaming ? "Running" : "Details") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + } + } + } + .padding(T4Spacing.md) + .background(T4Color.raised) + .clipShape(RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + .stroke(T4Color.border) + } + .accessibilityLabel(streaming ? "Tool is running" : "Tool result") + } + + private var reasoningMessage: some View { + DisclosureGroup { + TranscriptMarkdownView(text: item.text) + .font(T4Typography.body(.callout)) + .padding(.top, T4Spacing.sm) + } label: { + Label(streaming ? "Reasoning · streaming" : "Reasoning", systemImage: "brain") + .font(T4Typography.heading(.callout)) + .foregroundStyle(T4Color.info) + } + .padding(T4Spacing.md) + .background(T4Color.infoSoft) + .clipShape(RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous)) + .accessibilityLabel(streaming ? "Reasoning, streaming" : "Reasoning") + } + + private var messageBackground: Color { + switch messageKind { + case .user: T4Color.accentSoft + case .system: T4Color.raised + case .assistant, .tool, .reasoning, .image: T4Color.surface + } + } + + private var roleLabel: String { + switch messageKind { + case .user: "You" + case .assistant: "Assistant" + case .system: "System" + case .tool: "Tool" + case .reasoning: "Reasoning" + case .image: "Image" + } + } + + private var messageKind: TranscriptMessageKind { + let role = item.role.lowercased() + if role.contains("tool") { return .tool } + if role.contains("reason") || role.contains("thinking") { return .reasoning } + if role.contains("image") || item.text.lowercased().hasPrefix("[image") { return .image } + if role == "user" || role == "human" { return .user } + if role == "system" || role == "event" { return .system } + return .assistant + } +} + +private enum TranscriptMessageKind { + case user + case assistant + case system + case tool + case reasoning + case image +} + +private struct TranscriptRepresentedImage: View { + let item: T4TranscriptItem + let onRetry: () -> Void + + var body: some View { + if let url = representedURL { + TranscriptRemoteImage(url: url, altText: item.text) + } else { + VStack(spacing: T4Spacing.sm) { + Image(systemName: "photo") + .font(T4Typography.heading(.title)) + .foregroundStyle(T4Color.mutedText) + .accessibilityHidden(true) + + Text("Image unavailable") + .font(T4Typography.heading(.callout)) + + Text(item.text.isEmpty ? "Reconnect to request this transcript image again." : item.text) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + .multilineTextAlignment(.center) + .textSelection(.enabled) + + Button { + onRetry() + } label: { + Label("Retry image", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity) + .padding(T4Spacing.lg) + .background(T4Color.raised) + .clipShape(RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + .stroke(T4Color.border) + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Transcript image unavailable") + } + } + + private var representedURL: URL? { + let source = item.text.trimmingCharacters(in: .whitespacesAndNewlines) + if let url = URL(string: source), url.scheme == "https" || url.scheme == "http" { + return url + } + let pattern = #"!?\[[^\]]*\]\((https?://[^\s\)]+)\)"# + guard + let expression = try? NSRegularExpression(pattern: pattern), + let match = expression.firstMatch(in: source, range: NSRange(source.startIndex..., in: source)), + let range = Range(match.range(at: 1), in: source) + else { return nil } + return URL(string: String(source[range])) + } +} + +private enum ConversationLimits { + static let historyPageEntries = 128 + static let historyPageBytes = 512 * 1024 +} + +private enum ConversationError: LocalizedError { + case invalidHistoryResponse + + var errorDescription: String? { + "The host returned an invalid transcript page." + } +} + +private struct TranscriptMarkdownView: View { + let text: String + + var body: some View { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + switch block { + case let .prose(source): + Text(formatted(source)) + .font(T4Typography.body()) + .foregroundStyle(T4Color.foreground) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + case let .code(language, source): + VStack(alignment: .leading, spacing: T4Spacing.xs) { + if let language, !language.isEmpty { + Text(language.uppercased()) + .font(T4Typography.body(.caption2, weight: .semibold)) + .foregroundStyle(T4Color.secondaryText) + } + ScrollView(.horizontal, showsIndicators: false) { + Text(source.isEmpty ? " " : source) + .font(T4Typography.monospaced(.callout)) + .foregroundStyle(T4Color.foreground) + .textSelection(.enabled) + .fixedSize(horizontal: true, vertical: false) + } + } + .padding(T4Spacing.sm) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T4Color.raised, in: RoundedRectangle(cornerRadius: T4Radius.sm, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.sm, style: .continuous) + .stroke(T4Color.border) + } + case let .image(altText, url): + TranscriptRemoteImage(url: url, altText: altText) + } + } + } + } + + private var blocks: [TranscriptMarkdownBlock] { + TranscriptMarkdownParser.parse(text) + } + + private func formatted(_ source: String) -> AttributedString { + (try? AttributedString(markdown: source)) ?? AttributedString(source) + } +} + +private enum TranscriptMarkdownBlock { + case prose(String) + case code(language: String?, text: String) + case image(altText: String, url: URL) +} + +private enum TranscriptMarkdownParser { + static func parse(_ source: String) -> [TranscriptMarkdownBlock] { + var blocks: [TranscriptMarkdownBlock] = [] + var proseLines: [String] = [] + var codeLines: [String] = [] + var codeLanguage: String? + var insideCode = false + + func flushProse() { + guard !proseLines.isEmpty else { return } + appendProseWithImages(proseLines.joined(separator: "\n"), to: &blocks) + proseLines.removeAll(keepingCapacity: true) + } + + func flushCode() { + blocks.append(.code(language: codeLanguage, text: codeLines.joined(separator: "\n"))) + codeLines.removeAll(keepingCapacity: true) + codeLanguage = nil + } + + for line in source.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("```") { + if insideCode { + flushCode() + insideCode = false + } else { + flushProse() + let language = trimmed.dropFirst(3).trimmingCharacters(in: .whitespaces) + codeLanguage = language.isEmpty ? nil : language + insideCode = true + } + } else if insideCode { + codeLines.append(line) + } else { + proseLines.append(line) + } + } + + if insideCode { + flushCode() + } else { + flushProse() + } + if blocks.isEmpty { + blocks.append(.prose(source)) + } + return blocks + } + + private static func appendProseWithImages( + _ source: String, + to blocks: inout [TranscriptMarkdownBlock] + ) { + let pattern = #"!\[([^\]]*)\]\((https?://[^\s\)]+)\)"# + guard let expression = try? NSRegularExpression(pattern: pattern) else { + blocks.append(.prose(source)) + return + } + let matches = expression.matches(in: source, range: NSRange(source.startIndex..., in: source)) + guard !matches.isEmpty else { + blocks.append(.prose(source)) + return + } + + var cursor = source.startIndex + for match in matches { + guard + let wholeRange = Range(match.range(at: 0), in: source), + let altRange = Range(match.range(at: 1), in: source), + let urlRange = Range(match.range(at: 2), in: source), + let url = URL(string: String(source[urlRange])) + else { continue } + + if cursor < wholeRange.lowerBound { + let prose = String(source[cursor.. Bool) { + value = value && nextValue() + } +} diff --git a/apps/apple/Sources/T4UI/DeveloperWorkspaceView.swift b/apps/apple/Sources/T4UI/DeveloperWorkspaceView.swift new file mode 100644 index 00000000..bfd7aaab --- /dev/null +++ b/apps/apple/Sources/T4UI/DeveloperWorkspaceView.swift @@ -0,0 +1,1074 @@ +import Foundation +import SwiftUI +import T4Client +import T4Protocol + +@MainActor +public struct DeveloperWorkspaceView: View { + private let controller: T4ClientController + + @State private var selectedTab: DeveloperTab = .activity + @State private var busyAction: String? + @State private var actionError: String? + + @State private var activityPaused = false + @State private var activityFilter: String? + @State private var activitySnapshot: [String] = [] + + @State private var directoryPath = "" + @State private var fileEntries: [DeveloperFileEntry] = [] + @State private var selectedFilePath: String? + @State private var fileContent = "" + @State private var fileDraft = "" + @State private var fileRevision: String? + @State private var fileDirty = false + @State private var pendingFileTarget: FileTarget? + @State private var showDiscardConfirmation = false + + @State private var diffText: String? + @State private var reviewItems: [DeveloperReviewItem] = [] + @State private var pendingReview: DeveloperReviewItem? + @State private var showApplyConfirmation = false + + public init(controller: T4ClientController) { + self.controller = controller + } + + public var body: some View { + VStack(spacing: 0) { + tabStrip + Divider() + if let actionError { + DeveloperErrorBanner(message: actionError) { + self.actionError = nil + } + } + if busyAction != nil { + ProgressView() + .progressViewStyle(.linear) + .accessibilityLabel("Developer operation in progress") + } + selectedSurface + } + .background(T4Color.background) + .onAppear { + if activitySnapshot.isEmpty { + activitySnapshot = controller.state.developer.messages + } + } + .onChange(of: controller.state.developer.messages) { _, messages in + if !activityPaused { + activitySnapshot = messages + } + } + .alert("Discard unsaved changes?", isPresented: $showDiscardConfirmation) { + Button("Keep Editing", role: .cancel) { + pendingFileTarget = nil + } + Button("Discard", role: .destructive) { + discardAndContinue() + } + } message: { + Text("Your edits to \(selectedFilePath ?? "this file") have not been saved.") + } + .alert("Apply review?", isPresented: $showApplyConfirmation, presenting: pendingReview) { review in + Button("Cancel", role: .cancel) { + pendingReview = nil + } + Button("Apply", role: .destructive) { + Task { await applyReview(review) } + } + } message: { review in + Text("Apply \(review.title) to the selected session workspace? This changes files on the paired host.") + } + } + + private var tabStrip: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: T4Spacing.xs) { + ForEach(DeveloperTab.allCases) { tab in + Button { + selectedTab = tab + } label: { + Label(tab.title, systemImage: tab.systemImage) + .font(.subheadline.weight(selectedTab == tab ? .semibold : .regular)) + .padding(.horizontal, T4Spacing.sm) + .padding(.vertical, T4Spacing.xs) + .foregroundStyle(selectedTab == tab ? T4Color.accentForeground : T4Color.secondaryText) + .background(selectedTab == tab ? T4Color.accent : T4Color.surface, in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(tab.title) developer tab") + .accessibilityAddTraits(selectedTab == tab ? .isSelected : []) + } + } + .padding(.horizontal, T4Spacing.sm) + .padding(.vertical, T4Spacing.xs) + } + .background(T4Color.raised) + .accessibilityElement(children: .contain) + .accessibilityLabel("Developer tools tabs") + } + + @ViewBuilder + private var selectedSurface: some View { + switch selectedTab { + case .activity: + activitySurface + case .files: + filesSurface + case .review: + reviewSurface + case .terminal: + TerminalView(controller: controller) + case .preview: + PreviewView(controller: controller) + } + } + + private var activitySurface: some View { + Group { + if !isConnected && activitySnapshot.isEmpty { + DeveloperEmptyState( + systemImage: "icloud.slash", + title: "Activity is offline", + detail: "Connect to a host to inspect redacted protocol activity." + ) + } else if !hasCapability("audit.read") { + DeveloperEmptyState( + systemImage: "lock", + title: "Activity is unavailable", + detail: "The paired host did not grant audit.read access." + ) + } else { + VStack(spacing: 0) { + activityToolbar + if activityPaused { + DeveloperNotice( + systemImage: "pause.circle", + message: "Activity is paused. New rows are held until you resume." + ) + } + Divider() + if filteredActivity.isEmpty { + DeveloperEmptyState( + systemImage: activitySnapshot.isEmpty ? "waveform.path.ecg" : "line.3.horizontal.decrease.circle", + title: activitySnapshot.isEmpty ? "No activity yet" : "No matching activity", + detail: activitySnapshot.isEmpty + ? "Refresh to request recent host activity." + : "Choose a different category filter." + ) + } else { + List(filteredActivity) { row in + VStack(alignment: .leading, spacing: T4Spacing.xs) { + HStack { + Text(row.category) + .font(.caption.weight(.semibold)) + .foregroundStyle(T4Color.accent) + Spacer() + Text("Redacted") + .font(.caption2) + .foregroundStyle(T4Color.mutedText) + } + Text(row.message) + .font(T4Typography.monospaced()) + .foregroundStyle(T4Color.foreground) + .textSelection(.enabled) + } + .padding(.vertical, T4Spacing.xs) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(row.category) activity. \(row.message)") + } + .listStyle(.plain) + } + } + } + } + } + + private var activityToolbar: some View { + HStack(spacing: T4Spacing.sm) { + Picker("Activity filter", selection: $activityFilter) { + Text("All activity").tag(String?.none) + ForEach(activityCategories, id: \.self) { category in + Text(category).tag(String?.some(category)) + } + } + .pickerStyle(.menu) + .disabled(busyAction != nil) + + Spacer() + + Button { + activityPaused.toggle() + if !activityPaused { + activitySnapshot = controller.state.developer.messages + } + } label: { + Label(activityPaused ? "Resume" : "Pause", systemImage: activityPaused ? "play.fill" : "pause.fill") + } + .disabled(busyAction != nil) + .accessibilityLabel(activityPaused ? "Resume activity" : "Pause activity") + + Button { + Task { await refreshActivity() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(!isConnected || busyAction != nil) + .accessibilityLabel("Refresh activity") + } + .labelStyle(.iconOnly) + .padding(T4Spacing.sm) + } + + private var filesSurface: some View { + Group { + if !isConnected && fileEntries.isEmpty && selectedFilePath == nil { + DeveloperEmptyState( + systemImage: "externaldrive.badge.xmark", + title: "Files are offline", + detail: "Connect to browse the selected session workspace." + ) + } else if !hasCapability("files.list") || !hasCapability("files.read") { + DeveloperEmptyState( + systemImage: "lock", + title: "Files are unavailable", + detail: "Browsing requires files.list and files.read access from the paired host." + ) + } else { + GeometryReader { proxy in + if proxy.size.width >= T4Layout.wideBreakpoint { + HStack(spacing: 0) { + fileBrowser + .frame(width: T4DeveloperDesign.fileRailWidth) + Divider() + sourceEditor + } + } else { + VStack(spacing: 0) { + fileBrowser + .frame(maxHeight: T4DeveloperDesign.compactBrowserHeight) + Divider() + sourceEditor + } + } + } + } + } + } + + private var fileBrowser: some View { + VStack(spacing: 0) { + HStack(spacing: T4Spacing.xs) { + Button { + requestFileTarget(.directory(parentPath(of: directoryPath))) + } label: { + Image(systemName: "arrow.up") + } + .disabled(directoryPath.isEmpty || !canMutateWorkspace) + .accessibilityLabel("Open parent directory") + + Text(directoryPath.isEmpty ? "Workspace root" : directoryPath) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + requestFileTarget(.directory(directoryPath)) + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(!canMutateWorkspace) + .accessibilityLabel("Refresh directory") + } + .padding(T4Spacing.sm) + + Divider() + + if busyAction == "files.list" && fileEntries.isEmpty { + ProgressView("Loading files") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if fileEntries.isEmpty { + DeveloperEmptyState( + systemImage: "folder", + title: "No files loaded", + detail: "Refresh to inspect this directory." + ) + } else { + List(sortedFileEntries) { entry in + Button { + requestFileTarget(entry.isDirectory ? .directory(entry.path) : .file(entry.path)) + } label: { + HStack(spacing: T4Spacing.sm) { + Image(systemName: entry.isDirectory ? "folder" : "doc.text") + .foregroundStyle(entry.isDirectory ? T4Color.accent : T4Color.secondaryText) + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(entry.name) + .foregroundStyle(T4Color.foreground) + .lineLimit(1) + if let size = entry.size, !entry.isDirectory { + Text(byteCount(size)) + .font(.caption) + .foregroundStyle(T4Color.mutedText) + } + } + Spacer() + if entry.isDirectory { + Image(systemName: "chevron.right") + .foregroundStyle(T4Color.mutedText) + } + } + .padding(.leading, CGFloat(entry.depth) * T4Spacing.sm) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground(entry.path == selectedFilePath ? T4Color.accentSoft : T4Color.background) + .disabled(!canMutateWorkspace) + .accessibilityLabel(entry.isDirectory ? "Open directory \(entry.path)" : "Open file \(entry.path)") + } + .listStyle(.plain) + } + } + .background(T4Color.background) + } + + private var sourceEditor: some View { + Group { + if busyAction == "files.read" { + ProgressView("Reading file") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let selectedFilePath { + VStack(spacing: 0) { + HStack(spacing: T4Spacing.sm) { + Text(selectedFilePath) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + if fileDirty { + T4StatusPill("Unsaved", tone: .warning) + } else if !fileIsEditable { + T4StatusPill("Read only", tone: .neutral) + } + Spacer() + Button("Discard") { + fileDraft = fileContent + fileDirty = false + } + .disabled(!fileDirty || busyAction != nil) + Button { + Task { await saveFile() } + } label: { + Label(fileDirty ? "Save" : "Saved", systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderedProminent) + .disabled(!fileDirty || !fileIsEditable || busyAction != nil) + .accessibilityHint(fileEditDisabledReason ?? "Saves changes to the paired host") + } + .padding(T4Spacing.sm) + + Divider() + + TextEditor(text: $fileDraft) + .font(T4Typography.monospaced()) + .foregroundStyle(T4Color.foreground) + .scrollContentBackground(.hidden) + .padding(T4Spacing.sm) + .background(T4Color.input) + .disabled(!fileIsEditable || busyAction != nil) + .onChange(of: fileDraft) { _, value in + fileDirty = value != fileContent + } + .accessibilityLabel("Source editor for \(selectedFilePath)") + .accessibilityHint(fileEditDisabledReason ?? "Edit this file, then choose Save") + } + } else { + DeveloperEmptyState( + systemImage: "chevron.left.forwardslash.chevron.right", + title: "Select a source file", + detail: "Choose a file to inspect protocol-provided contents." + ) + } + } + .background(T4Color.input) + } + + private var reviewSurface: some View { + Group { + if !isConnected && diffText == nil && combinedReviewItems.isEmpty { + DeveloperEmptyState( + systemImage: "doc.badge.clock", + title: "Review is offline", + detail: "Connect to load review items and file diffs." + ) + } else { + GeometryReader { proxy in + if proxy.size.width >= T4Layout.wideBreakpoint { + HStack(spacing: 0) { + reviewQueue + .frame(width: T4DeveloperDesign.reviewRailWidth) + Divider() + diffViewer + } + } else { + VStack(spacing: 0) { + reviewQueue + .frame(maxHeight: T4DeveloperDesign.compactReviewHeight) + Divider() + diffViewer + } + } + } + } + } + } + + private var reviewQueue: some View { + VStack(spacing: 0) { + HStack { + Text("Review queue") + .font(.headline) + Spacer() + Text("\(combinedReviewItems.count)") + .foregroundStyle(T4Color.mutedText) + .accessibilityLabel("\(combinedReviewItems.count) review items") + } + .padding(T4Spacing.sm) + Divider() + + if combinedReviewItems.isEmpty { + DeveloperEmptyState( + systemImage: "checkmark.circle", + title: "Queue is clear", + detail: "Load a selected file diff or wait for a host review request." + ) + } else { + List(combinedReviewItems) { review in + VStack(alignment: .leading, spacing: T4Spacing.sm) { + Text(review.title) + .font(.headline) + if !review.detail.isEmpty { + Text(review.detail) + .font(.subheadline) + .foregroundStyle(T4Color.secondaryText) + .lineLimit(3) + } + HStack { + T4StatusPill(review.status.capitalized, tone: review.isFinal ? .success : .working) + Spacer() + Button("Refresh") { + Task { await refreshReview(review) } + } + .disabled(!isConnected || !hasCapability("files.read") || busyAction != nil) + Button("Apply") { + pendingReview = review + showApplyConfirmation = true + } + .buttonStyle(.borderedProminent) + .disabled(review.isFinal || review.revision == nil || !isConnected || !hasCapability("files.write") || busyAction != nil) + .accessibilityHint(review.revision == nil ? "Refresh this review before applying it." : "Applies this review to the paired host.") + } + } + .padding(.vertical, T4Spacing.xs) + .accessibilityElement(children: .contain) + } + .listStyle(.plain) + } + } + .background(T4Color.background) + } + + private var diffViewer: some View { + VStack(spacing: 0) { + HStack(spacing: T4Spacing.sm) { + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text("File diff") + .font(.headline) + Text(selectedFilePath ?? "No file selected") + .font(.caption) + .foregroundStyle(T4Color.mutedText) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Button { + Task { await loadSelectedFileDiff() } + } label: { + Label(diffText == nil ? "Load Diff" : "Reload", systemImage: "arrow.clockwise") + } + .disabled(selectedFilePath == nil || !isConnected || !hasCapability("files.diff") || busyAction != nil) + } + .padding(T4Spacing.sm) + Divider() + + if !hasCapability("files.diff") { + DeveloperEmptyState( + systemImage: "lock", + title: "File diff unavailable", + detail: "The paired host did not grant files.diff access." + ) + } else if busyAction == "files.diff" { + ProgressView("Loading diff") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let diffText { + if diffText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + DeveloperEmptyState( + systemImage: "checkmark.circle", + title: "No changes", + detail: "The selected file has no changes to review." + ) + } else { + ScrollView([.horizontal, .vertical]) { + Text(diffText) + .font(T4Typography.monospaced()) + .foregroundStyle(T4Color.foreground) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(T4Spacing.md) + } + .background(T4Color.input) + .accessibilityLabel("Diff for \(selectedFilePath ?? "selected file")") + } + } else { + DeveloperEmptyState( + systemImage: "doc.text.magnifyingglass", + title: selectedFilePath == nil ? "Choose a file first" : "Diff not loaded", + detail: selectedFilePath == nil + ? "Select a file in Files, then return here to inspect its diff." + : "Load the selected file diff to begin review." + ) + } + } + .background(T4Color.input) + } + + private var filteredActivity: [DeveloperActivityRow] { + activitySnapshot.enumerated().reversed().compactMap { offset, message in + let row = DeveloperActivityRow(index: offset, source: message) + guard activityFilter == nil || row.category == activityFilter else { return nil } + return row + } + } + + private var activityCategories: [String] { + Array(Set(activitySnapshot.enumerated().map { DeveloperActivityRow(index: $0.offset, source: $0.element).category })).sorted() + } + + private var sortedFileEntries: [DeveloperFileEntry] { + fileEntries.sorted { + if $0.isDirectory != $1.isDirectory { return $0.isDirectory } + return $0.path.localizedCaseInsensitiveCompare($1.path) == .orderedAscending + } + } + + private var combinedReviewItems: [DeveloperReviewItem] { + var byID = Dictionary(uniqueKeysWithValues: reviewItems.map { ($0.id, $0) }) + for attention in controller.state.attention + where attention.kind.localizedCaseInsensitiveContains("review") && byID[attention.id] == nil { + byID[attention.id] = DeveloperReviewItem( + id: attention.id, + title: attention.title, + detail: attention.detail, + status: "pending", + revision: nil + ) + } + return byID.values.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + + private var isConnected: Bool { + controller.state.connection == .connected + } + + private var canMutateWorkspace: Bool { + isConnected && busyAction == nil + } + + private var fileIsEditable: Bool { + isConnected && hasCapability("files.write") && fileRevision != nil + } + + private var fileEditDisabledReason: String? { + guard isConnected else { return "Reconnect to edit this file." } + guard hasCapability("files.write") else { return "Requires files.write access." } + guard fileRevision != nil else { return "Read the file again to obtain its current revision before editing." } + return nil + } + + private func hasCapability(_ capability: String) -> Bool { + DeveloperCapabilities.allows(capability, state: controller.state) + } + + private func refreshActivity() async { + await perform("audit.read") { + let response = try await controller.command( + "audit.read", + args: ["limit": .integer(T4DeveloperDesign.activityLimit)] + ) + let messages = response.result?["events"]?.arrayValue?.map(prettyJSON) ?? [] + guard !activityPaused else { return } + activitySnapshot = messages.isEmpty ? controller.state.developer.messages : messages + } + } + + private func requestFileTarget(_ target: FileTarget) { + if fileDirty { + pendingFileTarget = target + showDiscardConfirmation = true + return + } + Task { await open(target) } + } + + private func discardAndContinue() { + fileDraft = fileContent + fileDirty = false + guard let target = pendingFileTarget else { return } + pendingFileTarget = nil + Task { await open(target) } + } + + private func open(_ target: FileTarget) async { + switch target { + case let .directory(path): + await listDirectory(path) + case let .file(path): + await readFile(path) + } + } + + private func listDirectory(_ path: String) async { + await perform("files.list") { + let args: [String: JSONValue] = path.isEmpty ? [:] : ["path": .string(path)] + let response = try await controller.command( + "files.list", + sessionID: controller.state.selectedSessionID, + args: args + ) + directoryPath = path + selectedFilePath = nil + fileContent = "" + fileDraft = "" + fileRevision = nil + fileDirty = false + diffText = nil + fileEntries = DeveloperFileEntry.decode(response.result?["entries"]) + } + } + + private func readFile(_ path: String) async { + await perform("files.read") { + let response = try await controller.command( + "files.read", + sessionID: controller.state.selectedSessionID, + args: ["path": .string(path)] + ) + guard let content = response.result?["content"]?.stringValue else { + throw DeveloperSurfaceError("The host returned no text content for this file.") + } + selectedFilePath = path + directoryPath = parentPath(of: path) + fileContent = content + fileDraft = content + fileRevision = response.result?["revision"]?.stringValue + fileDirty = false + diffText = nil + } + } + + private func saveFile() async { + guard let path = selectedFilePath, fileDirty else { return } + guard let expectedRevision = fileRevision else { + actionError = "Read the file again to obtain its current revision before saving." + return + } + let savedDraft = fileDraft + await perform("files.write") { + let response = try await controller.command( + "files.write", + sessionID: controller.state.selectedSessionID, + expectedRevision: expectedRevision, + args: ["path": .string(path), "content": .string(savedDraft)] + ) + fileContent = savedDraft + fileDraft = savedDraft + fileRevision = response.result?["revision"]?.stringValue ?? fileRevision + fileDirty = false + } + } + + private func loadSelectedFileDiff() async { + guard let selectedFilePath else { return } + await perform("files.diff") { + let response = try await controller.command( + "files.diff", + sessionID: controller.state.selectedSessionID, + args: ["path": .string(selectedFilePath)] + ) + diffText = response.result?["diff"]?.stringValue ?? "" + let revision = response.result?["revision"]?.stringValue + let decoded = DeveloperReviewItem.decode(response.result?["reviews"]).map { item in + var item = item + item.revision = item.revision ?? revision + return item + } + if !decoded.isEmpty { reviewItems = decoded } + } + } + + private func refreshReview(_ review: DeveloperReviewItem) async { + await perform("review.read") { + let response = try await controller.command( + "review.read", + sessionID: controller.state.selectedSessionID, + args: ["reviewId": .string(review.id)] + ) + if let diff = response.result?["diff"]?.stringValue { + diffText = diff + } + if let path = response.result?["path"]?.stringValue { + selectedFilePath = path + } + var refreshed = review + refreshed.revision = response.result?["revision"]?.stringValue + ?? response.result?["review"]?.objectValue?["revision"]?.stringValue + ?? review.revision + if let index = reviewItems.firstIndex(where: { $0.id == review.id }) { + reviewItems[index] = refreshed + } else { + reviewItems.append(refreshed) + } + } + } + + private func applyReview(_ review: DeveloperReviewItem) async { + pendingReview = nil + guard let expectedRevision = review.revision else { + actionError = "Refresh this review to obtain its current revision before applying it." + return + } + await perform("review.apply") { + _ = try await controller.command( + "review.apply", + sessionID: controller.state.selectedSessionID, + expectedRevision: expectedRevision, + args: ["reviewId": .string(review.id)] + ) + if let index = reviewItems.firstIndex(where: { $0.id == review.id }) { + reviewItems[index].status = "applied" + } else { + var applied = review + applied.status = "applied" + reviewItems.append(applied) + } + } + } + + private func perform(_ name: String, operation: @escaping @MainActor () async throws -> Void) async { + guard busyAction == nil else { return } + busyAction = name + actionError = nil + do { + try await operation() + } catch { + actionError = developerErrorMessage(error) + } + busyAction = nil + } +} + +private enum DeveloperTab: String, CaseIterable, Identifiable { + case activity + case files + case review + case terminal + case preview + + var id: String { rawValue } + + var title: String { rawValue.capitalized } + + var systemImage: String { + switch self { + case .activity: "waveform.path.ecg" + case .files: "folder" + case .review: "doc.text.magnifyingglass" + case .terminal: "apple.terminal" + case .preview: "rectangle.inset.filled.and.person.filled" + } + } +} + +private enum FileTarget { + case directory(String) + case file(String) +} + +private struct DeveloperActivityRow: Identifiable { + let id: String + let category: String + let message: String + + init(index: Int, source: String) { + id = "\(index)-\(source.hashValue)" + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("["), let end = trimmed.firstIndex(of: "]") { + category = String(trimmed[trimmed.index(after: trimmed.startIndex).. [DeveloperFileEntry] { + value?.arrayValue?.compactMap { value in + guard let object = value.objectValue, + let path = object["path"]?.stringValue else { return nil } + return DeveloperFileEntry( + path: path, + kind: object["kind"]?.stringValue ?? object["type"]?.stringValue ?? "file", + size: object["size"]?.intValue + ) + } ?? [] + } +} + +private struct DeveloperReviewItem: Identifiable { + let id: String + let title: String + let detail: String + var status: String + var revision: String? + + var isFinal: Bool { + ["applied", "discarded"].contains(status.lowercased()) + } + + static func decode(_ value: JSONValue?) -> [DeveloperReviewItem] { + value?.arrayValue?.compactMap { value in + guard let object = value.objectValue, + let id = object["reviewId"]?.stringValue ?? object["id"]?.stringValue else { return nil } + return DeveloperReviewItem( + id: id, + title: object["path"]?.stringValue ?? object["title"]?.stringValue ?? id, + detail: object["summary"]?.stringValue ?? object["detail"]?.stringValue ?? "", + status: object["status"]?.stringValue ?? "pending", + revision: object["revision"]?.stringValue + ) + } ?? [] + } +} + +enum T4DeveloperDesign { + static let fileRailWidth: CGFloat = 304 + static let reviewRailWidth: CGFloat = 336 + static let compactBrowserHeight: CGFloat = 248 + static let compactReviewHeight: CGFloat = 272 + static let minimumEmptyStateWidth: CGFloat = 240 + static let maximumEmptyStateWidth: CGFloat = 520 + static let activityLimit = 200 + static let maximumCategoryLength = 28 + static let terminalPastePreviewLength = 320 + static let terminalOutputLimit = 256_000 + static let terminalDefaultColumns = 80 + static let terminalDefaultRows = 24 + static let terminalMaximumColumns = 1_000 + static let terminalMaximumRows = 500 + static let terminalControlWidth: CGFloat = 320 + static let terminalOutputMinimumHeight: CGFloat = 240 + static let previewControlWidth: CGFloat = 344 + static let previewCaptureMinimumHeight: CGFloat = 280 + static let previewCaptureChunkBytes = 256 * 1024 + static let previewCaptureMaximumBytes = 8 * 1024 * 1024 + static let previewCaptureMaximumPixels = 16 * 1024 * 1024 + static let previewURLMaximumBytes = 4_096 + static let previewInteractionMaximumBytes = 8 * 1024 +} + +@MainActor +enum DeveloperCapabilities { + static func allows(_ capability: String, state: AppState) -> Bool { + guard state.developer.isEnabled else { return false } + let advertised = state.settings.values["client.grantedCapabilities"] + ?? state.settings.values["grantedCapabilities"] + guard let advertised else { return true } + let values = Set(advertised.split(whereSeparator: { !$0.isLetter && !$0.isNumber && $0 != "." && $0 != "-" }).map(String.init)) + return values.contains(capability) + } +} + +struct DeveloperSurfaceError: Error { + let message: String + + init(_ message: String) { + self.message = message + } +} + +func developerErrorMessage(_ error: Error) -> String { + if let error = error as? DeveloperSurfaceError { return error.message } + if let error = error as? T4ClientControllerError { + switch error { + case .disconnected: return "The host is offline. Reconnect and try again." + case .staleGeneration: return "The connection changed before the operation finished." + case .invalidFrame: return "The host returned an invalid protocol frame." + case let .remote(_, message): return message + case let .transport(message): return message + } + } + return String(describing: error) +} + +func parentPath(of path: String) -> String { + let normalized = path.replacingOccurrences(of: "\\", with: "/").trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let separator = normalized.lastIndex(of: "/") else { return "" } + return String(normalized[.. String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(count)) +} + +func prettyJSON(_ value: JSONValue) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: value.toFoundation(), options: [.prettyPrinted, .sortedKeys]), + let text = String(data: data, encoding: .utf8) else { + return "Protocol event" + } + return redactDeveloperText(text) +} + +func redactDeveloperText(_ source: String) -> String { + T4Privacy.redacted(source) +} + +struct DeveloperNotice: View { + let systemImage: String + let message: String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: T4Spacing.sm) { + Image(systemName: systemImage) + .foregroundStyle(T4Color.info) + Text(message) + .font(.footnote) + .foregroundStyle(T4Color.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, T4Spacing.md) + .padding(.vertical, T4Spacing.sm) + .background(T4Color.infoSoft) + .accessibilityElement(children: .combine) + } +} + +struct DeveloperErrorBanner: View { + let message: String + var dismiss: (() -> Void)? + + init(message: String, dismiss: (() -> Void)? = nil) { + self.message = message + self.dismiss = dismiss + } + + var body: some View { + HStack(spacing: T4Spacing.sm) { + Image(systemName: "exclamationmark.triangle.fill") + Text(message) + .frame(maxWidth: .infinity, alignment: .leading) + if let dismiss { + Button("Dismiss", systemImage: "xmark", action: dismiss) + .labelStyle(.iconOnly) + .accessibilityLabel("Dismiss error") + } + } + .foregroundStyle(T4Color.destructive) + .padding(.horizontal, T4Spacing.md) + .padding(.vertical, T4Spacing.sm) + .background(T4Color.destructiveSoft) + .accessibilityElement(children: .contain) + .accessibilityLabel("Developer tools error: \(message)") + } +} + +struct DeveloperEmptyState: View { + let systemImage: String + let title: String + let detail: String + var action: (() -> AnyView)? + + init(systemImage: String, title: String, detail: String, action: (() -> AnyView)? = nil) { + self.systemImage = systemImage + self.title = title + self.detail = detail + self.action = action + } + + var body: some View { + ScrollView { + VStack(spacing: T4Spacing.sm) { + Image(systemName: systemImage) + .font(.largeTitle) + .foregroundStyle(T4Color.mutedText) + Text(title) + .font(.headline) + .foregroundStyle(T4Color.foreground) + .multilineTextAlignment(.center) + Text(detail) + .font(.subheadline) + .foregroundStyle(T4Color.secondaryText) + .multilineTextAlignment(.center) + if let action { + action() + .padding(.top, T4Spacing.xs) + } + } + .frame(minWidth: T4DeveloperDesign.minimumEmptyStateWidth, maxWidth: T4DeveloperDesign.maximumEmptyStateWidth) + .padding(T4Spacing.xl) + .frame(maxWidth: .infinity, minHeight: T4DeveloperDesign.compactBrowserHeight) + .accessibilityElement(children: .combine) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +extension JSONValue { + var arrayValue: [JSONValue]? { + guard case let .array(value) = self else { return nil } + return value + } + + var intValue: Int? { + guard case let .number(value) = self, + value.rounded(.towardZero) == value, + value >= Double(Int.min), value <= Double(Int.max) else { return nil } + return Int(value) + } + + var boolValue: Bool? { + guard case let .bool(value) = self else { return nil } + return value + } +} diff --git a/apps/apple/Sources/T4UI/HostManagementView.swift b/apps/apple/Sources/T4UI/HostManagementView.swift new file mode 100644 index 00000000..9578a71b --- /dev/null +++ b/apps/apple/Sources/T4UI/HostManagementView.swift @@ -0,0 +1,469 @@ +import SwiftUI +import T4Client +import T4Platform + +/// Remote-host directory editor used by iOS onboarding and the connected +/// shell. Endpoint parsing stays in `TailnetEndpointForm`; all mutations are +/// delegated to the root runtime so a profile change also replaces the socket +/// and controller generation. +@MainActor +public struct HostManagementView: View { + private enum Flow: Equatable { + case profiles + case add + case remove(HostProfile) + } + + private let directory: HostDirectory + private let controller: T4ClientController? + private let isWorking: Bool + private let errorMessage: String? + private let presentsOnboarding: Bool + private let onAdd: (HostProfile) -> Void + private let onSelect: (HostProfile) -> Void + private let onRemove: (HostProfile) -> Void + private let onRetry: () -> Void + + @State private var flow: Flow = .profiles + @State private var connectionActionPending = false + + public init( + directory: HostDirectory, + controller: T4ClientController?, + isWorking: Bool = false, + errorMessage: String? = nil, + presentsOnboarding: Bool = false, + onAdd: @escaping (HostProfile) -> Void, + onSelect: @escaping (HostProfile) -> Void, + onRemove: @escaping (HostProfile) -> Void, + onRetry: @escaping () -> Void + ) { + self.directory = directory + self.controller = controller + self.isWorking = isWorking + self.errorMessage = errorMessage + self.presentsOnboarding = presentsOnboarding + self.onAdd = onAdd + self.onSelect = onSelect + self.onRemove = onRemove + self.onRetry = onRetry + } + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: T4Spacing.xl) { + header +#if os(macOS) + if let controller { + connectionCard(controller) + } +#endif + + if let controller, + controller.state.authentication == .pairingRequired { + PairingCodeView(controller: controller) + } + + if let errorMessage, + !errorMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + T4ErrorState( + title: "Remote host change failed", + message: T4Privacy.redacted(errorMessage), + retry: onRetry + ) + } + + flowContent + } + .frame(maxWidth: T4Layout.readableMeasure, alignment: .leading) + .padding(T4Spacing.xl) + .frame(maxWidth: .infinity) + } + .background(T4Color.background) + .navigationTitle("Remote hosts") + .onAppear { + if shouldPresentRemoteOnboarding(for: directory) { + flow = .add + } + } + .onChange(of: directory) { oldDirectory, newDirectory in + switch flow { + case .add + where Self.remoteProfiles(in: newDirectory).count > + Self.remoteProfiles(in: oldDirectory).count: + flow = .profiles + case let .remove(profile) + where !Self.remoteProfiles(in: newDirectory).contains(where: { $0.endpointKey == profile.endpointKey }): + flow = shouldPresentRemoteOnboarding(for: newDirectory) ? .add : .profiles + default: + break + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text(isRemoteOnboarding + ? "Connect to a remote T4 host" + : "Remote hosts") + .font(T4Typography.heading(.largeTitle, weight: .bold)) + .foregroundStyle(T4Color.foreground) + + Text(headerDetail) + .font(T4Typography.body()) + .foregroundStyle(T4Color.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var isRemoteOnboarding: Bool { + shouldPresentRemoteOnboarding(for: directory) + } + + private var headerDetail: String { + if isRemoteOnboarding { + return "OMP and your projects stay on your computer. Add its encrypted Tailnet address to use T4 Code from this device." + } + if remoteProfiles.isEmpty { +#if os(macOS) + return "T4 Code uses the automatically managed local service by default. Add a Tailnet host only when you want to work with another computer." +#else + return "Add an encrypted Tailnet address when you want to connect this device to a T4 host." +#endif + } + return "Manage optional Tailnet connections without changing projects, sessions, or transcripts on either host." + } + + private var emptyRemoteHostsMessage: String { +#if os(macOS) + "The local service remains your default. Add a secure .ts.net address only to connect to another computer." +#else + "Add the secure .ts.net address shown by T4 on the computer you want to use." +#endif + } + + private var remoteProfiles: [HostProfile] { + Self.remoteProfiles(in: directory) + } + + private static func remoteProfiles(in directory: HostDirectory) -> [HostProfile] { + directory.profiles.filter { $0.endpointKey != "local" } + } + + private func shouldPresentRemoteOnboarding(for directory: HostDirectory) -> Bool { +#if os(iOS) + presentsOnboarding && Self.remoteProfiles(in: directory).isEmpty +#else + false +#endif + } + + @ViewBuilder + private var flowContent: some View { + switch flow { + case .profiles: + profilesContent + case .add: + addContent + case let .remove(profile): + removeContent(profile) + } + } + + private var profilesContent: some View { + VStack(alignment: .leading, spacing: T4Spacing.lg) { +#if os(iOS) + if let controller { + connectionCard(controller) + } +#endif + + if remoteProfiles.isEmpty { + T4EmptyState( + icon: "network.slash", + title: "No remote hosts", + message: emptyRemoteHostsMessage, + actionTitle: "Add remote host", + action: { flow = .add } + ) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + T4Color.surface, + in: RoundedRectangle(cornerRadius: T4Radius.lg, style: .continuous) + ) + } else { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + Text("Saved remote hosts") + .font(T4Typography.heading()) + ForEach(remoteProfiles, id: \.endpointKey) { profile in + profileRow(profile) + } + } + + Button { + flow = .add + } label: { + Label("Add remote host", systemImage: "plus") + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(isWorking) + } + } + } + + private func profileRow(_ profile: HostProfile) -> some View { + let isActive = directory.activeEndpointKey == profile.endpointKey + return HStack(alignment: .center, spacing: T4Spacing.md) { + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + HStack(spacing: T4Spacing.xs) { + Text(profile.label) + .font(T4Typography.body(.body, weight: .semibold)) + .foregroundStyle(T4Color.foreground) + if isActive { + T4StatusPill("Current", tone: activeProfileTone) + } + } + Text(profile.profileID == "default" + ? "Default profile" + : "Profile · \(profile.profileID)") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + Text(profile.origin) + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.mutedText) + .lineLimit(1) + .textSelection(.enabled) + } + .accessibilityElement(children: .combine) + + Spacer(minLength: T4Spacing.sm) + + if !isActive { + Button("Select") { + onSelect(profile) + } + .buttonStyle(.bordered) + .disabled(isWorking) + .accessibilityLabel("Select \(profile.label), profile \(profile.profileID)") + } + + Button(role: .destructive) { + flow = .remove(profile) + } label: { + Label("Remove", systemImage: "trash") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .disabled(isWorking) + .accessibilityLabel("Remove \(profile.label), profile \(profile.profileID)") + } + .padding(T4Spacing.md) + .background( + isActive ? T4Color.accentSoft : T4Color.surface, + in: RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + .stroke(isActive ? T4Color.accent : T4Color.border) + } + } + + private var addContent: some View { + VStack(alignment: .leading, spacing: T4Spacing.lg) { + CredentialVolatilityBanner() + flowHeading( + title: remoteProfiles.isEmpty ? "Add a remote host" : "Add another remote host", + detail: "Remote hosts must use an encrypted .ts.net HTTPS origin or its canonical WSS endpoint. Nothing changes unless the address validates and saves successfully." + ) + + TailnetEndpointForm( + isSubmitting: isWorking, + submissionMessage: errorMessage, + submitTitle: "Save remote host" + ) { profile in + onAdd(profile) + } + + if !remoteProfiles.isEmpty { + Button("Back to remote hosts") { + flow = .profiles + } + .buttonStyle(.borderless) + .disabled(isWorking) + } + } + .padding(T4Spacing.lg) + .background( + T4Color.surface, + in: RoundedRectangle(cornerRadius: T4Radius.lg, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.lg, style: .continuous) + .stroke(T4Color.border) + } + } + + private func removeContent(_ profile: HostProfile) -> some View { + VStack(alignment: .leading, spacing: T4Spacing.lg) { + flowHeading( + title: "Remove \(profile.label)?", + detail: "This deletes the saved endpoint and its scoped Keychain credential from this device. OMP, projects, sessions, and transcripts on the computer are not changed." + ) + + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text(profile.endpointKey) + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.foreground) + .textSelection(.enabled) + + if directory.activeEndpointKey == profile.endpointKey { + Label( + "This is the current profile. T4 will disconnect it and activate the next saved profile, if one exists.", + systemImage: "exclamationmark.triangle.fill" + ) + .font(T4Typography.body(.caption, weight: .medium)) + .foregroundStyle(T4Color.warning) + } + } + .padding(T4Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + T4Color.warningSoft, + in: RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + ) + + HStack(spacing: T4Spacing.sm) { + Button("Keep host") { + flow = .profiles + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(isWorking) + + Button(role: .destructive) { + onRemove(profile) + } label: { + HStack(spacing: T4Spacing.xs) { + if isWorking { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + } + Text(isWorking ? "Removing…" : "Remove host and credential") + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isWorking) + } + } + .padding(T4Spacing.lg) + .background( + T4Color.surface, + in: RoundedRectangle(cornerRadius: T4Radius.lg, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.lg, style: .continuous) + .stroke(T4Color.border) + } + } + + private func connectionCard(_ controller: T4ClientController) -> some View { + HStack(alignment: .center, spacing: T4Spacing.md) { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text(connectionCardTitle) + .font(T4Typography.heading(.subheadline)) + T4StatusPill( + connectionLabel(controller.state.connection), + tone: connectionTone(controller.state.connection), + isPulsing: controller.state.connection == .connecting || + controller.state.connection == .reconnecting + ) + } + + Spacer(minLength: T4Spacing.sm) + + Button(connectionActionLabel(controller.state.connection)) { + runConnectionAction(controller) + } + .buttonStyle(.bordered) + .disabled(connectionActionPending || controller.state.connection == .connecting) + } + .padding(T4Spacing.md) + .background( + T4Color.raised, + in: RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + .stroke(T4Color.border) + } + } + + private var connectionCardTitle: String { +#if os(macOS) + "Local service" +#else + "Remote connection" +#endif + } + + private func flowHeading(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text(title) + .font(T4Typography.heading(.title2)) + .foregroundStyle(T4Color.foreground) + Text(detail) + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var activeProfileTone: T4StatusTone { + guard let controller else { return .neutral } + return connectionTone(controller.state.connection) + } + + private func connectionLabel(_ connection: T4ConnectionState) -> String { + switch connection { + case .disconnected: "Offline" + case .connecting: "Connecting" + case .connected: "Connected" + case .reconnecting: "Reconnecting" + case .failed: "Connection failed" + } + } + + private func connectionTone(_ connection: T4ConnectionState) -> T4StatusTone { + switch connection { + case .disconnected: .neutral + case .connecting, .reconnecting: .working + case .connected: .success + case .failed: .error + } + } + + private func connectionActionLabel(_ connection: T4ConnectionState) -> String { + switch connection { + case .connected, .reconnecting: "Disconnect" + case .failed: "Retry" + case .disconnected, .connecting: "Connect" + } + } + + private func runConnectionAction(_ controller: T4ClientController) { + guard !connectionActionPending else { return } + connectionActionPending = true + Task { @MainActor in + if controller.state.connection == .connected || + controller.state.connection == .reconnecting { + await controller.disconnect() + } else { + await controller.connect() + } + connectionActionPending = false + } + } +} diff --git a/apps/apple/Sources/T4UI/HostViews.swift b/apps/apple/Sources/T4UI/HostViews.swift new file mode 100644 index 00000000..89898322 --- /dev/null +++ b/apps/apple/Sources/T4UI/HostViews.swift @@ -0,0 +1,827 @@ +import Foundation +import SwiftUI +import T4Client +import T4Platform +import T4Protocol + +public struct CredentialVolatilityBanner: View { + public init() {} + + public var body: some View { + HStack(alignment: .top, spacing: T4Spacing.sm) { + Image(systemName: "key.horizontal") + .foregroundStyle(T4Color.warning) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text("Pairing credential storage") + .font(T4Typography.body(.subheadline, weight: .semibold)) +#if DEBUG + Text("This unsigned development build may keep a pairing credential only for the current run. If it cannot use Keychain, you will need to pair again after the app closes.") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) +#else + Text("Pairing credentials are scoped to each remote endpoint and stored in this device’s Keychain. Removing a remote host also removes its saved credential.") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) +#endif + } + Spacer(minLength: 0) + } + .padding(T4Spacing.md) + .background(T4Color.warningSoft, in: RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous)) + .accessibilityElement(children: .combine) + } +} + +public struct TailnetEndpointForm: View { + @State private var address = "" + @State private var profileID = "" + @State private var validationMessage: String? + + private let isSubmitting: Bool + private let submissionMessage: String? + private let submitTitle: String + private let onSubmit: (HostProfile) -> Void + + public init( + isSubmitting: Bool = false, + submissionMessage: String? = nil, + submitTitle: String = "Save remote host", + onSubmit: @escaping (HostProfile) -> Void + ) { + self.isSubmitting = isSubmitting + self.submissionMessage = submissionMessage + self.submitTitle = submitTitle + self.onSubmit = onSubmit + } + + public var body: some View { + VStack(alignment: .leading, spacing: T4Spacing.md) { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text("Remote host address") + .font(T4Typography.body(.subheadline, weight: .semibold)) + TextField("https://host.tailnet-name.ts.net:8445", text: $address) + .font(T4Typography.monospaced()) + .textFieldStyle(.roundedBorder) + .controlSize(.large) + .autocorrectionDisabled() + .disabled(isSubmitting) + .accessibilityLabel("Remote host Tailnet HTTPS or WSS address") + .accessibilityHint("Enter the secure .ts.net address shown by T4 on the remote computer") + .onChange(of: address) { _, _ in validationMessage = nil } + } + + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text("Profile ID") + .font(T4Typography.body(.subheadline, weight: .semibold)) + TextField("default", text: $profileID) + .font(T4Typography.monospaced()) + .textFieldStyle(.roundedBorder) + .controlSize(.large) + .autocorrectionDisabled() + .disabled(isSubmitting) + .accessibilityHint("Optional. Leave empty to use the remote host’s default profile") + .onChange(of: profileID) { _, _ in validationMessage = nil } + Text("Use a full Tailnet HTTPS origin or its canonical WSS endpoint. Only encrypted .ts.net addresses are accepted.") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + } + + if let message = validationMessage ?? submissionMessage { + Label(message, systemImage: "exclamationmark.circle.fill") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.destructive) + .accessibilityLabel("Endpoint error: \(message)") + } + + Button { + submit() + } label: { + HStack(spacing: T4Spacing.xs) { + if isSubmitting { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + } + Text(isSubmitting ? "Saving remote host…" : submitTitle) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isSubmitting || address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityHint("Validates the address before it is saved") + } + .onSubmit(submit) + } + + private func submit() { + guard !isSubmitting else { return } + do { + let profile = try Self.parseEndpoint(address, profileID: profileID) + validationMessage = nil + onSubmit(profile) + } catch { + validationMessage = "Enter a secure .ts.net HTTPS origin or WSS endpoint and a valid profile ID." + } + } + + private static func parseEndpoint(_ rawAddress: String, profileID rawProfileID: String) throws -> HostProfile { + let trimmed = rawAddress.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedProfileID = try HostProfile.normalizeProfileID(rawProfileID) + let candidate = trimmed.contains("://") ? trimmed : "https://\(trimmed)" + guard var components = URLComponents(string: candidate), + let scheme = components.scheme?.lowercased(), + scheme == "https" || scheme == "wss" else { + throw HostProfileStoreError.invalidProfile + } + + let canonicalPath: String + if normalizedProfileID == "default" { + canonicalPath = "/v1/ws" + } else { + let encodedProfile = normalizedProfileID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? normalizedProfileID + canonicalPath = "/v1/profiles/\(encodedProfile)/ws" + } + let path = components.percentEncodedPath + guard (path.isEmpty || path == "/" || path == canonicalPath), + components.query == nil, + components.fragment == nil else { + throw HostProfileStoreError.invalidProfile + } + + components.scheme = "https" + components.percentEncodedPath = "" + guard let origin = components.string else { throw HostProfileStoreError.invalidProfile } + return try HostProfile.parseTailnetAddress(origin, profileID: normalizedProfileID) + } +} + +@MainActor +public struct PairingCodeView: View { + fileprivate enum SubmissionState: Equatable { + case idle + case sending + case sent + case failed + } + + private let controller: T4ClientController + private let state: AppState + @State private var code = "" + @State private var submissionState: SubmissionState = .idle + + public init(controller: T4ClientController) { + self.controller = controller + self.state = controller.state + } + + public var body: some View { + VStack(alignment: .leading, spacing: T4Spacing.md) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text("Pair this device") + .font(T4Typography.heading()) + Text("Enter the one-time six-digit code shown by the host.") + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + } + Spacer(minLength: 0) + connectionLabel + } + + pairingField + + switch submissionState { + case .idle, .sending: + if state.connection != .connected { + Label("Connect to the host before submitting the code.", systemImage: "wifi.exclamationmark") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.warning) + } + case .sent: + Label("Code sent. The host will reconnect this device after pairing completes.", systemImage: "checkmark.circle.fill") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.success) + .accessibilityLabel("Pairing code sent") + case .failed: + Label("The code could not be sent. Check the connection and try again.", systemImage: "exclamationmark.circle.fill") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.destructive) + .accessibilityLabel("Pairing failed") + } + + Button { + sendPairingCode() + } label: { + HStack(spacing: T4Spacing.xs) { + if submissionState == .sending { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + } + Text(submissionState == .sending ? "Submitting code…" : submissionState == .failed ? "Try pairing again" : "Pair device") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(code.count != 6 || state.connection != .connected || submissionState == .sending || submissionState == .sent) + } + .padding(T4Spacing.lg) + .background(T4Color.surface, in: RoundedRectangle(cornerRadius: T4Radius.lg, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.lg, style: .continuous) + .stroke(T4Color.border) + } + } + + @ViewBuilder + private var pairingField: some View { +#if os(iOS) + TextField("000000", text: $code) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .pairingCodeStyle(code: $code, submissionState: $submissionState) +#else + TextField("000000", text: $code) + .pairingCodeStyle(code: $code, submissionState: $submissionState) +#endif + } + + private var connectionLabel: some View { + HStack(spacing: T4Spacing.xs) { + Circle() + .fill(state.connection == .connected ? T4Color.success : T4Color.mutedText) + .frame(width: T4Spacing.xs, height: T4Spacing.xs) + .accessibilityHidden(true) + Text(state.connection == .connected ? "Host online" : "Host offline") + .font(T4Typography.body(.caption, weight: .medium)) + .foregroundStyle(T4Color.secondaryText) + } + .accessibilityElement(children: .combine) + } + + private func sendPairingCode() { + guard code.count == 6, state.connection == .connected, submissionState != .sending else { return } + submissionState = .sending + let submittedCode = code + Task { @MainActor in + do { + let data = try WireEncoder.pairStart( + requestId: UUID().uuidString.lowercased(), + code: submittedCode, + deviceId: Self.deviceID, + deviceName: "Apple device", + platform: Self.platformName, + requestedCapabilities: [] + ) + try await controller.transport.send(try WireDecoder.decode(data)) + submissionState = .sent + } catch { + submissionState = .failed + } + } + } + + private static let deviceID: String = { + let key = "t4-code:apple-device-id:v1" + if let value = UserDefaults.standard.string(forKey: key), !value.isEmpty { return value } + let value = UUID().uuidString.lowercased() + UserDefaults.standard.set(value, forKey: key) + return value + }() + + private static var platformName: String { +#if os(macOS) + "macos" +#else + "ios" +#endif + } +} + +private extension View { + func pairingCodeStyle(code: Binding, submissionState: Binding) -> some View { + self + .font(T4Typography.monospaced(.title2, weight: .semibold)) + .multilineTextAlignment(.center) + .textFieldStyle(.roundedBorder) + .controlSize(.large) + .autocorrectionDisabled() + .disabled(submissionState.wrappedValue == .sending || submissionState.wrappedValue == .sent) + .accessibilityLabel("Six-digit pairing code") + .accessibilityValue("\(code.wrappedValue.count) of 6 digits entered") + .onChange(of: code.wrappedValue) { _, newValue in + let digits = newValue.filter(\.isNumber) + let normalized = String(digits.prefix(6)) + if code.wrappedValue != normalized { code.wrappedValue = normalized } + if submissionState.wrappedValue == .failed { submissionState.wrappedValue = .idle } + } + } +} + +@MainActor +public struct HostOnboardingView: View { + private let controller: T4ClientController + + public init(controller: T4ClientController) { + self.controller = controller + } + + public var body: some View { + HostManagerView(controller: controller, presentsOnboarding: true) + } +} + +@MainActor +public struct HostManagerView: View { + private enum ManagerFlow: Equatable { + case profiles + case add + case remove(HostProfile) + } + + private let controller: T4ClientController + private let state: AppState + private let profileStore: any HostProfileStore + private let credentialStore: any CredentialStore + private let presentsOnboarding: Bool + + @State private var directory = HostDirectory.empty + @State private var flow: ManagerFlow = .profiles + @State private var isLoading = true + @State private var isWorking = false + @State private var hasLoaded = false + @State private var errorMessage: String? + + public init( + controller: T4ClientController, + profileStore: any HostProfileStore = UserDefaultsHostProfileStore(), + credentialStore: any CredentialStore = KeychainCredentialStore(), + presentsOnboarding: Bool = false + ) { + self.controller = controller + self.state = controller.state + self.profileStore = profileStore + self.credentialStore = credentialStore + self.presentsOnboarding = presentsOnboarding + } + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: T4Spacing.xl) { + header +#if os(macOS) + localServiceStatus +#endif + + if state.authentication == .pairingRequired { + PairingCodeView(controller: controller) + } + + if isLoading { + HStack(spacing: T4Spacing.sm) { + ProgressView() + Text("Loading remote hosts…") + .font(T4Typography.body()) + .foregroundStyle(T4Color.secondaryText) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, T4Spacing.xxl) + .accessibilityElement(children: .combine) + } else { + flowContent + } + } + .frame(maxWidth: T4Layout.readableMeasure, alignment: .leading) + .padding(T4Spacing.xl) + .frame(maxWidth: .infinity) + } + .background(T4Color.background) + .task { + guard !hasLoaded else { return } + hasLoaded = true + await loadDirectory() + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text(isRemoteOnboarding ? "Connect to a remote T4 host" : "Remote hosts") + .font(T4Typography.heading(.largeTitle, weight: .bold)) + Text(headerDetail) + .font(T4Typography.body()) + .foregroundStyle(T4Color.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var isRemoteOnboarding: Bool { + shouldPresentRemoteOnboarding(for: directory) + } + + private var headerDetail: String { + if isRemoteOnboarding { + return "OMP and your projects stay on your computer. Add its encrypted Tailnet address to use T4 Code from this device." + } + if directory.profiles.isEmpty { +#if os(macOS) + return "T4 Code uses the automatically managed local service by default. Add a Tailnet host only when you want to work with another computer." +#else + return "Add an encrypted Tailnet address when you want to connect this device to a T4 host." +#endif + } + return "Manage optional Tailnet connections without changing projects, sessions, or transcripts on either host." + } + + private var emptyRemoteHostsMessage: String { +#if os(macOS) + "The local service remains your default. Add a secure .ts.net address only to connect to another computer." +#else + "Add the secure .ts.net address shown by T4 on the computer you want to use." +#endif + } + + private func shouldPresentRemoteOnboarding(for directory: HostDirectory) -> Bool { +#if os(iOS) + presentsOnboarding && directory.profiles.isEmpty +#else + false +#endif + } + +#if os(macOS) + private var localServiceStatus: some View { + HStack(alignment: .center, spacing: T4Spacing.md) { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text("Local service") + .font(T4Typography.heading(.subheadline)) + T4StatusPill( + localServiceLabel, + tone: localServiceTone, + isPulsing: state.connection == .connecting || state.connection == .reconnecting + ) + } + Spacer(minLength: T4Spacing.sm) + Text("This Mac") + .font(T4Typography.body(.caption, weight: .medium)) + .foregroundStyle(T4Color.secondaryText) + } + .padding(T4Spacing.md) + .background( + T4Color.raised, + in: RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + .stroke(T4Color.border) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Local service: \(localServiceLabel)") + } + + private var localServiceLabel: String { + switch state.connection { + case .disconnected: "Offline" + case .connecting: "Starting" + case .connected: "Ready" + case .reconnecting: "Restarting" + case .failed: "Service error" + } + } + + private var localServiceTone: T4StatusTone { + switch state.connection { + case .disconnected: .neutral + case .connecting, .reconnecting: .working + case .connected: .success + case .failed: .error + } + } +#endif + + @ViewBuilder + private var flowContent: some View { + switch flow { + case .profiles: + profilesContent + case .add: + addProfileContent + case let .remove(profile): + removalContent(profile) + } + } + + private var profilesContent: some View { + VStack(alignment: .leading, spacing: T4Spacing.lg) { + if let errorMessage { + errorBanner( + errorMessage, + retry: directory.profiles.isEmpty + ? { Task { @MainActor in await loadDirectory() } } + : nil + ) + } + + if directory.profiles.isEmpty { + VStack(alignment: .leading, spacing: T4Spacing.md) { + Label("No remote hosts", systemImage: "network.slash") + .font(T4Typography.heading()) + Text(emptyRemoteHostsMessage) + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + Button("Add remote host") { + errorMessage = nil + flow = .add + } + .buttonStyle(.bordered) + .controlSize(.large) + } + .padding(T4Spacing.lg) + .background(T4Color.surface, in: RoundedRectangle(cornerRadius: T4Radius.lg, style: .continuous)) + } else { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + Text("Saved remote hosts") + .font(T4Typography.heading()) + ForEach(directory.profiles, id: \.endpointKey) { profile in + profileRow(profile) + } + } + + Button { + errorMessage = nil + flow = .add + } label: { + Label("Add remote host", systemImage: "plus") + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(isWorking) + } + } + } + + private func profileRow(_ profile: HostProfile) -> some View { + let isCurrent = directory.activeEndpointKey == profile.endpointKey + return HStack(alignment: .center, spacing: T4Spacing.md) { + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + HStack(spacing: T4Spacing.xs) { + Text(profile.label) + .font(T4Typography.body(.body, weight: .semibold)) + if isCurrent { + Text("CURRENT") + .font(T4Typography.body(.caption2, weight: .bold)) + .foregroundStyle(T4Color.accent) + .padding(.horizontal, T4Spacing.xs) + .padding(.vertical, T4Spacing.xxs) + .background(T4Color.accentSoft, in: Capsule()) + } + } + Text(profile.profileID == "default" ? "Default profile" : "Profile · \(profile.profileID)") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + Text(profile.origin) + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.mutedText) + .lineLimit(1) + } + .accessibilityElement(children: .combine) + + Spacer(minLength: T4Spacing.sm) + + if !isCurrent { + Button("Select") { + activate(profile) + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(isWorking) + .accessibilityLabel("Select \(profile.label), profile \(profile.profileID)") + } + + Button(role: .destructive) { + errorMessage = nil + flow = .remove(profile) + } label: { + Label("Remove", systemImage: "trash") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .frame( + minWidth: T4Layout.minimumControlHeight, + minHeight: T4Layout.minimumControlHeight + ) + .disabled(isWorking) + .accessibilityLabel("Remove \(profile.label), profile \(profile.profileID)") + } + .padding(T4Spacing.md) + .background(T4Color.surface, in: RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + .stroke(isCurrent ? T4Color.accent : T4Color.border) + } + } + + private var addProfileContent: some View { + VStack(alignment: .leading, spacing: T4Spacing.lg) { + CredentialVolatilityBanner() + flowHeading( + title: directory.profiles.isEmpty ? "Add a remote host" : "Add another remote host", + detail: "Remote hosts must use an encrypted .ts.net HTTPS origin or its canonical WSS endpoint. The current selection remains unchanged if validation or saving fails." + ) + TailnetEndpointForm( + isSubmitting: isWorking, + submissionMessage: errorMessage, + submitTitle: "Save remote host" + ) { profile in + saveProfile(profile) + } + if !directory.profiles.isEmpty { + Button("Back to remote hosts") { + errorMessage = nil + flow = .profiles + } + .buttonStyle(.borderless) + .disabled(isWorking) + } + } + .padding(T4Spacing.lg) + .background(T4Color.surface, in: RoundedRectangle(cornerRadius: T4Radius.lg, style: .continuous)) + } + + private func removalContent(_ profile: HostProfile) -> some View { + VStack(alignment: .leading, spacing: T4Spacing.lg) { + flowHeading( + title: "Remove \(profile.label)?", + detail: "This deletes only the saved endpoint and its scoped pairing credential from this device. The host, projects, sessions, and transcripts on your computer are not touched." + ) + + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text(profile.endpointKey) + .font(T4Typography.monospaced(.caption)) + .textSelection(.enabled) + if directory.activeEndpointKey == profile.endpointKey { + Label("This is the selected remote profile. Removing it disconnects that host.", systemImage: "exclamationmark.triangle.fill") + .font(T4Typography.body(.caption, weight: .medium)) + .foregroundStyle(T4Color.warning) + } + } + .padding(T4Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T4Color.warningSoft, in: RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous)) + + if let errorMessage { + errorBanner(errorMessage, retry: nil) + } + + HStack(spacing: T4Spacing.sm) { + Button("Keep remote host") { + errorMessage = nil + flow = .profiles + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(isWorking) + + Button(role: .destructive) { + remove(profile) + } label: { + HStack(spacing: T4Spacing.xs) { + if isWorking { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + } + Text(isWorking ? "Removing…" : "Remove remote host and credential") + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isWorking) + } + } + .padding(T4Spacing.lg) + .background(T4Color.surface, in: RoundedRectangle(cornerRadius: T4Radius.lg, style: .continuous)) + } + + private func flowHeading(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text(title) + .font(T4Typography.heading(.title2)) + Text(detail) + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + } + } + + private func errorBanner(_ message: String, retry: (() -> Void)?) -> some View { + HStack(alignment: .top, spacing: T4Spacing.sm) { + Image(systemName: "exclamationmark.octagon.fill") + .foregroundStyle(T4Color.destructive) + .accessibilityHidden(true) + Text(message) + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.foreground) + Spacer(minLength: 0) + if let retry { + Button("Try again", action: retry) + .buttonStyle(.borderless) + } + } + .padding(T4Spacing.md) + .background(T4Color.destructiveSoft, in: RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous)) + .accessibilityElement(children: .combine) + .accessibilityLabel("Error: \(message)") + } + + private func loadDirectory() async { + isLoading = true + errorMessage = nil + do { + let loaded = try await profileStore.load() + directory = loaded + synchronizeAppState(with: loaded) + if shouldPresentRemoteOnboarding(for: loaded) { flow = .add } + } catch { + errorMessage = "Remote hosts could not be loaded. Check this device’s storage and try again." + } + isLoading = false + } + + private func saveProfile(_ profile: HostProfile) { + guard !isWorking else { return } + isWorking = true + errorMessage = nil + Task { @MainActor in + do { + var profiles = directory.profiles.filter { $0.endpointKey != profile.endpointKey } + guard profiles.count < maximumSavedHosts else { throw HostProfileStoreError.tooManyProfiles } + profiles.append(profile) + let activeKey = directory.activeEndpointKey ?? profile.endpointKey + let next = try HostDirectory(profiles: profiles, activeEndpointKey: activeKey) + try await profileStore.save(next) + directory = next + synchronizeAppState(with: next) + flow = .profiles + } catch { + errorMessage = "The host could not be saved. Check the endpoint and available device storage." + } + isWorking = false + } + } + + private func activate(_ profile: HostProfile) { + guard !isWorking else { return } + isWorking = true + errorMessage = nil + Task { @MainActor in + do { + let next = try directory.activating(endpointKey: profile.endpointKey) + try await profileStore.save(next) + directory = next + synchronizeAppState(with: next) + } catch { + errorMessage = "The selected remote profile could not be saved. The current selection has not changed." + } + isWorking = false + } + } + + private func remove(_ profile: HostProfile) { + guard !isWorking else { return } + isWorking = true + errorMessage = nil + let removingCurrent = directory.activeEndpointKey == profile.endpointKey + Task { @MainActor in + do { + if removingCurrent && state.connection != .disconnected { + await controller.disconnect() + } + try await credentialStore.delete(for: profile) + let next = try directory.removing(endpointKey: profile.endpointKey) + try await profileStore.save(next) + directory = next + synchronizeAppState(with: next) + flow = shouldPresentRemoteOnboarding(for: next) ? .add : .profiles + } catch { + errorMessage = "The remote host or its pairing credential could not be removed. Nothing on the computer was changed." + } + isWorking = false + } + } + + private func synchronizeAppState(with directory: HostDirectory) { + state.profiles = directory.profiles.map { profile in + T4Profile( + id: profile.endpointKey, + label: profile.label, + targetID: profile.profileID, + isEnabled: true, + isSelected: profile.endpointKey == directory.activeEndpointKey + ) + } + controller.selectProfile(directory.activeEndpointKey) + } +} + diff --git a/apps/apple/Sources/T4UI/PreviewView.swift b/apps/apple/Sources/T4UI/PreviewView.swift new file mode 100644 index 00000000..0c8d4952 --- /dev/null +++ b/apps/apple/Sources/T4UI/PreviewView.swift @@ -0,0 +1,931 @@ +import Foundation +import CryptoKit +import ImageIO +import SwiftUI +import T4Client +import T4Protocol + +#if os(macOS) +import AppKit +#elseif os(iOS) +import UIKit +#endif + +@MainActor +public struct PreviewView: View { + private let controller: T4ClientController + + @State private var previews: [DeveloperPreview] = [] + @State private var selectedPreviewID: String? + @State private var launchURL = "" + @State private var navigationURL = "" + @State private var busyAction: String? + @State private var actionError: String? + @State private var showInteraction = false + + public init(controller: T4ClientController) { + self.controller = controller + } + + public var body: some View { + Group { + if !isConnected && previews.isEmpty { + DeveloperEmptyState( + systemImage: "network.slash", + title: "Preview is offline", + detail: "Connect to a host to request a capture-only preview." + ) + } else if !hasAnyPreviewCapability && previews.isEmpty { + DeveloperEmptyState( + systemImage: "lock", + title: "Preview is unavailable", + detail: "The paired host did not grant preview.read, preview.control, or preview.input." + ) + } else { + previewWorkspace + } + } + .background(T4Color.background) + .onChange(of: selectedPreviewID) { _, _ in + navigationURL = selectedPreview?.url ?? "" + } + .sheet(isPresented: $showInteraction) { + PreviewInteractionSheet( + allowInput: hasCapability("preview.input"), + allowHandoff: hasCapability("preview.control"), + onSubmit: { action, arguments in + showInteraction = false + Task { await runInteraction(action: action, arguments: arguments) } + } + ) + .presentationDetents([.medium, .large]) + } + } + + private var previewWorkspace: some View { + VStack(spacing: 0) { + if let actionError { + DeveloperErrorBanner(message: actionError) { + self.actionError = nil + } + } + if busyAction != nil { + ProgressView() + .progressViewStyle(.linear) + .accessibilityLabel("Preview operation in progress") + } + DeveloperNotice( + systemImage: "checkmark.shield.fill", + message: "Safe preview: page code never runs in this app. Only captured image bytes delivered by the paired host protocol are decoded." + ) + Divider() + + GeometryReader { proxy in + if proxy.size.width >= T4Layout.wideBreakpoint { + HStack(spacing: 0) { + previewControls + .frame(width: T4DeveloperDesign.previewControlWidth) + Divider() + capturePane + } + } else { + VStack(spacing: 0) { + previewControls + Divider() + capturePane + .frame(minHeight: T4DeveloperDesign.previewCaptureMinimumHeight) + } + } + } + } + } + + private var previewControls: some View { + ScrollView { + VStack(alignment: .leading, spacing: T4Spacing.md) { + launchSection + + if !previews.isEmpty { + Divider() + selectionSection + } + + if let selectedPreview { + Divider() + navigationSection(selectedPreview) + Divider() + actionSection(selectedPreview) + previewStatus(selectedPreview) + } + + if !isConnected { + DeveloperNotice( + systemImage: "network.slash", + message: "This is a cached capture. Navigation and interaction remain disabled while offline." + ) + } + } + .padding(T4Spacing.md) + } + .background(T4Color.raised) + } + + private var launchSection: some View { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + Text("Launch preview") + .font(T4Typography.heading()) + TextField("https://localhost:3000", text: $launchURL) + .textFieldStyle(.roundedBorder) + .textContentType(.URL) + .keyboardTypeURL() + .disabled(!canControl || busyAction != nil) + .onSubmit { + Task { await launchPreview() } + } + .accessibilityLabel("Preview launch URL") + HStack { + Button { + Task { await refreshPreviews() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(!isConnected || !hasCapability("preview.read") || busyAction != nil) + + Spacer() + + Button { + Task { await launchPreview() } + } label: { + Label("Launch", systemImage: "play.rectangle") + } + .buttonStyle(.borderedProminent) + .disabled(launchURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !canControl || busyAction != nil) + } + } + } + + private var selectionSection: some View { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text("Open previews") + .font(T4Typography.heading(.subheadline)) + Picker( + "Select preview", + selection: Binding( + get: { selectedPreviewID }, + set: { previewID in + guard let previewID else { return } + Task { await activatePreview(previewID) } + } + ) + ) { + ForEach(previews) { preview in + Text(preview.displayTitle).tag(String?.some(preview.id)) + } + } + .pickerStyle(.menu) + .disabled(!canControl || busyAction != nil) + .accessibilityLabel("Select captured preview") + } + } + + private func navigationSection(_ preview: DeveloperPreview) -> some View { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + Text("Navigate") + .font(T4Typography.heading(.subheadline)) + HStack(spacing: T4Spacing.xs) { + Button { + Task { await runPreviewAction("back") } + } label: { + Image(systemName: "chevron.backward") + } + .disabled(!canControl || !preview.canGoBack || busyAction != nil) + .accessibilityLabel("Preview back") + + Button { + Task { await runPreviewAction("forward") } + } label: { + Image(systemName: "chevron.forward") + } + .disabled(!canControl || !preview.canGoForward || busyAction != nil) + .accessibilityLabel("Preview forward") + + Button { + Task { await runPreviewAction("reload") } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(!canControl || busyAction != nil) + .accessibilityLabel("Reload preview") + } + + TextField("Preview address", text: $navigationURL) + .textFieldStyle(.roundedBorder) + .textContentType(.URL) + .keyboardTypeURL() + .disabled(!canControl || busyAction != nil) + .onSubmit { + Task { await navigatePreview() } + } + .accessibilityLabel("Preview address") + + Button { + Task { await navigatePreview() } + } label: { + Label("Navigate", systemImage: "arrow.right") + } + .disabled(navigationURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !canControl || busyAction != nil) + } + } + + private func actionSection(_ preview: DeveloperPreview) -> some View { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + Text("Remote controls") + .font(T4Typography.heading(.subheadline)) + Text("Controls send protocol intents to the paired host. This app never loads the page itself.") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + + HStack(spacing: T4Spacing.sm) { + Button { + showInteraction = true + } label: { + Label("Interact", systemImage: "hand.tap") + } + .disabled(!canInteract || busyAction != nil) + + Button { + Task { await capturePreview() } + } label: { + Label("Capture", systemImage: "camera") + } + .buttonStyle(.borderedProminent) + .disabled(!canCapture || busyAction != nil) + } + + Button(role: .destructive) { + Task { await runPreviewAction("close") } + } label: { + Label("Close Preview", systemImage: "xmark") + } + .disabled(!canControl || busyAction != nil) + } + } + + private func previewStatus(_ preview: DeveloperPreview) -> some View { + HStack(spacing: T4Spacing.sm) { + Image(systemName: preview.error == nil ? "circle.fill" : "exclamationmark.circle.fill") + .font(.caption) + .foregroundStyle(preview.error == nil ? T4Color.success : T4Color.destructive) + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(preview.state.capitalized) + .font(T4Typography.heading(.caption)) + Text(preview.captureData == nil ? "Capture required" : "Protocol capture ready") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + } + Spacer() + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Preview status \(preview.state). \(preview.captureData == nil ? "No capture available" : "Capture available")") + } + + private var capturePane: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(selectedPreview?.displayTitle ?? "Protocol capture") + .font(T4Typography.heading()) + if let selectedPreview { + Text(selectedPreview.url) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.mutedText) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer() + if let mimeType = selectedPreview?.captureMIMEType { + Text(mimeType) + .font(T4Typography.monospaced(.caption2)) + .foregroundStyle(T4Color.mutedText) + } + } + .padding(T4Spacing.sm) + .background(T4Color.raised) + Divider() + + if busyAction == "preview.capture" && selectedPreview?.captureData == nil { + ProgressView("Requesting capture") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let selectedPreview { + if let error = selectedPreview.error { + DeveloperEmptyState( + systemImage: "exclamationmark.triangle", + title: "Preview error", + detail: error + ) + } else if let data = selectedPreview.captureData, + let captureImage = CapturedPlatformImage(data: data) { + captureImage.image + .resizable() + .interpolation(.high) + .scaledToFit() + .padding(T4Spacing.sm) + .background(T4Color.input) + .accessibilityLabel("Captured preview of \(selectedPreview.displayTitle)") + } else { + DeveloperEmptyState( + systemImage: "photo", + title: "No protocol capture", + detail: "Choose Capture to request rendered image bytes. HTML and page scripts are never loaded here." + ) + } + } else { + DeveloperEmptyState( + systemImage: "rectangle.on.rectangle.slash", + title: "No preview open", + detail: "Enter a URL to launch a host-rendered, capture-only preview." + ) + } + } + .background(T4Color.input) + } + + private var selectedPreview: DeveloperPreview? { + guard let selectedPreviewID else { return nil } + return previews.first { $0.id == selectedPreviewID } + } + + private var isConnected: Bool { + controller.state.connection == .connected + } + + private var hasAnyPreviewCapability: Bool { + hasCapability("preview.read") || hasCapability("preview.control") || hasCapability("preview.input") + } + + private var canControl: Bool { + isConnected && + controller.state.selectedSessionID != nil && + hasCapability("preview.control") + } + + private var canCapture: Bool { + isConnected && + controller.state.selectedSessionID != nil && + hasCapability("preview.read") && + selectedPreview != nil + } + + private var canInteract: Bool { + isConnected && + controller.state.selectedSessionID != nil && + (hasCapability("preview.input") || hasCapability("preview.control")) && + selectedPreview != nil + } + + private func hasCapability(_ capability: String) -> Bool { + DeveloperCapabilities.allows(capability, state: controller.state) + } + + private func refreshPreviews() async { + guard isConnected, + hasCapability("preview.read"), + let sessionID = controller.state.selectedSessionID else { return } + await perform("preview.state") { + let response = try await controller.command( + "preview.state", + sessionID: sessionID + ) + guard let list = response.result?["previews"]?.arrayValue else { + throw DeveloperSurfaceError("The host returned no preview list.") + } + let decoded = DeveloperPreview.decodeList(response.result) + guard decoded.count == list.count else { + throw DeveloperSurfaceError("The host returned invalid preview state.") + } + let incomingIDs = Set(decoded.map(\.id)) + previews = mergePreviews(decoded, into: previews).filter { incomingIDs.contains($0.id) } + if selectedPreviewID == nil || !previews.contains(where: { $0.id == selectedPreviewID }) { + selectedPreviewID = previews.first?.id + } + } + } + + private func launchPreview() async { + guard canControl, let sessionID = controller.state.selectedSessionID else { return } + let rawURL = launchURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = normalizedPreviewURL(rawURL) else { + actionError = "Enter an HTTP or HTTPS URL without embedded credentials." + return + } + await perform("preview.launch") { + let response = try await controller.command( + "preview.launch", + sessionID: sessionID, + args: ["url": .string(url), "authorityId": .string("omp-session")] + ) + guard let preview = DeveloperPreview.decode(response.result, fallbackURL: url) else { + throw DeveloperSurfaceError("The host launched no valid preview.") + } + previews = mergePreviews([preview], into: previews) + selectedPreviewID = preview.id + navigationURL = preview.url + launchURL = "" + } + } + + private func activatePreview(_ previewID: String) async { + guard canControl, + let sessionID = controller.state.selectedSessionID, + let preview = previews.first(where: { $0.id == previewID }) else { return } + await perform("preview.activate") { + let response = try await controller.command( + "preview.activate", + sessionID: sessionID, + args: ["previewId": .string(previewID)] + ) + selectedPreviewID = previewID + navigationURL = preview.url + updatePreview(from: response.result, fallbackID: previewID, fallbackURL: preview.url) + } + } + + private func navigatePreview() async { + guard let preview = selectedPreview, + canControl, + let sessionID = controller.state.selectedSessionID else { return } + let rawURL = navigationURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = normalizedPreviewURL(rawURL) else { + actionError = "Enter an HTTP or HTTPS URL without embedded credentials." + return + } + guard url != preview.url else { return } + await perform("preview.navigate") { + let response = try await controller.command( + "preview.navigate", + sessionID: sessionID, + args: ["previewId": .string(preview.id), "url": .string(url)] + ) + updatePreview(from: response.result, fallbackID: preview.id, fallbackURL: url) + } + } + + private func runPreviewAction(_ action: String) async { + guard DeveloperPreview.safeActions.contains(action), + let preview = selectedPreview, + canControl, + let sessionID = controller.state.selectedSessionID else { return } + let command = "preview.\(action)" + await perform(command) { + let response = try await controller.command( + command, + sessionID: sessionID, + args: ["previewId": .string(preview.id)] + ) + if action == "close" { + previews.removeAll { $0.id == preview.id } + selectedPreviewID = previews.last?.id + } else { + updatePreview(from: response.result, fallbackID: preview.id, fallbackURL: preview.url) + } + } + } + + private func runInteraction(action: String, arguments: [String: JSONValue]) async { + guard DeveloperPreview.interactionActions.contains(action), + arguments["previewId"] == nil, + let preview = selectedPreview, + let sessionID = controller.state.selectedSessionID else { return } + let capability = action == "handoff" ? "preview.control" : "preview.input" + guard isConnected, hasCapability(capability) else { return } + var args = arguments + args["previewId"] = .string(preview.id) + await perform("preview.\(action)") { + let response = try await controller.command( + "preview.\(action)", + sessionID: sessionID, + args: args + ) + updatePreview(from: response.result, fallbackID: preview.id, fallbackURL: preview.url) + } + } + + private func capturePreview() async { + guard let preview = selectedPreview, + canCapture, + let sessionID = controller.state.selectedSessionID else { return } + await perform("preview.capture") { + let response = try await controller.command( + "preview.capture", + sessionID: sessionID, + args: ["previewId": .string(preview.id)] + ) + guard var updated = DeveloperPreview.decode( + response.result, + fallbackID: preview.id, + fallbackURL: preview.url + ), let capture = updated.capture else { + throw DeveloperSurfaceError("The host returned no valid capture metadata.") + } + let data = try await readCapture(capture, previewID: updated.id, sessionID: sessionID) + updated.captureData = data + updated.captureMIMEType = capture.mimeType + previews = mergePreviews([updated], into: previews) + selectedPreviewID = updated.id + navigationURL = updated.url + } + } + + private func readCapture( + _ capture: DeveloperPreviewCapture, + previewID: String, + sessionID: String + ) async throws -> Data { + var data = Data() + data.reserveCapacity(capture.size) + var offset = 0 + while offset < capture.size { + let response = try await controller.command( + "preview.capture.read", + sessionID: sessionID, + args: [ + "previewId": .string(previewID), + "captureId": .string(capture.id), + "offset": .integer(offset) + ] + ) + guard let result = response.result, + result["previewId"]?.stringValue == previewID, + result["captureId"]?.stringValue == capture.id, + result["size"]?.intValue == capture.size, + result["offset"]?.intValue == offset, + let nextOffset = result["nextOffset"]?.intValue, + nextOffset > offset, + nextOffset <= capture.size, + nextOffset - offset <= T4DeveloperDesign.previewCaptureChunkBytes, + result["complete"]?.boolValue == (nextOffset == capture.size), + let encoded = result["content"]?.stringValue, + let chunk = Data(base64Encoded: encoded), + chunk.base64EncodedString() == encoded, + chunk.count == nextOffset - offset else { + throw DeveloperSurfaceError("The host returned an invalid preview capture chunk.") + } + data.append(chunk) + offset = nextOffset + } + guard data.count == capture.size, + previewSHA256(data) == capture.sha256, + previewRasterIsValid(data, capture: capture) else { + throw DeveloperSurfaceError("The preview capture failed its integrity checks.") + } + return data + } + + private func updatePreview(from result: [String: JSONValue]?, fallbackID: String, fallbackURL: String) { + if let decoded = DeveloperPreview.decode(result, fallbackID: fallbackID, fallbackURL: fallbackURL) { + previews = mergePreviews([decoded], into: previews) + selectedPreviewID = decoded.id + navigationURL = decoded.url + } + } + + private func perform(_ name: String, operation: @escaping @MainActor () async throws -> Void) async { + guard busyAction == nil else { return } + busyAction = name + actionError = nil + defer { busyAction = nil } + do { + try await operation() + } catch { + actionError = T4Privacy.redacted(developerErrorMessage(error)) + } + } +} + +private struct DeveloperPreview: Identifiable { + static let safeActions: Set = ["back", "forward", "reload", "close"] + static let interactionActions: Set = [ + "click", "fill", "type", "select", "press", "scroll", "upload", "handoff" + ] + private static let states: Set = ["launching", "ready", "running", "stopped", "failed"] + + let id: String + var title: String? + var url: String + var state: String + var canGoBack: Bool + var canGoForward: Bool + var capture: DeveloperPreviewCapture? + var captureData: Data? + var captureMIMEType: String? + var error: String? + + var displayTitle: String { + let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? url : trimmed + } + + static func decodeList(_ result: [String: JSONValue]?) -> [DeveloperPreview] { + guard let list = result?["previews"]?.arrayValue else { return [] } + return list.compactMap { value in + guard let object = value.objectValue else { return nil } + return decode(object) + } + } + + static func decode( + _ result: [String: JSONValue]?, + fallbackID: String? = nil, + fallbackURL: String = "" + ) -> DeveloperPreview? { + guard let result else { return nil } + let object = result["preview"]?.objectValue ?? result + guard let id = object["previewId"]?.stringValue ?? object["id"]?.stringValue ?? fallbackID, + !id.isEmpty, + let url = normalizedPreviewURL(object["url"]?.stringValue ?? fallbackURL) else { return nil } + let state = object["state"]?.stringValue ?? "ready" + guard states.contains(state) else { return nil } + + let capture: DeveloperPreviewCapture? + if let captureObject = object["capture"]?.objectValue { + guard let decoded = DeveloperPreviewCapture(captureObject) else { return nil } + capture = decoded + } else { + capture = nil + } + + return DeveloperPreview( + id: id, + title: object["title"]?.stringValue, + url: url, + state: state, + canGoBack: object["canGoBack"]?.boolValue ?? false, + canGoForward: object["canGoForward"]?.boolValue ?? false, + capture: capture, + captureData: nil, + captureMIMEType: capture?.mimeType, + error: object["error"]?.stringValue ?? object["message"]?.stringValue + ) + } +} + +private struct DeveloperPreviewCapture { + private static let MIMETypeValues: Set = ["image/png", "image/jpeg", "image/webp"] + + let id: String + let mimeType: String + let size: Int + let width: Int + let height: Int + let sha256: String + + init?(_ object: [String: JSONValue]) { + guard let id = object["captureId"]?.stringValue, + !id.isEmpty, + let mimeType = object["mimeType"]?.stringValue, + Self.MIMETypeValues.contains(mimeType), + let size = object["size"]?.intValue, + (1...T4DeveloperDesign.previewCaptureMaximumBytes).contains(size), + let width = object["width"]?.intValue, + let height = object["height"]?.intValue, + width > 0, + height > 0, + width <= T4DeveloperDesign.previewCaptureMaximumPixels, + height <= T4DeveloperDesign.previewCaptureMaximumPixels, + width * height <= T4DeveloperDesign.previewCaptureMaximumPixels, + object["capturedAt"]?.intValue != nil, + let sha256 = object["sha256"]?.stringValue, + sha256.utf8.count == 64, + sha256.utf8.allSatisfy({ + ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102) + }) else { return nil } + self.id = id + self.mimeType = mimeType + self.size = size + self.width = width + self.height = height + self.sha256 = sha256 + } +} + +private func mergePreviews(_ incoming: [DeveloperPreview], into existing: [DeveloperPreview]) -> [DeveloperPreview] { + var merged = existing + for preview in incoming { + if let index = merged.firstIndex(where: { $0.id == preview.id }) { + var replacement = preview + let previous = merged[index] + if replacement.capture == nil { + replacement.capture = previous.capture + replacement.captureData = previous.captureData + replacement.captureMIMEType = previous.captureMIMEType + } else if replacement.capture?.id == previous.capture?.id, replacement.captureData == nil { + replacement.captureData = previous.captureData + replacement.captureMIMEType = previous.captureMIMEType + } + merged[index] = replacement + } else { + merged.append(preview) + } + } + return merged +} + +private func normalizedPreviewURL(_ source: String) -> String? { + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed.utf8.count <= T4DeveloperDesign.previewURLMaximumBytes, + let components = URLComponents(string: trimmed), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = components.host, + !host.isEmpty, + components.user == nil, + components.password == nil else { return nil } + return components.string +} + +private func previewSHA256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() +} + +private func previewRasterIsValid(_ data: Data, capture: DeveloperPreviewCapture) -> Bool { + let signatureIsValid: Bool + switch capture.mimeType { + case "image/png": + signatureIsValid = data.starts(with: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + case "image/jpeg": + signatureIsValid = data.starts(with: [0xff, 0xd8, 0xff]) + case "image/webp": + signatureIsValid = data.starts(with: [0x52, 0x49, 0x46, 0x46]) && + data.dropFirst(8).starts(with: [0x57, 0x45, 0x42, 0x50]) + default: + signatureIsValid = false + } + guard signatureIsValid, + let source = CGImageSourceCreateWithData(data as CFData, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return false } + return image.width == capture.width && image.height == capture.height +} + +@MainActor +private struct CapturedPlatformImage { + let image: Image + +#if os(macOS) + init?(data: Data) { + guard let value = NSImage(data: data) else { return nil } + image = Image(nsImage: value) + } +#elseif os(iOS) + init?(data: Data) { + guard let value = UIImage(data: data) else { return nil } + image = Image(uiImage: value) + } +#endif +} + +@MainActor +private struct PreviewInteractionSheet: View { + private static let templates: [String: String] = [ + "click": #"{"selector":"button"}"#, + "fill": #"{"selector":"input","text":"value"}"#, + "type": #"{"selector":"input","text":"value"}"#, + "select": #"{"selector":"select","value":"option"}"#, + "press": #"{"key":"Enter"}"#, + "scroll": #"{"deltaX":0,"deltaY":480}"#, + "upload": #"{"selector":"input[type=file]","path":"relative/file.txt"}"#, + "handoff": #"{"message":"Complete this step","mode":"manual"}"# + ] + private static let inputActions = ["click", "fill", "type", "select", "press", "scroll", "upload"] + + let onSubmit: (String, [String: JSONValue]) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var action: String + @State private var arguments: String + @State private var validationError: String? + + private let actions: [String] + + init( + allowInput: Bool, + allowHandoff: Bool, + onSubmit: @escaping (String, [String: JSONValue]) -> Void + ) { + self.onSubmit = onSubmit + let actions = (allowInput ? Self.inputActions : []) + (allowHandoff ? ["handoff"] : []) + self.actions = actions + let initialAction = actions.first ?? "click" + self._action = State(initialValue: initialAction) + self._arguments = State(initialValue: Self.templates[initialAction] ?? "{}") + } + + var body: some View { + NavigationStack { + Form { + Section("Remote interaction") { + Picker("Action", selection: $action) { + ForEach(actions, id: \.self) { value in + Text(value.capitalized).tag(value) + } + } + .onChange(of: action) { _, value in + arguments = Self.templates[value] ?? "{}" + validationError = nil + } + + TextEditor(text: $arguments) + .font(T4Typography.monospaced()) + .frame(minHeight: T4DeveloperDesign.terminalOutputMinimumHeight / 2) + .accessibilityLabel("Preview interaction arguments in JSON") + + if let validationError { + Text(validationError) + .foregroundStyle(T4Color.destructive) + .accessibilityLabel("Interaction arguments error: \(validationError)") + } + } + + Section { + DeveloperNotice( + systemImage: "checkmark.shield", + message: "This sends a structured protocol action to the host. Captured page code is never evaluated in this app." + ) + } + } + .navigationTitle("Preview interaction") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Send") { submit() } + .disabled(actions.isEmpty) + } + } + } + } + + private func submit() { + do { + let value = try previewArguments(arguments) + guard value["previewId"] == nil else { + throw DeveloperSurfaceError("Arguments cannot override the selected preview.") + } + onSubmit(action, value) + } catch { + validationError = developerErrorMessage(error) + } + } +} + +private func previewArguments(_ source: String) throws -> [String: JSONValue] { + guard let data = source.data(using: .utf8), + data.count <= T4DeveloperDesign.previewInteractionMaximumBytes else { + throw DeveloperSurfaceError("Arguments must be a bounded UTF-8 JSON object.") + } + let foundation: Any + do { + foundation = try JSONSerialization.jsonObject(with: data) + } catch { + throw DeveloperSurfaceError("Arguments must be a valid JSON object.") + } + guard let object = foundation as? [String: Any] else { + throw DeveloperSurfaceError("Arguments must be a JSON object.") + } + return try object.mapValues(jsonValue) +} + +private func jsonValue(_ value: Any) throws -> JSONValue { + if value is NSNull { return .null } + if let value = value as? Bool { return .bool(value) } + if let value = value as? String { return .string(value) } + if let value = value as? NSNumber { + let number = value.doubleValue + guard number.isFinite, abs(number) <= 9_007_199_254_740_991 else { + throw DeveloperSurfaceError("Arguments contain an unsafe number.") + } + return .number(number) + } + if let value = value as? [Any] { + return .array(try value.map(jsonValue)) + } + if let value = value as? [String: Any] { + return .object(try value.mapValues(jsonValue)) + } + throw DeveloperSurfaceError("Arguments contain an unsupported JSON value.") +} + +private extension View { + @ViewBuilder + func keyboardTypeURL() -> some View { + #if os(iOS) + self.keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + #else + self + #endif + } +} diff --git a/apps/apple/Sources/T4UI/SearchUsageSettingsViews.swift b/apps/apple/Sources/T4UI/SearchUsageSettingsViews.swift new file mode 100644 index 00000000..d09ce103 --- /dev/null +++ b/apps/apple/Sources/T4UI/SearchUsageSettingsViews.swift @@ -0,0 +1,1409 @@ +import Foundation +#if os(macOS) +import AppKit +#elseif os(iOS) +import UIKit +#endif +import SwiftUI +import T4Client +import T4Platform +import T4Protocol + +public enum SearchUsageMode: String, CaseIterable, Identifiable, Sendable { + case search + case usage + + public var id: String { rawValue } +} + +@MainActor +public struct SearchUsageView: View { + @Bindable private var controller: T4ClientController + public let mode: SearchUsageMode + + public init(controller: T4ClientController, mode: SearchUsageMode) { + self.controller = controller + self.mode = mode + } + + @ViewBuilder + public var body: some View { + switch mode { + case .search: + TranscriptSearchView(controller: controller) + case .usage: + UsageAccountView(controller: controller) + } + } +} + +private struct T4SearchResult: Identifiable, Sendable { + let id: String + let sessionID: String + let sessionTitle: String + let projectID: String + let role: String + let timestamp: String + let snippet: String +} + +@MainActor +public struct TranscriptSearchView: View { + @Bindable private var controller: T4ClientController + @State private var query = "" + @State private var role = "all" + @State private var includesArchived = true + @State private var results: [T4SearchResult] = [] + @State private var isLoading = false + @State private var hasSearched = false + @State private var errorMessage: String? + @State private var announcement = "" + + public init(controller: T4ClientController) { + self.controller = controller + } + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: T4Spacing.lg) { + pageHeading( + title: "Transcript search", + detail: "Find decisions and prior work without loading every session." + ) + + VStack(alignment: .leading, spacing: T4Spacing.sm) { + HStack(spacing: T4Spacing.xs) { + TextField("Search transcripts", text: $query) + .textFieldStyle(.roundedBorder) + .submitLabel(.search) + .onSubmit { Task { await search() } } + .accessibilityLabel("Transcript search query") + Button("Search") { Task { await search() } } + .buttonStyle(.borderedProminent) + .tint(T4Color.accent) + .disabled(trimmedQuery.count < 2 || isLoading || controller.state.connection != .connected) + } + + HStack(spacing: T4Spacing.sm) { + Picker("Role", selection: $role) { + Text("All roles").tag("all") + Text("You").tag("user") + Text("Assistant").tag("assistant") + Text("Summaries").tag("summary") + } + .pickerStyle(.menu) + Toggle("Include archived", isOn: $includesArchived) + .toggleStyle(.switch) + } + .font(T4Typography.body(.subheadline)) + } + .padding(T4Spacing.md) + .background(T4Color.raised, in: RoundedRectangle(cornerRadius: T4Radius.lg)) + .overlay { RoundedRectangle(cornerRadius: T4Radius.lg).stroke(T4Color.border) } + + searchContent + } + .frame(maxWidth: T4Layout.readableMeasure, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .top) + .padding(T4Spacing.lg) + } + .background(T4Color.background) + .navigationTitle("Search") + .overlay(alignment: .topLeading) { + Text(announcement) + .frame(width: 1, height: 1) + .opacity(0.001) + .accessibilityHidden(announcement.isEmpty) + } + } + + private var trimmedQuery: String { query.trimmingCharacters(in: .whitespacesAndNewlines) } + + @ViewBuilder + private var searchContent: some View { + if controller.state.connection != .connected && !isLoading { + T4EmptyState( + icon: "network.slash", + title: "Search is offline", + message: "Connect to an OMP host to search its transcript index.", + actionTitle: "Connect", + action: { Task { await controller.connect() } } + ) + } else if isLoading { + loadingState("Searching transcripts…") + } else if let errorMessage { + T4ErrorState( + title: "Search failed", + message: errorMessage, + retry: { Task { await search() } } + ) + } else if !hasSearched { + T4EmptyState( + icon: "text.magnifyingglass", + title: "Search past conversations", + message: "Enter at least two characters. Results stay on the connected host until requested." + ) + } else if results.isEmpty { + T4EmptyState( + icon: "magnifyingglass", + title: "No matching transcript entries", + message: "Try fewer words, another role, or include archived sessions." + ) + } else { + LazyVStack(alignment: .leading, spacing: T4Spacing.xs) { + Text("\(results.count) result\(results.count == 1 ? "" : "s")") + .font(T4Typography.heading(.headline)) + ForEach(results) { result in + Button { + Task { await controller.selectSession(result.sessionID) } + } label: { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + HStack(alignment: .firstTextBaseline, spacing: T4Spacing.xs) { + Text(result.sessionTitle.isEmpty ? "Untitled session" : result.sessionTitle) + .font(T4Typography.heading(.subheadline)) + .foregroundStyle(T4Color.foreground) + Spacer(minLength: T4Spacing.sm) + T4StatusPill(result.role.capitalized, tone: result.role == "user" ? .input : .neutral) + } + Text(T4Privacy.redacted(result.snippet)) + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + .multilineTextAlignment(.leading) + .lineLimit(4) + HStack(spacing: T4Spacing.xs) { + Text(result.projectID) + Text(result.timestamp) + } + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.mutedText) + } + .padding(T4Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T4Color.raised, in: RoundedRectangle(cornerRadius: T4Radius.md)) + .overlay { RoundedRectangle(cornerRadius: T4Radius.md).stroke(T4Color.border) } + } + .buttonStyle(.plain) + .accessibilityHint("Opens this session") + } + } + } + } + + @MainActor + private func search() async { + guard trimmedQuery.count >= 2, !isLoading else { return } + isLoading = true + hasSearched = true + errorMessage = nil + defer { isLoading = false } + do { + var args: [String: JSONValue] = [ + "query": .string(trimmedQuery), + "limit": .integer(50), + "archived": .string(includesArchived ? "include" : "exclude"), + ] + if role != "all" { args["roles"] = .array([.string(role)]) } + let response = try await controller.command("transcript.search", args: args) + results = decodeSearchResults(response.result) + announcement = "Search complete. \(results.count) result\(results.count == 1 ? "" : "s")." + } catch { + results = [] + errorMessage = T4Privacy.redacted(String(describing: error)) + announcement = "Search failed." + } + } +} + +private struct T4UsageLimit: Identifiable, Sendable { + let id: String + let label: String + let status: String + let used: Double? + let limit: Double? + let remaining: Double? + let usedFraction: Double? + let unit: String + let resetLabel: String? +} + +private struct T4UsageReport: Identifiable, Sendable { + let id: String + let provider: String + let plan: String? + let fetchedAt: Double? + let limits: [T4UsageLimit] +} + +private struct T4BrokerStatus: Sendable { + let state: String + let endpoint: String? + + var label: String { + switch state { + case "local": "Local account" + case "connected": "Broker connected" + case "missingToken": "Sign-in required" + case "unreachable": "Broker unreachable" + default: state.capitalized + } + } + + var detail: String { + switch state { + case "local": "Provider accounts are managed by this OMP host." + case "connected": "OMP can read account status from the configured broker." + case "missingToken": "The host needs a broker token before account status is available." + case "unreachable": "The configured account broker did not respond." + default: "OMP returned an account state this app does not recognize." + } + } + + var tone: T4StatusTone { + switch state { + case "local", "connected": .success + case "missingToken": .warning + case "unreachable": .error + default: .neutral + } + } +} + +@MainActor +public struct UsageAccountView: View { + @Bindable private var controller: T4ClientController + @State private var reports: [T4UsageReport] = [] + @State private var accountCountWithoutUsage = 0 + @State private var brokerStatus: T4BrokerStatus? + @State private var brokerError: String? + @State private var isLoading = false + @State private var hasLoaded = false + @State private var errorMessage: String? + @State private var announcement = "" + + public init(controller: T4ClientController) { + self.controller = controller + } + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: T4Spacing.lg) { + HStack(alignment: .top, spacing: T4Spacing.md) { + pageHeading( + title: "Usage & accounts", + detail: "Provider limits reported by OMP. Credentials are never returned or displayed." + ) + Spacer(minLength: T4Spacing.sm) + Button { Task { await loadUsage() } } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(isLoading || controller.state.connection != .connected) + } + usageContent + } + .frame(maxWidth: T4Layout.readableMeasure, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .top) + .padding(T4Spacing.lg) + } + .background(T4Color.background) + .navigationTitle("Usage") + .task { + if !hasLoaded, controller.state.connection == .connected { await loadUsage() } + } + .overlay(alignment: .topLeading) { + Text(announcement) + .frame(width: 1, height: 1) + .opacity(0.001) + .accessibilityHidden(announcement.isEmpty) + } + } + + @ViewBuilder + private var usageContent: some View { + if controller.state.connection != .connected && !hasLoaded { + T4EmptyState( + icon: "network.slash", + title: "Usage is offline", + message: "Connect to an OMP host to read provider account status.", + actionTitle: "Connect", + action: { Task { await controller.connect() } } + ) + } else if isLoading && !hasLoaded { + loadingState("Loading usage and account status…") + } else { + accountStatus + if let errorMessage, reports.isEmpty { + T4ErrorState(title: "Usage refresh failed", message: errorMessage, retry: { Task { await loadUsage() } }) + } else if hasLoaded && reports.isEmpty { + T4EmptyState( + icon: "gauge.with.dots.needle.0percent", + title: "No usage reports", + message: accountCountWithoutUsage > 0 + ? "\(accountCountWithoutUsage) configured account\(accountCountWithoutUsage == 1 ? " does" : "s do") not publish usage. No credentials were exposed." + : "This OMP host has no provider usage to report." + ) + } else { + if let errorMessage { + T4ErrorState(title: "Showing saved usage", message: errorMessage, retry: { Task { await loadUsage() } }) + } + LazyVStack(spacing: T4Spacing.sm) { + ForEach(reports) { report in usageReport(report) } + } + } + } + } + + @ViewBuilder + private var accountStatus: some View { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + Text("Account connection") + .font(T4Typography.heading(.headline)) + if let brokerStatus { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + HStack(spacing: T4Spacing.xs) { + T4StatusPill(brokerStatus.label, tone: brokerStatus.tone) + Spacer(minLength: T4Spacing.sm) + if let endpoint = brokerStatus.endpoint { + Text(T4Privacy.redacted(endpoint)) + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.mutedText) + } + } + Text(brokerStatus.detail) + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + } + .padding(T4Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T4Color.raised, in: RoundedRectangle(cornerRadius: T4Radius.md)) + .overlay { RoundedRectangle(cornerRadius: T4Radius.md).stroke(T4Color.border) } + .accessibilityElement(children: .combine) + .accessibilityLabel("Account connection: \(brokerStatus.label). \(brokerStatus.detail)") + } else if let brokerError { + T4ErrorState(title: "Account status unavailable", message: brokerError, retry: { Task { await loadUsage() } }) + Text("The connected device must be granted broker.read.") + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.mutedText) + } + } + } + + private func usageReport(_ report: T4UsageReport) -> some View { + VStack(alignment: .leading, spacing: T4Spacing.md) { + HStack(alignment: .firstTextBaseline, spacing: T4Spacing.xs) { + Text(report.provider.capitalized) + .font(T4Typography.heading(.headline)) + if let plan = report.plan, !plan.isEmpty { + T4StatusPill(plan, tone: .neutral) + } + Spacer(minLength: T4Spacing.sm) + if let fetchedAt = report.fetchedAt { + Text(Date(timeIntervalSince1970: fetchedAt / 1_000), style: .relative) + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.mutedText) + } + } + if report.limits.isEmpty { + Text("Account connected; this provider returned no limit windows.") + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + } else { + ForEach(report.limits) { limit in usageLimit(limit) } + } + } + .padding(T4Spacing.md) + .background(T4Color.raised, in: RoundedRectangle(cornerRadius: T4Radius.lg)) + .overlay { RoundedRectangle(cornerRadius: T4Radius.lg).stroke(T4Color.border) } + } + + private func usageLimit(_ limit: T4UsageLimit) -> some View { + let fraction = max(0, min(1, limit.usedFraction ?? inferredFraction(limit))) + let tone = usageTone(limit.status, fraction: fraction) + return VStack(alignment: .leading, spacing: T4Spacing.xs) { + HStack(alignment: .firstTextBaseline, spacing: T4Spacing.xs) { + Text(limit.label) + .font(T4Typography.body(.subheadline, weight: .medium)) + Spacer(minLength: T4Spacing.sm) + T4StatusPill(usageStatusLabel(limit.status), tone: tone) + } + ProgressView(value: fraction) + .tint(tone.colorForUsage) + .accessibilityLabel(limit.label) + .accessibilityValue("\(Int((fraction * 100).rounded())) percent used") + HStack(spacing: T4Spacing.xs) { + Text(usageAmount(limit)) + if let resetLabel = limit.resetLabel { Text("· \(resetLabel)") } + } + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.mutedText) + } + .padding(.vertical, T4Spacing.xxs) + } + + @MainActor + private func loadUsage() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + brokerError = nil + defer { isLoading = false } + + do { + let response = try await controller.command("usage.read") + let decoded = decodeUsage(response.result) + reports = decoded.reports + accountCountWithoutUsage = decoded.accountCountWithoutUsage + hasLoaded = true + announcement = "Usage refreshed. \(reports.count) provider report\(reports.count == 1 ? "" : "s")." + } catch { + errorMessage = T4Privacy.redacted(String(describing: error)) + announcement = "Usage refresh failed." + } + + do { + let response = try await controller.command("broker.status") + brokerStatus = decodeBrokerStatus(response.result) + if brokerStatus == nil { + brokerError = "The host returned an invalid broker.status response." + } + } catch { + brokerError = T4Privacy.redacted(String(describing: error)) + } + } +} + +private struct T4SettingValue: Sendable { + let value: String + let sensitive: Bool + let writableScopes: [String] + let restartRequired: Bool + let available: Bool + let group: String + let label: String? + let help: String? + + init( + value: String, + sensitive: Bool, + writableScopes: [String] = [], + restartRequired: Bool = false, + available: Bool = true, + group: String = "General", + label: String? = nil, + help: String? = nil + ) { + self.value = value + self.sensitive = sensitive + self.writableScopes = writableScopes + self.restartRequired = restartRequired + self.available = available + self.group = group + self.label = label + self.help = help + } + + var isWritable: Bool { available && !sensitive && !writableScopes.isEmpty } + var scope: String { writableScopes.first ?? "global" } +} + +private struct T4SettingMetadata { + let path: String + let group: String + let label: String? + let help: String? + let sensitive: Bool + let configured: Bool + let restartRequired: Bool + let available: Bool + let writableScopes: [String] +} + +private struct T4SettingSection: Identifiable { + let name: String + let paths: [String] + var id: String { name } +} + +@MainActor +public struct SettingsView: View { + @Bindable private var controller: T4ClientController + @AppStorage("t4-code:apple-theme-preference:v1") private var storedTheme = T4ThemePreference.system.rawValue + @State private var baseline: [String: T4SettingValue] = [:] + @State private var drafts: [String: String] = [:] + @State private var resets: Set = [] + @State private var revision: String? + @State private var isLoading = false + @State private var isSaving = false + @State private var hasLoaded = false + @State private var errorMessage: String? + @State private var revisionConflict = false + @State private var confirmsSave = false + @State private var confirmsUninstall = false + @State private var announcement = "" + @State private var lifecycleStatus: RuntimeServiceStatus? + @State private var lifecycleOperation: String? + @State private var lifecycleError: String? + + private let lifecycle: any PlatformLifecycle + + public init(controller: T4ClientController, lifecycle: any PlatformLifecycle) { + self.controller = controller + self.lifecycle = lifecycle + } + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: T4Spacing.xl) { + pageHeading( + title: "Settings", + detail: "Appearance on this device, staged OMP configuration, and local service diagnostics." + ) + appearanceSection + ompSettingsSection + lifecycleSection + diagnosticsSection + } + .frame(maxWidth: T4Layout.readableMeasure, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .top) + .padding(T4Spacing.lg) + } + .background(T4Color.background) + .navigationTitle("Settings") + .task { + async let settingsLoad: Void = loadSettings() + async let lifecycleLoad: Void = inspectLifecycle() + _ = await (settingsLoad, lifecycleLoad) + } + .confirmationDialog( + "Save \(pendingEditCount) OMP setting change\(pendingEditCount == 1 ? "" : "s")?", + isPresented: $confirmsSave, + titleVisibility: .visible + ) { + Button("Save changes") { Task { await saveSettings() } } + Button("Cancel", role: .cancel) {} + } message: { + Text("OMP may request a second security confirmation before applying these revisioned changes.") + } + .confirmationDialog( + "Remove the local OMP service?", + isPresented: $confirmsUninstall, + titleVisibility: .visible + ) { + Button("Remove service", role: .destructive) { Task { await runLifecycle("Remove") { try await lifecycle.uninstall() } } } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes the launch service definition. It does not delete projects or transcripts.") + } + .overlay(alignment: .topLeading) { + Text(announcement) + .frame(width: 1, height: 1) + .opacity(0.001) + .accessibilityHidden(announcement.isEmpty) + } + .t4Theme(themePreference) + } + + private var themePreference: T4ThemePreference { + T4ThemePreference(rawValue: storedTheme) ?? .system + } + + private var pendingEditCount: Int { + let changedValues = drafts.filter { baseline[$0.key]?.value != $0.value }.count + return changedValues + resets.count + } + + private var pendingConfirmation: T4AttentionItem? { + controller.state.attention.first { + $0.kind.lowercased() == "confirmation" && $0.commandID != nil + } + } + + private var appearanceSection: some View { + settingsGroup(title: "Appearance", detail: "Applied immediately on this device") { + Picker("Theme", selection: $storedTheme) { + ForEach(T4ThemePreference.allCases) { preference in + Text(preference.title).tag(preference.rawValue) + } + } + .pickerStyle(.segmented) + .accessibilityLabel("Color theme") + } + } + + @ViewBuilder + private var ompSettingsSection: some View { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + HStack(alignment: .firstTextBaseline, spacing: T4Spacing.xs) { + Text("OMP settings") + .font(T4Typography.heading(.title3)) + if pendingEditCount > 0 { T4StatusPill("\(pendingEditCount) unsaved", tone: .warning) } + Spacer(minLength: T4Spacing.sm) + Button("Reload") { Task { await loadSettings() } } + .buttonStyle(.borderless) + .disabled(isLoading || isSaving || controller.state.connection != .connected) + } + Text("Changes are staged here and sent together against the host revision.") + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + + if revisionConflict { + T4ErrorState( + title: "Settings changed on the host", + message: "Your staged values were not applied. Reload the latest revision, review, and save again.", + retry: { Task { await loadSettings() } } + ) + } else if let errorMessage { + T4ErrorState(title: "Settings unavailable", message: errorMessage, retry: { Task { await loadSettings() } }) + if let settingsAccessMessage { + Label(settingsAccessMessage, systemImage: "lock.trianglebadge.exclamationmark") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.warning) + .accessibilityLabel("Settings capability: \(settingsAccessMessage)") + } + } + + if let pendingConfirmation, isSaving { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + T4StatusPill("Security confirmation", tone: .approval) + Text("OMP is waiting for permission to apply the staged settings.") + .font(T4Typography.body(.subheadline)) + HStack(spacing: T4Spacing.xs) { + Button("Approve changes") { Task { await decideSettingsConfirmation(pendingConfirmation, decision: "approve") } } + .buttonStyle(.borderedProminent) + .tint(T4Color.accent) + Button("Deny", role: .destructive) { Task { await decideSettingsConfirmation(pendingConfirmation, decision: "deny") } } + .buttonStyle(.bordered) + } + } + .padding(T4Spacing.md) + .background(T4Color.warningSoft, in: RoundedRectangle(cornerRadius: T4Radius.md)) + } + + if isLoading && !hasLoaded { + loadingState("Loading OMP settings…") + } else if controller.state.connection != .connected && !hasLoaded { + T4EmptyState( + icon: "network.slash", + title: "OMP settings are offline", + message: "Connect before reading or changing host configuration.", + actionTitle: "Connect", + action: { Task { await controller.connect() } } + ) + } else if baseline.isEmpty && hasLoaded { + T4EmptyState( + icon: "slider.horizontal.3", + title: "No editable settings", + message: "This OMP host did not publish any settings available to this device." + ) + } else { + ForEach(settingGroups) { group in + settingsGroup(title: group.name, detail: nil) { + VStack(spacing: 0) { + ForEach(group.paths, id: \.self) { path in + settingRow(path) + if path != group.paths.last { Divider().overlay(T4Color.border) } + } + } + } + } + } + + if pendingEditCount > 0 { + HStack(spacing: T4Spacing.xs) { + Button("Save changes") { confirmsSave = true } + .buttonStyle(.borderedProminent) + .tint(T4Color.accent) + .disabled(isSaving || revision == nil || controller.state.connection != .connected) + Button("Discard") { discardStagedChanges() } + .buttonStyle(.bordered) + .disabled(isSaving) + if isSaving { ProgressView().controlSize(.small).accessibilityLabel("Saving settings") } + } + .padding(.top, T4Spacing.xs) + } + } + } + + private var settingGroups: [T4SettingSection] { + let grouped = Dictionary(grouping: baseline.keys.sorted()) { path in + baseline[path]?.group ?? "General" + } + return grouped.keys.sorted().map { + T4SettingSection(name: $0, paths: grouped[$0, default: []]) + } + } + + private func settingRow(_ path: String) -> some View { + let setting = baseline[path] ?? T4SettingValue(value: "", sensitive: T4Privacy.isSecretKey(path)) + return VStack(alignment: .leading, spacing: T4Spacing.xs) { + HStack(alignment: .firstTextBaseline, spacing: T4Spacing.sm) { + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(setting.label ?? settingLabel(path)) + .font(T4Typography.body(.subheadline, weight: .medium)) + Text(path) + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.mutedText) + } + Spacer(minLength: T4Spacing.sm) + if setting.sensitive { T4StatusPill("Secret", tone: .neutral) } + if !setting.available { T4StatusPill("Unavailable", tone: .warning) } + if !setting.sensitive && !setting.isWritable { T4StatusPill("Read only", tone: .neutral) } + if setting.restartRequired { T4StatusPill("Restart required", tone: .warning) } + if resets.contains(path) { T4StatusPill("Will reset", tone: .warning) } + if setting.isWritable { + Button("Reset") { + resets.insert(path) + drafts[path] = nil + } + .buttonStyle(.borderless) + .font(T4Typography.body(.caption, weight: .semibold)) + .disabled(isSaving) + .accessibilityLabel("Reset \(setting.label ?? settingLabel(path))") + } + } + + if let help = setting.help, !help.isEmpty { + Text(help) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + } + + if setting.sensitive || T4Privacy.isSecretKey(path) { + Label( + setting.value.isEmpty ? "No secret is configured." : "A secret is configured. Its value is never displayed.", + systemImage: "lock.fill" + ) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.mutedText) + .accessibilityLabel("Secret setting. Value hidden. Read only in this app.") + } else if setting.value == "true" || setting.value == "false" { + Toggle("Enabled", isOn: booleanBinding(path)) + .disabled(resets.contains(path) || !setting.isWritable || isSaving) + .accessibilityHint(setting.isWritable ? "Stages this value until Save changes is selected" : "This setting is read only") + } else { + TextField("Value", text: valueBinding(path)) + .textFieldStyle(.roundedBorder) + .disabled(resets.contains(path) || !setting.isWritable || isSaving) + .accessibilityHint(setting.isWritable ? "Stages this value until Save changes is selected" : "This setting is read only") + } + } + .padding(T4Spacing.md) + } + + @ViewBuilder + private var lifecycleSection: some View { + settingsGroup(title: "Local OMP service", detail: "Lifecycle actions affect this Mac only") { +#if os(iOS) + T4EmptyState( + icon: "desktopcomputer.trianglebadge.exclamationmark", + title: "Service management is unavailable on iOS", + message: "iPhone and iPad can connect to OMP hosts, but launch, stop, restart, and installation are supported on macOS only." + ) +#else + if let status = lifecycleStatus { + VStack(alignment: .leading, spacing: T4Spacing.md) { + HStack(spacing: T4Spacing.xs) { + T4StatusPill( + lifecycleLabel(status), + tone: status.service == .running ? .success : status.service == .failed ? .error : .neutral, + isPulsing: lifecycleOperation != nil + ) + if let operation = lifecycleOperation { + Text("\(operation)…") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.mutedText) + } + } + if let message = status.message { + Text(T4Privacy.redacted(message)) + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + } + Text(T4Privacy.redacted(status.diagnostics)) + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.secondaryText) + .textSelection(.enabled) + .padding(T4Spacing.sm) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T4Color.surface, in: RoundedRectangle(cornerRadius: T4Radius.sm)) + .accessibilityLabel("Service diagnostics") + if let lifecycleError { + T4ErrorState(title: "Service action failed", message: lifecycleError, retry: { Task { await inspectLifecycle() } }) + } + HStack(spacing: T4Spacing.xs) { + if status.definition == .missing { + Button("Install") { Task { await runLifecycle("Installing") { try await lifecycle.install() } } } + .buttonStyle(.borderedProminent) + .tint(T4Color.accent) + } + if status.service == .running { + Button("Restart") { Task { await runLifecycle("Restarting") { try await lifecycle.restart() } } } + .buttonStyle(.bordered) + Button("Stop") { Task { await runLifecycle("Stopping") { try await lifecycle.stop() } } } + .buttonStyle(.bordered) + } else if status.definition != .missing { + Button("Start") { Task { await runLifecycle("Starting") { try await lifecycle.start() } } } + .buttonStyle(.borderedProminent) + .tint(T4Color.accent) + } + Button("Inspect") { Task { await inspectLifecycle() } } + .buttonStyle(.bordered) + if status.definition != .missing { + Button("Remove", role: .destructive) { confirmsUninstall = true } + .buttonStyle(.bordered) + } + } + .disabled(lifecycleOperation != nil) + } + } else { + loadingState("Inspecting local OMP service…") + } +#endif + } + } + + private func valueBinding(_ path: String) -> Binding { + Binding( + get: { drafts[path] ?? baseline[path]?.value ?? "" }, + set: { drafts[path] = $0; resets.remove(path) } + ) + } + + + private func booleanBinding(_ path: String) -> Binding { + Binding( + get: { (drafts[path] ?? baseline[path]?.value) == "true" }, + set: { drafts[path] = $0 ? "true" : "false"; resets.remove(path) } + ) + } + + @MainActor + private func loadSettings() async { + guard !isLoading, controller.state.connection == .connected else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let catalogResponse = try await controller.command("catalog.get") + let metadata = decodeSettingCatalog(catalogResponse.result) + let response = try await controller.command("settings.read") + let decoded = decodeSettings(response.result, catalog: metadata) + baseline = decoded.values + revision = decoded.revision + drafts.removeAll(keepingCapacity: true) + resets.removeAll(keepingCapacity: true) + revisionConflict = false + hasLoaded = true + announcement = "OMP settings loaded." + } catch { + if baseline.isEmpty, !controller.state.settings.values.isEmpty { + baseline = Dictionary(uniqueKeysWithValues: controller.state.settings.values.map { path, value in + ( + path, + T4SettingValue( + value: T4Privacy.isSecretKey(path) ? "configured" : value, + sensitive: T4Privacy.isSecretKey(path), + group: "Host snapshot" + ) + ) + }) + hasLoaded = true + } + errorMessage = T4Privacy.redacted(String(describing: error)) + announcement = "OMP settings failed to load." + } + } + + @MainActor + private func saveSettings() async { + guard let revision, pendingEditCount > 0, !isSaving else { return } + isSaving = true + errorMessage = nil + revisionConflict = false + let edits = settingsEdits() + do { + _ = try await controller.command( + "settings.write", + expectedRevision: revision, + args: [ + "edits": .array(edits), + "expectedRevision": .string(revision), + ] + ) + isSaving = false + announcement = "OMP settings saved." + await loadSettings() + } catch { + isSaving = false + let message = T4Privacy.redacted(String(describing: error)) + if message.lowercased().contains("stale_revision") || message.lowercased().contains("stale revision") { + revisionConflict = true + announcement = "Settings conflict. Reload required." + } else { + errorMessage = message + announcement = "OMP settings were not saved." + } + } + } + + private func settingsEdits() -> [JSONValue] { + var edits: [JSONValue] = resets.sorted().compactMap { path in + guard let setting = baseline[path], setting.isWritable else { return nil } + return .object(["path": .string(path), "scope": .string(setting.scope), "reset": .bool(true)]) + } + for (path, value) in drafts.sorted(by: { $0.key < $1.key }) + where baseline[path]?.value != value && !resets.contains(path) && baseline[path]?.isWritable == true { + let scope = baseline[path]?.scope ?? "global" + edits.append(.object(["path": .string(path), "scope": .string(scope), "value": settingJSON(value)])) + } + return edits + } + + @MainActor + private func decideSettingsConfirmation(_ item: T4AttentionItem, decision: String) async { + do { + try await sendT4ConfirmationDecision(controller: controller, item: item, decision: decision) + controller.state.attention.removeAll { $0.id == item.id } + announcement = decision == "approve" ? "Settings changes approved." : "Settings changes denied." + } catch { + errorMessage = T4Privacy.redacted(String(describing: error)) + announcement = "Confirmation failed." + } + } + + private func discardStagedChanges() { + drafts.removeAll(keepingCapacity: true) + resets.removeAll(keepingCapacity: true) + revisionConflict = false + announcement = "Staged changes discarded." + } + + private var settingsAccessMessage: String? { + guard let message = errorMessage?.lowercased() else { return nil } + if message.contains("config.read") || message.contains("permission") || message.contains("capability") { + return "Permission denied. This device needs config.read and catalog.read." + } + if message.contains("catalog") || message.contains("metadata") { + return "The connected host did not publish settings metadata." + } + return nil + } + + private var diagnosticsSection: some View { + settingsGroup(title: "Diagnostics", detail: "Allowlisted and redacted before leaving this screen") { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + diagnosticLine("Connection", controller.state.connection.rawValue) + diagnosticLine("Authentication", controller.state.authentication.rawValue) + diagnosticLine("Indexed sessions", "\(controller.state.sessions.count)") + diagnosticLine("Attention items", "\(controller.state.attention.count)") + if let lifecycleStatus { + diagnosticLine("Runtime service", lifecycleStatus.service.rawValue) + diagnosticLine("Runtime definition", lifecycleStatus.definition.rawValue) + } + Button { + copyDiagnostics() + } label: { + Label("Copy redacted diagnostics", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + .accessibilityHint("Copies connection and runtime status without transcripts, credentials, or setting values") + } + } + } + + private func diagnosticLine(_ label: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: T4Spacing.sm) { + Text(label) + .font(T4Typography.body(.caption, weight: .medium)) + Spacer(minLength: T4Spacing.sm) + Text(T4Privacy.redacted(value)) + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.secondaryText) + } + .accessibilityElement(children: .combine) + } + + private func copyDiagnostics() { + let payload: [String: Any] = [ + "kind": "t4-code.apple-diagnostics", + "generatedAt": ISO8601DateFormatter().string(from: Date()), + "connection": controller.state.connection.rawValue, + "authentication": controller.state.authentication.rawValue, + "hostConnected": controller.hostID != nil, + "sessionCount": controller.state.sessions.count, + "attentionCount": controller.state.attention.count, + "publishedSettingKeys": baseline.keys.filter { !T4Privacy.isSecretKey($0) }.sorted(), + "runtimeService": lifecycleStatus?.service.rawValue ?? "unknown", + "runtimeDefinition": lifecycleStatus?.definition.rawValue ?? "unknown", + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]), + let text = String(data: data, encoding: .utf8) + else { + announcement = "Diagnostics could not be prepared." + return + } +#if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) +#elseif os(iOS) + UIPasteboard.general.string = text +#endif + announcement = "Redacted diagnostics copied." + } + + @MainActor + private func inspectLifecycle() async { + lifecycleStatus = await lifecycle.status() + lifecycleError = nil + } + + @MainActor + private func runLifecycle( + _ label: String, + action: @MainActor () async throws -> RuntimeServiceStatus + ) async { + guard lifecycleOperation == nil else { return } + lifecycleOperation = label + lifecycleError = nil + defer { lifecycleOperation = nil } + do { + lifecycleStatus = try await action() + announcement = "Local OMP service updated." + } catch { + lifecycleError = T4Privacy.redacted(String(describing: error)) + announcement = "Local OMP service action failed." + } + } +} + +private struct T4SettingGroup: View { + let title: String + let detail: String? + let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(title) + .font(T4Typography.heading(.headline)) + if let detail { + Text(detail) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.mutedText) + } + } + content + } + .padding(T4Spacing.md) + .background(T4Color.raised, in: RoundedRectangle(cornerRadius: T4Radius.lg)) + .overlay { RoundedRectangle(cornerRadius: T4Radius.lg).stroke(T4Color.border) } + } +} + +@MainActor +private func settingsGroup( + title: String, + detail: String?, + @ViewBuilder content: () -> Content +) -> some View { + T4SettingGroup(title: title, detail: detail, content: content()) +} + +private func pageHeading(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text(title) + .font(T4Typography.heading(.largeTitle, weight: .bold)) + .foregroundStyle(T4Color.foreground) + Text(detail) + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + } +} + +private func loadingState(_ label: String) -> some View { + HStack(spacing: T4Spacing.sm) { + ProgressView().controlSize(.small) + Text(label) + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + } + .padding(T4Spacing.lg) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel(label) +} + +private func decodeSearchResults(_ result: [String: JSONValue]?) -> [T4SearchResult] { + guard case let .array(items)? = result?["items"] else { return [] } + return items.compactMap { value in + guard case let .object(item) = value, + let sessionID = item["sessionId"]?.stringValue, + let anchorID = item["anchorId"]?.stringValue, + let snippet = item["snippet"]?.stringValue + else { return nil } + return T4SearchResult( + id: "\(sessionID):\(anchorID)", + sessionID: sessionID, + sessionTitle: item["sessionTitle"]?.stringValue ?? "", + projectID: item["projectId"]?.stringValue ?? "", + role: item["role"]?.stringValue ?? "assistant", + timestamp: item["timestamp"]?.stringValue ?? "", + snippet: snippet + ) + } +} + +private func decodeBrokerStatus(_ result: [String: JSONValue]?) -> T4BrokerStatus? { + guard let state = result?["state"]?.stringValue else { return nil } + return T4BrokerStatus(state: state, endpoint: result?["endpoint"]?.stringValue) +} + +private func decodeUsage(_ result: [String: JSONValue]?) -> (reports: [T4UsageReport], accountCountWithoutUsage: Int) { + guard let result else { return ([], 0) } + let accountCount: Int + if case let .array(accounts)? = result["accountsWithoutUsage"] { accountCount = accounts.count } else { accountCount = 0 } + guard case let .array(rawReports)? = result["reports"] else { return ([], accountCount) } + let reports = rawReports.enumerated().compactMap { index, raw -> T4UsageReport? in + guard case let .object(report) = raw, + let provider = report["provider"]?.stringValue + else { return nil } + let limits: [T4UsageLimit] + if case let .array(rawLimits)? = report["limits"] { + limits = rawLimits.compactMap(decodeUsageLimit) + } else { + limits = [] + } + let plan: String? + if case let .object(metadata)? = report["metadata"] { + plan = metadata["plan"]?.stringValue ?? metadata["planType"]?.stringValue ?? metadata["currentTierName"]?.stringValue + } else { + plan = nil + } + return T4UsageReport( + id: "\(provider):\(index)", + provider: provider, + plan: plan, + fetchedAt: jsonNumber(report["fetchedAt"]), + limits: limits + ) + } + return (reports, accountCount) +} + +private func decodeUsageLimit(_ raw: JSONValue) -> T4UsageLimit? { + guard case let .object(limit) = raw, + let id = limit["id"]?.stringValue, + let label = limit["label"]?.stringValue, + case let .object(amount)? = limit["amount"] + else { return nil } + let resetLabel: String? + if case let .object(window)? = limit["window"], let resets = jsonNumber(window["resetsAt"]) { + resetLabel = "resets \(Date(timeIntervalSince1970: resets / 1_000).formatted(date: .abbreviated, time: .shortened))" + } else { + resetLabel = nil + } + return T4UsageLimit( + id: id, + label: label, + status: limit["status"]?.stringValue ?? "unknown", + used: jsonNumber(amount["used"]), + limit: jsonNumber(amount["limit"]), + remaining: jsonNumber(amount["remaining"]), + usedFraction: jsonNumber(amount["usedFraction"]), + unit: amount["unit"]?.stringValue ?? "unknown", + resetLabel: resetLabel + ) +} + +private func inferredFraction(_ limit: T4UsageLimit) -> Double { + guard let used = limit.used, let maximum = limit.limit, maximum > 0 else { return 0 } + return used / maximum +} + +private func usageAmount(_ limit: T4UsageLimit) -> String { + if let used = limit.used, let maximum = limit.limit { + return "\(used.formatted()) of \(maximum.formatted()) \(limit.unit)" + } + if let remaining = limit.remaining { return "\(remaining.formatted()) \(limit.unit) remaining" } + return "Amount not reported" +} + +private func usageTone(_ status: String, fraction: Double) -> T4StatusTone { + switch status { + case "exhausted": .error + case "warning": .warning + case "ok": .success + default: fraction >= 1 ? .error : fraction >= 0.8 ? .warning : .neutral + } +} + +private func usageStatusLabel(_ status: String) -> String { + switch status { + case "exhausted": "Exhausted" + case "warning": "Running low" + case "ok": "Available" + default: "Unknown" + } +} + +private extension T4StatusTone { + var colorForUsage: Color { + switch self { + case .success, .done: T4Color.success + case .warning, .approval: T4Color.warning + case .error: T4Color.destructive + case .working: T4Color.info + case .input: T4Color.statusInput + case .plan: T4Color.statusPlan + case .neutral: T4Color.mutedText + } + } +} + +private func jsonNumber(_ value: JSONValue?) -> Double? { + guard case let .number(number)? = value else { return nil } + return number +} + +private func decodeSettingCatalog(_ result: [String: JSONValue]?) -> [String: T4SettingMetadata] { + guard case let .array(items)? = result?["items"] else { return [:] } + var catalog: [String: T4SettingMetadata] = [:] + for itemValue in items { + guard case let .object(item) = itemValue, + item["kind"]?.stringValue == "setting", + case let .object(metadata)? = item["metadata"] + else { continue } + let path = metadata["path"]?.stringValue ?? item["name"]?.stringValue ?? item["id"]?.stringValue + guard let path, !path.isEmpty, !path.hasPrefix("/"), !path.hasPrefix("~") else { continue } + let tab = metadata["tab"]?.stringValue + let subgroup = metadata["group"]?.stringValue + let group = [tab, subgroup].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ") + let sensitive = (jsonBool(metadata["sensitive"]) ?? false) + || metadata["controlType"]?.stringValue == "secret" + || T4Privacy.isSecretKey(path) + let available = (jsonBool(item["supported"]) ?? true) && (jsonBool(metadata["availability"]) ?? true) + let inlineEditable = ["boolean", "number", "string", "enum"].contains( + metadata["controlType"]?.stringValue ?? "" + ) + let readOnly = jsonBool(metadata["readOnly"]) ?? false + let scopes: [String] + if readOnly || sensitive || !available || !inlineEditable { + scopes = [] + } else if case let .array(rawScopes)? = metadata["scopes"] { + scopes = rawScopes.compactMap(\.stringValue).filter { $0 == "global" || $0 == "session" } + } else { + scopes = ["global"] + } + catalog[path] = T4SettingMetadata( + path: path, + group: group.isEmpty ? "General" : group, + label: metadata["label"]?.stringValue, + help: metadata["description"]?.stringValue, + sensitive: sensitive, + configured: jsonBool(metadata["configured"]) ?? false, + restartRequired: jsonBool(metadata["restartRequired"]) ?? false, + available: available, + writableScopes: scopes + ) + } + return catalog +} + +private func decodeSettings( + _ result: [String: JSONValue]?, + catalog: [String: T4SettingMetadata] +) -> (revision: String?, values: [String: T4SettingValue]) { + let revision = result?["revision"]?.stringValue + let settings: [String: JSONValue] + let responseValid: Bool + if case let .object(rawSettings)? = result?["settings"], revision?.isEmpty == false { + settings = rawSettings + responseValid = true + } else { + settings = [:] + responseValid = false + } + var values: [String: T4SettingValue] = [:] + for path in Set(settings.keys).union(catalog.keys).sorted() { + let raw = settings[path] + let published = catalog[path] + let sensitiveByName = T4Privacy.isSecretKey(path) + let valueMetadata = raw?.objectValue + let malformedValue = raw != nil && valueMetadata == nil + let sensitive = (jsonBool(valueMetadata?["sensitive"]) ?? false) + || (published?.sensitive ?? false) + || sensitiveByName + let configured = jsonBool(valueMetadata?["configured"]) ?? published?.configured ?? false + let structuredValue: Bool + if let effective = valueMetadata?["effective"] { + switch effective { + case .array, .object: structuredValue = true + case .null, .bool, .number, .string: structuredValue = false + } + } else { + structuredValue = false + } + let available = responseValid + && !malformedValue + && (jsonBool(valueMetadata?["availability"]) ?? published?.available ?? false) + let displayValue: String + if sensitive { + displayValue = configured ? "configured" : "" + } else if let effective = valueMetadata?["effective"], !structuredValue { + displayValue = displaySettingValue(effective) + } else { + displayValue = "" + } + values[path] = T4SettingValue( + value: displayValue, + sensitive: sensitive, + writableScopes: available && !structuredValue ? (published?.writableScopes ?? []) : [], + restartRequired: published?.restartRequired ?? false, + available: available, + group: published?.group ?? "Other", + label: published?.label, + help: published?.help + ) + } + return (revision, values) +} + +private func jsonBool(_ value: JSONValue?) -> Bool? { + guard case let .bool(boolean)? = value else { return nil } + return boolean +} + +private func displaySettingValue(_ value: JSONValue) -> String { + switch value { + case let .string(text): text + case let .bool(boolean): boolean ? "true" : "false" + case let .number(number): number.formatted() + case .null: "" + case .array, .object: "Structured value" + } +} + +private func settingJSON(_ value: String) -> JSONValue { + if value == "true" { return .bool(true) } + if value == "false" { return .bool(false) } + if let number = Double(value) { return .number(number) } + return .string(value) +} + +private func settingLabel(_ path: String) -> String { + let leaf = path.split(separator: ".").last.map(String.init) ?? path + return leaf + .replacingOccurrences(of: "([a-z0-9])([A-Z])", with: "$1 $2", options: .regularExpression) + .replacingOccurrences(of: "-", with: " ") + .capitalized +} + +private func lifecycleLabel(_ status: RuntimeServiceStatus) -> String { + switch status.service { + case .running: "Running" + case .starting: "Starting" + case .stopped: "Stopped" + case .failed: "Failed" + case .unknown: "Unknown" + } +} diff --git a/apps/apple/Sources/T4UI/SessionNavigationView.swift b/apps/apple/Sources/T4UI/SessionNavigationView.swift new file mode 100644 index 00000000..6b326d41 --- /dev/null +++ b/apps/apple/Sources/T4UI/SessionNavigationView.swift @@ -0,0 +1,754 @@ +import SwiftUI +import T4Client +import T4Protocol + +@MainActor +public struct SessionNavigationView: View { + private let controller: T4ClientController + + private let onSelect: (String) -> Void + + @State private var query = "" + @State private var listMode: SessionListMode = .current + @State private var pendingSessionIDs: Set = [] + @State private var isCreating = false + @State private var editor: SessionEditor? + @State private var pendingConfirmation: SessionConfirmation? + @State private var deleteCandidate: T4Session? + @State private var actionError: String? + + public init( + controller: T4ClientController, + onSelect: @escaping (String) -> Void = { _ in } + ) { + self.controller = controller + self.onSelect = onSelect + } + + public var body: some View { + let state = controller.state + let visibleSessions = filteredSessions(state.sessions) + let groups = groupedSessions(visibleSessions) + + VStack(spacing: T4Spacing.sm) { + navigationHeader(state: state) + + TextField("Search sessions", text: $query) + .textFieldStyle(.roundedBorder) + .accessibilityLabel("Search sessions") + .padding(.horizontal, T4Spacing.md) + + Picker("Session list", selection: $listMode) { + ForEach(SessionListMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .accessibilityLabel("Current or archived sessions") + .padding(.horizontal, T4Spacing.md) + + if groups.isEmpty { + T4EmptyState( + title: emptyTitle(state: state), + message: emptyMessage(state: state), + systemImage: emptySystemImage(state: state) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: T4Spacing.md, pinnedViews: [.sectionHeaders]) { + ForEach(groups) { group in + Section { + ForEach(group.sessions) { session in + sessionRow(session, state: state) + } + } header: { + HStack(spacing: T4Spacing.xs) { + Image(systemName: "folder") + Text(group.title) + .lineLimit(1) + Spacer(minLength: T4Spacing.xs) + Text("\(group.sessions.count)") + .foregroundStyle(T4Color.mutedText) + } + .font(T4Typography.body(.caption, weight: .semibold)) + .foregroundStyle(T4Color.secondaryText) + .padding(.horizontal, T4Spacing.md) + .padding(.vertical, T4Spacing.xs) + .background(T4Color.background) + .accessibilityElement(children: .combine) + } + } + } + .padding(.bottom, T4Spacing.lg) + } + .accessibilityLabel("Sessions grouped by host project scope") + } + + capabilityNotice + } + .background(T4Color.background) + .sheet(item: $editor) { editor in + SessionEditorSheet( + editor: editor, + isPending: editor.session.map { pendingSessionIDs.contains($0.id) } ?? isCreating, + onCancel: { self.editor = nil }, + onCommit: { projectID, title in + self.editor = nil + Task { @MainActor in + switch editor { + case .create: + await createSession(projectID: projectID, title: title) + case let .rename(session): + await renameSession(session, title: title) + } + } + } + ) + } + .sheet(item: $deleteCandidate) { session in + DeleteSessionSheet( + session: session, + isPending: pendingSessionIDs.contains(session.id), + onCancel: { deleteCandidate = nil }, + onDelete: { + deleteCandidate = nil + Task { @MainActor in + await runRevisionedCommand(.delete, session: session) + } + } + ) + } + .confirmationDialog( + pendingConfirmation?.title ?? "Confirm session action", + isPresented: confirmationIsPresented, + titleVisibility: .visible + ) { + if let confirmation = pendingConfirmation { + Button(confirmation.actionLabel, role: confirmation.role) { + pendingConfirmation = nil + Task { @MainActor in + await runRevisionedCommand(confirmation.action, session: confirmation.session) + } + } + Button("Cancel", role: .cancel) { + pendingConfirmation = nil + } + } + } message: { + if let confirmation = pendingConfirmation { + Text(confirmation.message) + } + } + .alert("Session action failed", isPresented: actionErrorIsPresented) { + Button("OK") { actionError = nil } + } message: { + Text(actionError ?? "The host rejected the request.") + } + } + + private func navigationHeader(state: AppState) -> some View { + HStack(alignment: .firstTextBaseline, spacing: T4Spacing.sm) { + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text("Sessions") + .font(T4Typography.heading(.title2)) + .foregroundStyle(T4Color.foreground) + + Text(connectionLabel(state.connection)) + .font(T4Typography.body(.caption)) + .foregroundStyle(state.connection == .connected ? T4Color.success : T4Color.secondaryText) + } + + Spacer(minLength: T4Spacing.sm) + + Button { + editor = .create + } label: { + Label("New session", systemImage: "plus") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .disabled(createDisabledReason(state: state) != nil) + .help(createDisabledReason(state: state) ?? "Create a session") + .accessibilityLabel("Create session") + .accessibilityHint(createDisabledReason(state: state) ?? "Opens the new session form") + } + .padding(.horizontal, T4Spacing.md) + .padding(.top, T4Spacing.md) + } + + @ViewBuilder + private func sessionRow(_ session: T4Session, state: AppState) -> some View { + let selected = session.id == state.selectedSessionID + let pending = pendingSessionIDs.contains(session.id) + let title = displayTitle(session) + + HStack(spacing: T4Spacing.sm) { + Button { + guard !pending else { return } + pendingSessionIDs.insert(session.id) + Task { @MainActor in + await controller.selectSession(session.id) + pendingSessionIDs.remove(session.id) + onSelect(session.id) + } + } label: { + HStack(spacing: T4Spacing.sm) { + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(title) + .font(T4Typography.body(.body, weight: selected ? .semibold : .regular)) + .foregroundStyle(T4Color.foreground) + .lineLimit(1) + + HStack(spacing: T4Spacing.xs) { + T4StatusPill( + text: session.status.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Idle" : session.status, + tone: statusTone(session.status) + ) + if let updatedAt = session.updatedAt { + Text(updatedAt, style: .relative) + .font(T4Typography.body(.caption2)) + .foregroundStyle(T4Color.mutedText) + } + } + } + + Spacer(minLength: T4Spacing.xs) + + if pending { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Selecting \(title)") + } else if selected { + Image(systemName: "checkmark") + .foregroundStyle(T4Color.accent) + .accessibilityHidden(true) + } + } + .contentShape(Rectangle()) + .padding(.vertical, T4Spacing.sm) + .padding(.leading, T4Spacing.md) + } + .buttonStyle(.plain) + .disabled(pending) + .accessibilityLabel("\(title), \(session.status.isEmpty ? "idle" : session.status)") + .accessibilityValue(selected ? "Selected" : "Not selected") + .accessibilityHint("Selects this session") + + sessionActions(session, state: state) + .padding(.trailing, T4Spacing.sm) + } + .background(selected ? T4Color.accentSoft : T4Color.surface) + .clipShape(RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + .stroke(selected ? T4Color.accent : T4Color.border) + } + .padding(.horizontal, T4Spacing.sm) + } + + private func sessionActions(_ session: T4Session, state: AppState) -> some View { + let disabledReason = managementDisabledReason(session: session, state: state) + let archived = isArchived(session) + let working = isWorking(session.status) + + return Menu { + if !archived { + Button("Rename") { editor = .rename(session) } + .disabled(disabledReason != nil) + + Button("Terminate runtime", role: .destructive) { + pendingConfirmation = SessionConfirmation(action: .terminate, session: session) + } + .disabled(disabledReason != nil || session.status.lowercased().contains("closed")) + + Button("Archive", role: .destructive) { + pendingConfirmation = SessionConfirmation(action: .archive, session: session) + } + .disabled(disabledReason != nil || working) + } else { + Button("Restore") { + pendingConfirmation = SessionConfirmation(action: .restore, session: session) + } + .disabled(disabledReason != nil) + } + + Divider() + + Button("Delete permanently", role: .destructive) { + deleteCandidate = session + } + .disabled(disabledReason != nil || working) + } label: { + Image(systemName: "ellipsis") + .frame(minWidth: T4Spacing.xl, minHeight: T4Spacing.xl) + .contentShape(Rectangle()) + } + .help(disabledReason ?? "Session actions") + .accessibilityLabel("Actions for \(displayTitle(session))") + .accessibilityHint(disabledReason ?? "Rename or manage this session") + } + + @ViewBuilder + private var capabilityNotice: some View { + if controller.state.connection == .connected, + !SessionCapabilities.allows("sessions.manage", state: controller.state) { + HStack(alignment: .top, spacing: T4Spacing.xs) { + Image(systemName: "lock") + .accessibilityHidden(true) + Text("The paired host did not grant sessions.manage access. Selection remains available; create and management actions are disabled.") + .font(T4Typography.body(.caption)) + } + .foregroundStyle(T4Color.secondaryText) + .padding(.horizontal, T4Spacing.md) + .padding(.bottom, T4Spacing.md) + .accessibilityElement(children: .combine) + } + } + + private var confirmationIsPresented: Binding { + Binding( + get: { pendingConfirmation != nil }, + set: { if !$0 { pendingConfirmation = nil } } + ) + } + + private var actionErrorIsPresented: Binding { + Binding( + get: { actionError != nil }, + set: { if !$0 { actionError = nil } } + ) + } + + private func filteredSessions(_ sessions: [T4Session]) -> [T4Session] { + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return sessions + .filter { session in + isArchived(session) == (listMode == .archived) + && (normalizedQuery.isEmpty + || session.title.lowercased().contains(normalizedQuery) + || session.hostID.lowercased().contains(normalizedQuery) + || session.status.lowercased().contains(normalizedQuery)) + } + .sorted { lhs, rhs in + switch (lhs.updatedAt, rhs.updatedAt) { + case let (left?, right?) where left != right: + return left > right + default: + return displayTitle(lhs).localizedCaseInsensitiveCompare(displayTitle(rhs)) == .orderedAscending + } + } + } + + private func groupedSessions(_ sessions: [T4Session]) -> [SessionGroup] { + Dictionary(grouping: sessions, by: \.hostID) + .map { hostID, sessions in + SessionGroup(id: hostID, title: hostID.isEmpty ? "Local project scope" : hostID, sessions: sessions) + } + .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + + private func createDisabledReason(state: AppState) -> String? { + guard state.connection == .connected else { return "Connect before creating a session." } + guard SessionCapabilities.allows("sessions.manage", state: state) else { + return "The paired host did not grant sessions.manage access." + } + guard !isCreating && pendingSessionIDs.isEmpty else { return "Wait for the current session action to finish." } + return nil + } + + private func managementDisabledReason(session: T4Session, state: AppState) -> String? { + guard state.connection == .connected else { return "Connect before managing this session." } + guard SessionCapabilities.allows("sessions.manage", state: state) else { + return "The paired host did not grant sessions.manage access." + } + guard !isCreating && pendingSessionIDs.isEmpty else { return "Wait for the current session action to finish." } + guard revision(for: session, state: state) != nil else { + return "Select the session and wait for a revision before changing it." + } + return nil + } + + private func revision(for session: T4Session, state: AppState) -> String? { + SessionRuntime.revision(for: session.id, state: state) + } + + private func createSession(projectID: String, title: String) async { + let project = projectID.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !project.isEmpty else { + actionError = "Enter a project identifier." + return + } + guard createDisabledReason(state: controller.state) == nil else { + actionError = createDisabledReason(state: controller.state) + return + } + + isCreating = true + defer { isCreating = false } + do { + var arguments: [String: JSONValue] = ["projectId": .string(project)] + if !normalizedTitle.isEmpty { + arguments["title"] = .string(normalizedTitle) + } + let response = try await controller.command( + "session.create", + sessionID: nil, + args: arguments + ) + guard + let created = response.result?["session"]?.objectValue, + let id = (created["sessionId"] ?? created["id"])?.stringValue, + !id.isEmpty + else { + actionError = "The host returned an invalid session.create response." + return + } + + let hostID = created["hostId"]?.stringValue ?? response.hostID + let createdSession = T4Session( + id: id, + hostID: hostID, + title: created["title"]?.stringValue ?? normalizedTitle, + status: created["status"]?.stringValue ?? "idle" + ) + if let index = controller.state.sessions.firstIndex(where: { $0.id == id }) { + controller.state.sessions[index] = createdSession + } else { + controller.state.sessions.append(createdSession) + } + await controller.selectSession(id) + onSelect(id) + } catch { + actionError = error.localizedDescription + } + } + + private func renameSession(_ session: T4Session, title: String) async { + let normalized = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { + actionError = "Enter a session title." + return + } + await runRevisionedCommand(.rename(normalized), session: session) + } + + private func runRevisionedCommand(_ action: RevisionedSessionAction, session: T4Session) async { + guard managementDisabledReason(session: session, state: controller.state) == nil else { + actionError = managementDisabledReason(session: session, state: controller.state) + return + } + guard let revision = revision(for: session, state: controller.state) else { + actionError = "The session revision is not available. Select the session and wait for it to finish loading." + return + } + guard !pendingSessionIDs.contains(session.id) else { return } + + pendingSessionIDs.insert(session.id) + defer { pendingSessionIDs.remove(session.id) } + + do { + _ = try await controller.command( + action.command, + sessionID: session.id, + expectedRevision: revision, + args: action.arguments + ) + await applySuccessful(action, to: session) + } catch { + actionError = error.localizedDescription + } + } + + private func applySuccessful(_ action: RevisionedSessionAction, to session: T4Session) async { + guard let index = controller.state.sessions.firstIndex(where: { $0.id == session.id }) else { return } + switch action { + case let .rename(title): + controller.state.sessions[index].title = title + case .terminate: + controller.state.sessions[index].status = "closed" + case .archive: + controller.state.sessions[index].status = "archived" + if controller.state.selectedSessionID == session.id { + let replacement = controller.state.sessions.first { !isArchived($0) && $0.id != session.id } + await controller.selectSession(replacement?.id) + if let replacement { onSelect(replacement.id) } + } + case .restore: + controller.state.sessions[index].status = "idle" + case .delete: + controller.state.sessions.remove(at: index) + if controller.state.selectedSessionID == session.id { + let replacement = controller.state.sessions.first { !isArchived($0) } + await controller.selectSession(replacement?.id) + if let replacement { onSelect(replacement.id) } + } + } + } + + private func emptyTitle(state: AppState) -> String { + if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matching sessions" } + if state.connection != .connected { return "Connect to load sessions" } + return listMode == .archived ? "No archived sessions" : "No current sessions" + } + + private func emptyMessage(state: AppState) -> String { + if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Try a session title, status, or host identifier." + } + if state.connection != .connected { + return "Your session list will appear after the host is connected." + } + return listMode == .archived + ? "Archived sessions will appear here and can be restored when management capabilities are available." + : "Create a session from a project when management capabilities and project metadata are available." + } + + private func emptySystemImage(state: AppState) -> String { + state.connection == .connected ? "rectangle.stack" : "network.slash" + } + + private func displayTitle(_ session: T4Session) -> String { + let title = session.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? "Untitled session" : title + } + + private func isArchived(_ session: T4Session) -> Bool { + session.status.localizedCaseInsensitiveContains("archiv") + } + + private func isWorking(_ status: String) -> Bool { + SessionRuntime.isActive(status) + } + + private func connectionLabel(_ connection: T4ConnectionState) -> String { + switch connection { + case .connected: "Connected" + case .connecting: "Connecting" + case .reconnecting: "Reconnecting" + case .failed: "Connection failed" + case .disconnected: "Disconnected" + } + } + + private func statusTone(_ status: String) -> T4StatusTone { + let normalized = status.lowercased() + if normalized.contains("error") || normalized.contains("failed") { return .error } + if normalized.contains("working") || normalized.contains("running") || normalized.contains("stream") { return .working } + if normalized.contains("approval") { return .approval } + if normalized.contains("input") || normalized.contains("question") { return .input } + if normalized.contains("done") || normalized.contains("complete") { return .done } + return .neutral + } +} + +private enum SessionListMode: String, CaseIterable, Identifiable { + case current + case archived + + var id: String { rawValue } + var title: String { rawValue.capitalized } +} + +private struct SessionGroup: Identifiable { + let id: String + let title: String + let sessions: [T4Session] +} + +private enum SessionEditor: Identifiable { + case create + case rename(T4Session) + + var id: String { + switch self { + case .create: "create" + case let .rename(session): "rename-\(session.id)" + } + } + + var session: T4Session? { + if case let .rename(session) = self { return session } + return nil + } +} + +private enum RevisionedSessionAction { + case rename(String) + case terminate + case archive + case restore + case delete + + var command: String { + switch self { + case .rename: "session.rename" + case .terminate: "session.close" + case .archive: "session.archive" + case .restore: "session.restore" + case .delete: "session.delete" + } + } + + var arguments: [String: JSONValue] { + if case let .rename(title) = self { return ["name": .string(title)] } + return [:] + } +} + +private struct SessionConfirmation { + let action: RevisionedSessionAction + let session: T4Session + + var title: String { + switch action { + case .terminate: "Terminate runtime?" + case .archive: "Archive session?" + case .restore: "Restore session?" + case .rename, .delete: "Confirm session action" + } + } + + var message: String { + let title = session.title.isEmpty ? "this session" : "“\(session.title)”" + return switch action { + case .terminate: + "This stops the running agent for \(title). The session and transcript remain available." + case .archive: + "Archive \(title)? It will move out of the current session list." + case .restore: + "Restore \(title) to the current session list?" + case .rename, .delete: + "Confirm this session action." + } + } + + var actionLabel: String { + switch action { + case .terminate: "Terminate" + case .archive: "Archive" + case .restore: "Restore" + case .rename: "Rename" + case .delete: "Delete" + } + } + + var role: ButtonRole? { + switch action { + case .terminate, .archive, .delete: .destructive + case .rename, .restore: nil + } + } +} + +private struct SessionEditorSheet: View { + let editor: SessionEditor + let isPending: Bool + let onCancel: () -> Void + let onCommit: (_ projectID: String, _ title: String) -> Void + + @State private var projectID = "" + @State private var title = "" + + var body: some View { + NavigationStack { + Form { + if case .create = editor { + Section("Project") { + TextField("Project identifier", text: $projectID) + Text("Project metadata is not available in AppState, so an exact project identifier is required.") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + } + } + + Section("Session") { + TextField("Title", text: $title) + .onSubmit(commit) + Text("Titles can contain up to 512 characters.") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + } + } + .navigationTitle(editor.session == nil ? "New session" : "Rename session") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: onCancel) + .disabled(isPending) + } + ToolbarItem(placement: .confirmationAction) { + Button(editor.session == nil ? "Create" : "Rename", action: commit) + .disabled(!isValid || isPending) + } + } + } + .onAppear { + if let session = editor.session { title = session.title } + } + } + + private var isValid: Bool { + let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedTitle.count <= 512 else { return false } + if case .create = editor { + return !projectID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + return !normalizedTitle.isEmpty + } + + private func commit() { + guard isValid else { return } + onCommit(projectID, title) + } +} + +private struct DeleteSessionSheet: View { + let session: T4Session + let isPending: Bool + let onCancel: () -> Void + let onDelete: () -> Void + + @State private var confirmation = "" + + private var confirmationText: String { + let title = session.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? "delete" : title + } + + var body: some View { + NavigationStack { + Form { + Section { + Label("This permanently deletes the session and its transcript.", systemImage: "exclamationmark.triangle") + .foregroundStyle(T4Color.destructive) + Text("This action cannot be undone. Type “\(confirmationText)” to confirm.") + .foregroundStyle(T4Color.secondaryText) + TextField("Session title", text: $confirmation) + .onSubmit(deleteIfValid) + } + } + .navigationTitle("Delete session?") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: onCancel) + .disabled(isPending) + } + ToolbarItem(placement: .confirmationAction) { + Button("Delete permanently", role: .destructive, action: deleteIfValid) + .disabled(!isValid || isPending) + } + } + } + .interactiveDismissDisabled(isPending) + } + + private var isValid: Bool { + confirmation.trimmingCharacters(in: .whitespacesAndNewlines) == confirmationText + } + + private func deleteIfValid() { + guard isValid else { return } + onDelete() + } +} diff --git a/apps/apple/Sources/T4UI/T4UI.swift b/apps/apple/Sources/T4UI/T4UI.swift new file mode 100644 index 00000000..2d375658 --- /dev/null +++ b/apps/apple/Sources/T4UI/T4UI.swift @@ -0,0 +1,1104 @@ +import Foundation +import Observation +import SwiftUI +import T4Client +import T4Platform +import T4Protocol + +public typealias T4ControllerFactory = + @MainActor @Sendable (HostProfile, DeviceCredentials?) -> T4ClientController + +/// The process-wide dependencies used by the native shell. The live +/// composition keeps persistence and service ownership stable while controllers +/// remain disposable per-host projections. +public struct T4Composition: Sendable { + public let profileStore: any HostProfileStore + public let credentialStore: any CredentialStore + public let lifecycle: any PlatformLifecycle + public let localHostSupervisor: LocalHostSupervisor? + + private let localProfile: HostProfile? + private let controllerFactory: T4ControllerFactory + + public init( + profileStore: any HostProfileStore = UserDefaultsHostProfileStore(), + credentialStore: any CredentialStore = KeychainCredentialStore(), + lifecycle: any PlatformLifecycle = PlatformLifecycleService(), + controllerFactory: T4ControllerFactory? = nil, + localHostSupervisor: LocalHostSupervisor? = nil, + localProfile: HostProfile? = nil + ) { + self.profileStore = profileStore + self.credentialStore = credentialStore + self.lifecycle = lifecycle + self.localHostSupervisor = localHostSupervisor + self.localProfile = localProfile + self.controllerFactory = controllerFactory ?? { profile, credentials in + let state = AppState() + let transport = URLSessionWebSocketTransport( + url: profile.webSocketURL, + profile: profile, + credentialStore: credentialStore, + credentials: credentials, + onWelcome: { [weak state] welcome in + guard let state else { return } + state.settings.values["client.grantedCapabilities"] = + welcome.grantedCapabilities.joined(separator: ",") + state.settings.values["client.grantedFeatures"] = + welcome.grantedFeatures.joined(separator: ",") + state.developer.isEnabled = welcome.grantedCapabilities.contains { capability in + capability.hasPrefix("term.") || + capability.hasPrefix("files.") || + capability == "audit.read" || + capability == "preview.read" || + capability == "preview.control" || + capability == "preview.input" + } + }, + onPairingSaved: { [weak state] in + state?.authentication = .paired + } + ) + return T4ClientController(transport: transport, state: state) + } + } + + public static func live() -> T4Composition { + T4Composition( + profileStore: UserDefaultsHostProfileStore(), + credentialStore: KeychainCredentialStore(), + lifecycle: PlatformLifecycleService() + ) + } + + public static func local(bundle: Bundle = .main) -> T4Composition { + let profile = try! HostProfile( + endpointKey: "local", + origin: "local", + profileID: "default", + webSocketURL: URL(string: "wss://local/v1/ws")!, + label: "This Mac" + ) + return T4Composition( + profileStore: UserDefaultsHostProfileStore(), + credentialStore: KeychainCredentialStore(), + lifecycle: PlatformLifecycleService(), + localHostSupervisor: LocalHostSupervisor(bundle: bundle), + localProfile: profile + ) + } + + var usesLocalHost: Bool { localHostSupervisor != nil } + + @MainActor + func makeController( + profile: HostProfile, + credentials: DeviceCredentials? + ) -> T4ClientController { + controllerFactory(profile, credentials) + } + + @MainActor + func makeLocalController(socketPath: String) throws -> T4ClientController { + let state = AppState() + let transport = try UnixWebSocketTransport(socketPath: socketPath) + return T4ClientController(transport: transport, state: state) + } + + func localProfileValue() -> HostProfile? { localProfile } +} + +@MainActor +@Observable +final class T4AppRuntime { + private let composition: T4Composition + + var directory = HostDirectory.empty + var controller: T4ClientController? + var isLoadingHosts = true + var isHostOperationInFlight = false + var hasLoadedHosts = false + var hostError: String? + var localPhase: LocalHostPhase = .stopped + var localError: String? + + init(composition: T4Composition) { + self.composition = composition + } + + var lifecycle: any PlatformLifecycle { composition.lifecycle } + var usesLocalHost: Bool { composition.usesLocalHost } + + func loadHosts() async { + guard !hasLoadedHosts else { return } + isLoadingHosts = true + hostError = nil + do { + directory = try await composition.profileStore.load() + hasLoadedHosts = true + isLoadingHosts = false + if let profile = directory.activeProfile { + await replaceController(with: profile) + } + } catch { + hasLoadedHosts = true + isLoadingHosts = false + hostError = "Saved hosts could not be loaded. Check this device’s storage and try again." + } + } + + func startLocalHost() async { + guard let supervisor = composition.localHostSupervisor, + let profile = composition.localProfileValue() + else { return } + guard localPhase != .starting else { return } + if localPhase == .running, controller?.state.connection == .connected { return } + + if let controller { + await controller.disconnect() + self.controller = nil + } + localPhase = .starting + localError = nil + do { + _ = try await supervisor.start() + let socketPath = (await supervisor.status()).socketURL.path + let next = try composition.makeLocalController(socketPath: socketPath) + directory = try HostDirectory(profiles: [profile], activeEndpointKey: profile.endpointKey) + controller = next + synchronizeProfiles() + await next.connect() + guard next.state.connection == .connected else { + throw LocalHostSupervisorError.launchFailed( + next.state.errorMessage ?? "The local T4 runtime could not establish a connection." + ) + } + localPhase = .running + } catch { + if let controller { + await controller.disconnect() + self.controller = nil + } + localPhase = .failed + localError = localMessage(for: error) + } + } + + func stopLocalHost() async { + guard let supervisor = composition.localHostSupervisor else { return } + if let controller { + await controller.disconnect() + self.controller = nil + } + do { + let status = try await supervisor.stop() + localPhase = status.phase + } catch { + localPhase = .failed + localError = localMessage(for: error) + } + } + + func reloadHosts() async { + guard !isHostOperationInFlight else { return } + isHostOperationInFlight = true + defer { isHostOperationInFlight = false } + hostError = nil + do { + let loaded = try await composition.profileStore.load() + directory = loaded + if let profile = loaded.activeProfile { + if controller?.state.selectedProfileID != profile.endpointKey { + await replaceController(with: profile) + } else { + synchronizeProfiles() + } + } else if let controller { + await controller.disconnect() + self.controller = nil + } + } catch { + hostError = "Saved hosts could not be loaded. Check this device’s storage and try again." + } + } + + func add(_ profile: HostProfile) async { + guard !isHostOperationInFlight else { return } + isHostOperationInFlight = true + defer { isHostOperationInFlight = false } + hostError = nil + do { + let next = try directory.upserting(profile) + try await composition.profileStore.save(next) + directory = next + await replaceController(with: profile) + } catch { + hostError = "The host could not be saved. Check the endpoint and available device storage." + } + } + + func select(_ profile: HostProfile) async { + guard !isHostOperationInFlight, + profile.endpointKey != directory.activeEndpointKey + else { return } + isHostOperationInFlight = true + defer { isHostOperationInFlight = false } + hostError = nil + do { + let next = try directory.activating(endpointKey: profile.endpointKey) + try await composition.profileStore.save(next) + directory = next + await replaceController(with: profile) + } catch { + hostError = "The selected host could not be activated. The previous host remains saved." + } + } + + func remove(_ profile: HostProfile) async { + guard !isHostOperationInFlight else { return } + isHostOperationInFlight = true + defer { isHostOperationInFlight = false } + hostError = nil + let wasActive = directory.activeEndpointKey == profile.endpointKey + do { + if wasActive, let controller { + await controller.disconnect() + } + try await composition.credentialStore.delete(for: profile) + let next = try directory.removing(endpointKey: profile.endpointKey) + try await composition.profileStore.save(next) + directory = next + + if wasActive { + controller = nil + if let replacement = next.activeProfile { + await replaceController(with: replacement) + } + } else { + synchronizeProfiles() + } + } catch { + hostError = "The host or its pairing credential could not be removed. Nothing on the computer was changed." + } + } + + func retryConnection() async { + guard !isHostOperationInFlight else { return } + if let controller { + await controller.connect() + } else if let profile = directory.activeProfile { + await replaceController(with: profile) + } + } + + private func replaceController(with profile: HostProfile) async { + if let controller { + await controller.disconnect() + } + + let credentials: DeviceCredentials? + do { + credentials = try await composition.credentialStore.read(for: profile) + } catch { + credentials = nil + hostError = "The saved pairing credential could not be read. You may need to pair this host again." + } + + let next = composition.makeController(profile: profile, credentials: credentials) + controller = next + synchronizeProfiles() + await next.connect() + } + + private func synchronizeProfiles() { + guard let controller else { return } + controller.state.profiles = directory.profiles.map { profile in + T4Profile( + id: profile.endpointKey, + label: profile.label, + targetID: profile.profileID, + isEnabled: true, + isSelected: profile.endpointKey == directory.activeEndpointKey + ) + } + controller.selectProfile(directory.activeEndpointKey) + } + + private func localMessage(for error: Error) -> String { + switch error { + case let error as LocalHostSupervisorError: + switch error { + case .unsupported: "Local T4 runtime is unavailable on this Mac." + case .invalidResource(let message), + .invalidPath(let message), + .launchFailed(let message), + .timedOut(let message): + message + case .processExited(_, let message): + message + } + default: + String(describing: error) + } + } +} + +@MainActor +public struct T4RootView: View { + @State private var runtime: T4AppRuntime + @State private var route: T4RootRoute = .conversation + @State private var showsCompactNavigation = false + @AppStorage("t4-code:apple-theme-preference:v1") + private var themeValue = T4ThemePreference.system.rawValue + + public init(composition: T4Composition) { + _runtime = State(initialValue: T4AppRuntime(composition: composition)) + } + + public var body: some View { + GeometryReader { geometry in + Group { + if runtime.usesLocalHost { + if runtime.localPhase == .starting || runtime.localPhase == .stopped { + localStartingSurface + } else if runtime.localPhase == .failed { + localErrorSurface + } else if let controller = runtime.controller { + if geometry.size.width >= T4Layout.wideBreakpoint { + wideShell(controller: controller) + } else { + compactShell(controller: controller) + } + } else { + localStartingSurface + } + } else if runtime.isLoadingHosts && !runtime.hasLoadedHosts { + loadingSurface + } else if runtime.directory.profiles.isEmpty { + hostManagement(presentsOnboarding: true) + } else if let controller = runtime.controller { + if geometry.size.width >= T4Layout.wideBreakpoint { + wideShell(controller: controller) + } else { + compactShell(controller: controller) + } + } else { + unavailableHostSurface + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T4Color.background) + } + .t4Theme(T4ThemePreference(rawValue: themeValue) ?? .system) + .task { + if runtime.usesLocalHost { + await runtime.startLocalHost() + } else { + await runtime.loadHosts() + } + } + .onDisappear { + guard runtime.usesLocalHost else { return } + Task { await runtime.stopLocalHost() } + } + } + + private var loadingSurface: some View { + VStack(spacing: T4Spacing.md) { + ProgressView() + .controlSize(.large) + Text("Loading saved hosts…") + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Loading saved hosts") + } + + private var localStartingSurface: some View { + VStack(spacing: T4Spacing.md) { + ProgressView() + .controlSize(.large) + Text("Starting the local T4 runtime…") + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + Text("Preparing this Mac’s private runtime connection.") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Starting the local T4 runtime") + } + + private var localErrorSurface: some View { + T4ErrorState( + title: "The local T4 runtime could not start", + message: runtime.localError ?? "T4 could not prepare the local runtime connection.", + retry: { + Task { await runtime.startLocalHost() } + } + ) + .frame(maxWidth: T4Layout.readableMeasure) + .padding(T4Spacing.xl) + } + + private var unavailableHostSurface: some View { + T4ErrorState( + title: "The active host is unavailable", + message: runtime.hostError ?? "T4 could not prepare the saved host connection.", + retry: { + Task { await runtime.retryConnection() } + } + ) + .frame(maxWidth: T4Layout.readableMeasure) + .padding(T4Spacing.xl) + } + + private func wideShell(controller: T4ClientController) -> some View { + NavigationSplitView { + VStack(spacing: 0) { + T4ConnectionControl(controller: controller) + Divider() + SessionNavigationView(controller: controller) { _ in + route = .conversation + } + Divider() + T4RootRouteList( + route: $route, + attentionCount: controller.state.attention.count + ) + } + .background(T4Color.surface) + .navigationSplitViewColumnWidth( + min: T4Layout.settingsRailWidth, + ideal: T4Layout.settingsRailWidth + T4Spacing.xxl, + max: T4Layout.settingsRailWidth + T4Spacing.xxl + T4Spacing.xxl + ) + } detail: { + detail(controller: controller) + } + .navigationSplitViewStyle(.balanced) + } + + private func compactShell(controller: T4ClientController) -> some View { + NavigationStack { + detail(controller: controller) + .navigationTitle(route.title) + .toolbar { + ToolbarItem(placement: .automatic) { + Button { + showsCompactNavigation = true + } label: { + Label("Open navigation", systemImage: "sidebar.left") + } + .accessibilityHint("Shows sessions and app destinations") + } + + ToolbarItemGroup(placement: .automatic) { + if !controller.state.attention.isEmpty && route == .conversation { + Button { + route = .attention + } label: { + Label( + "\(controller.state.attention.count) attention items", + systemImage: "tray.full" + ) + } + } + T4CompactConnectionButton(controller: controller) + } + } + } + .sheet(isPresented: $showsCompactNavigation) { + NavigationStack { + T4CompactNavigationSheet( + controller: controller, + route: $route, + dismiss: { showsCompactNavigation = false } + ) + .navigationTitle("T4 Code") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + showsCompactNavigation = false + } + } + } + } + } + } + + @ViewBuilder + private func detail(controller: T4ClientController) -> some View { + if route == .hosts { + hostManagement(presentsOnboarding: false) + } else if controller.state.authentication == .pairingRequired { + pairingSurface(controller: controller) + } else { + switch route { + case .conversation: + ConversationView(controller: controller) + case .attention: + AttentionView(controller: controller) + case .developer: + DeveloperWorkspaceView(controller: controller) + case .search: + SearchUsageView(controller: controller, mode: .search) + case .usage: + SearchUsageView(controller: controller, mode: .usage) + case .settings: + SettingsView(controller: controller, lifecycle: runtime.lifecycle) + case .hosts: + hostManagement(presentsOnboarding: false) + } + } + } + + private func pairingSurface(controller: T4ClientController) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: T4Spacing.lg) { + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text("Authorize this Apple device") + .font(T4Typography.heading(.largeTitle, weight: .bold)) + Text("Pairing is scoped to the selected Tailnet endpoint. T4 will reconnect with the saved device credential after approval.") + .font(T4Typography.body()) + .foregroundStyle(T4Color.secondaryText) + } + CredentialVolatilityBanner() + PairingCodeView(controller: controller) + } + .frame(maxWidth: T4Layout.readableMeasure, alignment: .leading) + .padding(T4Spacing.xl) + .frame(maxWidth: .infinity) + } + .background(T4Color.background) + } + + private func hostManagement(presentsOnboarding: Bool) -> some View { + HostManagementView( + directory: runtime.directory, + controller: runtime.controller, + isWorking: runtime.isHostOperationInFlight, + errorMessage: runtime.hostError, + presentsOnboarding: presentsOnboarding, + onAdd: { profile in + Task { await runtime.add(profile) } + }, + onSelect: { profile in + Task { await runtime.select(profile) } + }, + onRemove: { profile in + Task { await runtime.remove(profile) } + }, + onRetry: { + Task { await runtime.reloadHosts() } + } + ) + } +} + +private enum T4RootRoute: String, CaseIterable, Identifiable { + case conversation + case attention + case developer + case search + case usage + case settings + case hosts + + var id: String { rawValue } + + var title: String { + switch self { + case .conversation: "Conversation" + case .attention: "Attention" + case .developer: "Developer" + case .search: "Search" + case .usage: "Usage" + case .settings: "Settings" + case .hosts: "Hosts" + } + } + + var systemImage: String { + switch self { + case .conversation: "bubble.left.and.bubble.right" + case .attention: "tray" + case .developer: "chevron.left.forwardslash.chevron.right" + case .search: "text.magnifyingglass" + case .usage: "gauge.with.dots.needle.33percent" + case .settings: "gearshape" + case .hosts: "network" + } + } + + static let secondary: [T4RootRoute] = [ + .conversation, .attention, .developer, .search, .usage, .settings, .hosts, + ] +} + +@MainActor +private struct T4RootRouteList: View { + @Binding var route: T4RootRoute + let attentionCount: Int + + var body: some View { + ScrollView { + VStack(spacing: T4Spacing.xxs) { + ForEach(T4RootRoute.secondary) { destination in + T4RootRouteButton( + destination: destination, + selected: route == destination, + badge: destination == .attention ? attentionCount : 0 + ) { + route = destination + } + } + } + .padding(T4Spacing.xs) + } + .frame(maxHeight: T4Layout.settingsRailWidth) + .accessibilityLabel("App destinations") + } +} + +@MainActor +private struct T4RootRouteButton: View { + let destination: T4RootRoute + let selected: Bool + let badge: Int + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: T4Spacing.sm) { + Image(systemName: destination.systemImage) + .frame(width: T4Spacing.lg) + .accessibilityHidden(true) + Text(destination.title) + .font(T4Typography.body(.subheadline, weight: selected ? .semibold : .regular)) + Spacer(minLength: T4Spacing.xs) + if badge > 0 { + Text("\(badge)") + .font(T4Typography.body(.caption2, weight: .bold)) + .foregroundStyle(T4Color.warning) + .padding(.horizontal, T4Spacing.xs) + .padding(.vertical, T4Spacing.xxs) + .background(T4Color.warningSoft, in: Capsule()) + } + } + .foregroundStyle(selected ? T4Color.accent : T4Color.foreground) + .padding(.horizontal, T4Spacing.sm) + .frame(minHeight: T4Layout.minimumControlHeight) + .contentShape(Rectangle()) + .background( + selected ? T4Color.accentSoft : Color.clear, + in: RoundedRectangle(cornerRadius: T4Radius.md, style: .continuous) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(destination.title) + .accessibilityValue(selected ? "Selected" : "Not selected") + } +} + +@MainActor +private struct T4CompactNavigationSheet: View { + let controller: T4ClientController + @Binding var route: T4RootRoute + let dismiss: () -> Void + + var body: some View { + VStack(spacing: 0) { + SessionNavigationView(controller: controller) { _ in + route = .conversation + dismiss() + } + Divider() + ScrollView { + VStack(spacing: T4Spacing.xxs) { + ForEach(T4RootRoute.secondary) { destination in + T4RootRouteButton( + destination: destination, + selected: route == destination, + badge: destination == .attention ? controller.state.attention.count : 0 + ) { + route = destination + dismiss() + } + } + } + .padding(T4Spacing.xs) + } + .frame(maxHeight: T4Layout.settingsRailWidth) + } + .background(T4Color.background) + } +} + +@MainActor +private struct T4ConnectionControl: View { + let controller: T4ClientController + @State private var actionPending = false + + var body: some View { + VStack(alignment: .leading, spacing: T4Spacing.sm) { + HStack(spacing: T4Spacing.sm) { + Image(systemName: "network") + .foregroundStyle(T4Color.accent) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text("T4 Code") + .font(T4Typography.heading()) + T4StatusPill( + connectionLabel, + tone: connectionTone, + isPulsing: isConnecting + ) + } + Spacer(minLength: T4Spacing.xs) + Button(actionLabel) { + runAction() + } + .buttonStyle(.borderless) + .disabled(actionPending || isConnecting) + } + + if let message = controller.state.errorMessage, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(T4Privacy.redacted(message)) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.destructive) + .lineLimit(2) + .accessibilityLabel("Connection error: \(T4Privacy.redacted(message))") + } + } + .padding(T4Spacing.md) + .background(T4Color.raised) + } + + private var isConnecting: Bool { + controller.state.connection == .connecting || + controller.state.connection == .reconnecting + } + + private var connectionLabel: String { + switch controller.state.connection { + case .disconnected: "Offline" + case .connecting: "Connecting" + case .connected: "Connected" + case .reconnecting: "Reconnecting" + case .failed: "Connection failed" + } + } + + private var connectionTone: T4StatusTone { + switch controller.state.connection { + case .disconnected: .neutral + case .connecting, .reconnecting: .working + case .connected: .success + case .failed: .error + } + } + + private var actionLabel: String { + switch controller.state.connection { + case .connected, .reconnecting: "Disconnect" + case .failed: "Retry" + case .disconnected, .connecting: "Connect" + } + } + + private func runAction() { + guard !actionPending else { return } + actionPending = true + Task { @MainActor in + if controller.state.connection == .connected || + controller.state.connection == .reconnecting { + await controller.disconnect() + } else { + await controller.connect() + } + actionPending = false + } + } +} + +@MainActor +private struct T4CompactConnectionButton: View { + let controller: T4ClientController + @State private var actionPending = false + + var body: some View { + Button { + guard !actionPending else { return } + actionPending = true + Task { @MainActor in + if controller.state.connection == .connected || + controller.state.connection == .reconnecting { + await controller.disconnect() + } else { + await controller.connect() + } + actionPending = false + } + } label: { + if actionPending || + controller.state.connection == .connecting || + controller.state.connection == .reconnecting { + ProgressView() + .controlSize(.small) + .accessibilityLabel(compactActionLabel) + } else { + Label(compactActionLabel, systemImage: compactActionImage) + } + } + .disabled(actionPending || controller.state.connection == .connecting) + .accessibilityHint("Changes the active host connection") + } + + private var compactActionLabel: String { + switch controller.state.connection { + case .connected, .reconnecting: "Disconnect" + case .failed: "Retry connection" + case .disconnected, .connecting: "Connect" + } + } + + private var compactActionImage: String { + switch controller.state.connection { + case .connected, .reconnecting: "link.badge.minus" + case .failed: "arrow.clockwise" + case .disconnected, .connecting: "power" + } + } +} + +private enum CredentialTransportError: Error { + case invalidPairingResponse +} + +/// URLSession transport boundary that adds only the selected profile's scoped +/// credential and persists a validated pair result before exposing it to UI. +private actor URLSessionWebSocketTransport: T4ClientTransport { + nonisolated var incoming: AsyncThrowingStream { + streamBox.current + } + + private let transport: WebSocketTransport + private let profile: HostProfile + private let credentialStore: any CredentialStore + private let streamBox = T4IncomingStreamBox() + private let onWelcome: @MainActor @Sendable (WelcomeFrame) -> Void + private let onPairingSaved: @MainActor @Sendable () -> Void + + private var credentials: DeviceCredentials? + private var pendingPair: PairStartFrame? + private var relayTask: Task? + private var generation: UInt64 = 0 + + init( + url: URL, + profile: HostProfile, + credentialStore: any CredentialStore, + credentials: DeviceCredentials?, + onWelcome: @escaping @MainActor @Sendable (WelcomeFrame) -> Void, + onPairingSaved: @escaping @MainActor @Sendable () -> Void + ) { + transport = WebSocketTransport(url: url) + self.profile = profile + self.credentialStore = credentialStore + self.credentials = credentials + self.onWelcome = onWelcome + self.onPairingSaved = onPairingSaved + } + + func connect() async throws { + generation &+= 1 + let currentGeneration = generation + relayTask?.cancel() + streamBox.replace() + try await transport.connect() + let upstream = transport.incoming + relayTask = Task { [weak self] in + do { + for try await frame in upstream { + guard let self else { return } + try await self.receive(frame, generation: currentGeneration) + } + await self?.finishRelay(generation: currentGeneration, error: nil) + } catch { + await self?.finishRelay(generation: currentGeneration, error: error) + } + } + } + + func send(_ frame: WireFrame) async throws { + switch frame { + case let .hello(hello): + var raw = hello.raw + raw["requestedFeatures"] = .array(Self.requestedFeatures.map(JSONValue.string)) + raw["capabilities"] = .object([ + "client": .array(Self.requestedCapabilities.map(JSONValue.string)), + ]) + if let credentials { + raw["authentication"] = .object([ + "deviceId": .string(credentials.deviceID), + "deviceToken": .string(credentials.deviceToken), + ]) + } + try await transport.send(try Self.frame(raw)) + + case let .pairStart(pair): + var raw = pair.raw + raw["requestedCapabilities"] = + .array(Self.requestedCapabilities.map(JSONValue.string)) + let normalized = try Self.frame(raw) + guard case let .pairStart(request) = normalized else { + throw CredentialTransportError.invalidPairingResponse + } + pendingPair = request + try await transport.send(normalized) + + default: + try await transport.send(frame) + } + } + + func disconnect() async { + generation &+= 1 + relayTask?.cancel() + relayTask = nil + pendingPair = nil + await transport.disconnect() + streamBox.finish() + } + + private func receive(_ frame: WireFrame, generation currentGeneration: UInt64) async throws { + guard generation == currentGeneration else { return } + switch frame { + case let .welcome(welcome): + await onWelcome(welcome) + + case let .pairOK(pairOK): + let saved = try validatedCredentials(pairOK) + try await credentialStore.write(saved, for: profile) + guard generation == currentGeneration else { return } + credentials = saved + pendingPair = nil + await onPairingSaved() + + case .pairError: + pendingPair = nil + + default: + break + } + streamBox.yield(frame) + } + + private func validatedCredentials(_ response: PairOKFrame) throws -> DeviceCredentials { + guard let pendingPair, + response.raw["requestId"]?.stringValue == pendingPair.requestId, + response.raw["deviceId"]?.stringValue == pendingPair.deviceId, + response.raw["deviceName"]?.stringValue == pendingPair.deviceName, + response.raw["platform"]?.stringValue == pendingPair.platform, + let token = response.raw["deviceToken"]?.stringValue, + let requested = Self.strings(response.raw["requestedCapabilities"]), + let granted = Self.strings(response.raw["grantedCapabilities"]), + Set(requested).isSubset(of: Set(pendingPair.requestedCapabilities)), + Set(granted).isSubset(of: Set(pendingPair.requestedCapabilities)), + let expiresAt = response.raw["expiresAt"]?.stringValue, + let expiration = ISO8601DateFormatter().date(from: expiresAt), + expiration > Date() + else { + throw CredentialTransportError.invalidPairingResponse + } + return try DeviceCredentials(deviceID: pendingPair.deviceId, deviceToken: token) + } + + private func finishRelay(generation currentGeneration: UInt64, error: Error?) async { + guard generation == currentGeneration else { return } + relayTask = nil + if let error { + await transport.disconnect() + streamBox.finish(throwing: error) + } else { + streamBox.finish() + } + } + + private static func strings(_ value: JSONValue?) -> [String]? { + guard case let .array(values) = value else { return nil } + let strings = values.compactMap(\.stringValue) + return strings.count == values.count ? strings : nil + } + + private static func frame(_ raw: [String: JSONValue]) throws -> WireFrame { + try WireDecoder.decode(try JSONValue.object(raw).encodedData()) + } + + private static let requestedFeatures = [ + "resume", "host.watch", "session.watch", "session.state", "session.delta", + "session.observer", "controller.lease", "prompt.lease", "prompt.images", + "transcript.images", "transcript.search", "transcript.page", + "agent.lifecycle", "agent.progress", "agent.event", "agent.transcript", + "terminal.io", "files.list", "files.search", "files.diff", "audit.tail", + "catalog.metadata", "settings.metadata", "preview.control", + ] + + private static let requestedCapabilities = [ + "sessions.read", "sessions.prompt", "sessions.control", "sessions.manage", + "term.open", "term.input", "term.resize", "files.read", "files.write", + "files.list", "files.diff", "agents.control", "audit.read", "config.read", + "catalog.read", "config.write", "broker.read", "usage.read", "preview.read", + "preview.control", "preview.input", + ] +} + +private final class T4IncomingStreamBox: @unchecked Sendable { + private let lock = NSLock() + private var stream: AsyncThrowingStream + private var continuation: AsyncThrowingStream.Continuation + + init() { + var continuation: AsyncThrowingStream.Continuation! + stream = AsyncThrowingStream { continuation = $0 } + self.continuation = continuation + } + + var current: AsyncThrowingStream { + lock.lock() + defer { lock.unlock() } + return stream + } + + func replace() { + let old: AsyncThrowingStream.Continuation + lock.lock() + old = continuation + var next: AsyncThrowingStream.Continuation! + stream = AsyncThrowingStream { next = $0 } + continuation = next + lock.unlock() + old.finish() + } + + func yield(_ frame: WireFrame) { + lock.lock() + let continuation = continuation + lock.unlock() + continuation.yield(frame) + } + + func finish(throwing error: Error? = nil) { + lock.lock() + let continuation = continuation + lock.unlock() + if let error { + continuation.finish(throwing: error) + } else { + continuation.finish() + } + } +} diff --git a/apps/apple/Sources/T4UI/TerminalView.swift b/apps/apple/Sources/T4UI/TerminalView.swift new file mode 100644 index 00000000..db482ddc --- /dev/null +++ b/apps/apple/Sources/T4UI/TerminalView.swift @@ -0,0 +1,510 @@ +import Foundation +import SwiftUI +import T4Client +import T4Protocol + +#if os(macOS) +import AppKit +#elseif os(iOS) +import UIKit +#endif + +@MainActor +public struct TerminalView: View { + private let controller: T4ClientController + + @State private var terminals: [DeveloperTerminalSession] = [] + @State private var selectedTerminalID: String? + @State private var input = "" + @State private var columns = T4DeveloperDesign.terminalDefaultColumns + @State private var rows = T4DeveloperDesign.terminalDefaultRows + @State private var busyAction: String? + @State private var actionError: String? + @State private var pendingPaste = "" + @State private var showPasteConfirmation = false + @FocusState private var inputFocused: Bool + + public init(controller: T4ClientController) { + self.controller = controller + } + + public var body: some View { + Group { + if !isConnected && terminals.isEmpty { + offlineState + } else if !hasCapability("term.open") && terminals.isEmpty { + DeveloperEmptyState( + systemImage: "lock", + title: "Terminal is unavailable", + detail: "The paired host did not grant term.open access." + ) + } else { + GeometryReader { proxy in + if proxy.size.width >= T4Layout.wideBreakpoint { + HStack(spacing: 0) { + controlPane + .frame(width: T4DeveloperDesign.terminalControlWidth) + Divider() + outputPane + } + } else { + VStack(spacing: 0) { + controlPane + Divider() + outputPane + .frame(minHeight: T4DeveloperDesign.terminalOutputMinimumHeight) + } + } + } + } + } + .background(T4Color.background) + .onChange(of: selectedTerminalID) { _, _ in + guard let terminal = selectedTerminal else { return } + columns = terminal.columns + rows = terminal.rows + } + .alert("Paste into terminal?", isPresented: $showPasteConfirmation) { + Button("Cancel", role: .cancel) { + pendingPaste = "" + } + Button("Paste") { + let value = pendingPaste + pendingPaste = "" + Task { await sendInput(value, source: "paste") } + } + } message: { + Text(pasteWarning) + } + } + + private var offlineState: some View { + DeveloperEmptyState( + systemImage: "network.slash", + title: "Terminal is offline", + detail: "Reconnect to open a protocol-backed terminal for the selected session." + ) { + AnyView( + Button { + Task { await controller.connect() } + } label: { + Label("Reconnect", systemImage: "arrow.clockwise") + } + .buttonStyle(.borderedProminent) + .disabled(controller.state.connection == .connecting || controller.state.connection == .reconnecting) + ) + } + } + + private var controlPane: some View { + VStack(spacing: 0) { + terminalStrip + Divider() + if let actionError { + DeveloperErrorBanner(message: actionError) { + self.actionError = nil + } + } + if busyAction != nil { + ProgressView() + .progressViewStyle(.linear) + .accessibilityLabel("Terminal operation in progress") + } + + if selectedTerminal == nil { + DeveloperEmptyState( + systemImage: "apple.terminal", + title: "No terminal open", + detail: selectedSessionAvailable + ? "Open a terminal to run commands on the paired host." + : "Select a conversation before opening a terminal." + ) { + AnyView( + Button { + Task { await openTerminal() } + } label: { + Label("Open Terminal", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + .disabled(!canOpen) + ) + } + } else { + terminalControls + } + } + .background(T4Color.raised) + } + + private var terminalStrip: some View { + HStack(spacing: T4Spacing.xs) { + Picker("Active terminal", selection: $selectedTerminalID) { + Text("No terminal").tag(String?.none) + ForEach(terminals) { terminal in + Text(terminal.title).tag(String?.some(terminal.id)) + } + } + .pickerStyle(.menu) + .disabled(terminals.isEmpty || busyAction != nil) + .accessibilityLabel("Select terminal") + + Spacer() + + Button { + Task { await openTerminal() } + } label: { + Image(systemName: "plus") + } + .disabled(!canOpen) + .accessibilityLabel("Open terminal") + + Button(role: .destructive) { + Task { await closeSelectedTerminal() } + } label: { + Image(systemName: "xmark") + } + .disabled(!canClose || busyAction != nil) + .accessibilityLabel("Close selected terminal") + } + .padding(T4Spacing.sm) + } + + private var terminalControls: some View { + ScrollView { + VStack(alignment: .leading, spacing: T4Spacing.md) { + terminalStatus + + VStack(alignment: .leading, spacing: T4Spacing.xs) { + Text("Input") + .font(T4Typography.heading(.subheadline)) + TextField("Command", text: $input, axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(T4Typography.monospaced()) + .lineLimit(1...4) + .focused($inputFocused) + .disabled(!canInput || busyAction != nil) + .onSubmit { + Task { await sendTypedInput() } + } + .accessibilityLabel("Terminal input") + .accessibilityHint("Press Return or choose Send to submit this command") + + HStack { + Button { + preparePaste() + } label: { + Label("Paste", systemImage: "doc.on.clipboard") + } + .disabled(!canInput || busyAction != nil) + .accessibilityHint("Shows a confirmation before clipboard text is sent") + + Spacer() + + Button { + Task { await sendTypedInput() } + } label: { + Label("Send", systemImage: "paperplane.fill") + } + .buttonStyle(.borderedProminent) + .disabled(input.isEmpty || !canInput || busyAction != nil) + } + } + + Divider() + + VStack(alignment: .leading, spacing: T4Spacing.sm) { + Text("Terminal size") + .font(T4Typography.heading(.subheadline)) + Stepper( + "Columns: \(columns)", + value: $columns, + in: 1...T4DeveloperDesign.terminalMaximumColumns + ) + .disabled(!canResize || busyAction != nil) + Stepper( + "Rows: \(rows)", + value: $rows, + in: 1...T4DeveloperDesign.terminalMaximumRows + ) + .disabled(!canResize || busyAction != nil) + Button { + Task { await resizeTerminal() } + } label: { + Label("Apply Size", systemImage: "arrow.up.left.and.arrow.down.right") + } + .disabled(!canResize || busyAction != nil) + } + + if !hasCapability("term.input") { + DeveloperNotice( + systemImage: "lock", + message: "This terminal is read-only because term.input access is unavailable." + ) + } else if !hasCapability("term.resize") { + DeveloperNotice( + systemImage: "lock", + message: "The paired host did not grant term.resize access." + ) + } + } + .padding(T4Spacing.md) + } + } + + private var terminalStatus: some View { + HStack(spacing: T4Spacing.sm) { + Image(systemName: isConnected ? "circle.fill" : "exclamationmark.circle.fill") + .font(.caption) + .foregroundStyle(isConnected ? T4Color.success : T4Color.warning) + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(selectedTerminal?.title ?? "Terminal") + .font(T4Typography.heading(.subheadline)) + Text(isConnected ? "Connected through omp-app terminal I/O" : "Offline · input is disabled") + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + } + Spacer() + } + .accessibilityElement(children: .combine) + .accessibilityLabel(isConnected ? "Terminal connected" : "Terminal offline and read only") + } + + private var outputPane: some View { + VStack(spacing: 0) { + HStack { + Text("Terminal output") + .font(T4Typography.heading()) + Spacer() + if let selectedTerminal { + Text("\(selectedTerminal.columns) × \(selectedTerminal.rows)") + .font(T4Typography.monospaced(.caption)) + .foregroundStyle(T4Color.mutedText) + .accessibilityLabel("\(selectedTerminal.columns) columns by \(selectedTerminal.rows) rows") + } + } + .padding(T4Spacing.sm) + .background(T4Color.raised) + Divider() + + if let selectedTerminal { + ScrollView([.horizontal, .vertical]) { + Text(selectedTerminal.output.isEmpty ? "Terminal opened. Host output will appear as protocol events arrive." : selectedTerminal.output) + .font(T4Typography.monospaced()) + .foregroundStyle(T4Color.foreground) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(T4Spacing.md) + } + .background(T4Color.input) + .accessibilityLabel("Terminal output for \(selectedTerminal.title)") + } else { + DeveloperEmptyState( + systemImage: "text.alignleft", + title: "No terminal selected", + detail: "Open or select a terminal to inspect its output." + ) + } + } + } + + private var selectedTerminal: DeveloperTerminalSession? { + guard let selectedTerminalID else { return nil } + return terminals.first { $0.id == selectedTerminalID } + } + + private var selectedSessionAvailable: Bool { + controller.state.selectedSessionID != nil + } + + private var isConnected: Bool { + controller.state.connection == .connected + } + + private var canOpen: Bool { + selectedSessionAvailable && isConnected && hasCapability("term.open") && busyAction == nil + } + + private var canInput: Bool { + guard let terminal = selectedTerminal else { return false } + return terminal.isRunning && + terminal.sessionID == controller.state.selectedSessionID && + isConnected && + hasCapability("term.input") + } + + private var canResize: Bool { + guard let terminal = selectedTerminal else { return false } + return terminal.isRunning && + terminal.sessionID == controller.state.selectedSessionID && + isConnected && + hasCapability("term.resize") + } + + private var canClose: Bool { + selectedTerminal != nil && isConnected && hasCapability("term.open") + } + + private var pasteWarning: String { + let lines = pendingPaste.split(separator: "\n", omittingEmptySubsequences: false).count + let preview = pendingPaste.count > T4DeveloperDesign.terminalPastePreviewLength + ? String(pendingPaste.prefix(T4DeveloperDesign.terminalPastePreviewLength)) + "…" + : pendingPaste + return "\(lines) \(lines == 1 ? "line" : "lines") may execute immediately. Review before sending:\n\n\(preview)" + } + + private func hasCapability(_ capability: String) -> Bool { + DeveloperCapabilities.allows(capability, state: controller.state) + } + + private func openTerminal() async { + guard canOpen, let sessionID = controller.state.selectedSessionID else { return } + await perform("term.open") { + let response = try await controller.command( + "term.open", + sessionID: sessionID, + args: ["cols": .integer(columns), "rows": .integer(rows)] + ) + guard let terminalID = response.result?["terminalId"]?.stringValue else { + throw DeveloperSurfaceError("The host opened no terminal identifier.") + } + if !terminals.contains(where: { $0.id == terminalID }) { + terminals.append( + DeveloperTerminalSession( + id: terminalID, + sessionID: sessionID, + title: response.result?["title"]?.stringValue ?? "Terminal \(terminals.count + 1)", + output: response.result?["output"]?.stringValue ?? "", + columns: columns, + rows: rows, + isRunning: true + ) + ) + } + selectedTerminalID = terminalID + inputFocused = true + } + } + + private func sendTypedInput() async { + guard !input.isEmpty else { return } + let pendingInput = input + let command = pendingInput.hasSuffix("\n") ? pendingInput : pendingInput + "\n" + if await sendInput(command, source: "typed") { + input = "" + } + } + + @discardableResult + private func sendInput(_ data: String, source: String) async -> Bool { + guard let terminal = selectedTerminal, + canInput, + let hostID = controller.hostID else { return false } + return await perform("terminal.input") { + let encodedFrame = try WireEncoder.terminalInput( + hostId: hostID, + sessionId: terminal.sessionID, + terminalId: terminal.id, + data: data + ) + try await controller.transport.send(try WireDecoder.decode(encodedFrame)) + appendOutput(data, to: terminal.id, source: source) + } + } + + private func resizeTerminal() async { + guard let terminal = selectedTerminal, + canResize, + let hostID = controller.hostID else { return } + await perform("terminal.resize") { + let encodedFrame = try WireEncoder.terminalResize( + hostId: hostID, + sessionId: terminal.sessionID, + terminalId: terminal.id, + cols: columns, + rows: rows + ) + try await controller.transport.send(try WireDecoder.decode(encodedFrame)) + guard let index = terminals.firstIndex(where: { $0.id == terminal.id }) else { return } + terminals[index].columns = columns + terminals[index].rows = rows + } + } + + private func closeSelectedTerminal() async { + guard let terminal = selectedTerminal, + canClose, + let hostID = controller.hostID else { return } + await perform("terminal.close") { + let encodedFrame = try WireEncoder.terminalClose( + hostId: hostID, + sessionId: terminal.sessionID, + terminalId: terminal.id, + reason: "user" + ) + try await controller.transport.send(try WireDecoder.decode(encodedFrame)) + terminals.removeAll { $0.id == terminal.id } + selectedTerminalID = terminals.last?.id + } + } + + private func preparePaste() { + guard canInput else { return } + guard let clipboardText = terminalClipboardText(), !clipboardText.isEmpty else { + actionError = "The clipboard does not contain text." + return + } + pendingPaste = clipboardText + showPasteConfirmation = true + } + + private func appendOutput(_ value: String, to terminalID: String, source: String) { + guard let index = terminals.firstIndex(where: { $0.id == terminalID }) else { return } + let marker: String + switch source { + case "paste": marker = "[pasted] " + case "typed": marker = "> " + default: marker = "" + } + terminals[index].output += marker + value + if terminals[index].output.count > T4DeveloperDesign.terminalOutputLimit { + terminals[index].output = String(terminals[index].output.suffix(T4DeveloperDesign.terminalOutputLimit)) + } + } + + @discardableResult + private func perform(_ name: String, operation: @escaping @MainActor () async throws -> Void) async -> Bool { + guard busyAction == nil else { return false } + busyAction = name + actionError = nil + defer { busyAction = nil } + do { + try await operation() + return true + } catch { + actionError = developerErrorMessage(error) + return false + } + } +} + +#if os(macOS) +@MainActor +private func terminalClipboardText() -> String? { + NSPasteboard.general.string(forType: .string) +} +#elseif os(iOS) +@MainActor +private func terminalClipboardText() -> String? { + UIPasteboard.general.string +} +#endif + +private struct DeveloperTerminalSession: Identifiable { + let id: String + let sessionID: String + let title: String + var output: String + var columns: Int + var rows: Int + var isRunning: Bool +} diff --git a/apps/apple/Sources/T4UI/Theme.swift b/apps/apple/Sources/T4UI/Theme.swift new file mode 100644 index 00000000..0b04f3e5 --- /dev/null +++ b/apps/apple/Sources/T4UI/Theme.swift @@ -0,0 +1,376 @@ +import Foundation +import SwiftUI + +#if os(macOS) +import AppKit +#elseif os(iOS) +import UIKit +#endif + +/// The user-facing appearance preference. `system` intentionally leaves the +/// color scheme unset so each window follows the platform setting live. +public enum T4ThemePreference: String, CaseIterable, Identifiable, Sendable { + case system + case light + case dark + + public var id: String { rawValue } + + public var title: String { + switch self { + case .system: "System" + case .light: "Light" + case .dark: "Dark" + } + } + + public var colorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} + +private struct T4RGB { + let red: Double + let green: Double + let blue: Double + let opacity: Double + + init(_ red: Double, _ green: Double, _ blue: Double, opacity: Double = 1) { + self.red = red + self.green = green + self.blue = blue + self.opacity = opacity + } + +#if os(macOS) + var nativeColor: NSColor { + NSColor(srgbRed: red, green: green, blue: blue, alpha: opacity) + } +#elseif os(iOS) + var nativeColor: UIColor { + UIColor(red: red, green: green, blue: blue, alpha: opacity) + } +#endif +} + +/// Adaptive T4 color tokens. Raw color components live here and nowhere else +/// in the Apple UI, mirroring the web token graph while tinting both extremes. +public enum T4Color { + public static var background: Color { adaptive(light: .init(0.985, 0.982, 0.990), dark: .init(0.059, 0.059, 0.067)) } + public static var surface: Color { adaptive(light: .init(0.965, 0.961, 0.972), dark: .init(0.078, 0.078, 0.090)) } + public static var raised: Color { adaptive(light: .init(0.996, 0.993, 0.998), dark: .init(0.100, 0.100, 0.114)) } + public static var foreground: Color { adaptive(light: .init(0.145, 0.137, 0.153), dark: .init(0.890, 0.894, 0.902)) } + public static var secondaryText: Color { adaptive(light: .init(0.337, 0.322, 0.353), dark: .init(0.710, 0.710, 0.745)) } + public static var mutedText: Color { adaptive(light: .init(0.470, 0.455, 0.486), dark: .init(0.590, 0.590, 0.625)) } + public static var border: Color { adaptive(light: .init(0.145, 0.137, 0.153, opacity: 0.11), dark: .init(0.890, 0.894, 0.902, opacity: 0.10)) } + public static var input: Color { adaptive(light: .init(0.145, 0.137, 0.153, opacity: 0.15), dark: .init(0.890, 0.894, 0.902, opacity: 0.14)) } + + public static var accent: Color { adaptive(light: .init(0.710, 0.105, 0.350), dark: .init(0.955, 0.350, 0.575)) } + public static var accentForeground: Color { adaptive(light: .init(0.990, 0.975, 0.982), dark: .init(0.105, 0.055, 0.075)) } + public static var accentSoft: Color { adaptive(light: .init(0.710, 0.105, 0.350, opacity: 0.10), dark: .init(0.955, 0.350, 0.575, opacity: 0.18)) } + + public static var destructive: Color { adaptive(light: .init(0.720, 0.120, 0.120), dark: .init(0.965, 0.390, 0.375)) } + public static var destructiveSoft: Color { adaptive(light: .init(0.720, 0.120, 0.120, opacity: 0.09), dark: .init(0.965, 0.390, 0.375, opacity: 0.16)) } + public static var warning: Color { adaptive(light: .init(0.635, 0.390, 0.025), dark: .init(0.940, 0.680, 0.225)) } + public static var warningSoft: Color { adaptive(light: .init(0.635, 0.390, 0.025, opacity: 0.10), dark: .init(0.940, 0.680, 0.225, opacity: 0.16)) } + public static var success: Color { adaptive(light: .init(0.110, 0.500, 0.330), dark: .init(0.365, 0.820, 0.600)) } + public static var successSoft: Color { adaptive(light: .init(0.110, 0.500, 0.330, opacity: 0.10), dark: .init(0.365, 0.820, 0.600, opacity: 0.16)) } + public static var info: Color { adaptive(light: .init(0.120, 0.355, 0.735), dark: .init(0.390, 0.650, 0.980)) } + public static var infoSoft: Color { adaptive(light: .init(0.120, 0.355, 0.735, opacity: 0.09), dark: .init(0.390, 0.650, 0.980, opacity: 0.16)) } + + public static var statusWorking: Color { info } + public static var statusApproval: Color { warning } + public static var statusInput: Color { adaptive(light: .init(0.310, 0.240, 0.760), dark: .init(0.635, 0.570, 0.980)) } + public static var statusPlan: Color { adaptive(light: .init(0.495, 0.205, 0.725), dark: .init(0.735, 0.525, 0.940)) } + public static var statusDone: Color { success } + public static var statusError: Color { destructive } + + private static func adaptive(light: T4RGB, dark: T4RGB) -> Color { +#if os(macOS) + let dynamic = NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + ? dark.nativeColor + : light.nativeColor + } + return Color(nsColor: dynamic) +#elseif os(iOS) + return Color(uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark ? dark.nativeColor : light.nativeColor + }) +#else + return Color(red: light.red, green: light.green, blue: light.blue, opacity: light.opacity) +#endif + } +} + +/// Four-point/ eight-point spacing vocabulary used across native surfaces. +public enum T4Spacing { + public static let xxs: CGFloat = 4 + public static let xs: CGFloat = 8 + public static let sm: CGFloat = 12 + public static let md: CGFloat = 16 + public static let lg: CGFloat = 24 + public static let xl: CGFloat = 32 + public static let xxl: CGFloat = 48 +} + +public enum T4Radius { + public static let sm: CGFloat = 6 + public static let md: CGFloat = 10 + public static let lg: CGFloat = 14 + public static let pill: CGFloat = 1_000 +} + +public enum T4Layout { + public static let wideBreakpoint: CGFloat = 980 + public static let readableMeasure: CGFloat = 760 + public static let settingsRailWidth: CGFloat = 240 + public static let minimumControlHeight: CGFloat = 44 +} + +/// Central display-boundary redaction. It is deliberately conservative: +/// secret-like settings are identified by key and never rendered, while +/// incidental diagnostics have credentials and URL query data removed. +public enum T4Privacy { + public static func isSecretKey(_ key: String) -> Bool { + let normalized = key + .precomposedStringWithCompatibilityMapping + .lowercased() + .filter { $0.isLetter || $0.isNumber } + return [ + "password", "passwd", "secret", "token", "credential", "apikey", + "privatekey", "cookie", "auth", "sessionkey", + ].contains { normalized.contains($0) } + } + + public static func redacted(_ value: String) -> String { + value + .replacingOccurrences( + of: "(?i)\\b(authorization|auth|cookie|credential|password|passwd|secret|token|api[_-]?key|private[_-]?key|session[_-]?key)\\b\\s*[:=]\\s*[^\\s,;]+", + with: "$1=", + options: .regularExpression + ) + .replacingOccurrences( + of: "(?i)(https?://[^\\s/?#]+)[^\\s]*", + with: "$1", + options: .regularExpression + ) + } +} + +/// Native fallbacks for the product's DM Sans / JetBrains Mono type intent. +/// SwiftUI's semantic styles retain Dynamic Type scaling on both platforms. +public enum T4Typography { + public static func heading( + _ style: Font.TextStyle = .headline, + weight: Font.Weight = .semibold + ) -> Font { + .system(style, design: .default, weight: weight) + } + + public static func body( + _ style: Font.TextStyle = .body, + weight: Font.Weight = .regular + ) -> Font { + .system(style, design: .default, weight: weight) + } + + public static func monospaced( + _ style: Font.TextStyle = .body, + weight: Font.Weight = .regular + ) -> Font { + .system(style, design: .monospaced, weight: weight) + } +} + +public enum T4StatusTone: Sendable { + case neutral + case working + case approval + case input + case plan + case success + case done + case warning + case error + + fileprivate var color: Color { + switch self { + case .neutral: T4Color.mutedText + case .working: T4Color.statusWorking + case .approval: T4Color.statusApproval + case .input: T4Color.statusInput + case .plan: T4Color.statusPlan + case .success, .done: T4Color.statusDone + case .warning: T4Color.warning + case .error: T4Color.statusError + } + } + + fileprivate var background: Color { + switch self { + case .neutral: T4Color.surface + case .working: T4Color.infoSoft + case .approval, .warning: T4Color.warningSoft + case .input, .plan: T4Color.accentSoft + case .success, .done: T4Color.successSoft + case .error: T4Color.destructiveSoft + } + } +} + +/// Dot-plus-label status presentation. Its meaning never depends on color. +public struct T4StatusPill: View { + private let label: String + private let tone: T4StatusTone + private let isPulsing: Bool + + public init(_ label: String, tone: T4StatusTone = .neutral, isPulsing: Bool = false) { + self.label = label + self.tone = tone + self.isPulsing = isPulsing + } + + public init(text: String, tone: T4StatusTone = .neutral, isPulsing: Bool = false) { + self.init(text, tone: tone, isPulsing: isPulsing) + } + + public var body: some View { + HStack(spacing: T4Spacing.xxs) { + Circle() + .fill(tone.color) + .frame(width: T4Spacing.xxs + 2, height: T4Spacing.xxs + 2) + .opacity(isPulsing ? 0.82 : 1) + .accessibilityHidden(true) + Text(label) + .font(T4Typography.body(.caption, weight: .medium)) + } + .foregroundStyle(tone.color) + .padding(.horizontal, T4Spacing.xs) + .padding(.vertical, T4Spacing.xxs) + .background(tone.background, in: Capsule()) + .accessibilityElement(children: .combine) + .accessibilityLabel(label) + } +} + +/// A compact, action-oriented empty state suitable for full pages and panels. +public struct T4EmptyState: View { + private let icon: String + private let title: String + private let message: String + private let actionTitle: String? + private let action: (() -> Void)? + + public init( + icon: String, + title: String, + message: String, + actionTitle: String? = nil, + action: (() -> Void)? = nil + ) { + self.icon = icon + self.title = title + self.message = message + self.actionTitle = actionTitle + self.action = action + } + + public init( + title: String, + message: String, + systemImage: String, + actionTitle: String? = nil, + action: (() -> Void)? = nil + ) { + self.init( + icon: systemImage, + title: title, + message: message, + actionTitle: actionTitle, + action: action + ) + } + + public var body: some View { + VStack(alignment: .leading, spacing: T4Spacing.md) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(T4Color.mutedText) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(title) + .font(T4Typography.heading(.title3)) + .foregroundStyle(T4Color.foreground) + Text(message) + .font(T4Typography.body(.subheadline)) + .foregroundStyle(T4Color.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + if let actionTitle, let action { + Button(actionTitle, action: action) + .buttonStyle(.bordered) + .tint(T4Color.accent) + } + } + .frame(maxWidth: 420, alignment: .leading) + .padding(T4Spacing.lg) + .accessibilityElement(children: .contain) + } +} + +/// Error presentation with an assertive announcement and an optional retry. +public struct T4ErrorState: View { + private let title: String + private let message: String + private let retry: (() -> Void)? + + public init( + title: String = "Something went wrong", + message: String, + retry: (() -> Void)? = nil + ) { + self.title = title + self.message = message + self.retry = retry + } + + public var body: some View { + HStack(alignment: .top, spacing: T4Spacing.sm) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(T4Color.destructive) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: T4Spacing.xxs) { + Text(title) + .font(T4Typography.heading(.subheadline)) + Text(message) + .font(T4Typography.body(.caption)) + .foregroundStyle(T4Color.secondaryText) + .fixedSize(horizontal: false, vertical: true) + if let retry { + Button("Try again", action: retry) + .buttonStyle(.borderless) + .font(T4Typography.body(.caption, weight: .semibold)) + .foregroundStyle(T4Color.destructive) + } + } + } + .padding(T4Spacing.sm) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T4Color.destructiveSoft, in: RoundedRectangle(cornerRadius: T4Radius.md)) + .accessibilityElement(children: .contain) + } +} + +public extension View { + /// Applies a persisted T4 appearance without converting `system` into a + /// frozen light/dark snapshot. + func t4Theme(_ preference: T4ThemePreference) -> some View { + preferredColorScheme(preference.colorScheme) + .tint(T4Color.accent) + } +} diff --git a/apps/apple/T4AppleApps.xcodeproj/project.pbxproj b/apps/apple/T4AppleApps.xcodeproj/project.pbxproj new file mode 100644 index 00000000..09524b2d --- /dev/null +++ b/apps/apple/T4AppleApps.xcodeproj/project.pbxproj @@ -0,0 +1,459 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0522AB8551C0091E125953D0 /* T4IOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0A1BAA36142F08EBC2ACDE /* T4IOSApp.swift */; }; + 4D52BB37AA04636961AB81ED /* T4MacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D5AD9EE42402FC33165ED3 /* T4MacApp.swift */; }; + 6ABC124C2A14723127A3E56B /* T4UI in Frameworks */ = {isa = PBXBuildFile; productRef = D9B148532BA22C50B335B900 /* T4UI */; }; + 84A107D5638F8836E2C905F6 /* T4UI in Frameworks */ = {isa = PBXBuildFile; productRef = ADF7E98FA68DCC63CDD01F7E /* T4UI */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1C0A1BAA36142F08EBC2ACDE /* T4IOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = T4IOSApp.swift; sourceTree = ""; }; + 52D5AD9EE42402FC33165ED3 /* T4MacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = T4MacApp.swift; sourceTree = ""; }; + 597CDD7B091412A800C8B11D /* T4MacApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = T4MacApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 79682F63EACA2AB5BD8B5A41 /* apple */ = {isa = PBXFileReference; lastKnownFileType = folder; name = apple; path = .; sourceTree = SOURCE_ROOT; }; + E1B2C06EFB8C24AFB65F8E79 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + ED3A5711B3A65EEEDC777D4A /* T4IOSApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = T4IOSApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 54420830AE2C1140D3B05648 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ABC124C2A14723127A3E56B /* T4UI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6F4C76AC544A5A3C3E894704 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 84A107D5638F8836E2C905F6 /* T4UI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1883A67059F6079B4A408841 /* T4MacApp */ = { + isa = PBXGroup; + children = ( + 52D5AD9EE42402FC33165ED3 /* T4MacApp.swift */, + ); + name = T4MacApp; + path = Sources/T4MacApp; + sourceTree = ""; + }; + 4FF9B2387DAB1F7F84086C3D /* T4IOSApp */ = { + isa = PBXGroup; + children = ( + E1B2C06EFB8C24AFB65F8E79 /* Info.plist */, + 1C0A1BAA36142F08EBC2ACDE /* T4IOSApp.swift */, + ); + name = T4IOSApp; + path = Apps/T4IOSApp; + sourceTree = ""; + }; + A67F206CC6E1C0DB5B806517 = { + isa = PBXGroup; + children = ( + B15859A65FE3ACD242438A08 /* Packages */, + 4FF9B2387DAB1F7F84086C3D /* T4IOSApp */, + 1883A67059F6079B4A408841 /* T4MacApp */, + EA30222FBE1DB76EAD331EC5 /* Products */, + ); + sourceTree = ""; + }; + B15859A65FE3ACD242438A08 /* Packages */ = { + isa = PBXGroup; + children = ( + 79682F63EACA2AB5BD8B5A41 /* apple */, + ); + name = Packages; + sourceTree = ""; + }; + EA30222FBE1DB76EAD331EC5 /* Products */ = { + isa = PBXGroup; + children = ( + ED3A5711B3A65EEEDC777D4A /* T4IOSApp.app */, + 597CDD7B091412A800C8B11D /* T4MacApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2A665232EE7EA39F71555B22 /* T4MacApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B5E929AC1BE1F657183A2F9 /* Build configuration list for PBXNativeTarget "T4MacApp" */; + buildPhases = ( + DA95FBF2D3583F38942E71F8 /* Stage local runtime */, + FAC0ECA9BCF44EBBF8B04CE4 /* Sources */, + 54420830AE2C1140D3B05648 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = T4MacApp; + packageProductDependencies = ( + D9B148532BA22C50B335B900 /* T4UI */, + ); + productName = T4MacApp; + productReference = 597CDD7B091412A800C8B11D /* T4MacApp.app */; + productType = "com.apple.product-type.application"; + }; + ECDCD42653612E9CC2DA8E06 /* T4IOSApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5EF92CA1F5BBF100A30C693E /* Build configuration list for PBXNativeTarget "T4IOSApp" */; + buildPhases = ( + 8EDA7BB91A48B3C55A0565C8 /* Sources */, + 6F4C76AC544A5A3C3E894704 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = T4IOSApp; + packageProductDependencies = ( + ADF7E98FA68DCC63CDD01F7E /* T4UI */, + ); + productName = T4IOSApp; + productReference = ED3A5711B3A65EEEDC777D4A /* T4IOSApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B206C83EC56B4FB5C2ED0046 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + ECDCD42653612E9CC2DA8E06 = { + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 56C47D289195C7EAB8D5146A /* Build configuration list for PBXProject "T4AppleApps" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = A67F206CC6E1C0DB5B806517; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 673B12545CA29256F4833D38 /* XCLocalSwiftPackageReference "." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = EA30222FBE1DB76EAD331EC5 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + ECDCD42653612E9CC2DA8E06 /* T4IOSApp */, + 2A665232EE7EA39F71555B22 /* T4MacApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXShellScriptBuildPhase section */ + DA95FBF2D3583F38942E71F8 /* Stage local runtime */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/scripts/stage-local-runtime.sh", + ); + name = "Stage local runtime"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$SRCROOT/scripts/stage-local-runtime.sh\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8EDA7BB91A48B3C55A0565C8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0522AB8551C0091E125953D0 /* T4IOSApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FAC0ECA9BCF44EBBF8B04CE4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D52BB37AA04636961AB81ED /* T4MacApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 09C236BEFF4D8B949847614D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Apps/T4MacApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = "dev.oh-my-pi.t4code.macos"; + SDKROOT = macosx; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + 0D1274E0184C86C5D67E237E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 3D5FD3B287E713037DD24CCB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + INFOPLIST_FILE = Apps/T4IOSApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "dev.oh-my-pi.t4code"; + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 62C517E8BF634B9E4BC22DF5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + INFOPLIST_FILE = Apps/T4IOSApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "dev.oh-my-pi.t4code"; + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + BC6D3FB95174727D1FFBD65A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C10643C7413221F4F63B2A43 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Apps/T4MacApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = "dev.oh-my-pi.t4code.macos"; + SDKROOT = macosx; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 56C47D289195C7EAB8D5146A /* Build configuration list for PBXProject "T4AppleApps" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BC6D3FB95174727D1FFBD65A /* Debug */, + 0D1274E0184C86C5D67E237E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 5EF92CA1F5BBF100A30C693E /* Build configuration list for PBXNativeTarget "T4IOSApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3D5FD3B287E713037DD24CCB /* Debug */, + 62C517E8BF634B9E4BC22DF5 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 8B5E929AC1BE1F657183A2F9 /* Build configuration list for PBXNativeTarget "T4MacApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C10643C7413221F4F63B2A43 /* Debug */, + 09C236BEFF4D8B949847614D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 673B12545CA29256F4833D38 /* XCLocalSwiftPackageReference "." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = .; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + ADF7E98FA68DCC63CDD01F7E /* T4UI */ = { + isa = XCSwiftPackageProductDependency; + productName = T4UI; + }; + D9B148532BA22C50B335B900 /* T4UI */ = { + isa = XCSwiftPackageProductDependency; + productName = T4UI; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = B206C83EC56B4FB5C2ED0046 /* Project object */; +} diff --git a/apps/apple/T4AppleApps.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/apple/T4AppleApps.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/apps/apple/T4AppleApps.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/apple/T4AppleApps.xcodeproj/xcshareddata/xcschemes/T4IOSApp.xcscheme b/apps/apple/T4AppleApps.xcodeproj/xcshareddata/xcschemes/T4IOSApp.xcscheme new file mode 100644 index 00000000..d493a5b3 --- /dev/null +++ b/apps/apple/T4AppleApps.xcodeproj/xcshareddata/xcschemes/T4IOSApp.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/apple/T4AppleApps.xcodeproj/xcshareddata/xcschemes/T4MacApp.xcscheme b/apps/apple/T4AppleApps.xcodeproj/xcshareddata/xcschemes/T4MacApp.xcscheme new file mode 100644 index 00000000..ece572a3 --- /dev/null +++ b/apps/apple/T4AppleApps.xcodeproj/xcshareddata/xcschemes/T4MacApp.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/apple/Tests/T4ClientTests/LocalHostSupervisorTests.swift b/apps/apple/Tests/T4ClientTests/LocalHostSupervisorTests.swift new file mode 100644 index 00000000..31810c21 --- /dev/null +++ b/apps/apple/Tests/T4ClientTests/LocalHostSupervisorTests.swift @@ -0,0 +1,191 @@ +import Foundation +import XCTest +@testable import T4Platform + +final class LocalHostSupervisorTests: XCTestCase { + func testHealthySocketIsReadyWithoutLaunchingOrClaimingProcess() async throws { +#if os(macOS) + let fixture = Fixture(socketHealthy: true) + let supervisor = fixture.supervisor() + + let started = try await supervisor.start() + XCTAssertEqual(started.phase, .running) + XCTAssertFalse(started.ownsProcess) + XCTAssertEqual(fixture.factory.processes.count, 0) + + let stopped = try await supervisor.stop() + XCTAssertEqual(stopped.phase, .running) + XCTAssertEqual(fixture.factory.processes.count, 0) +#else + let fixture = Fixture(socketHealthy: true) + do { _ = try await fixture.supervisor().start(); XCTFail("iOS must be unsupported") } + catch { XCTAssertEqual(error as? LocalHostSupervisorError, .unsupported) } +#endif + } + + func testStartUsesOnlyLocalArgumentsAndWaitsForSocket() async throws { +#if os(macOS) + let fixture = Fixture(socketHealthy: false) + fixture.probe.responses = [false, false, true] + let supervisor = fixture.supervisor() + + let result = try await supervisor.start() + XCTAssertEqual(result.phase, .running) + XCTAssertTrue(result.ownsProcess) + XCTAssertEqual(fixture.factory.processes.count, 1) + XCTAssertEqual(fixture.factory.processes[0].arguments, [ + "serve", "--omp", fixture.omp.path, + "--profile", "default", + "--state-root", fixture.stateRoot.path, + ]) + XCTAssertEqual(fixture.factory.processes[0].executableURL, fixture.t4Host) + XCTAssertFalse(fixture.factory.processes[0].arguments.contains("--port")) + XCTAssertFalse(fixture.factory.processes[0].arguments.contains("--origin")) +#else + do { _ = try await Fixture().supervisor().start(); XCTFail("iOS must be unsupported") } + catch { XCTAssertEqual(error as? LocalHostSupervisorError, .unsupported) } +#endif + } + + func testStopAndRestartOnlyTerminateOwnedProcess() async throws { +#if os(macOS) + let fixture = Fixture(socketHealthy: false) + fixture.probe.responses = [false, true, false, true] + let supervisor = fixture.supervisor() + + _ = try await supervisor.start() + let first = fixture.factory.processes[0] + _ = try await supervisor.restart() + XCTAssertEqual(first.terminateCount, 1) + XCTAssertEqual(fixture.factory.processes.count, 2) + XCTAssertEqual(fixture.factory.processes[1].terminateCount, 0) + + _ = try await supervisor.stop() + XCTAssertEqual(fixture.factory.processes[1].terminateCount, 1) +#else + do { _ = try await Fixture().supervisor().restart(); XCTFail("iOS must be unsupported") } + catch { XCTAssertEqual(error as? LocalHostSupervisorError, .unsupported) } +#endif + } + + func testInvalidResourcesAreRejectedBeforeLaunch() async throws { +#if os(macOS) + let fixture = Fixture(socketHealthy: false) + fixture.fileSystem.symbolicLinks.insert(fixture.t4Host.path) + let supervisor = fixture.supervisor() + + do { _ = try await supervisor.start(); XCTFail("a symlinked executable must be rejected") } + catch let error as LocalHostSupervisorError { + guard case .invalidResource = error else { return XCTFail("unexpected error: \\(error)") } + } + XCTAssertTrue(fixture.factory.processes.isEmpty) +#else + do { _ = try await Fixture().supervisor().start(); XCTFail("iOS must be unsupported") } + catch { XCTAssertEqual(error as? LocalHostSupervisorError, .unsupported) } +#endif + } + + func testTimeoutStopsOwnedProcessAndRedactsDiagnostics() async throws { +#if os(macOS) + let fixture = Fixture(socketHealthy: false) + fixture.processDiagnostics = "token=super-secret-value " + String(repeating: "x", count: 6000) + let supervisor = fixture.supervisor(waitNanoseconds: 2_000_000, pollNanoseconds: 0) + + do { _ = try await supervisor.start(); XCTFail("socket readiness should time out") } + catch let error as LocalHostSupervisorError { + guard case .timedOut(let message) = error else { return XCTFail("unexpected error: \\(error)") } + XCTAssertFalse(message.contains("super-secret-value")) + XCTAssertLessThanOrEqual(message.count, LocalHostSupervisorDiagnostics.maximumLength) + } + XCTAssertEqual(fixture.factory.processes.count, 1) + XCTAssertEqual(fixture.factory.processes[0].terminateCount, 1) + #else + do { _ = try await Fixture().supervisor().start(); XCTFail("iOS must be unsupported") } + catch { XCTAssertEqual(error as? LocalHostSupervisorError, .unsupported) } +#endif + } +} + +private final class Fixture: @unchecked Sendable { + let home = URL(fileURLWithPath: "/tmp/t4-local-host-tests/home", isDirectory: true) + let t4Host = URL(fileURLWithPath: "/tmp/t4-local-host-tests/app/t4-host") + let omp = URL(fileURLWithPath: "/tmp/t4-local-host-tests/app/omp") + let stateRoot = URL(fileURLWithPath: "/tmp/t4-local-host-tests/home/.t4-code/host", isDirectory: true) + let socket = URL(fileURLWithPath: "/tmp/t4-local-host-tests/home/.omp/run/appserver.sock") + let fileSystem = FakeFileSystem() + let factory = FakeProcessFactory() + let probe: FakeSocketProbe + var processDiagnostics = "" + + init(socketHealthy: Bool = false) { + probe = FakeSocketProbe(defaultValue: socketHealthy) + fileSystem.regularFiles = [t4Host.path, omp.path] + fileSystem.executableFiles = [t4Host.path, omp.path] + } + + func supervisor(waitNanoseconds: UInt64 = LocalHostSupervisor.defaultWaitNanoseconds, pollNanoseconds: UInt64 = 0) -> LocalHostSupervisor { + factory.diagnostics = { [weak self] in self?.processDiagnostics ?? "" } + return LocalHostSupervisor( + t4HostURL: t4Host, + ompURL: omp, + stateRootURL: stateRoot, + socketURL: socket, + homeDirectoryURL: home, + fileSystem: fileSystem, + processFactory: factory, + socketProbe: probe, + waitNanoseconds: waitNanoseconds, + pollNanoseconds: pollNanoseconds + ) + } +} + +private final class FakeFileSystem: LocalHostFileSystem, @unchecked Sendable { + var regularFiles = Set() + var executableFiles = Set() + var symbolicLinks = Set() + var home = URL(fileURLWithPath: "/tmp/t4-local-host-tests/home", isDirectory: true) + func homeDirectoryURL() -> URL { home } + func isRegularFile(at url: URL) -> Bool { regularFiles.contains(url.path) } + func isExecutableFile(at url: URL) -> Bool { executableFiles.contains(url.path) } + func isSymbolicLink(at url: URL) -> Bool { symbolicLinks.contains(url.path) } +} + +private final class FakeSocketProbe: LocalHostSocketProbe, @unchecked Sendable { + let defaultValue: Bool + var responses: [Bool] = [] + init(defaultValue: Bool) { self.defaultValue = defaultValue } + func isHealthy(at socketURL: URL) -> Bool { + if responses.isEmpty { return defaultValue } + return responses.removeFirst() + } +} + +private final class FakeProcessFactory: LocalHostProcessFactory, @unchecked Sendable { + var processes: [FakeProcess] = [] + var diagnostics: () -> String = { "" } + func makeProcess(executableURL: URL, arguments: [String]) -> any LocalHostProcessHandle { + let process = FakeProcess(executableURL: executableURL, arguments: arguments, diagnostics: diagnostics) + processes.append(process) + return process + } +} + +private final class FakeProcess: LocalHostProcessHandle, @unchecked Sendable { + let executableURL: URL + let arguments: [String] + private let diagnosticsProvider: () -> String + var isRunning = false + var terminationStatus: Int32 = 0 + var launchCount = 0 + var terminateCount = 0 + + init(executableURL: URL, arguments: [String], diagnostics: @escaping () -> String) { + self.executableURL = executableURL + self.arguments = arguments + diagnosticsProvider = diagnostics + } + var diagnostics: String { diagnosticsProvider() } + func launch() throws { launchCount += 1; isRunning = true } + func terminate() { terminateCount += 1; isRunning = false } +} diff --git a/apps/apple/Tests/T4ClientTests/PlatformStoreTests.swift b/apps/apple/Tests/T4ClientTests/PlatformStoreTests.swift new file mode 100644 index 00000000..7339261b --- /dev/null +++ b/apps/apple/Tests/T4ClientTests/PlatformStoreTests.swift @@ -0,0 +1,91 @@ +import Foundation +import XCTest +@testable import T4Platform +final class PlatformStoreTests: XCTestCase { + private func profile(_ id: String = "default") throws -> HostProfile { + try HostProfile.parseTailnetAddress("machine.example.ts.net", profileID: id) + } + + func testUserDefaultsStoreUsesV3KeyAndRemovesEmptyDirectory() async throws { + let suite = "PlatformStoreTests.\(UUID().uuidString)" + let store = UserDefaultsHostProfileStore( + defaults: try XCTUnwrap(UserDefaults(suiteName: suite)) + ) + let profile = try profile() + let directory = try HostDirectory(profiles: [profile], activeEndpointKey: profile.endpointKey) + try await store.save(directory) + XCTAssertNotNil(UserDefaults(suiteName: suite)?.data(forKey: hostDirectoryStorageKey)) + XCTAssertNil(UserDefaults(suiteName: suite)?.data(forKey: "t4-code:mobile-backends:v2")) + let empty = try HostDirectory() + try await store.save(empty) + XCTAssertNil(UserDefaults(suiteName: suite)?.data(forKey: hostDirectoryStorageKey)) + let loaded = try await store.load() + XCTAssertEqual(loaded, .empty) + UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) + } + + func testUserDefaultsStoreRejectsInconsistentAndUnsupportedRecords() async throws { + let suite = "PlatformStoreTests.\(UUID().uuidString)" + let seed = try XCTUnwrap(UserDefaults(suiteName: suite)) + seed.set(Data(#"{"version":2,"activeEndpointKey":"x","backends":[]}"#.utf8), forKey: hostDirectoryStorageKey) + let store = UserDefaultsHostProfileStore( + defaults: try XCTUnwrap(UserDefaults(suiteName: suite)) + ) + do { _ = try await store.load(); XCTFail("future records must be rejected") } + catch { XCTAssertEqual(error as? HostProfileStoreError, .invalidSavedData) } + UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) + } + + func testCredentialAccountIsVersionedUnpaddedBase64URL() throws { + let account = KeychainCredentialStore.account(for: "https://machine.ts.net#profile=default") + XCTAssertTrue(account.hasPrefix(credentialStoragePrefix)) + XCTAssertFalse(account.contains("=")); XCTAssertFalse(account.contains("+")); XCTAssertFalse(account.contains("/")) + XCTAssertEqual(account, "t4-code:device-credentials:v1:aHR0cHM6Ly9tYWNoaW5lLnRzLm5ldCNwcm9maWxlPWRlZmF1bHQ") + } + + func testDefaultOriginMigrationRollsBackCurrentRecordWhenCleanupFails() async throws { + let host = try profile() + let keychain = MemoryKeychain(failDeleteAccount: KeychainCredentialStore.account(for: host.origin)) + let store = KeychainCredentialStore(keychain: keychain) + let old = try DeviceCredentials(deviceID: "device", deviceToken: "token") + try keychain.write(try JSONEncoder().encode(MemoryCredentialRecord(version: 1, deviceId: old.deviceID, deviceToken: old.deviceToken)), service: credentialKeychainService, account: KeychainCredentialStore.account(for: host.origin)) + do { _ = try await store.read(for: host); XCTFail("migration should report cleanup failure") } + catch { XCTAssertEqual(error as? CredentialStoreError, .migrationFailed) } + XCTAssertNil(try keychain.read(service: credentialKeychainService, account: KeychainCredentialStore.account(for: host.endpointKey))) + XCTAssertNotNil(try keychain.read(service: credentialKeychainService, account: KeychainCredentialStore.account(for: host.origin))) + } + + func testLifecycleSerializesOperationsAndProvidesBoundedRedactedFailures() async throws { + let process = RecordingProcess() + process.result = PlatformProcessResult(status: 1, stderr: "token=secret-value " + String(repeating: "x", count: 700)) + let service = PlatformLifecycleService(process: process, serviceLabel: "com.test.host", definitionURL: URL(fileURLWithPath: "/tmp/missing-t4.plist")) +#if os(macOS) + do { _ = try await service.start(); XCTFail("start should fail") } + catch let error as PlatformLifecycleError { + guard case .processFailed(let message) = error else { return XCTFail("unexpected error") } + XCTAssertFalse(message.contains("secret-value")); XCTAssertLessThanOrEqual(message.count, 512) + } + XCTAssertEqual(process.commands.count, 1) +#else + do { _ = try await service.start(); XCTFail("iOS lifecycle must be unsupported") } + catch { XCTAssertEqual(error as? PlatformLifecycleError, .unsupported) } +#endif + } +} + +private struct MemoryCredentialRecord: Codable { let version: Int; let deviceId: String; let deviceToken: String } + +private final class MemoryKeychain: KeychainStore, @unchecked Sendable { + private var values: [String: Data] = [:] + private let failDeleteAccount: String? + init(failDeleteAccount: String? = nil) { self.failDeleteAccount = failDeleteAccount } + func read(service: String, account: String) throws -> Data? { values[service + "\n" + account] } + func write(_ data: Data, service: String, account: String) throws { values[service + "\n" + account] = data } + func delete(service: String, account: String) throws { if account == failDeleteAccount { throw CredentialStoreError.keychainFailure }; values.removeValue(forKey: service + "\n" + account) } +} + +private final class RecordingProcess: PlatformProcessFacade, @unchecked Sendable { + var commands: [[String]] = [] + var result = PlatformProcessResult(status: 0) + func run(executable: String, arguments: [String]) throws -> PlatformProcessResult { commands.append([executable] + arguments); return result } +} diff --git a/apps/apple/Tests/T4ClientTests/T4ClientControllerTests.swift b/apps/apple/Tests/T4ClientTests/T4ClientControllerTests.swift new file mode 100644 index 00000000..83a1e0f2 --- /dev/null +++ b/apps/apple/Tests/T4ClientTests/T4ClientControllerTests.swift @@ -0,0 +1,250 @@ +import Foundation +import XCTest +@testable import T4Client + +@MainActor +final class T4ClientControllerTests: XCTestCase { + func testStaleResponseFromPreviousGenerationDoesNotMutateState() async throws { + let transport = TestTransport() + let controller = T4ClientController(transport: transport, reconnectPolicy: .init(baseDelay: .milliseconds(1), maximumDelay: .milliseconds(1))) + let connect = Task { await controller.connect() } + try await transport.waitFor(type: "hello") + transport.emit(Self.welcome(host: "host-a")) + let list = try await transport.waitFor(command: "session.list") + await controller.disconnect() + transport.emit(Self.response(requestID: list.requestID, commandID: list.commandID, command: "session.list", host: "host-a", result: ["sessions": []])) + await connect.value + XCTAssertEqual(controller.state.connection, .disconnected) + XCTAssertTrue(controller.state.sessions.isEmpty) + } + + func testBootstrapListsAndWatchesWithIndependentIndexCursor() async throws { + let transport = TestTransport() + let controller = T4ClientController(transport: transport) + let connect = Task { await controller.connect() } + try await transport.waitFor(type: "hello") + transport.emit(Self.welcome(host: "host-a")) + let list = try await transport.waitFor(command: "session.list") + transport.emit(Self.response(requestID: list.requestID, commandID: list.commandID, command: "session.list", host: "host-a", result: [ + "cursor": ["epoch": "index", "seq": 4], + "sessions": [["sessionId": "s1", "hostId": "host-a", "title": "One"]] + ])) + let watch = try await transport.waitFor(command: "host.watch") + XCTAssertEqual((watch.args["cursor"] as? [String: Any])?["epoch"] as? String, "index") + transport.emit(Self.response(requestID: watch.requestID, commandID: watch.commandID, command: "host.watch", host: "host-a", result: [:])) + await connect.value + await controller.disconnect() + XCTAssertEqual(controller.state.sessions.map(\.id), ["s1"]) + XCTAssertEqual(controller.state.sessionIndexCursor, SessionIndexCursor(epoch: "index", seq: 4)) + XCTAssertNil(controller.state.transcriptCursor) + } + + func testReconnectRetainsSelectionAndReattachesWithTranscriptCursor() async throws { + let transport = TestTransport() + let controller = T4ClientController(transport: transport, reconnectPolicy: .init(baseDelay: .milliseconds(1), maximumDelay: .milliseconds(1), maximumAttempts: 2)) + controller.state.selectedSessionID = "s1" + controller.state.transcriptCursor = TranscriptCursor(epoch: "stream", seq: 9) + let connect = Task { await controller.connect() } + try await transport.waitFor(type: "hello") + transport.emit(Self.welcome(host: "host-a")) + let list = try await transport.waitFor(command: "session.list") + transport.emit(Self.response(requestID: list.requestID, commandID: list.commandID, command: "session.list", host: "host-a", result: ["sessions": []])) + let attach = try await transport.waitFor(command: "session.attach") + transport.emit(Self.response(requestID: attach.requestID, commandID: attach.commandID, command: "session.attach", host: "host-a", session: "s1", result: ["attached": true])) + await connect.value + transport.drop() + try await transport.waitFor(type: "hello") + transport.emit(Self.welcome(host: "host-a")) + let relist = try await transport.waitFor(command: "session.list") + transport.emit(Self.response(requestID: relist.requestID, commandID: relist.commandID, command: "session.list", host: "host-a", result: ["sessions": []])) + let reattach = try await transport.waitFor(command: "session.attach") + XCTAssertEqual((reattach.args["cursor"] as? [String: Any])?["epoch"] as? String, "stream") + transport.emit(Self.response(requestID: reattach.requestID, commandID: reattach.commandID, command: "session.attach", host: "host-a", session: "s1", result: ["attached": true])) + await controller.disconnect() + XCTAssertEqual(controller.state.selectedSessionID, "s1") + } + + func testMismatchedCommandCorrelationLeavesRequestPending() async throws { + let transport = TestTransport() + let controller = T4ClientController(transport: transport) + let connect = Task { await controller.connect() } + try await transport.waitFor(type: "hello") + transport.emit(Self.welcome(host: "host-a")) + let list = try await transport.waitFor(command: "session.list") + transport.emit(Self.response(requestID: list.requestID, commandID: list.commandID, command: "session.list", host: "host-a", result: ["sessions": []])) + await connect.value + let command = Task { try await controller.command("session.cancel", hostID: "host-a", sessionID: "s1") } + let frame = try await transport.waitFor(command: "session.cancel") + transport.emit(Self.response(requestID: frame.requestID, commandID: "wrong", command: "session.cancel", host: "host-a", session: "s1", result: [:])) + transport.emit(Self.response(requestID: frame.requestID, commandID: frame.commandID, command: "session.cancel", host: "host-a", session: "s1", result: [:])) + let response = try await command.value + XCTAssertEqual(response.commandID, frame.commandID) + await controller.disconnect() + } + + private static func welcome(host: String) -> WireFrame { + try! WireDecoder.decode(try! JSONSerialization.data(withJSONObject: ["v": "omp-app/1", "type": "welcome", "selectedProtocol": "omp-app/1", "hostId": host, "authentication": "local", "grantedCapabilities": [], "grantedFeatures": ["host.watch"], "negotiatedLimits": [:], "epoch": "host", "resumed": false])) + } + + private static func response(requestID: String, commandID: String?, command: String, host: String, session: String? = nil, result: [String: Any]) -> WireFrame { + var value: [String: Any] = ["v": "omp-app/1", "type": "response", "requestId": requestID, "commandId": commandID ?? "", "command": command, "hostId": host, "ok": true, "result": result] + if let session { value["sessionId"] = session } + return try! WireDecoder.decode(try! JSONSerialization.data(withJSONObject: value)) + } +} +private final class TestTransport: T4ClientTransport, @unchecked Sendable { + var incoming: AsyncThrowingStream + private var continuation: AsyncThrowingStream.Continuation + private var recreateOnConnect = false + private var streamFinished = false + private var waitOffsets: [String: Int] = [:] + private var typeWaiters: [String: [CheckedContinuation]] = [:] + private var commandWaiters: [String: [CheckedContinuation]] = [:] + private(set) var sent: [WireFrame] = [] + private(set) var connected = false + + init() { + let stream = Self.makeStream() + incoming = stream.stream + continuation = stream.continuation + } + + func connect() async throws { + if recreateOnConnect { + let stream = Self.makeStream() + incoming = stream.stream + continuation = stream.continuation + streamFinished = false + recreateOnConnect = false + } + connected = true + } + + func disconnect() async { + connected = false + recreateOnConnect = true + failWaiters() + finishCurrentStream(with: TestTransportError.disconnected) + } + + func send(_ frame: WireFrame) async throws { + guard connected else { throw TestTransportError.disconnected } + let index = sent.endIndex + sent.append(frame) + let object = frame.raw.mapValues { $0.toFoundation() } + fulfillTypeWaiter(for: frame.type, index: index) + guard frame.type == "command", let command = object["command"] as? String else { return } + guard var waiters = commandWaiters[command], !waiters.isEmpty else { return } + let waiter = waiters.removeFirst() + advanceOffset(for: "command:\(command)", past: index) + if waiters.isEmpty { + commandWaiters.removeValue(forKey: command) + } else { + commandWaiters[command] = waiters + } + waiter.resume(returning: Self.sentCommand(from: object)) + } + + func emit(_ frame: WireFrame) { + _ = continuation.yield(frame) + } + + func drop() { + connected = false + recreateOnConnect = true + failWaiters() + finishCurrentStream(with: T4ClientControllerError.transport("dropped")) + } + + func waitFor(type: String) async throws { + let key = "type:\(type)" + let start = waitOffsets[key, default: 0] + if let index = sent[start...].firstIndex(where: { $0.type == type }) { + advanceOffset(for: key, past: index) + return + } + try await withCheckedThrowingContinuation { continuation in + typeWaiters[type, default: []].append(continuation) + } + } + + func waitFor(command: String) async throws -> SentCommand { + let key = "command:\(command)" + let start = waitOffsets[key, default: 0] + if let index = sent[start...].firstIndex(where: { Self.command(in: $0) == command }) { + advanceOffset(for: key, past: index) + return Self.sentCommand(from: sent[index].raw.mapValues { $0.toFoundation() }) + } + return try await withCheckedThrowingContinuation { continuation in + commandWaiters[command, default: []].append(continuation) + } + } + + private func fulfillTypeWaiter(for type: String, index: Int) { + guard var waiters = typeWaiters[type], !waiters.isEmpty else { return } + let waiter = waiters.removeFirst() + advanceOffset(for: "type:\(type)", past: index) + if waiters.isEmpty { + typeWaiters.removeValue(forKey: type) + } else { + typeWaiters[type] = waiters + } + waiter.resume() + } + + private func failWaiters() { + let typeWaiters = self.typeWaiters + let commandWaiters = self.commandWaiters + self.typeWaiters.removeAll() + self.commandWaiters.removeAll() + for waiters in typeWaiters.values { + for waiter in waiters { + waiter.resume(throwing: TestTransportError.disconnected) + } + } + for waiters in commandWaiters.values { + for waiter in waiters { + waiter.resume(throwing: TestTransportError.disconnected) + } + } + } + + private func advanceOffset(for key: String, past index: Int) { + waitOffsets[key] = max(waitOffsets[key, default: 0], index + 1) + } + + private func finishCurrentStream(with error: Error) { + guard !streamFinished else { return } + streamFinished = true + continuation.finish(throwing: error) + } + + private static func makeStream() -> (stream: AsyncThrowingStream, continuation: AsyncThrowingStream.Continuation) { + var continuation: AsyncThrowingStream.Continuation! + let stream = AsyncThrowingStream { continuation = $0 } + return (stream, continuation) + } + + private static func command(in frame: WireFrame) -> String? { + guard frame.type == "command" else { return nil } + return frame.raw["command"]?.stringValue + } + + private static func sentCommand(from object: [String: Any]) -> SentCommand { + SentCommand( + requestID: object["requestId"] as? String ?? "", + commandID: object["commandId"] as? String, + args: object["args"] as? [String: Any] ?? [:] + ) + } + + private enum TestTransportError: Error { + case disconnected + } + + struct SentCommand { + let requestID: String + let commandID: String? + let args: [String: Any] + } +} diff --git a/apps/apple/Tests/T4ClientTests/UnixWebSocketTransportTests.swift b/apps/apple/Tests/T4ClientTests/UnixWebSocketTransportTests.swift new file mode 100644 index 00000000..30282de7 --- /dev/null +++ b/apps/apple/Tests/T4ClientTests/UnixWebSocketTransportTests.swift @@ -0,0 +1,171 @@ +import Foundation +import XCTest +import CryptoKit +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif +@testable import T4Client +@testable import T4Protocol + +final class UnixWebSocketTransportTests: XCTestCase { + func testHandshakeUsesPinnedEndpointAndMasksOutboundText() async throws { + let (client, server) = try makeSocketPair() + defer { close(server) } + let transport = UnixWebSocketTransport(fileDescriptor: client, keyProvider: { Data(repeating: 7, count: 16) }) + let serverTask = Task.detached { () throws -> RawWebSocketFrame in + try completeHandshake(server) + return try readRawFrame(server) + } + try await transport.connect() + try await transport.send(data: Data("hello".utf8)) + let frame = try await serverTask.value + XCTAssertEqual(frame.opcode, 1) + XCTAssertTrue(frame.masked) + XCTAssertEqual(frame.payload, Data("hello".utf8)) + await transport.disconnect() + } + + func testFragmentationPingPongAndClose() async throws { + let (client, server) = try makeSocketPair() + defer { close(server) } + let transport = UnixWebSocketTransport(fileDescriptor: client, keyProvider: { Data(repeating: 2, count: 16) }) + let handshake = Task.detached { () throws -> Void in try completeHandshake(server) } + try await transport.connect() + try await handshake.value + let incoming = transport.incoming + let payload = try WireEncoder.ping(nonce: "n", timestamp: "t") + let split = payload.count / 2 + try writeAll(server, serverFrame(fin: false, opcode: 1, payload: Data(payload.prefix(split)))) + try writeAll(server, serverFrame(fin: true, opcode: 0, payload: Data(payload.dropFirst(split)))) + let received = try await Task.detached { () throws -> WireFrame in + for try await frame in incoming { return frame } + throw UnixWebSocketTransportError.closed + }.value + guard case .ping(let ping) = received else { return XCTFail("expected ping") } + XCTAssertEqual(ping.nonce, "n") + try writeAll(server, serverFrame(fin: true, opcode: 9, payload: Data("x".utf8))) + let pong = try readRawFrame(server) + XCTAssertEqual(pong.opcode, 10) + XCTAssertEqual(pong.payload, Data("x".utf8)) + try writeAll(server, serverFrame(fin: true, opcode: 8, payload: Data([0x03, 0xE8]))) + await transport.disconnect() + } + + func testInboundMessagesAreBoundedToOneMiB() async throws { + let (client, server) = try makeSocketPair() + defer { close(server) } + let transport = UnixWebSocketTransport(fileDescriptor: client, keyProvider: { Data(repeating: 3, count: 16) }) + let handshake = Task.detached { () throws -> Void in try completeHandshake(server) } + try await transport.connect() + try await handshake.value + let incoming = transport.incoming + let oversized = Data([0x82, 0x7F, 0, 0, 0, 0, 0, 0x10, 0, 0x01]) + try writeAll(server, oversized) + do { + for try await _ in incoming { } + XCTFail("expected oversized frame to terminate stream") + } catch let error as UnixWebSocketTransportError { + XCTAssertEqual(error, .messageTooLarge) + } + await transport.disconnect() + } + + func testRejectsUnsafePathAndMalformedAccept() async throws { + XCTAssertThrowsError(try UnixWebSocketTransport(socketPath: "relative.sock")) + XCTAssertThrowsError(try UnixWebSocketTransport(socketPath: "/tmp/../socket")) + let (client, server) = try makeSocketPair() + defer { close(server) } + let transport = UnixWebSocketTransport(fileDescriptor: client, keyProvider: { Data(repeating: 1, count: 16) }) + let serverTask = Task.detached { + _ = try? readUntil(server, marker: Data([13, 10, 13, 10])) + try? writeAll(server, Data("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: wrong\r\n\r\n".utf8)) + } + do { + try await transport.connect() + XCTFail("expected malformed accept to fail") + } catch let error as UnixWebSocketTransportError { + XCTAssertEqual(error, .invalidHandshake("Sec-WebSocket-Accept mismatch")) + } + await serverTask.value + } +} + +private struct RawWebSocketFrame: Sendable { + let opcode: UInt8 + let masked: Bool + let payload: Data +} + +private func makeSocketPair() throws -> (Int32, Int32) { + var fds: [Int32] = [0, 0] + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0 else { + throw UnixWebSocketTransportError.connectionFailed(errno) + } + return (fds[0], fds[1]) +} + +private func completeHandshake(_ fd: Int32) throws { + let request = try readUntil(fd, marker: Data([13, 10, 13, 10])) + let text = String(decoding: request, as: UTF8.self) + guard let line = text.components(separatedBy: "\r\n").first(where: { $0.hasPrefix("Sec-WebSocket-Key:") }) else { + throw UnixWebSocketTransportError.invalidHandshake("test request did not include key") + } + let key = String(line.split(separator: ":", maxSplits: 1)[1]).trimmingCharacters(in: .whitespaces) + let accept = Data(Insecure.SHA1.hash(data: Data((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").utf8))).base64EncodedString() + try writeAll(fd, Data("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: \(accept)\r\n\r\n".utf8)) +} + +private func readUntil(_ fd: Int32, marker: Data) throws -> Data { + var data = Data() + while data.range(of: marker) == nil { + var byte: UInt8 = 0 + let count = recv(fd, &byte, 1, 0) + guard count == 1 else { throw UnixWebSocketTransportError.closed } + data.append(byte) + } + return data +} + +private func writeAll(_ fd: Int32, _ data: Data) throws { + try data.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + var offset = 0 + while offset < raw.count { + let count = send(fd, base.advanced(by: offset), raw.count - offset, 0) + guard count > 0 else { throw UnixWebSocketTransportError.closed } + offset += count + } + } +} + +private func readRawFrame(_ fd: Int32) throws -> RawWebSocketFrame { + let first = try readExactly(fd, 1)[0] + let second = try readExactly(fd, 1)[0] + let masked = second & 0x80 != 0 + var length = Int(second & 0x7F) + if length == 126 { length = try readExactly(fd, 2).reduce(0) { ($0 << 8) | Int($1) } } + if length == 127 { throw UnixWebSocketTransportError.messageTooLarge } + let mask = masked ? try readExactly(fd, 4) : Data() + var payload = try readExactly(fd, length) + if masked { + payload = Data(payload.enumerated().map { $0.element ^ mask[$0.offset & 3] }) + } + return RawWebSocketFrame(opcode: first & 0x0F, masked: masked, payload: payload) +} + +private func serverFrame(fin: Bool, opcode: UInt8, payload: Data) -> Data { + Data([fin ? (UInt8(0x80) | opcode) : opcode, UInt8(payload.count)]) + payload +} + +private func readExactly(_ fd: Int32, _ count: Int) throws -> Data { + var data = Data() + while data.count < count { + var bytes = [UInt8](repeating: 0, count: count - data.count) + let received = recv(fd, &bytes, bytes.count, 0) + guard received > 0 else { throw UnixWebSocketTransportError.closed } + data.append(contentsOf: bytes[0.. Data { + try JSONSerialization.data(withJSONObject: object.mapValues { $0.toFoundation() }, options: []) + } + + func testVersionBoundaryRejectsOtherProtocol() throws { + let data = try fixture(["v": .string("omp-app/2"), "type": .string("event")]) + XCTAssertThrowsError(try WireDecoder.decode(data)) { error in + let error = error as? WireFormatError + XCTAssertEqual(error?.path, "v") + XCTAssertEqual(error?.message, "protocol version must be exactly omp-app/1") + } + } + + func testAdditiveEventDataAndEnvelopeArePreserved() throws { + let data = try fixture([ + "v": .string(WireLimits.protocolVersion), "type": .string("event"), + "hostId": .string("host"), "sessionId": .string("session"), + "cursor": .object(["epoch": .string("e"), "seq": .number(2)]), + "event": .object(["type": .string("future.message"), "futureData": .object(["enabled": .bool(true), "labels": .array([.string("kept")])])]), + "futureEnvelopeData": .object(["generation": .number(2)]), + ]) + guard case let .event(frame) = try WireDecoder.decode(data) else { + return XCTFail("expected event") + } + XCTAssertEqual(frame.event["futureData"], .object(["enabled": .bool(true), "labels": .array([.string("kept")])])) + XCTAssertEqual(frame.raw["futureEnvelopeData"], .object(["generation": .number(2)])) + } + + func testStrictLimitsRejectOversizedInputAndDeepValues() throws { + XCTAssertThrowsError(try WireDecoder.decode(Data(repeating: 0x20, count: WireLimits.maxFrameBytes + 1))) + let nested = String(repeating: "[", count: WireLimits.maxDepth + 2) + "0" + String(repeating: "]", count: WireLimits.maxDepth + 2) + XCTAssertThrowsError(try WireDecoder.decode(Data(nested.utf8))) + } + + func testCursorsHaveDistinctTypes() throws { + let transcript = TranscriptCursor(epoch: "t", seq: 1) + let index = SessionIndexCursor(epoch: "i", seq: 1) + XCTAssertNotEqual(String(describing: type(of: transcript)), String(describing: type(of: index))) + } + + func testEncoderProducesPinnedCommandAndPingFrames() throws { + let command = try WireEncoder.list(requestId: "r", commandId: "c", hostId: "h") + guard case let .command(frame) = try WireDecoder.decode(command) else { return XCTFail("expected command") } + XCTAssertEqual(frame.command, "session.list") + let ping = try WireEncoder.ping(nonce: "n", timestamp: "now") + guard case let .ping(frame) = try WireDecoder.decode(ping) else { return XCTFail("expected ping") } + XCTAssertEqual(frame.nonce, "n") + } +} diff --git a/apps/apple/project.yml b/apps/apple/project.yml new file mode 100644 index 00000000..7092daea --- /dev/null +++ b/apps/apple/project.yml @@ -0,0 +1,74 @@ +name: T4AppleApps + +packages: + T4Apple: + path: . + +targets: + T4IOSApp: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - path: Apps/T4IOSApp + info: + path: Apps/T4IOSApp/Info.plist + settings: + base: + SWIFT_VERSION: "6.0" + PRODUCT_BUNDLE_IDENTIFIER: "dev.oh-my-pi.t4code" + CODE_SIGN_STYLE: "Manual" + CODE_SIGNING_ALLOWED: "NO" + CODE_SIGNING_REQUIRED: "NO" + CODE_SIGN_IDENTITY: "" + dependencies: + - package: T4Apple + product: T4UI + + T4MacApp: + type: application + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/T4MacApp + info: + path: Apps/T4MacApp/Info.plist + properties: + CFBundleDisplayName: T4 Code + CFBundleShortVersionString: "1.0" + CFBundleVersion: "1" + NSPrincipalClass: NSApplication + settings: + base: + SWIFT_VERSION: "6.0" + PRODUCT_BUNDLE_IDENTIFIER: "dev.oh-my-pi.t4code.macos" + CODE_SIGNING_ALLOWED: "NO" + CODE_SIGNING_REQUIRED: "NO" + dependencies: + - package: T4Apple + product: T4UI + preBuildScripts: + - name: Stage local runtime + script: | + "$SRCROOT/scripts/stage-local-runtime.sh" + basedOnDependencyAnalysis: false + inputFiles: + - "$(SRCROOT)/scripts/stage-local-runtime.sh" + +schemes: + T4IOSApp: + build: + targets: + T4IOSApp: all + run: + config: Debug + archive: + config: Release + T4MacApp: + build: + targets: + T4MacApp: all + run: + config: Debug + archive: + config: Release diff --git a/apps/apple/scripts/stage-local-runtime.sh b/apps/apple/scripts/stage-local-runtime.sh new file mode 100755 index 00000000..fcfa5826 --- /dev/null +++ b/apps/apple/scripts/stage-local-runtime.sh @@ -0,0 +1,96 @@ +#!/bin/sh +set -eu + +fail() { + printf '%s\n' "stage-local-runtime: $*" >&2 + exit 1 +} + +script_input="${SCRIPT_INPUT_FILE:-}" +if [ -z "$script_input" ]; then + script_input="${SCRIPT_INPUT_FILE_0:-}" +fi + +if [ -n "$script_input" ]; then + script_dir="$(CDPATH= cd -- "$(dirname -- "$script_input")" 2>/dev/null && pwd -P)" || fail "cannot resolve SCRIPT_INPUT_FILE" + repo_root="$(CDPATH= cd -- "$script_dir/../../.." 2>/dev/null && pwd -P)" || fail "cannot resolve repository root" +else + project_path="${PROJECT_DIR:-${SRCROOT:-}}" + [ -n "$project_path" ] || fail "SCRIPT_INPUT_FILE or project path is required" + project_dir="$(CDPATH= cd -- "$project_path" 2>/dev/null && pwd -P)" || fail "cannot resolve project path" + repo_root="$(CDPATH= cd -- "$project_dir/../.." 2>/dev/null && pwd -P)" || fail "cannot resolve repository root" +fi + +[ -f "$repo_root/package.json" ] || fail "repository root is missing package.json" +[ -f "$repo_root/pnpm-lock.yaml" ] || fail "repository root is missing pnpm-lock.yaml" +[ -f "$repo_root/scripts/stage-omp-runtime.mjs" ] || fail "repository root is missing runtime staging script" +[ -f "$repo_root/packages/host-daemon/package.json" ] || fail "repository root is missing host-daemon package" + +host_binary="$repo_root/packages/host-daemon/dist/t4-host" +host_package="$repo_root/packages/host-daemon/package.json" +host_lockfile="$repo_root/pnpm-lock.yaml" +host_stale=0 + +if [ ! -f "$host_binary" ] || [ ! -x "$host_binary" ]; then + host_stale=1 +else + for dependency in \ + "$host_package" \ + "$host_lockfile" \ + "$repo_root/packages/host-daemon/tsconfig.json" \ + "$repo_root/packages/host-service/package.json" \ + "$repo_root/packages/host-wire/package.json" + do + if [ -f "$dependency" ] && [ "$dependency" -nt "$host_binary" ]; then + host_stale=1 + break + fi + done + + if [ "$host_stale" -eq 0 ]; then + for source_root in \ + "$repo_root/packages/host-daemon" \ + "$repo_root/packages/host-service" \ + "$repo_root/packages/host-wire" + do + if [ -d "$source_root" ] && [ -n "$(find "$source_root" -type f -newer "$host_binary" -print -quit)" ]; then + host_stale=1 + break + fi + done + fi +fi + +if [ "$host_stale" -eq 1 ]; then + pnpm_bin="$(command -v pnpm 2>/dev/null || true)" + [ -n "$pnpm_bin" ] || fail "pnpm is required to build t4-host" + ( + cd "$repo_root" + "$pnpm_bin" --filter @t4-code/host-daemon build:binary + ) || fail "failed to build t4-host" +fi + +[ -f "$host_binary" ] && [ -x "$host_binary" ] || fail "t4-host is not an executable" + +node_bin="$(command -v node 2>/dev/null || true)" +[ -n "$node_bin" ] || fail "node is required to stage omp" +"$node_bin" "$repo_root/scripts/stage-omp-runtime.mjs" \ + --platform darwin \ + --arch arm64 \ + --runtime verified + +omp_binary="$repo_root/.artifacts/omp-runtime/omp" +[ -f "$omp_binary" ] && [ -x "$omp_binary" ] || fail "omp is not an executable" + +built_products_dir="${BUILT_PRODUCTS_DIR:-}" +contents_folder_path="${CONTENTS_FOLDER_PATH:-}" +[ -n "$built_products_dir" ] || fail "BUILT_PRODUCTS_DIR is required" +[ -n "$contents_folder_path" ] || fail "CONTENTS_FOLDER_PATH is required" + +resources_dir="$built_products_dir/$contents_folder_path/Resources/T4Runtime" +mkdir -p "$resources_dir" +install -m 755 "$host_binary" "$resources_dir/t4-host" +install -m 755 "$omp_binary" "$resources_dir/omp" + +[ -f "$resources_dir/t4-host" ] && [ -x "$resources_dir/t4-host" ] || fail "staged t4-host is not executable" +[ -f "$resources_dir/omp" ] && [ -x "$resources_dir/omp" ] || fail "staged omp is not executable"