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"