diff --git a/.gitignore b/.gitignore index 4a0cea7..a4f6a30 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ __pycache__/ *.egg-info/ .pytest_cache/ target/ +.build/ +.swiftpm/ .claude/ examples/spa/angular/.angular/ .agents/plugins/marketplace.json diff --git a/packages/swift/slop-ai/Package.resolved b/packages/swift/slop-ai/Package.resolved new file mode 100644 index 0000000..d59c914 --- /dev/null +++ b/packages/swift/slop-ai/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "77b84ac2cd2ac9e4ac67d19f045fd5b434f56967", + "version" : "2.101.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "669763cfd5806a67e21972d7e5e2d6b80b1ea985", + "version" : "1.6.5" + } + } + ], + "version" : 2 +} diff --git a/packages/swift/slop-ai/Package.swift b/packages/swift/slop-ai/Package.swift new file mode 100644 index 0000000..0f421f8 --- /dev/null +++ b/packages/swift/slop-ai/Package.swift @@ -0,0 +1,40 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "slop-ai", + platforms: [ + .macOS(.v13), + .iOS(.v16), + .tvOS(.v16), + .watchOS(.v9), + ], + products: [ + .library(name: "SlopAI", targets: ["SlopAI"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), + ], + targets: [ + .target( + name: "SlopAI", + dependencies: [ + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOWebSocket", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + ] + ), + .testTarget( + name: "SlopAITests", + dependencies: [ + "SlopAI", + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOEmbedded", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOWebSocket", package: "swift-nio"), + ] + ), + ] +) diff --git a/packages/swift/slop-ai/README.md b/packages/swift/slop-ai/README.md new file mode 100644 index 0000000..8e0e3a0 --- /dev/null +++ b/packages/swift/slop-ai/README.md @@ -0,0 +1,223 @@ +# SlopAI Swift SDK + +Swift Package Manager SDK for the SLOP protocol. + +This package mirrors the TypeScript SDK's core behavior while using Swift-native +types and Codable wire models: + +- wire types for nodes, affordances, metadata, content references, patches, and schemas +- descriptor normalization with Swift closures for action handlers +- tree assembly from path registrations +- tree diffing, scaling, filtering, windowing, and subtree lookup +- invoke parameter validation +- provider/server registration, subscription, query, invoke, event, and patch handling +- consumer-side state mirror and affordance-to-tool helpers +- `URLSessionWebSocketTransport` for iOS/macOS consumers +- `UnixSocketClientTransport` for local macOS consumers +- provider transports for Unix sockets, stdio NDJSON, and SwiftNIO WebSocket servers +- local provider descriptor registration/reading with ownership and permission hardening +- bridge discovery and relay transport support for browser-backed providers +- `DiscoveryService` for local and bridge-backed providers + +## Quick Start + +```swift +import SlopAI + +let slop = SlopServer(id: "todos", name: "Todos") + +try slop.register("list", descriptor: NodeDescriptor( + type: "collection", + props: ["count": 1], + items: [ + ItemDescriptor(id: "first", props: ["title": "Write Swift SDK"]) + ], + actions: [ + "clear": .value(dangerous: true) { _ in + .object(["ok": true]) + } + ] +)) + +print(formatTree(slop.tree)) +``` + +## iOS Consumer + +```swift +import SlopAI + +let transport = URLSessionWebSocketTransport( + url: URL(string: "ws://127.0.0.1:7777/slop")! +) +let consumer = SlopConsumer(transport: transport) + +let hello = try await consumer.connect() +let subscription = try await consumer.subscribe(path: "/", depth: 2) +let result = try await consumer.invoke(path: "/button", action: "press") + +print(hello) +print(formatTree(subscription.snapshot)) +print(result) +``` + +## macOS Unix Socket Consumer + +```swift +import SlopAI + +let transport = UnixSocketClientTransport(path: "/tmp/slop/my-app.sock") +let consumer = SlopConsumer(transport: transport) + +_ = try await consumer.connect() +let subscription = try await consumer.subscribe(path: "/", depth: -1) +let tree = try await consumer.query(path: "/", depth: 2) + +print(formatTree(subscription.snapshot)) +print(formatTree(tree)) +``` + +## macOS App Provider + +Make a Swift macOS app SLOP-native by registering state on a `SlopServer` and +listening on a local Unix socket: + +```swift +import SlopAI + +let slop = SlopServer(id: "notes", name: "Notes") + +try slop.register("document", descriptor: NodeDescriptor( + type: "document", + props: ["title": "Draft"], + actions: [ + "rename": action(params: ["title": .type("string")]) { params in + .object(["title": params["title"] ?? .null]) + } + ] +)) + +let listener = try slop.listenUnix( + path: "/tmp/slop/notes.sock", + discover: true +) + +// Keep `listener` alive for as long as the app should expose SLOP. +``` + +## App Intents Adapter + +On Apple platforms, reuse `AppEntity` and `AppIntent` types as the source for +SLOP nodes and affordances. The adapter keeps the mapping explicit because App +Intents doesn't expose a supported runtime API for enumerating every entity +property or mutating arbitrary intent parameters. + +```swift +import AppIntents +import SlopAI + +let noteAdapter = AppEntityAdapter( + type: "notes:note", + properties: { note in + ["modified": .string(note.modified.formatted(.iso8601))] + }, + actions: { note in + [ + "open": .appIntent( + OpenNoteIntent.self, + makeIntent: { _ in OpenNoteIntent(note: note) } + ) + ] + } +) + +try slop.registerAppEntities( + "notes", + entities: notes, + adapter: noteAdapter, + properties: ["count": .number(Double(notes.count))] +) +``` + +The projection automatically includes the entity's display title, subtitle, +and original App Intents identifier. App-specific properties and result values +must be mapped to `JSONValue`. On the 27 releases, +`registerAppEntityCollection` can resolve and publish an `EntityCollection`. + +Calling an `AppIntent` through SLOP runs the same `perform()` implementation, +but it doesn't pass through the Siri/Shortcuts dispatcher. App Intents +authentication policy, automatic system confirmation, presentation, +interaction donations, and `LongRunningIntent` progress UI therefore aren't +applied to the SLOP invocation. Continue to authorize in the provider and set +`dangerous: true` where the SLOP consumer must confirm. Use SLOP task nodes when +progress must also be visible to a SLOP consumer. + +## Provider Transports + +Expose a provider over stdio NDJSON: + +```swift +let stdio = slop.listenStdio() +``` + +Expose a provider over WebSocket: + +```swift +let ws = try slop.listenWebSocket( + options: WebSocketProviderOptions( + host: "127.0.0.1", + port: 8765, + path: "/slop" + ) +) +``` + +WebSocket upgrades follow the same default security posture as the other SDKs: +browser upgrades require an `allowedOrigins` allowlist, and non-loopback +upgrades require an `authenticate` callback. + +## Bridge Relay + +Bridge-backed browser providers can be discovered and consumed through the +relay transport: + +```swift +let bridge = BridgeClient() +bridge.start() + +let discovery = DiscoveryService( + options: DiscoveryOptions(bridges: [bridge]) +) + +if let provider = try await discovery.ensureConnected("browser-tab") { + print(formatTree(provider.consumer.getTree(subscriptionID: provider.subscriptionID)!)) +} +``` + +Or connect from a local discovery descriptor: + +```swift +let discovery = DiscoveryService() +await discovery.start() + +if let provider = try await discovery.ensureConnected("todos") { + print(formatTree(provider.consumer.getTree(subscriptionID: provider.subscriptionID)!)) +} +``` + +You can also create a consumer directly from a discovery descriptor when its +transport is supported: + +```swift +if let consumer = SlopConsumer(descriptor: descriptor) { + _ = try await consumer.connect() + let tree = try await consumer.query(path: "/", depth: 2) + print(formatTree(tree)) +} +``` + +Run the package tests with: + +```sh +swift test +``` diff --git a/packages/swift/slop-ai/Sources/SlopAI/AppIntentsAdapter.swift b/packages/swift/slop-ai/Sources/SlopAI/AppIntentsAdapter.swift new file mode 100644 index 0000000..3fcc39e --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/AppIntentsAdapter.swift @@ -0,0 +1,236 @@ +#if canImport(AppIntents) +import AppIntents +import Foundation + +/// Produces a stable SLOP node ID from an App Intents entity identifier. +/// +/// App entity identifiers may contain `/`, `~`, or reserved SLOP path segments. +/// Encoding the complete identifier keeps the projection stable and addressable. +@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +public func slopNodeID(appEntityIdentifier: String) -> String { + let encoded = Data(appEntityIdentifier.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "app-entity-\(encoded)" +} + +/// Projects one App Intents entity type into SLOP node descriptors. +/// +/// Display title, subtitle, and the original App Intents identifier are included +/// automatically. Supply closures for app-specific properties, actions, and state. +@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +public struct AppEntityAdapter { + public typealias ID = (Entity) -> String + public typealias Properties = (Entity) -> [String: JSONValue] + public typealias Summary = (Entity) -> String? + public typealias Actions = (Entity) -> [String: Action]? + public typealias Metadata = (Entity) -> NodeMeta? + public typealias Content = (Entity) -> ContentRef? + + public var type: String + private let id: ID + private let properties: Properties + private let summary: Summary + private let actions: Actions + private let metadata: Metadata + private let content: Content + + public init( + type: String = "app-intents:entity", + id: @escaping ID = { + slopNodeID(appEntityIdentifier: $0.id.entityIdentifierString) + }, + properties: @escaping Properties = { _ in [:] }, + summary: @escaping Summary = { _ in nil }, + actions: @escaping Actions = { _ in nil }, + meta: @escaping Metadata = { _ in nil }, + contentRef: @escaping Content = { _ in nil } + ) { + self.type = type + self.id = id + self.properties = properties + self.summary = summary + self.actions = actions + self.metadata = meta + self.content = contentRef + } + + public func nodeID(for entity: Entity) -> String { + id(entity) + } + + public func nodeDescriptor(for entity: Entity) -> NodeDescriptor { + NodeDescriptor( + type: type, + props: mergedProperties(for: entity), + summary: summary(entity), + contentRef: content(entity), + actions: actions(entity), + meta: metadata(entity) + ) + } + + public func itemDescriptor(for entity: Entity) -> ItemDescriptor { + ItemDescriptor( + id: nodeID(for: entity), + type: type, + props: mergedProperties(for: entity), + summary: summary(entity), + actions: actions(entity), + meta: metadata(entity), + contentRef: content(entity) + ) + } + + public func collectionDescriptor( + for entities: [Entity], + type collectionType: String = "collection", + properties: [String: JSONValue]? = nil, + summary: String? = nil, + actions: [String: Action]? = nil, + meta: NodeMeta? = nil + ) -> NodeDescriptor { + NodeDescriptor( + type: collectionType, + props: properties, + summary: summary, + items: entities.map(itemDescriptor), + actions: actions, + meta: meta + ) + } + + private func mergedProperties(for entity: Entity) -> [String: JSONValue] { + let representation = entity.displayRepresentation + var result: [String: JSONValue] = [ + "app_intents_identifier": .string(entity.id.entityIdentifierString), + "label": .string(String(localized: representation.title)), + ] + if let subtitle = representation.subtitle { + result["description"] = .string(String(localized: subtitle)) + } + result.merge(properties(entity)) { _, projected in projected } + return result + } +} + +@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +extension Action { + /// Wraps a typed App Intent as a SLOP affordance. + /// + /// `makeIntent` maps validated SLOP parameters into the concrete intent. The + /// result mapper explicitly controls what crosses the JSON wire boundary. + /// This calls `perform()` directly; App Intents authentication policy and + /// system confirmation UI are not applied, so the SLOP provider must still + /// authorize the action and mark confirmation-sensitive actions dangerous. + public static func appIntent( + _ intentType: Intent.Type, + params: [String: ParamDef]? = nil, + label: String? = nil, + description: String? = nil, + dangerous: Bool = false, + idempotent: Bool = false, + estimate: ActionEstimate? = nil, + makeIntent: @escaping ([String: JSONValue]) throws -> Intent, + mapResult: @escaping (Intent.PerformResult) throws -> JSONValue? = { _ in nil } + ) -> Action { + .value( + params: params, + label: label ?? String(localized: intentType.title), + description: description, + dangerous: dangerous, + idempotent: idempotent, + estimate: estimate + ) { params in + let result = try await makeIntent(params).perform() + return try mapResult(result) + } + } + + /// Wraps a parameterless App Intent using its required `init()` initializer. + public static func appIntent( + _ intentType: Intent.Type, + label: String? = nil, + description: String? = nil, + dangerous: Bool = false, + idempotent: Bool = false, + estimate: ActionEstimate? = nil, + mapResult: @escaping (Intent.PerformResult) throws -> JSONValue? = { _ in nil } + ) -> Action { + appIntent( + intentType, + label: label, + description: description, + dangerous: dangerous, + idempotent: idempotent, + estimate: estimate, + makeIntent: { _ in Intent() }, + mapResult: mapResult + ) + } +} + +@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +extension SlopServer { + public func registerAppEntity( + _ path: String, + entity: Entity, + adapter: AppEntityAdapter + ) throws { + try register(path, descriptor: adapter.nodeDescriptor(for: entity)) + } + + public func registerAppEntities( + _ path: String, + entities: [Entity], + adapter: AppEntityAdapter, + type: String = "collection", + properties: [String: JSONValue]? = nil, + summary: String? = nil, + actions: [String: Action]? = nil, + meta: NodeMeta? = nil + ) throws { + try register( + path, + descriptor: adapter.collectionDescriptor( + for: entities, + type: type, + properties: properties, + summary: summary, + actions: actions, + meta: meta + ) + ) + } + +#if compiler(>=6.4) + /// Resolves a macOS 27 App Intents `EntityCollection` and publishes it as a + /// SLOP collection while preserving the collection's identifier order. + @available(macOS 27.0, iOS 27.0, watchOS 27.0, tvOS 27.0, *) + public func registerAppEntityCollection( + _ path: String, + collection: EntityCollection, + adapter: AppEntityAdapter, + type: String = "collection", + properties: [String: JSONValue]? = nil, + summary: String? = nil, + actions: [String: Action]? = nil, + meta: NodeMeta? = nil + ) async throws { + let entities = try await collection.resolvedEntities() + try registerAppEntities( + path, + entities: entities, + adapter: adapter, + type: type, + properties: properties, + summary: summary, + actions: actions, + meta: meta + ) + } +#endif +} +#endif diff --git a/packages/swift/slop-ai/Sources/SlopAI/AsyncAction.swift b/packages/swift/slop-ai/Sources/SlopAI/AsyncAction.swift new file mode 100644 index 0000000..e03ac03 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/AsyncAction.swift @@ -0,0 +1,166 @@ +import Foundation + +public final class TaskHandle { + public let id: String + private weak var server: SlopServer? + private let label: String? + private let cancelable: Bool + private var task: Task? + private var cancelled = false + private let lock = NSLock() + + init(id: String, server: SlopServer, label: String?, cancelable: Bool) { + self.id = id + self.server = server + self.label = label + self.cancelable = cancelable + } + + public var isCancelled: Bool { + lock.lock() + let value = cancelled + lock.unlock() + return value + } + + public func update(progress: Double, message: String) { + lock.lock() + guard !cancelled else { + lock.unlock() + return + } + defer { lock.unlock() } + guard let server else { return } + var actions: [String: Action]? + if cancelable { + actions = [ + "cancel": Action.value(dangerous: true) { [weak self] _ in + self?.cancel() + return .object(["cancelled": true]) + }, + ] + } + try? server.register( + "tasks/\(id)", + descriptor: NodeDescriptor( + type: "status", + props: [ + "progress": .number(progress), + "message": .string(message), + "status": "running", + "action": .string(label ?? "task"), + ], + actions: actions, + meta: NodeMeta(salience: 0.8) + ) + ) + } + + func attach(_ task: Task) { + lock.lock() + self.task = task + let cancelImmediately = cancelled + lock.unlock() + if cancelImmediately { + task.cancel() + } + } + + @discardableResult + func finish(properties: [String: JSONValue], salience: Double, urgency: Urgency? = nil) -> Bool { + lock.lock() + guard !cancelled, let server else { + lock.unlock() + return false + } + defer { lock.unlock() } + try? server.register( + "tasks/\(id)", + descriptor: NodeDescriptor( + type: "status", + props: properties, + meta: NodeMeta(salience: salience, urgency: urgency) + ) + ) + return true + } + + func cancel() { + lock.lock() + cancelled = true + let task = task + lock.unlock() + + task?.cancel() + guard let server else { return } + try? server.register( + "tasks/\(id)", + descriptor: NodeDescriptor( + type: "status", + props: ["status": "cancelled", "message": "Cancelled"], + meta: NodeMeta(salience: 0.3) + ) + ) + Task { + try? await Task.sleep(nanoseconds: 10_000_000_000) + try? server.unregister("tasks/\(id)") + } + } +} + +extension SlopServer { + public func asyncAction( + params: [String: ParamDef], + label: String? = nil, + description: String? = nil, + cancelable: Bool = false, + operation: @escaping ([String: JSONValue], TaskHandle) async throws -> JSONValue? + ) -> Action { + Action( + params: params, + label: label, + description: description, + estimate: .async + ) { [weak self] rawParams in + guard let self else { + throw SlopError.internalError("SLOP server is no longer available") + } + + let taskID = "task-\(UUID().uuidString.prefix(8))" + let handle = TaskHandle(id: taskID, server: self, label: label, cancelable: cancelable) + handle.update(progress: 0, message: label.map { "\($0)..." } ?? "Starting...") + + let task = Task { + do { + let result = try await operation(rawParams, handle) + guard !Task.isCancelled, !handle.isCancelled else { return } + var props: [String: JSONValue] = [ + "progress": 1, + "message": "Complete", + "status": "done", + ] + if let result { + props["result"] = result + } + guard handle.finish(properties: props, salience: 0.5) else { return } + try? await Task.sleep(nanoseconds: 30_000_000_000) + try? self.unregister("tasks/\(taskID)") + } catch { + guard !Task.isCancelled, !handle.isCancelled else { return } + _ = handle.finish( + properties: [ + "progress": 0, + "message": .string(error.localizedDescription), + "status": "failed", + ], + salience: 1.0, + urgency: .high + ) + } + } + handle.attach(task) + + return .accepted(taskId: taskID) + } + } +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Bridge.swift b/packages/swift/slop-ai/Sources/SlopAI/Bridge.swift new file mode 100644 index 0000000..53fb771 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Bridge.swift @@ -0,0 +1,1104 @@ +import Foundation +import NIOCore +import NIOHTTP1 +import NIOPosix +import NIOWebSocket + +public let defaultBridgeURL = "ws://127.0.0.1:9339/slop-bridge" +public let defaultBridgeHost = "127.0.0.1" +public let defaultBridgePort = 9339 +public let defaultBridgePath = "/slop-bridge" + +private func bridgeReconnectDelayNanoseconds(_ delay: TimeInterval) -> UInt64 { + guard !delay.isNaN, delay > 0 else { return 0 } + guard delay.isFinite else { return UInt64.max } + let nanoseconds = delay * 1_000_000_000 + guard nanoseconds < Double(UInt64.max) else { return UInt64.max } + return UInt64(nanoseconds) +} + +public struct BridgeProvider: Equatable { + public var providerKey: String + public var tabID: Int + public var id: String + public var name: String + public var transport: String + public var url: String? + + public init( + providerKey: String, + tabID: Int = 0, + id: String? = nil, + name: String? = nil, + transport: String = "postmessage", + url: String? = nil + ) { + self.providerKey = providerKey + self.tabID = tabID + self.id = id ?? providerKey + self.name = name ?? "Tab" + self.transport = transport + self.url = url + } +} + +public typealias BridgeRelayHandler = (SlopMessage) -> Void + +public protocol DiscoveryBridge: AnyObject { + var running: Bool { get } + func providers() -> [BridgeProvider] + @discardableResult + func onProviderChange(_ callback: @escaping () -> Void) -> () -> Void + @discardableResult + func onDisconnect(_ callback: @escaping () -> Void) -> () -> Void + @discardableResult + func subscribeRelay(providerKey: String, handler: @escaping BridgeRelayHandler) -> () -> Void + func send(_ message: SlopMessage) async throws + func stop() +} + +public func parseBridgeProvider(_ message: SlopMessage) -> BridgeProvider? { + guard let providerKey = message["providerKey"]?.stringValue, !providerKey.isEmpty else { + return nil + } + let tabID = message["tabId"]?.intValue ?? 0 + let provider = message["provider"]?.objectValue ?? [:] + return BridgeProvider( + providerKey: providerKey, + tabID: tabID, + id: provider["id"]?.stringValue, + name: provider["name"]?.stringValue, + transport: provider["transport"]?.stringValue ?? "postmessage", + url: provider["url"]?.stringValue + ) +} + +public func bridgeProviderToDescriptor(_ provider: BridgeProvider) -> ProviderDescriptor { + let transport: ProviderTransport + if provider.transport == "ws", let url = provider.url, !url.isEmpty { + transport = ProviderTransport(type: .ws, url: url) + } else { + transport = ProviderTransport(type: .relay) + } + + return ProviderDescriptor( + id: provider.providerKey, + name: provider.name, + slopVersion: "1.0", + transport: transport, + capabilities: [], + providerKey: provider.providerKey, + source: "bridge" + ) +} + +public final class BridgeRelayTransport: ClientTransport { + private let bridge: DiscoveryBridge + private let providerKey: String + + public init(bridge: DiscoveryBridge, providerKey: String) { + self.bridge = bridge + self.providerKey = providerKey + } + + public func connect() async throws -> SlopConnection { + let responseFlag = RelayResponseFlag() + let providerKey = self.providerKey + var connection: BridgeRelayConnection! + let unsubscribeRelay = bridge.subscribeRelay(providerKey: providerKey) { message in + responseFlag.mark() + connection?.receive(message) + } + let unsubscribeDisconnect = bridge.onDisconnect { + connection?.bridgeDidDisconnect() + } + let unsubscribeProviderChange = bridge.onProviderChange { [weak bridge] in + guard bridge?.providers().contains(where: { $0.providerKey == providerKey }) == false else { return } + connection?.bridgeDidDisconnect() + } + connection = BridgeRelayConnection( + bridge: bridge, + providerKey: providerKey, + unsubscribe: { + unsubscribeRelay() + unsubscribeDisconnect() + unsubscribeProviderChange() + } + ) + + do { + try await bridge.send(["type": "relay-open", "providerKey": .string(providerKey)]) + for _ in 0...3 { + try await bridge.send([ + "type": "slop-relay", + "providerKey": .string(providerKey), + "message": .object(["type": "connect"]), + ]) + if await responseFlag.wait(milliseconds: 300) { + break + } + } + return connection + } catch { + connection.bridgeDidDisconnect() + throw error + } + } +} + +public final class BridgeRelayConnection: SlopConnection { + private let bridge: DiscoveryBridge + private let providerKey: String + private let unsubscribe: () -> Void + private let lock = NSLock() + private var messageHandlers: [(SlopMessage) -> Void] = [] + private var closeHandlers: [() -> Void] = [] + private var earlyMessages: [SlopMessage] = [] + private var buffering = true + private var closed = false + private var sendTail: Task? + + init(bridge: DiscoveryBridge, providerKey: String, unsubscribe: @escaping () -> Void) { + self.bridge = bridge + self.providerKey = providerKey + self.unsubscribe = unsubscribe + } + + public func send(_ message: SlopMessage) { + lock.lock() + guard !closed else { + lock.unlock() + return + } + let previous = sendTail + let next = Task { [weak self, bridge, providerKey] in + await previous?.value + guard self?.isOpen == true else { return } + do { + try await bridge.send([ + "type": "slop-relay", + "providerKey": .string(providerKey), + "message": .object(message), + ]) + } catch { + self?.finishClose(notifyBridge: false) + } + } + sendTail = next + lock.unlock() + } + + public func onMessage(_ handler: @escaping (SlopMessage) -> Void) { + lock.lock() + messageHandlers.append(handler) + let replay = earlyMessages + earlyMessages.removeAll() + buffering = false + lock.unlock() + + for message in replay { + handler(message) + } + } + + public func onClose(_ handler: @escaping () -> Void) { + lock.lock() + let alreadyClosed = closed + if !alreadyClosed { + closeHandlers.append(handler) + } + lock.unlock() + if alreadyClosed { + handler() + } + } + + public func close() { + finishClose(notifyBridge: true) + } + + func bridgeDidDisconnect() { + finishClose(notifyBridge: false) + } + + private func finishClose(notifyBridge: Bool) { + lock.lock() + guard !closed else { + lock.unlock() + return + } + closed = true + let handlers = closeHandlers + let previousSend = sendTail + closeHandlers.removeAll() + lock.unlock() + + if notifyBridge { + Task { [bridge, providerKey] in + await previousSend?.value + try? await bridge.send(["type": "relay-close", "providerKey": .string(providerKey)]) + } + } + unsubscribe() + for handler in handlers { + handler() + } + } + + func receive(_ message: SlopMessage) { + lock.lock() + guard !closed else { + lock.unlock() + return + } + if buffering { + earlyMessages.append(message) + } + let handlers = messageHandlers + lock.unlock() + + for handler in handlers { + handler(message) + } + } + + private var isOpen: Bool { + lock.lock() + defer { lock.unlock() } + return !closed + } +} + +private struct BridgeConnectionAttempt { + var token: UUID + var generation: UInt64 + var task: Task +} + +public final class BridgeClient: DiscoveryBridge { + private let url: URL + private let reconnectDelayNanoseconds: UInt64 + private let transportFactory: () -> ClientTransport + private let lock = NSLock() + private var connection: SlopConnection? + private var providerMap: [String: BridgeProvider] = [:] + private var relaySubscribers: [String: [UUID: BridgeRelayHandler]] = [:] + private var changeCallbacks: [UUID: () -> Void] = [:] + private var disconnectCallbacks: [UUID: () -> Void] = [:] + private var started = false + private var reconnectTask: Task? + private var connectionAttempt: BridgeConnectionAttempt? + private var lifecycleGeneration: UInt64 = 0 + + public init(url: URL = URL(string: defaultBridgeURL)!, reconnectDelay: TimeInterval = 5.0) { + self.url = url + reconnectDelayNanoseconds = bridgeReconnectDelayNanoseconds(reconnectDelay) + transportFactory = { URLSessionWebSocketTransport(url: url) } + } + + init( + url: URL = URL(string: defaultBridgeURL)!, + reconnectDelay: TimeInterval = 5.0, + transportFactory: @escaping () -> ClientTransport + ) { + self.url = url + reconnectDelayNanoseconds = bridgeReconnectDelayNanoseconds(reconnectDelay) + self.transportFactory = transportFactory + } + + public var running: Bool { + locked { connection != nil } + } + + public func connectOnce() async throws { + let attempt = locked { () -> BridgeConnectionAttempt? in + guard self.connection == nil else { return nil } + if let connectionAttempt { + return connectionAttempt + } + let token = UUID() + let generation = lifecycleGeneration + let transportFactory = self.transportFactory + let task = Task { + try await transportFactory().connect() + } + let attempt = BridgeConnectionAttempt(token: token, generation: generation, task: task) + connectionAttempt = attempt + return attempt + } + guard let attempt else { return } + + let connection: SlopConnection + do { + connection = try await attempt.task.value + } catch { + locked { + if connectionAttempt?.token == attempt.token { + connectionAttempt = nil + } + } + throw error + } + + let installation = locked { () -> (accepted: Bool, installed: Bool) in + if let current = self.connection, ObjectIdentifier(current) == ObjectIdentifier(connection) { + return (true, false) + } + guard + connectionAttempt?.token == attempt.token, + lifecycleGeneration == attempt.generation, + self.connection == nil + else { + return (false, false) + } + connectionAttempt = nil + self.connection = connection + return (true, true) + } + guard installation.accepted else { + connection.close() + throw CancellationError() + } + guard installation.installed else { return } + + connection.onMessage { [weak self, weak connection] message in + self?.handleBridgeMessage(message, from: connection) + } + connection.onClose { [weak self, weak connection] in + guard let self else { return } + self.handleDisconnect(connection) + } + guard locked({ self.connection.map(ObjectIdentifier.init) == ObjectIdentifier(connection) }) else { + throw CancellationError() + } + } + + public func start() { + lock.lock() + guard !started else { + lock.unlock() + return + } + started = true + lock.unlock() + + scheduleReconnect(immediately: true) + } + + public func stop() { + lock.lock() + guard started || connection != nil || connectionAttempt != nil || !providerMap.isEmpty else { + lock.unlock() + return + } + started = false + lifecycleGeneration &+= 1 + reconnectTask?.cancel() + reconnectTask = nil + let connectionTask = connectionAttempt?.task + connectionAttempt = nil + let connection = connection + self.connection = nil + let changed = !providerMap.isEmpty + providerMap.removeAll() + relaySubscribers.removeAll() + let callbacks = Array(changeCallbacks.values) + let lifecycleCallbacks = Array(disconnectCallbacks.values) + lock.unlock() + + connectionTask?.cancel() + for callback in lifecycleCallbacks { + callback() + } + connection?.close() + if changed { + for callback in callbacks { + callback() + } + } + } + + public func providers() -> [BridgeProvider] { + lock.lock() + let providers = Array(providerMap.values) + lock.unlock() + return providers + } + + @discardableResult + public func onProviderChange(_ callback: @escaping () -> Void) -> () -> Void { + let token = UUID() + lock.lock() + changeCallbacks[token] = callback + lock.unlock() + return { [weak self] in + self?.lock.lock() + self?.changeCallbacks.removeValue(forKey: token) + self?.lock.unlock() + } + } + + @discardableResult + public func onDisconnect(_ callback: @escaping () -> Void) -> () -> Void { + let token = UUID() + lock.lock() + disconnectCallbacks[token] = callback + lock.unlock() + return { [weak self] in + self?.lock.lock() + self?.disconnectCallbacks.removeValue(forKey: token) + self?.lock.unlock() + } + } + + @discardableResult + public func subscribeRelay(providerKey: String, handler: @escaping BridgeRelayHandler) -> () -> Void { + let token = UUID() + lock.lock() + relaySubscribers[providerKey, default: [:]][token] = handler + lock.unlock() + return { [weak self] in + self?.lock.lock() + self?.relaySubscribers[providerKey]?.removeValue(forKey: token) + if self?.relaySubscribers[providerKey]?.isEmpty == true { + self?.relaySubscribers.removeValue(forKey: providerKey) + } + self?.lock.unlock() + } + } + + public func send(_ message: SlopMessage) async throws { + let connection: SlopConnection? = locked { self.connection } + guard let connection else { + throw SlopError.internalError("Bridge client is not connected") + } + connection.send(message) + } + + private func scheduleReconnect(immediately: Bool = false) { + lock.lock() + guard started, reconnectTask == nil else { + lock.unlock() + return + } + let delay = immediately ? 0 : reconnectDelayNanoseconds + reconnectTask = Task { [weak self] in + if delay > 0 { + try? await Task.sleep(nanoseconds: delay) + } + guard let self else { return } + while !Task.isCancelled { + do { + try await self.connectOnce() + let shouldReschedule = self.locked { + self.reconnectTask = nil + return self.started && self.connection == nil + } + if shouldReschedule { + self.scheduleReconnect(immediately: true) + } + return + } catch { + try? await Task.sleep(nanoseconds: self.reconnectDelayNanoseconds) + } + } + } + lock.unlock() + } + + private func handleBridgeMessage(_ message: SlopMessage, from source: SlopConnection?) { + switch message["type"]?.stringValue { + case "provider-available": + guard let provider = parseBridgeProvider(message) else { return } + let callbacks = locked { () -> [() -> Void]? in + guard isCurrentConnectionLocked(source) else { return nil } + providerMap[provider.providerKey] = provider + return Array(changeCallbacks.values) + } + guard let callbacks else { return } + for callback in callbacks { + guard isCurrentConnection(source) else { return } + callback() + } + case "provider-unavailable": + guard let providerKey = message["providerKey"]?.stringValue, !providerKey.isEmpty else { return } + let callbacks = locked { () -> [() -> Void]? in + guard isCurrentConnectionLocked(source) else { return nil } + providerMap.removeValue(forKey: providerKey) + relaySubscribers.removeValue(forKey: providerKey) + return Array(changeCallbacks.values) + } + guard let callbacks else { return } + for callback in callbacks { + guard isCurrentConnection(source) else { return } + callback() + } + case "slop-relay": + guard + let providerKey = message["providerKey"]?.stringValue, + let payload = message["message"]?.objectValue + else { + return + } + let subscribers = locked { () -> [BridgeRelayHandler]? in + guard isCurrentConnectionLocked(source) else { return nil } + return relaySubscribers[providerKey].map { Array($0.values) } ?? [] + } + guard let subscribers else { return } + for subscriber in subscribers { + guard isCurrentConnection(source) else { return } + subscriber(payload) + } + default: + break + } + } + + private func handleDisconnect(_ connection: SlopConnection?) { + lock.lock() + guard + let connection, + let current = self.connection, + ObjectIdentifier(connection) == ObjectIdentifier(current) + else { + lock.unlock() + return + } + self.connection = nil + let changed = !providerMap.isEmpty + providerMap.removeAll() + relaySubscribers.removeAll() + let callbacks = Array(changeCallbacks.values) + let lifecycleCallbacks = Array(disconnectCallbacks.values) + let shouldReconnect = started + lock.unlock() + + for callback in lifecycleCallbacks { + callback() + } + if changed { + for callback in callbacks { + callback() + } + } + if shouldReconnect { + scheduleReconnect() + } + } + + private func lockedChange(_ body: () -> Void) -> [() -> Void] { + lock.lock() + body() + let callbacks = Array(changeCallbacks.values) + lock.unlock() + return callbacks + } + + private func isCurrentConnection(_ candidate: SlopConnection?) -> Bool { + locked { isCurrentConnectionLocked(candidate) } + } + + private func isCurrentConnectionLocked(_ candidate: SlopConnection?) -> Bool { + guard let candidate, let connection else { return false } + return ObjectIdentifier(candidate) == ObjectIdentifier(connection) + } + + private func locked(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } +} + +public final class BridgeServer: DiscoveryBridge { + private let host: String + private let listenPort: Int + private let path: String + private let allowedOrigins: [String]? + private let authenticate: WebSocketUpgradeAuthenticator? + private let state = BridgeServerState() + private var group: EventLoopGroup? + private var channel: Channel? + + public init( + host: String = defaultBridgeHost, + port: Int = defaultBridgePort, + path: String = defaultBridgePath, + allowedOrigins: [String]? = nil, + authenticate: WebSocketUpgradeAuthenticator? = nil + ) { + self.host = host + listenPort = port + self.path = path + self.allowedOrigins = allowedOrigins + self.authenticate = authenticate + } + + public var running: Bool { + state.running + } + + public var url: String? { + guard let port = channel?.localAddress?.port else { return nil } + return "ws://\(host):\(port)\(path)" + } + + public func start() throws { + guard !state.running else { return } + let group = MultiThreadedEventLoopGroup(numberOfThreads: max(1, System.coreCount)) + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 64) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { [state, path, allowedOrigins, authenticate] channel in + let fallbackHandlerName = "SlopBridgeHTTPFallbackHandler" + let upgradeGuardName = "SlopBridgeWebSocketUpgradeGuardHandler" + let upgrader = NIOWebSocketServerUpgrader( + maxFrameSize: 1 << 20, + shouldUpgrade: { channel, _ in + channel.eventLoop.makeSucceededFuture(HTTPHeaders()) + }, + upgradePipelineHandler: { channel, _ in + channel.pipeline.removeHandler(name: fallbackHandlerName).flatMap { + channel.pipeline.removeHandler(name: upgradeGuardName) + }.flatMap { + do { + try channel.pipeline.syncOperations.addHandler(makeWebSocketFrameAggregator()) + return channel.pipeline.addHandler(BridgeServerWebSocketHandler(state: state)) + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + } + } + ) + let upgradeConfig = NIOHTTPServerUpgradeConfiguration(upgraders: [upgrader], completionHandler: { _ in }) + return channel.pipeline.configureHTTPServerPipeline( + withPipeliningAssistance: false, + withServerUpgrade: upgradeConfig + ).flatMap { + do { + let upgradeContext = try channel.pipeline.syncOperations.context(handlerType: HTTPServerUpgradeHandler.self) + try channel.pipeline.syncOperations.addHandler( + WebSocketUpgradeGuardHandler( + path: path, + allowedOrigins: allowedOrigins, + authenticate: authenticate + ), + name: upgradeGuardName, + position: .before(upgradeContext.handler) + ) + return channel.eventLoop.makeSucceededVoidFuture() + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + }.flatMap { + channel.pipeline.addHandler(BridgeHTTPFallbackHandler(webSocketPath: path), name: fallbackHandlerName) + } + } + + do { + channel = try bootstrap.bind(host: host, port: listenPort).wait() + self.group = group + state.setRunning(true) + } catch { + try? group.syncShutdownGracefully() + throw SlopError.internalError("Bridge server listen failed: \(error.localizedDescription)") + } + } + + public func stop() { + guard state.running else { return } + state.setRunning(false) + try? channel?.close().wait() + channel = nil + try? group?.syncShutdownGracefully() + group = nil + state.clear() + } + + public func providers() -> [BridgeProvider] { + state.providers() + } + + @discardableResult + public func onProviderChange(_ callback: @escaping () -> Void) -> () -> Void { + state.onProviderChange(callback) + } + + @discardableResult + public func onDisconnect(_ callback: @escaping () -> Void) -> () -> Void { + state.onDisconnect(callback) + } + + @discardableResult + public func subscribeRelay(providerKey: String, handler: @escaping BridgeRelayHandler) -> () -> Void { + state.subscribeRelay(providerKey: providerKey, handler: handler) + } + + public func send(_ message: SlopMessage) async throws { + state.broadcast(message) + } +} + +private final class BridgeServerWebSocketHandler: ChannelDuplexHandler { + typealias InboundIn = WebSocketFrame + typealias OutboundIn = WebSocketFrame + typealias OutboundOut = WebSocketFrame + + private let state: BridgeServerState + private var connection: NIOWebSocketConnection? + + init(state: BridgeServerState) { + self.state = state + } + + func handlerAdded(context: ChannelHandlerContext) { + let connection = NIOWebSocketConnection(channel: context.channel) + self.connection = connection + state.addSink(connection) + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let frame = unwrapInboundIn(data) + switch frame.opcode { + case .ping: + let pong = WebSocketFrame(fin: true, opcode: .pong, data: frame.unmaskedData) + context.writeAndFlush(wrapOutboundOut(pong), promise: nil) + case .text: + var payload = frame.unmaskedData + guard + let text = payload.readString(length: payload.readableBytes), + let data = text.data(using: .utf8), + case .object(let message) = try? JSONDecoder().decode(JSONValue.self, from: data) + else { + return + } + state.handle(message, from: connection) + case .binary: + var payload = frame.unmaskedData + guard + let bytes = payload.readBytes(length: payload.readableBytes), + case .object(let message) = try? JSONDecoder().decode(JSONValue.self, from: Data(bytes)) + else { + return + } + state.handle(message, from: connection) + case .connectionClose: + connection?.fireClose() + context.close(promise: nil) + default: + break + } + } + + func channelInactive(context: ChannelHandlerContext) { + if let connection { + state.removeSink(connection) + connection.fireClose() + } + } +} + +private final class BridgeHTTPFallbackHandler: ChannelInboundHandler, RemovableChannelHandler { + typealias InboundIn = HTTPServerRequestPart + typealias OutboundOut = HTTPServerResponsePart + + private let webSocketPath: String + private var head: HTTPRequestHead? + + init(webSocketPath: String) { + self.webSocketPath = webSocketPath + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch unwrapInboundIn(data) { + case .head(let head): + self.head = head + case .body: + break + case .end: + guard let head else { + sendResponse(status: .badRequest, body: "Bad Request", context: context) + return + } + let isRejectedUpgrade = requestPath(head.uri) == webSocketPath + && head.headers.first(name: "Upgrade")?.lowercased() == "websocket" + sendResponse( + status: isRejectedUpgrade ? .forbidden : .notFound, + body: isRejectedUpgrade ? "Forbidden" : "Not Found", + context: context + ) + self.head = nil + } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + if error is NIOWebSocketUpgradeError { + sendResponse(status: .forbidden, body: "Forbidden", context: context) + } else { + context.close(promise: nil) + } + } + + private func sendResponse(status: HTTPResponseStatus, body: String, context: ChannelHandlerContext) { + let buffer = context.channel.allocator.buffer(string: body) + var headers = HTTPHeaders() + headers.add(name: "Content-Type", value: "text/plain; charset=utf-8") + headers.add(name: "Content-Length", value: "\(buffer.readableBytes)") + headers.add(name: "Connection", value: "close") + let responseHead = HTTPResponseHead(version: head?.version ?? .http1_1, status: status, headers: headers) + context.write(wrapOutboundOut(.head(responseHead)), promise: nil) + context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) + context.writeAndFlush(wrapOutboundOut(.end(nil))).whenComplete { _ in + context.close(promise: nil) + } + } +} + +private final class BridgeServerState { + private let lock = NSLock() + private var sinkMap: [ObjectIdentifier: SlopConnection] = [:] + private var providerMap: [String: BridgeProvider] = [:] + private var providerOwners: [String: ObjectIdentifier] = [:] + private var relaySubscribers: [String: [UUID: BridgeRelayHandler]] = [:] + private var changeCallbacks: [UUID: () -> Void] = [:] + private var disconnectCallbacks: [UUID: () -> Void] = [:] + private var isRunning = false + + var running: Bool { + lock.lock() + let value = isRunning + lock.unlock() + return value + } + + func setRunning(_ value: Bool) { + lock.lock() + isRunning = value + lock.unlock() + } + + func providers() -> [BridgeProvider] { + lock.lock() + let values = Array(providerMap.values) + lock.unlock() + return values + } + + func addSink(_ sink: SlopConnection) { + lock.lock() + sinkMap[ObjectIdentifier(sink)] = sink + let providers = Array(providerMap.values) + lock.unlock() + + for provider in providers { + sink.send(providerAvailableMessage(provider)) + } + } + + func removeSink(_ sink: SlopConnection) { + let callbacks: [() -> Void] + let remainingSinks: [SlopConnection] + let removedProviderKeys: [String] + lock.lock() + let sinkID = ObjectIdentifier(sink) + sinkMap.removeValue(forKey: sinkID) + removedProviderKeys = providerOwners.compactMap { providerKey, owner in + owner == sinkID ? providerKey : nil + } + for providerKey in removedProviderKeys { + providerMap.removeValue(forKey: providerKey) + providerOwners.removeValue(forKey: providerKey) + relaySubscribers.removeValue(forKey: providerKey) + } + remainingSinks = Array(sinkMap.values) + callbacks = removedProviderKeys.isEmpty ? [] : Array(changeCallbacks.values) + lock.unlock() + + for providerKey in removedProviderKeys { + let message: SlopMessage = ["type": "provider-unavailable", "providerKey": .string(providerKey)] + for remainingSink in remainingSinks { + remainingSink.send(message) + } + } + for callback in callbacks { + callback() + } + } + + @discardableResult + func onProviderChange(_ callback: @escaping () -> Void) -> () -> Void { + let token = UUID() + lock.lock() + changeCallbacks[token] = callback + lock.unlock() + return { [weak self] in + self?.lock.lock() + self?.changeCallbacks.removeValue(forKey: token) + self?.lock.unlock() + } + } + + @discardableResult + func onDisconnect(_ callback: @escaping () -> Void) -> () -> Void { + let token = UUID() + lock.lock() + disconnectCallbacks[token] = callback + lock.unlock() + return { [weak self] in + self?.lock.lock() + self?.disconnectCallbacks.removeValue(forKey: token) + self?.lock.unlock() + } + } + + @discardableResult + func subscribeRelay(providerKey: String, handler: @escaping BridgeRelayHandler) -> () -> Void { + let token = UUID() + lock.lock() + relaySubscribers[providerKey, default: [:]][token] = handler + lock.unlock() + return { [weak self] in + self?.lock.lock() + self?.relaySubscribers[providerKey]?.removeValue(forKey: token) + if self?.relaySubscribers[providerKey]?.isEmpty == true { + self?.relaySubscribers.removeValue(forKey: providerKey) + } + self?.lock.unlock() + } + } + + func handle(_ message: SlopMessage, from source: SlopConnection?) { + switch message["type"]?.stringValue { + case "provider-available": + guard let provider = parseBridgeProvider(message), let source else { return } + let callbacks = lockedChange { + providerMap[provider.providerKey] = provider + providerOwners[provider.providerKey] = ObjectIdentifier(source) + } + broadcast(message) + for callback in callbacks { + callback() + } + case "provider-unavailable": + guard let providerKey = message["providerKey"]?.stringValue, !providerKey.isEmpty else { return } + guard let source else { return } + lock.lock() + guard providerOwners[providerKey] == ObjectIdentifier(source) else { + lock.unlock() + return + } + providerMap.removeValue(forKey: providerKey) + providerOwners.removeValue(forKey: providerKey) + relaySubscribers.removeValue(forKey: providerKey) + let callbacks = Array(changeCallbacks.values) + lock.unlock() + broadcast(message) + for callback in callbacks { + callback() + } + case "slop-relay": + guard + let providerKey = message["providerKey"]?.stringValue, + let payload = message["message"]?.objectValue + else { + return + } + lock.lock() + let subscribers = relaySubscribers[providerKey].map { Array($0.values) } ?? [] + lock.unlock() + for subscriber in subscribers { + subscriber(payload) + } + broadcast(message) + case "relay-open", "relay-close": + broadcast(message) + default: + break + } + } + + func broadcast(_ message: SlopMessage) { + lock.lock() + let sinks = Array(sinkMap.values) + lock.unlock() + + for sink in sinks { + sink.send(message) + } + } + + func clear() { + let callbacks: [() -> Void] + let lifecycleCallbacks: [() -> Void] + lock.lock() + let changed = !providerMap.isEmpty + providerMap.removeAll() + providerOwners.removeAll() + relaySubscribers.removeAll() + sinkMap.removeAll() + callbacks = changed ? Array(changeCallbacks.values) : [] + lifecycleCallbacks = Array(disconnectCallbacks.values) + lock.unlock() + + for callback in lifecycleCallbacks { + callback() + } + for callback in callbacks { + callback() + } + } + + private func lockedChange(_ body: () -> Void) -> [() -> Void] { + lock.lock() + body() + let callbacks = Array(changeCallbacks.values) + lock.unlock() + return callbacks + } + + private func providerAvailableMessage(_ provider: BridgeProvider) -> SlopMessage { + var providerObject: [String: JSONValue] = [ + "id": .string(provider.id), + "name": .string(provider.name), + "transport": .string(provider.transport), + ] + if let url = provider.url { + providerObject["url"] = .string(url) + } + return [ + "type": "provider-available", + "tabId": .number(Double(provider.tabID)), + "providerKey": .string(provider.providerKey), + "provider": .object(providerObject), + ] + } +} + +private final class RelayResponseFlag { + private let lock = NSLock() + private var seen = false + + func mark() { + lock.lock() + seen = true + lock.unlock() + } + + func wait(milliseconds: Int) async -> Bool { + let deadline = Date().addingTimeInterval(Double(milliseconds) / 1000) + while Date() < deadline { + if isSeen { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return isSeen + } + + private var isSeen: Bool { + lock.lock() + let value = seen + lock.unlock() + return value + } +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Consumer.swift b/packages/swift/slop-ai/Sources/SlopAI/Consumer.swift new file mode 100644 index 0000000..58584b2 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Consumer.swift @@ -0,0 +1,685 @@ +import Foundation + +public final class SlopConsumer { + private let lock = NSRecursiveLock() + private var connection: SlopConnection? + private var mirrors: [String: StateMirror] = [:] + private var subscriptions: [String: (path: String, depth: Int, options: OutputRequest)] = [:] + private var pending: [String: (Result) -> Void] = [:] + private let transport: ClientTransport + private var connectionAttempt: UUID? + private var connectionToken: UUID? + private var connectionGeneration: UInt64 = 0 + private var subscriptionCounter = 0 + private var requestCounter = 0 + private var errorCallbacks: [UUID: ([String: JSONValue], String?) -> Void] = [:] + private var eventCallbacks: [UUID: (String, JSONValue?) -> Void] = [:] + private var patchCallbacks: [UUID: (String, [PatchOp], UInt64) -> Void] = [:] + private var gapCallbacks: [UUID: (String, UInt64, UInt64) -> Void] = [:] + private var disconnectCallbacks: [UUID: () -> Void] = [:] + + public init(transport: ClientTransport) { + self.transport = transport + } + + public convenience init?(descriptor: ProviderDescriptor) { + guard let transport = Discovery.defaultTransportFactory(descriptor) else { + return nil + } + self.init(transport: transport) + } + + public static func connect(to descriptor: ProviderDescriptor) async throws -> SlopConsumer? { + guard let consumer = SlopConsumer(descriptor: descriptor) else { + return nil + } + _ = try await consumer.connect() + return consumer + } + + public func connect() async throws -> SlopMessage { + let attempt = try locked { () throws -> (token: UUID, generation: UInt64) in + guard connection == nil, connectionAttempt == nil else { + throw SlopError.internalError("SLOP consumer is already connected or connecting") + } + let token = UUID() + connectionAttempt = token + return (token, connectionGeneration) + } + return try await withTaskCancellationHandler { + try await connect(attempt: attempt) + } onCancel: { [weak self] in + self?.cancelConnectionAttempt(attempt.token) + } + } + + private func connect(attempt: (token: UUID, generation: UInt64)) async throws -> SlopMessage { + let connection: SlopConnection + do { + connection = try await transport.connect() + } catch { + locked { + if connectionAttempt == attempt.token { + connectionAttempt = nil + } + } + throw error + } + let installed = locked { () -> Bool in + guard + connectionAttempt == attempt.token, + connectionGeneration == attempt.generation, + self.connection == nil + else { + return false + } + connectionAttempt = nil + self.connection = connection + connectionToken = attempt.token + return true + } + guard installed else { + connection.close() + throw CancellationError() + } + let hello: SlopMessage = try await withCheckedThrowingContinuation { continuation in + let gate = ConnectContinuationGate() + connection.onMessage { [weak self, weak connection] message in + guard let self else { return } + guard self.isCurrentConnection(connection, token: attempt.token) else { return } + if messageString(message, "type") == "hello", gate.claim() { + do { + _ = try parseProviderHello(message) + continuation.resume(returning: message) + } catch { + continuation.resume(throwing: error) + connection?.close() + } + return + } + self.handleMessage(message, sourceToken: attempt.token) + } + connection.onClose { [weak self, weak connection] in + if gate.claim() { + continuation.resume(throwing: SlopError.internalError("SLOP connection closed before hello")) + } + self?.handleDisconnect(connection) + } + } + try Task.checkCancellation() + guard locked({ connectionToken == attempt.token }) else { + throw SlopError.internalError("SLOP connection closed during handshake") + } + return hello + } + + /// Manually inject a message. Useful for tests and custom embedding layers. + public func receive(_ message: SlopMessage) { + handleMessage(message, sourceToken: nil) + } + + public func subscribe(path: String = "/", depth: Int = 1, options: OutputRequest = OutputRequest()) async throws -> (id: String, snapshot: SlopNode) { + let id = locked { () -> String in + subscriptionCounter += 1 + let id = "sub-\(subscriptionCounter)" + subscriptions[id] = (path, depth, options) + return id + } + let snapshotValue: JSONValue + do { + snapshotValue = try await sendRequest(id: id) { [weak self] in + self?.sendSubscribe(id: id) + } + } catch { + locked { + mirrors.removeValue(forKey: id) + subscriptions.removeValue(forKey: id) + } + sendMessage(["type": "unsubscribe", "id": .string(id)]) + throw error + } + let snapshot = try decodeJSONValue(snapshotValue, as: SlopNode.self) + return (id, snapshot) + } + + public func unsubscribe(_ id: String) { + let connection = locked { () -> SlopConnection? in + mirrors.removeValue(forKey: id) + subscriptions.removeValue(forKey: id) + return self.connection + } + connection?.send(["type": "unsubscribe", "id": .string(id)]) + } + + public func query(path: String = "/", depth: Int = 1, options: OutputRequest = OutputRequest()) async throws -> SlopNode { + let id = locked { () -> String in + requestCounter += 1 + return "q-\(requestCounter)" + } + let value = try await sendRequest(id: id) { [weak self] in + var message: SlopMessage = [ + "type": "query", + "id": .string(id), + "path": .string(path), + "depth": .number(Double(depth)), + ] + if let maxNodes = options.maxNodes { + message["max_nodes"] = .number(Double(maxNodes)) + } + if let filter = options.filter { + var filterObject: [String: JSONValue] = [:] + if let types = filter.types { + filterObject["types"] = .array(types.map(JSONValue.string)) + } + if let minSalience = filter.minSalience { + filterObject["min_salience"] = .number(minSalience) + } + message["filter"] = .object(filterObject) + } + if let window = options.window { + message["window"] = .array([.number(Double(window.offset)), .number(Double(window.count))]) + } + self?.sendMessage(message) + } + return try decodeJSONValue(value, as: SlopNode.self) + } + + public func invoke(path: String, action: String, params: [String: JSONValue] = [:]) async throws -> SlopMessage { + let id = locked { () -> String in + requestCounter += 1 + return "inv-\(requestCounter)" + } + let value = try await sendRequest(id: id) { [weak self] in + self?.sendMessage([ + "type": "invoke", + "id": .string(id), + "path": .string(path), + "action": .string(action), + "params": .object(params), + ]) + } + return value.objectValue ?? [:] + } + + public func getTree(subscriptionID: String) -> SlopNode? { + locked { mirrors[subscriptionID]?.getTree() } + } + + @discardableResult + public func onError(_ callback: @escaping ([String: JSONValue], String?) -> Void) -> () -> Void { + let token = UUID() + locked { errorCallbacks[token] = callback } + return { [weak self] in + self?.removeCallback(token, from: \.errorCallbacks) + } + } + + @discardableResult + public func onEvent(_ callback: @escaping (String, JSONValue?) -> Void) -> () -> Void { + let token = UUID() + locked { eventCallbacks[token] = callback } + return { [weak self] in + self?.removeCallback(token, from: \.eventCallbacks) + } + } + + @discardableResult + public func onPatch(_ callback: @escaping (String, [PatchOp], UInt64) -> Void) -> () -> Void { + let token = UUID() + locked { patchCallbacks[token] = callback } + return { [weak self] in + self?.removeCallback(token, from: \.patchCallbacks) + } + } + + @discardableResult + public func onGap(_ callback: @escaping (String, UInt64, UInt64) -> Void) -> () -> Void { + let token = UUID() + locked { gapCallbacks[token] = callback } + return { [weak self] in + self?.removeCallback(token, from: \.gapCallbacks) + } + } + + @discardableResult + public func onDisconnect(_ callback: @escaping () -> Void) -> () -> Void { + let token = UUID() + locked { disconnectCallbacks[token] = callback } + return { [weak self] in + self?.removeCallback(token, from: \.disconnectCallbacks) + } + } + + public func disconnect() { + let connection = locked { () -> SlopConnection? in + connectionGeneration &+= 1 + connectionAttempt = nil + let connection = self.connection + self.connection = nil + connectionToken = nil + return connection + } + connection?.close() + } + + private func sendRequest(id: String, send: () -> Void) async throws -> JSONValue { + let value = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let registered = locked { () -> Bool in + guard connection != nil, !Task.isCancelled else { return false } + pending[id] = { result in + continuation.resume(with: result) + } + return true + } + guard registered else { + continuation.resume(throwing: Task.isCancelled ? CancellationError() : SlopError.internalError("SLOP connection is not connected")) + return + } + send() + } + } onCancel: { [weak self] in + self?.cancelPendingRequest(id) + } + try Task.checkCancellation() + return value + } + + private func sendSubscribe(id: String) { + guard let subscription = locked({ subscriptions[id] }) else { return } + var message: SlopMessage = [ + "type": "subscribe", + "id": .string(id), + "path": .string(subscription.path), + "depth": .number(Double(subscription.depth)), + ] + if let maxNodes = subscription.options.maxNodes { + message["max_nodes"] = .number(Double(maxNodes)) + } + if let filter = subscription.options.filter { + var filterObject: [String: JSONValue] = [:] + if let types = filter.types { + filterObject["types"] = .array(types.map(JSONValue.string)) + } + if let minSalience = filter.minSalience { + filterObject["min_salience"] = .number(minSalience) + } + message["filter"] = .object(filterObject) + } + sendMessage(message) + } + + private func removeCallback(_ token: UUID, from keyPath: ReferenceWritableKeyPath) { + _ = locked { + self[keyPath: keyPath].removeValue(forKey: token) + } + } + + private func handleDisconnect(_ closedConnection: SlopConnection?) { + let (pendingCallbacks, callbacks) = locked { () -> ([(Result) -> Void], [() -> Void]) in + if let closedConnection, let connection, ObjectIdentifier(closedConnection) != ObjectIdentifier(connection) { + return ([], []) + } + connection = nil + connectionToken = nil + let pendingCallbacks = Array(pending.values) + pending.removeAll() + return (pendingCallbacks, Array(disconnectCallbacks.values)) + } + let error = SlopError.internalError("SLOP connection closed") + for callback in pendingCallbacks { + callback(.failure(error)) + } + + for callback in callbacks { + callback() + } + } + + private func cancelConnectionAttempt(_ token: UUID) { + let connection = locked { () -> SlopConnection? in + var connectionToClose: SlopConnection? + if connectionAttempt == token { + connectionAttempt = nil + connectionGeneration &+= 1 + } + if connectionToken == token { + connectionToClose = self.connection + self.connection = nil + connectionToken = nil + connectionGeneration &+= 1 + } + return connectionToClose + } + connection?.close() + } + + private func cancelPendingRequest(_ id: String) { + let callback = locked { pending.removeValue(forKey: id) } + callback?(.failure(CancellationError())) + } + + private func isCurrentConnection(_ candidate: SlopConnection?, token: UUID) -> Bool { + locked { + guard let candidate, let connection else { return false } + return connectionToken == token && ObjectIdentifier(candidate) == ObjectIdentifier(connection) + } + } + + private func handleMessage(_ message: SlopMessage, sourceToken: UUID?) { + switch messageString(message, "type") { + case "snapshot": + handleSnapshot(message, sourceToken: sourceToken) + case "patch": + handlePatch(message, sourceToken: sourceToken) + case "result": + handleResult(message, sourceToken: sourceToken) + case "error": + let error = messageObject(message, "error") ?? [:] + let id = messageString(message, "id") + let (pendingCallback, callbacks) = locked { () -> (((Result) -> Void)?, [([String: JSONValue], String?) -> Void]) in + guard sourceIsValidLocked(sourceToken) else { return (nil, []) } + let pendingCallback = id.flatMap { pending.removeValue(forKey: $0) } + return (pendingCallback, Array(errorCallbacks.values)) + } + if let pendingCallback { + pendingCallback(.failure(SlopError.internalError(error["message"]?.stringValue ?? "SLOP error"))) + } + for callback in callbacks { + callback(error, id) + } + case "event": + if let name = messageString(message, "name") { + let callbacks = locked { + sourceIsValidLocked(sourceToken) ? Array(eventCallbacks.values) : [] + } + for callback in callbacks { + callback(name, message["data"]) + } + } + case "batch": + for inner in message["messages"]?.arrayValue ?? [] { + if let object = inner.objectValue { + handleMessage(object, sourceToken: sourceToken) + } + } + default: + break + } + } + + private func handleSnapshot(_ message: SlopMessage, sourceToken: UUID?) { + guard let id = messageString(message, "id") else { + return + } + guard let version = messageUInt64(message, "version") else { + recoverInvalidSnapshot( + id: id, + sourceToken: sourceToken, + error: SlopError.internalError("Snapshot \(id) is missing a valid version") + ) + return + } + guard let treeValue = message["tree"], let tree = try? decodeJSONValue(treeValue, as: SlopNode.self) else { + recoverInvalidSnapshot( + id: id, + sourceToken: sourceToken, + error: SlopError.internalError("Snapshot \(id) contains an invalid tree") + ) + return + } + + let result = locked { () -> SnapshotHandlingResult? in + guard sourceIsValidLocked(sourceToken) else { return nil } + if subscriptions[id] != nil, messageUInt64(message, "seq") != 0 { + mirrors.removeValue(forKey: id) + let callback = pending.removeValue(forKey: id) + if callback != nil { + subscriptions.removeValue(forKey: id) + } + return .invalidSubscription( + callback: callback, + errorCallbacks: Array(errorCallbacks.values), + shouldUnsubscribe: true, + shouldResubscribe: callback == nil + ) + } + let existed = mirrors[id] != nil + let callback = pending.removeValue(forKey: id) + let shouldMirror = sourceToken == nil || subscriptions[id] != nil + if shouldMirror, let mirror = try? StateMirror( + snapshot: SnapshotMessage(id: id, version: version, seq: messageUInt64(message, "seq"), tree: tree) + ) { + mirrors[id] = mirror + } + return .accepted(callback: callback, patchCallbacks: callback == nil && existed ? Array(patchCallbacks.values) : []) + } + guard let result else { return } + switch result { + case .accepted(let callback, let callbacks): + if let callback { + callback(.success(treeValue)) + } else { + for callback in callbacks { + callback(id, [], version) + } + } + case .invalidSubscription(let callback, let callbacks, let shouldUnsubscribe, let shouldResubscribe): + if shouldUnsubscribe { + sendMessage(["type": "unsubscribe", "id": .string(id)]) + } + let error = SlopError.internalError("Subscription snapshot \(id) must carry seq 0") + callback?(.failure(error)) + let protocolError: [String: JSONValue] = [ + "code": "invalid_snapshot", + "message": .string(error.localizedDescription), + ] + for callback in callbacks { + callback(protocolError, id) + } + if shouldResubscribe { + sendSubscribe(id: id) + } + } + } + + private func recoverInvalidSnapshot(id: String, sourceToken: UUID?, error: Error) { + let recovery = locked { () -> SnapshotHandlingResult? in + guard sourceIsValidLocked(sourceToken) else { return nil } + let isSubscription = subscriptions[id] != nil + mirrors.removeValue(forKey: id) + let callback = pending.removeValue(forKey: id) + if callback != nil, isSubscription { + subscriptions.removeValue(forKey: id) + } + return .invalidSubscription( + callback: callback, + errorCallbacks: Array(errorCallbacks.values), + shouldUnsubscribe: isSubscription, + shouldResubscribe: callback == nil && isSubscription + ) + } + guard case .invalidSubscription(let callback, let callbacks, let shouldUnsubscribe, let shouldResubscribe)? = recovery else { + return + } + if shouldUnsubscribe { + sendMessage(["type": "unsubscribe", "id": .string(id)]) + } + callback?(.failure(error)) + let protocolError: [String: JSONValue] = [ + "code": "invalid_snapshot", + "message": .string(error.localizedDescription), + ] + for callback in callbacks { + callback(protocolError, id) + } + if shouldResubscribe { + sendSubscribe(id: id) + } + } + + private func handlePatch(_ message: SlopMessage, sourceToken: UUID?) { + guard let subscription = messageString(message, "subscription") else { + return + } + guard let seq = messageUInt64(message, "seq") else { + recoverInvalidPatch( + subscription: subscription, + sourceToken: sourceToken, + error: SlopError.internalError("Subscription patch \(subscription) is missing a valid seq") + ) + return + } + guard let version = messageUInt64(message, "version") else { + recoverInvalidPatch( + subscription: subscription, + sourceToken: sourceToken, + error: SlopError.internalError("Subscription patch \(subscription) is missing a valid version") + ) + return + } + guard let opsValue = message["ops"], let ops = try? decodeJSONValue(opsValue, as: [PatchOp].self) else { + recoverInvalidPatch( + subscription: subscription, + sourceToken: sourceToken, + error: SlopError.internalError("Subscription patch \(subscription) contains invalid operations") + ) + return + } + + do { + let callbacks = try locked { () -> [(String, [PatchOp], UInt64) -> Void] in + guard sourceIsValidLocked(sourceToken) else { return [] } + guard let mirror = mirrors[subscription] else { return [] } + try mirror.applyPatch(PatchMessage(subscription: subscription, version: version, seq: seq, ops: ops)) + return Array(patchCallbacks.values) + } + for callback in callbacks { + callback(subscription, ops, version) + } + } catch SlopError.subscriptionGap(let expected, let received) { + let recovery = locked { () -> (Bool, [(String, UInt64, UInt64) -> Void]) in + guard sourceIsValidLocked(sourceToken) else { return (false, []) } + mirrors.removeValue(forKey: subscription) + return (true, Array(gapCallbacks.values)) + } + guard recovery.0 else { return } + sendMessage(["type": "unsubscribe", "id": .string(subscription)]) + for callback in recovery.1 { + callback(subscription, expected, received) + } + sendSubscribe(id: subscription) + } catch { + recoverInvalidPatch(subscription: subscription, sourceToken: sourceToken, error: error) + } + } + + private func recoverInvalidPatch(subscription: String, sourceToken: UUID?, error: Error) { + let recovery = locked { () -> (Bool, [([String: JSONValue], String?) -> Void]) in + guard sourceIsValidLocked(sourceToken) else { return (false, []) } + mirrors.removeValue(forKey: subscription) + return (true, Array(errorCallbacks.values)) + } + guard recovery.0 else { return } + sendMessage(["type": "unsubscribe", "id": .string(subscription)]) + let protocolError: [String: JSONValue] = [ + "code": "invalid_patch", + "message": .string(error.localizedDescription), + ] + for callback in recovery.1 { + callback(protocolError, subscription) + } + sendSubscribe(id: subscription) + } + + private func handleResult(_ message: SlopMessage, sourceToken: UUID?) { + guard + let id = messageString(message, "id"), + let callback = locked({ () -> ((Result) -> Void)? in + guard sourceIsValidLocked(sourceToken) else { return nil } + return pending.removeValue(forKey: id) + }) + else { + return + } + if messageString(message, "status") == "error" { + callback(.failure(SlopError.internalError(messageObject(message, "error")?["message"]?.stringValue ?? "SLOP invoke failed"))) + } else { + callback(.success(.object(message))) + } + } + + private func sendMessage(_ message: SlopMessage) { + let connection = locked { self.connection } + connection?.send(message) + } + + private func sourceIsValidLocked(_ sourceToken: UUID?) -> Bool { + sourceToken == nil || connectionToken == sourceToken + } + + private func locked(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body() + } +} + +struct ProviderHelloIdentity: Equatable { + var id: String + var name: String + var slopVersion: String + var capabilities: [String] +} + +func parseProviderHello(_ message: SlopMessage) throws -> ProviderHelloIdentity { + guard messageString(message, "type") == "hello", let provider = messageObject(message, "provider") else { + throw SlopError.internalError("SLOP hello is missing a provider object") + } + guard let id = provider["id"]?.stringValue, !id.isEmpty else { + throw SlopError.internalError("SLOP hello provider id must be a non-empty string") + } + guard let name = provider["name"]?.stringValue, !name.isEmpty else { + throw SlopError.internalError("SLOP hello provider name must be a non-empty string") + } + guard let slopVersion = provider["slop_version"]?.stringValue, slopVersion == "0.1" else { + throw SlopError.internalError("SLOP hello uses an unsupported protocol version") + } + guard + let capabilityValues = provider["capabilities"]?.arrayValue, + capabilityValues.allSatisfy({ $0.stringValue != nil }) + else { + throw SlopError.internalError("SLOP hello capabilities must be an array of strings") + } + let capabilities = capabilityValues.compactMap(\.stringValue) + guard capabilities.contains("state") else { + throw SlopError.internalError("SLOP hello provider must advertise the state capability") + } + return ProviderHelloIdentity(id: id, name: name, slopVersion: slopVersion, capabilities: capabilities) +} + +private enum SnapshotHandlingResult { + case accepted( + callback: ((Result) -> Void)?, + patchCallbacks: [(String, [PatchOp], UInt64) -> Void] + ) + case invalidSubscription( + callback: ((Result) -> Void)?, + errorCallbacks: [([String: JSONValue], String?) -> Void], + shouldUnsubscribe: Bool, + shouldResubscribe: Bool + ) +} + +private final class ConnectContinuationGate { + private let lock = NSLock() + private var claimed = false + + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !claimed else { return false } + claimed = true + return true + } +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Descriptor.swift b/packages/swift/slop-ai/Sources/SlopAI/Descriptor.swift new file mode 100644 index 0000000..8a867b2 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Descriptor.swift @@ -0,0 +1,291 @@ +import Foundation + +public enum ActionResult: Equatable { + case value(JSONValue?) + case accepted(taskId: String, data: [String: JSONValue] = [:]) +} + +public typealias ActionHandler = ([String: JSONValue]) async throws -> ActionResult + +public enum ParamDef: Equatable { + case type(String) + case schema(JSONSchema) +} + +public struct Action { + public var handler: ActionHandler + public var params: [String: ParamDef]? + public var label: String? + public var description: String? + public var dangerous: Bool + public var idempotent: Bool + public var estimate: ActionEstimate? + + public init( + params: [String: ParamDef]? = nil, + label: String? = nil, + description: String? = nil, + dangerous: Bool = false, + idempotent: Bool = false, + estimate: ActionEstimate? = nil, + handler: @escaping ActionHandler + ) { + self.handler = handler + self.params = params + self.label = label + self.description = description + self.dangerous = dangerous + self.idempotent = idempotent + self.estimate = estimate + } + + public static func value( + params: [String: ParamDef]? = nil, + label: String? = nil, + description: String? = nil, + dangerous: Bool = false, + idempotent: Bool = false, + estimate: ActionEstimate? = nil, + _ handler: @escaping ([String: JSONValue]) async throws -> JSONValue? + ) -> Action { + Action( + params: params, + label: label, + description: description, + dangerous: dangerous, + idempotent: idempotent, + estimate: estimate + ) { params in + .value(try await handler(params)) + } + } +} + +public struct WindowDescriptor { + public var items: [ItemDescriptor] + public var total: Int + public var offset: Int + + public init(items: [ItemDescriptor], total: Int, offset: Int) { + self.items = items + self.total = total + self.offset = offset + } +} + +public struct ItemDescriptor { + public var id: String + public var type: String + public var props: [String: JSONValue]? + public var summary: String? + public var actions: [String: Action]? + public var meta: NodeMeta? + public var children: [String: NodeDescriptor]? + public var contentRef: ContentRef? + + public init( + id: String, + type: String = "item", + props: [String: JSONValue]? = nil, + summary: String? = nil, + actions: [String: Action]? = nil, + meta: NodeMeta? = nil, + children: [String: NodeDescriptor]? = nil, + contentRef: ContentRef? = nil + ) { + self.id = id + self.type = type + self.props = props + self.summary = summary + self.actions = actions + self.meta = meta + self.children = children + self.contentRef = contentRef + } +} + +public struct NodeDescriptor { + public var type: String + public var props: [String: JSONValue]? + public var summary: String? + public var items: [ItemDescriptor]? + public var window: WindowDescriptor? + public var contentRef: ContentRef? + public var children: [String: NodeDescriptor]? + public var actions: [String: Action]? + public var meta: NodeMeta? + + public init( + type: String, + props: [String: JSONValue]? = nil, + summary: String? = nil, + items: [ItemDescriptor]? = nil, + window: WindowDescriptor? = nil, + contentRef: ContentRef? = nil, + children: [String: NodeDescriptor]? = nil, + actions: [String: Action]? = nil, + meta: NodeMeta? = nil + ) { + self.type = type + self.props = props + self.summary = summary + self.items = items + self.window = window + self.contentRef = contentRef + self.children = children + self.actions = actions + self.meta = meta + } +} + +public struct NormalizationResult { + public var node: SlopNode + public var handlers: [String: ActionHandler] +} + +/// Convert a developer-friendly descriptor into a wire-format node and action handler map. +public func normalizeDescriptor(path: String, id: String, descriptor: NodeDescriptor) throws -> NormalizationResult { + try validateNodeID(id) + var handlers: [String: ActionHandler] = [:] + var children: [SlopNode] = [] + var childIDs: Set = [] + var meta = descriptor.meta ?? NodeMeta() + if let summary = descriptor.summary { + meta.summary = summary + } + + if let window = descriptor.window { + for item in window.items { + try reserveChildID(item.id, parentPath: path, childIDs: &childIDs) + let itemPath = path.isEmpty ? item.id : "\(path)/\(item.id)" + let result = try normalizeItem(path: itemPath, item: item) + children.append(result.node) + handlers.merge(result.handlers) { _, new in new } + } + meta.totalChildren = window.total + meta.window = WindowRange(window.offset, window.items.count) + } else if let items = descriptor.items { + for item in items { + try reserveChildID(item.id, parentPath: path, childIDs: &childIDs) + let itemPath = path.isEmpty ? item.id : "\(path)/\(item.id)" + let result = try normalizeItem(path: itemPath, item: item) + children.append(result.node) + handlers.merge(result.handlers) { _, new in new } + } + } + + if let childDescriptors = descriptor.children { + for childID in childDescriptors.keys.sorted() { + guard let childDescriptor = childDescriptors[childID] else { continue } + try reserveChildID(childID, parentPath: path, childIDs: &childIDs) + let childPath = path.isEmpty ? childID : "\(path)/\(childID)" + let result = try normalizeDescriptor(path: childPath, id: childID, descriptor: childDescriptor) + children.append(result.node) + handlers.merge(result.handlers) { _, new in new } + } + } + + let affordances = normalizeActions(path: path, actions: descriptor.actions, handlers: &handlers) + var contentRef = descriptor.contentRef + if contentRef != nil && contentRef?.uri == nil { + contentRef?.uri = "slop://content/\(path)" + } + + return NormalizationResult( + node: SlopNode( + id: id, + type: descriptor.type, + properties: descriptor.props, + children: children.isEmpty ? nil : children, + affordances: affordances.isEmpty ? nil : affordances, + meta: meta.isEmpty ? nil : meta, + contentRef: contentRef + ), + handlers: handlers + ) +} + +private func reserveChildID(_ id: String, parentPath: String, childIDs: inout Set) throws { + guard childIDs.insert(id).inserted else { + let parent = parentPath.isEmpty ? "/" : "/\(parentPath)" + throw SlopError.duplicateNodeId("Duplicate child id \"\(id)\" under \(parent)") + } +} + +private func normalizeItem(path: String, item: ItemDescriptor) throws -> NormalizationResult { + try validateNodeID(item.id) + var handlers: [String: ActionHandler] = [:] + var children: [SlopNode] = [] + + if let childDescriptors = item.children { + for childID in childDescriptors.keys.sorted() { + guard let childDescriptor = childDescriptors[childID] else { continue } + let result = try normalizeDescriptor(path: "\(path)/\(childID)", id: childID, descriptor: childDescriptor) + children.append(result.node) + handlers.merge(result.handlers) { _, new in new } + } + } + + let affordances = normalizeActions(path: path, actions: item.actions, handlers: &handlers) + var meta = item.meta ?? NodeMeta() + if let summary = item.summary { + meta.summary = summary + } + var contentRef = item.contentRef + if contentRef != nil && contentRef?.uri == nil { + contentRef?.uri = "slop://content/\(path)" + } + + return NormalizationResult( + node: SlopNode( + id: item.id, + type: item.type, + properties: item.props, + children: children.isEmpty ? nil : children, + affordances: affordances.isEmpty ? nil : affordances, + meta: meta.isEmpty ? nil : meta, + contentRef: contentRef + ), + handlers: handlers + ) +} + +private func normalizeActions(path: String, actions: [String: Action]?, handlers: inout [String: ActionHandler]) -> [Affordance] { + guard let actions else { return [] } + var affordances: [Affordance] = [] + for name in actions.keys.sorted() { + guard let action = actions[name] else { continue } + let handlerKey = path.isEmpty ? name : "\(path)/\(name)" + handlers[handlerKey] = action.handler + affordances.append( + Affordance( + action: name, + label: action.label, + description: action.description, + params: action.params.map(normalizeParams), + dangerous: action.dangerous ? true : nil, + idempotent: action.idempotent ? true : nil, + estimate: action.estimate + ) + ) + } + return affordances +} + +public func normalizeParams(_ params: [String: ParamDef]) -> JSONSchema { + var properties: [String: JSONSchema] = [:] + var required: [String] = [] + + for key in params.keys.sorted() { + guard let definition = params[key] else { continue } + switch definition { + case .type(let type): + properties[key] = JSONSchema(type: type) + case .schema(let schema): + properties[key] = schema + } + required.append(key) + } + + return JSONSchema(type: "object", properties: properties, required: required) +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Diff.swift b/packages/swift/slop-ai/Sources/SlopAI/Diff.swift new file mode 100644 index 0000000..066b73c --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Diff.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Recursively diff two SLOP trees and produce JSON Patch-like operations. +/// Child paths use node IDs rather than array indices, matching the TS SDK. +public func diffNodes(_ oldNode: SlopNode, _ newNode: SlopNode, basePath: String = "") -> [PatchOp] { + var ops: [PatchOp] = [] + + let oldProperties = oldNode.properties ?? [:] + let newProperties = newNode.properties ?? [:] + for key in Set(oldProperties.keys).union(newProperties.keys).sorted() { + let escapedKey = escapeJSONPointerSegment(key) + let path = "\(basePath)/properties/\(escapedKey)" + switch (oldProperties[key], newProperties[key]) { + case (nil, .some(let value)): + ops.append(PatchOp(op: .add, path: path, value: value)) + case (.some, nil): + ops.append(PatchOp(op: .remove, path: path)) + case (.some(let oldValue), .some(let newValue)) where oldValue != newValue: + ops.append(PatchOp(op: .replace, path: path, value: newValue)) + default: + break + } + } + + if oldNode.affordances != newNode.affordances { + if let affordances = newNode.affordances { + ops.append(PatchOp(op: oldNode.affordances == nil ? .add : .replace, path: "\(basePath)/affordances", value: wireJSON(affordances))) + } else if oldNode.affordances != nil { + ops.append(PatchOp(op: .remove, path: "\(basePath)/affordances")) + } + } + + if oldNode.meta != newNode.meta { + if let meta = newNode.meta { + ops.append(PatchOp(op: oldNode.meta == nil ? .add : .replace, path: "\(basePath)/meta", value: wireJSON(meta))) + } else if oldNode.meta != nil { + ops.append(PatchOp(op: .remove, path: "\(basePath)/meta")) + } + } + + if oldNode.contentRef != newNode.contentRef { + if let contentRef = newNode.contentRef { + ops.append(PatchOp(op: oldNode.contentRef == nil ? .add : .replace, path: "\(basePath)/content_ref", value: wireJSON(contentRef))) + } else if oldNode.contentRef != nil { + ops.append(PatchOp(op: .remove, path: "\(basePath)/content_ref")) + } + } + + let oldChildren = oldNode.children ?? [] + let newChildren = newNode.children ?? [] + let oldMap = oldChildren.reduce(into: [String: SlopNode]()) { map, child in + map[child.id] = child + } + let newMap = newChildren.reduce(into: [String: SlopNode]()) { map, child in + map[child.id] = child + } + + var working: [String] = [] + for child in oldChildren { + if newMap[child.id] == nil { + ops.append(PatchOp(op: .remove, path: "\(basePath)/\(child.id)")) + } else { + working.append(child.id) + } + } + + for (index, child) in newChildren.enumerated() where oldMap[child.id] == nil { + ops.append(PatchOp(op: .add, path: "\(basePath)/\(child.id)", value: wireJSON(child), index: index)) + working.insert(child.id, at: min(index, working.count)) + } + + for (index, child) in newChildren.enumerated() where working.indices.contains(index) && working[index] != child.id { + guard let currentIndex = working[index...].firstIndex(of: child.id) else { continue } + ops.append(PatchOp(op: .move, path: "\(basePath)/\(child.id)", index: index)) + working.remove(at: currentIndex) + working.insert(child.id, at: index) + } + + for child in newChildren { + if let oldChild = oldMap[child.id] { + ops.append(contentsOf: diffNodes(oldChild, child, basePath: "\(basePath)/\(child.id)")) + } + } + + return ops +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Discovery.swift b/packages/swift/slop-ai/Sources/SlopAI/Discovery.swift new file mode 100644 index 0000000..f0dcd60 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Discovery.swift @@ -0,0 +1,875 @@ +import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public enum ProviderTransportType: String, Codable, Equatable { + case unix + case ws + case stdio + case relay +} + +public struct ProviderTransport: Codable, Equatable { + public var type: ProviderTransportType + public var path: String? + public var url: String? + + public init(type: ProviderTransportType, path: String? = nil, url: String? = nil) { + self.type = type + self.path = path + self.url = url + } +} + +public struct ProviderDescriptor: Codable, Equatable { + public var id: String + public var name: String + public var slopVersion: String + public var transport: ProviderTransport + public var pid: Int? + public var capabilities: [String] + public var providerKey: String? + public var source: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case slopVersion = "slop_version" + case transport + case pid + case capabilities + case providerKey + case source + } + + public init( + id: String, + name: String, + slopVersion: String, + transport: ProviderTransport, + pid: Int? = nil, + capabilities: [String], + providerKey: String? = nil, + source: String? = nil + ) { + self.id = id + self.name = name + self.slopVersion = slopVersion + self.transport = transport + self.pid = pid + self.capabilities = capabilities + self.providerKey = providerKey + self.source = source + } +} + +public struct ConnectedProvider { + public var id: String + public var name: String + public var descriptor: ProviderDescriptor + public var consumer: SlopConsumer + public var subscriptionID: String + public var status: String + + public init( + id: String, + name: String, + descriptor: ProviderDescriptor, + consumer: SlopConsumer, + subscriptionID: String, + status: String + ) { + self.id = id + self.name = name + self.descriptor = descriptor + self.consumer = consumer + self.subscriptionID = subscriptionID + self.status = status + } +} + +public struct ProviderRegistration: Equatable { + public let id: String + public let directory: URL + fileprivate let device: UInt64 + fileprivate let inode: UInt64 +} + +private struct DiscoveryConnectionAttempt { + var id: String + var token: UUID + var task: Task +} + +private final class DiscoveryConnectionLifecycle { + private let lock = NSLock() + private var disconnected = false + + var isDisconnected: Bool { + lock.lock() + defer { lock.unlock() } + return disconnected + } + + func markDisconnected() { + lock.lock() + disconnected = true + lock.unlock() + } +} + +public struct DiscoveryOptions { + public var providerDirectories: [URL] + public var autoConnect: Bool + public var transportFactory: (ProviderDescriptor) -> ClientTransport? + public var bridges: [DiscoveryBridge] + + public init( + providerDirectories: [URL] = Discovery.defaultProviderDirectories, + autoConnect: Bool = false, + bridges: [DiscoveryBridge] = [], + transportFactory: @escaping (ProviderDescriptor) -> ClientTransport? = Discovery.defaultTransportFactory + ) { + self.providerDirectories = providerDirectories + self.autoConnect = autoConnect + self.bridges = bridges + self.transportFactory = transportFactory + } +} + +public final class DiscoveryService { + private let options: DiscoveryOptions + private let lock = NSLock() + private var discovered: [ProviderDescriptor] = [] + private var providers: [String: ConnectedProvider] = [:] + private var providerAliases: [String: String] = [:] + private var connectionAttempts: [String: DiscoveryConnectionAttempt] = [:] + private var stateChangeCallbacks: [UUID: () -> Void] = [:] + private var bridgeUnsubscribes: [() -> Void] = [] + private var started = false + private var generation: UInt64 = 0 + + public init(options: DiscoveryOptions = DiscoveryOptions()) { + self.options = options + } + + public func start() async { + let shouldStart = locked { () -> Bool in + guard !started else { return false } + started = true + return true + } + guard shouldStart else { return } + + let unsubscribes = options.bridges.map { bridge in + bridge.onProviderChange { [weak self] in + self?.scan() + } + } + let keepSubscriptions = locked { () -> Bool in + guard started else { return false } + bridgeUnsubscribes = unsubscribes + return true + } + if !keepSubscriptions { + for unsubscribe in unsubscribes { + unsubscribe() + } + return + } + + scan() + guard options.autoConnect else { return } + let startGeneration = locked { generation } + for descriptor in getDiscovered() { + guard locked({ started && generation == startGeneration }) else { return } + _ = try? await ensureConnected(descriptor.id) + guard locked({ started && generation == startGeneration }) else { return } + } + } + + public func stop() { + let state = locked { () -> ([() -> Void], [ConnectedProvider], [Task], [() -> Void]) in + let hadActiveState = started || !providers.isEmpty || !connectionAttempts.isEmpty + started = false + generation &+= 1 + let unsubscribes = bridgeUnsubscribes + bridgeUnsubscribes.removeAll() + let connectedProviders = Array(providers.values) + providers.removeAll() + providerAliases.removeAll() + let tasks = connectionAttempts.values.map(\.task) + connectionAttempts.removeAll() + return (unsubscribes, connectedProviders, tasks, hadActiveState ? Array(stateChangeCallbacks.values) : []) + } + for unsubscribe in state.0 { + unsubscribe() + } + for task in state.2 { + task.cancel() + } + for provider in state.1 { + provider.consumer.disconnect() + } + emitStateChange(state.3) + } + + public func scan() { + let descriptors = Discovery.readDescriptors(from: options.providerDirectories) + bridgeDescriptors() + let callbacks = locked { () -> [() -> Void] in + discovered = descriptors + let descriptorIDs = Set(descriptors.map(\.id)) + providerAliases = providerAliases.filter { descriptorIDs.contains($0.key) || providers[$0.value] != nil } + return Array(stateChangeCallbacks.values) + } + emitStateChange(callbacks) + } + + public func getDiscovered() -> [ProviderDescriptor] { + let state = locked { (started, discovered) } + if state.0 { + return state.1 + } + return Discovery.readDescriptors(from: options.providerDirectories) + bridgeDescriptors() + } + + public func getProviders() -> [ConnectedProvider] { + locked { Array(providers.values) } + } + + public func getProvider(_ idOrName: String) -> ConnectedProvider? { + locked { + if let canonical = providers[idOrName] { + return canonical + } + if let providerID = providerAliases[idOrName], let aliased = providers[providerID] { + return aliased + } + return providers.values.first { $0.name == idOrName } + } + } + + public func ensureConnected(_ idOrName: String) async throws -> ConnectedProvider? { + let operationGeneration = locked { generation } + if let existing = getProvider(idOrName), existing.status == "connected" { + return existing + } + + if !locked({ started }) { + scan() + } + let attempt = locked { () -> DiscoveryConnectionAttempt? in + guard generation == operationGeneration else { return nil } + let existingByID = providers[idOrName] ?? providerAliases[idOrName].flatMap { providers[$0] } + if let existing = existingByID ?? providers.values.first(where: { $0.name == idOrName }), + existing.status == "connected" { + return DiscoveryConnectionAttempt( + id: existing.id, + token: UUID(), + task: Task { existing } + ) + } + guard let descriptor = discovered.first(where: { $0.id == idOrName || $0.name == idOrName }) else { + return nil + } + if let existingAttempt = connectionAttempts[descriptor.id] { + return existingAttempt + } + let token = UUID() + let task = Task { [weak self] in + guard let self else { throw CancellationError() } + return try await self.connect(descriptor, generation: operationGeneration) + } + let attempt = DiscoveryConnectionAttempt(id: descriptor.id, token: token, task: task) + connectionAttempts[descriptor.id] = attempt + return attempt + } + guard let attempt else { return nil } + + do { + let provider = try await attempt.task.value + removeConnectionAttempt(attempt.token, id: attempt.id) + return provider + } catch { + removeConnectionAttempt(attempt.token, id: attempt.id) + throw error + } + } + + @discardableResult + public func disconnect(_ idOrName: String) -> Bool { + let result = locked { () -> (ConnectedProvider, [() -> Void])? in + let providerByID = providers[idOrName] ?? providerAliases[idOrName].flatMap { providers[$0] } + guard let provider = providerByID ?? providers.values.first(where: { $0.name == idOrName }) else { + return nil + } + providers.removeValue(forKey: provider.id) + providerAliases = providerAliases.filter { $0.value != provider.id } + return (provider, Array(stateChangeCallbacks.values)) + } + guard let result else { return false } + let provider = result.0 + provider.consumer.disconnect() + emitStateChange(result.1) + return true + } + + @discardableResult + public func onStateChange(_ callback: @escaping () -> Void) -> () -> Void { + let token = UUID() + locked { + stateChangeCallbacks[token] = callback + } + return { [weak self] in + _ = self?.locked { + self?.stateChangeCallbacks.removeValue(forKey: token) + } + } + } + + private func emitStateChange() { + emitStateChange(locked { Array(stateChangeCallbacks.values) }) + } + + private func emitStateChange(_ callbacks: [() -> Void]) { + for callback in callbacks { + callback() + } + } + + private func connect(_ descriptor: ProviderDescriptor, generation: UInt64) async throws -> ConnectedProvider? { + guard let transport = options.transportFactory(descriptor) ?? relayTransport(for: descriptor) else { + return nil + } + let consumer = SlopConsumer(transport: transport) + let lifecycle = DiscoveryConnectionLifecycle() + consumer.onPatch { [weak self] _, _, _ in + self?.emitStateChange() + } + consumer.onDisconnect { [weak self, weak consumer] in + lifecycle.markDisconnected() + guard let self, let consumer else { return } + let callbacks = self.locked { () -> [() -> Void] in + guard + let entry = self.providers.first(where: { $0.value.consumer === consumer }), + var provider = self.providers[entry.key] + else { + return [] + } + provider.status = "disconnected" + self.providers[entry.key] = provider + return Array(self.stateChangeCallbacks.values) + } + self.emitStateChange(callbacks) + } + + return try await withTaskCancellationHandler { + do { + let hello = try await consumer.connect() + let identity = try parseProviderHello(hello) + try Task.checkCancellation() + let subscription = try await consumer.subscribe(path: "/", depth: -1) + try Task.checkCancellation() + guard !lifecycle.isDisconnected else { + throw SlopError.internalError("Provider disconnected during discovery") + } + var authoritativeDescriptor = descriptor + authoritativeDescriptor.id = identity.id + authoritativeDescriptor.name = identity.name + authoritativeDescriptor.slopVersion = identity.slopVersion + authoritativeDescriptor.capabilities = identity.capabilities + let provider = ConnectedProvider( + id: identity.id, + name: identity.name, + descriptor: authoritativeDescriptor, + consumer: consumer, + subscriptionID: subscription.id, + status: "connected" + ) + let callbacks = locked { () -> [() -> Void]? in + guard self.generation == generation else { return nil } + if let existing = providers[identity.id], existing.status == "connected", existing.consumer !== consumer { + return nil + } + providers[identity.id] = provider + providerAliases[descriptor.id] = identity.id + return Array(stateChangeCallbacks.values) + } + guard let callbacks else { + throw CancellationError() + } + emitStateChange(callbacks) + return provider + } catch { + consumer.disconnect() + throw error + } + } onCancel: { + consumer.disconnect() + } + } + + private func removeConnectionAttempt(_ token: UUID, id: String) { + locked { + guard connectionAttempts[id]?.token == token else { return } + connectionAttempts.removeValue(forKey: id) + } + } + + private func bridgeDescriptors() -> [ProviderDescriptor] { + options.bridges + .filter(\.running) + .flatMap { bridge in bridge.providers().map(bridgeProviderToDescriptor) } + } + + private func relayTransport(for descriptor: ProviderDescriptor) -> ClientTransport? { + guard descriptor.transport.type == .relay, let providerKey = descriptor.providerKey else { + return nil + } + guard let bridge = options.bridges.first(where: { bridge in + bridge.running && bridge.providers().contains { $0.providerKey == providerKey } + }) else { + return nil + } + return BridgeRelayTransport(bridge: bridge, providerKey: providerKey) + } + + private func locked(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body() + } +} + +public enum Discovery { + public static let defaultProviderDirectories: [URL] = [ + URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".slop/providers"), + URL(fileURLWithPath: "/tmp/slop/providers"), + ] + + public static func defaultTransportFactory(_ descriptor: ProviderDescriptor) -> ClientTransport? { + switch descriptor.transport.type { + case .ws: + guard let urlString = descriptor.transport.url, let url = URL(string: urlString) else { return nil } + return URLSessionWebSocketTransport(url: url) + case .unix: + guard let path = descriptor.transport.path else { return nil } + #if canImport(Darwin) + return UnixSocketClientTransport(path: path) + #else + return nil + #endif + case .stdio, .relay: + return nil + } + } + + public static func readDescriptors(from directories: [URL] = defaultProviderDirectories) -> [ProviderDescriptor] { + let fileManager = FileManager.default + let decoder = JSONDecoder() + var descriptors: [ProviderDescriptor] = [] + + for directory in directories { + guard isSecureProviderDirectory(directory) else { + continue + } + guard let files = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { + continue + } + for file in files where isValidDescriptorFilename(file.lastPathComponent) { + guard let data = readSecureDescriptorFile(file), var descriptor = try? decoder.decode(ProviderDescriptor.self, from: data) else { + continue + } + descriptor.source = descriptor.source ?? "local" + descriptors.append(descriptor) + } + } + + return descriptors + } + + @discardableResult + public static func registerProvider( + id: String, + name: String, + transport: ProviderTransport, + directory: URL = defaultProviderDirectories[0], + pid: Int = Int(ProcessInfo.processInfo.processIdentifier), + capabilities: [String] = ["state", "patches", "affordances", "attention", "windowing", "async", "content_refs"] + ) throws -> ProviderRegistration { + guard isValidDescriptorFilename("\(id).json") else { + throw SlopError.invalidNodeId( + "SLOP provider id \"\(id)\" is not a valid descriptor filename stem" + ) + } + + let fileManager = FileManager.default + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + try secureProviderDirectory(directory) + + let descriptor = ProviderDescriptor( + id: id, + name: name, + slopVersion: "0.1", + transport: transport, + pid: pid, + capabilities: capabilities + ) + let data = try JSONEncoder().encode(descriptor) + let finalURL = directory.appendingPathComponent("\(id).json") + let tempURL = directory.appendingPathComponent("\(id).json.tmp.\(UUID().uuidString)") + defer { try? fileManager.removeItem(at: tempURL) } + try data.write(to: tempURL, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tempURL.path) + guard readSecureDescriptorFile(tempURL) != nil else { + throw SlopError.internalError("Could not secure SLOP provider descriptor at \(tempURL.path)") + } + guard let identity = descriptorFileIdentity(tempURL) else { + throw SlopError.internalError("Could not identify SLOP provider descriptor before publishing at \(tempURL.path)") + } + #if canImport(Darwin) || canImport(Glibc) + guard rename(tempURL.path, finalURL.path) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + #else + if fileManager.fileExists(atPath: finalURL.path) { + _ = try fileManager.replaceItemAt(finalURL, withItemAt: tempURL) + } else { + try fileManager.moveItem(at: tempURL, to: finalURL) + } + #endif + return ProviderRegistration(id: id, directory: directory, device: identity.device, inode: identity.inode) + } + + @discardableResult + public static func registerUnixProvider( + id: String, + name: String, + socketPath: String, + directory: URL = defaultProviderDirectories[0], + pid: Int = Int(ProcessInfo.processInfo.processIdentifier) + ) throws -> ProviderRegistration { + try registerProvider( + id: id, + name: name, + transport: ProviderTransport(type: .unix, path: socketPath), + directory: directory, + pid: pid + ) + } + + public static func unregisterProvider(id: String, directory: URL = defaultProviderDirectories[0]) { + guard isValidDescriptorFilename("\(id).json") else { return } + try? FileManager.default.removeItem(at: directory.appendingPathComponent("\(id).json")) + } + + public static func unregisterProvider(_ registration: ProviderRegistration) { + guard isValidDescriptorFilename("\(registration.id).json") else { return } + let file = registration.directory.appendingPathComponent("\(registration.id).json") + quarantineAndRemoveDescriptor(file, registration: registration, beforeInspection: nil) + } + + static func unregisterProvider( + _ registration: ProviderRegistration, + beforeQuarantineInspection: @escaping () -> Void + ) { + guard isValidDescriptorFilename("\(registration.id).json") else { return } + let file = registration.directory.appendingPathComponent("\(registration.id).json") + quarantineAndRemoveDescriptor( + file, + registration: registration, + beforeInspection: beforeQuarantineInspection + ) + } + + public static func isValidDescriptorFilename(_ filename: String) -> Bool { + guard filename.hasSuffix(".json") else { return false } + let stem = filename.dropLast(5) + let scalars = Array(stem.unicodeScalars) + guard (1...64).contains(scalars.count), let first = scalars.first, isASCIILowercaseOrDigit(first) else { return false } + return scalars.allSatisfy { scalar in + isASCIILowercaseOrDigit(scalar) || scalar.value == 46 || scalar.value == 95 || scalar.value == 45 + } + } +} + +private func secureProviderDirectory(_ directory: URL) throws { + #if canImport(Darwin) || canImport(Glibc) + let fd = open(directory.path, O_RDONLY | O_DIRECTORY | descriptorNoFollowFlag()) + guard fd >= 0 else { + throw SlopError.internalError("Could not safely open SLOP provider directory at \(directory.path)") + } + defer { close(fd) } + + var statBuffer = stat() + guard fstat(fd, &statBuffer) == 0 else { + throw SlopError.internalError("Could not inspect SLOP provider directory at \(directory.path)") + } + let mode = Int(statBuffer.st_mode) + guard (mode & Int(S_IFMT)) == Int(S_IFDIR), currentUserID().map({ statBuffer.st_uid == $0 }) ?? true else { + throw SlopError.internalError("SLOP provider directory is not an owned real directory at \(directory.path)") + } + guard fchmod(fd, 0o700) == 0 else { + throw SlopError.internalError("Could not harden SLOP provider directory permissions at \(directory.path)") + } + guard fstat(fd, &statBuffer) == 0, Int(statBuffer.st_mode) & 0o077 == 0 else { + throw SlopError.internalError("Could not verify SLOP provider directory permissions at \(directory.path)") + } + #else + let fileManager = FileManager.default + guard + let attributes = try? fileManager.attributesOfItem(atPath: directory.path), + attributes[.type] as? FileAttributeType == .typeDirectory + else { + throw SlopError.internalError("SLOP provider path is not a directory at \(directory.path)") + } + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + guard isSecureProviderDirectory(directory) else { + throw SlopError.internalError("Could not secure SLOP provider directory at \(directory.path)") + } + #endif +} + +private func isSecureProviderDirectory(_ directory: URL) -> Bool { + #if canImport(Darwin) || canImport(Glibc) + var statBuffer = stat() + guard lstat(directory.path, &statBuffer) == 0 else { + return false + } + let mode = Int(statBuffer.st_mode) + guard (mode & Int(S_IFMT)) == Int(S_IFDIR) else { + return false + } + if let uid = currentUserID(), statBuffer.st_uid != uid { + return false + } + guard mode & 0o077 == 0 else { + return false + } + return true + #else + let fileManager = FileManager.default + guard + let attributes = try? fileManager.attributesOfItem(atPath: directory.path), + attributes[.type] as? FileAttributeType == .typeDirectory + else { + return false + } + + if let uid = currentUserID(), let owner = attributes[.ownerAccountID] as? NSNumber, owner.uint32Value != uid { + return false + } + + if let permissions = attributes[.posixPermissions] as? NSNumber, permissions.intValue & 0o077 != 0 { + return false + } + + return true + #endif +} + +private func readSecureDescriptorFile(_ file: URL) -> Data? { + #if canImport(Darwin) || canImport(Glibc) + let fd = open(file.path, O_RDONLY | descriptorNoFollowFlag()) + guard fd >= 0 else { + return nil + } + + var statBuffer = stat() + guard fstat(fd, &statBuffer) == 0 else { + close(fd) + return nil + } + + let mode = Int(statBuffer.st_mode) + guard (mode & Int(S_IFMT)) == Int(S_IFREG) else { + close(fd) + return nil + } + if let uid = currentUserID(), statBuffer.st_uid != uid { + close(fd) + return nil + } + guard mode & 0o077 == 0 else { + close(fd) + return nil + } + + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + let data = handle.readDataToEndOfFile() + try? handle.close() + return data + #else + guard + let attributes = try? FileManager.default.attributesOfItem(atPath: file.path), + attributes[.type] as? FileAttributeType == .typeRegular + else { + return nil + } + return try? Data(contentsOf: file) + #endif +} + +private func descriptorFileIdentity(_ file: URL) -> (device: UInt64, inode: UInt64)? { + #if canImport(Darwin) || canImport(Glibc) + var statBuffer = stat() + guard lstat(file.path, &statBuffer) == 0 else { return nil } + return (UInt64(statBuffer.st_dev), UInt64(statBuffer.st_ino)) + #else + guard + let attributes = try? FileManager.default.attributesOfItem(atPath: file.path), + let device = attributes[.systemNumber] as? NSNumber, + let inode = attributes[.systemFileNumber] as? NSNumber + else { + return nil + } + return (device.uint64Value, inode.uint64Value) + #endif +} + +private func quarantineAndRemoveDescriptor( + _ file: URL, + registration: ProviderRegistration, + beforeInspection: (() -> Void)? +) { + let quarantine = file.deletingLastPathComponent().appendingPathComponent(".slop-unregister-\(UUID().uuidString)") + #if canImport(Darwin) || canImport(Glibc) + guard rename(file.path, quarantine.path) == 0 else { return } + beforeInspection?() + guard let identity = descriptorFileIdentity(quarantine) else { return } + if identity.device == registration.device, identity.inode == registration.inode { + _ = unlink(quarantine.path) + return + } + if link(quarantine.path, file.path) == 0 { + _ = unlink(quarantine.path) + } else if errno == EEXIST { + _ = unlink(quarantine.path) + } + #else + guard (try? FileManager.default.moveItem(at: file, to: quarantine)) != nil else { return } + beforeInspection?() + guard let identity = descriptorFileIdentity(quarantine) else { return } + if identity.device == registration.device, identity.inode == registration.inode { + try? FileManager.default.removeItem(at: quarantine) + } else if !FileManager.default.fileExists(atPath: file.path) { + try? FileManager.default.moveItem(at: quarantine, to: file) + } + #endif +} + +private func currentUserID() -> UInt32? { + #if canImport(Darwin) || canImport(Glibc) + return getuid() + #else + return nil + #endif +} + +private func descriptorNoFollowFlag() -> Int32 { + #if canImport(Darwin) || canImport(Glibc) + return O_NOFOLLOW + #else + return 0 + #endif +} + +public struct DynamicToolEntry: Equatable { + public var name: String + public var description: String + public var inputSchema: JSONSchema + public var providerID: String + public var path: String? + public var action: String + public var targets: [String]? + + public init( + name: String, + description: String, + inputSchema: JSONSchema, + providerID: String, + path: String?, + action: String, + targets: [String]? = nil + ) { + self.name = name + self.description = description + self.inputSchema = inputSchema + self.providerID = providerID + self.path = path + self.action = action + self.targets = targets + } +} + +public struct DynamicToolSet { + public var tools: [DynamicToolEntry] + private var resolveMap: [String: (providerID: String, path: String?, action: String, targets: [String]?)] + + public init(tools: [DynamicToolEntry], resolveMap: [String: (providerID: String, path: String?, action: String, targets: [String]?)]) { + self.tools = tools + self.resolveMap = resolveMap + } + + public func resolve(_ toolName: String) -> (providerID: String, path: String?, action: String, targets: [String]?)? { + resolveMap[toolName] + } +} + +public func createDynamicTools(providers: [ConnectedProvider]) -> DynamicToolSet { + var entries: [DynamicToolEntry] = [] + var resolveMap: [String: (providerID: String, path: String?, action: String, targets: [String]?)] = [:] + var usedNames: Set = [] + + for provider in providers.sorted(by: { ($0.id, $0.name) < ($1.id, $1.name) }) { + guard let tree = provider.consumer.getTree(subscriptionID: provider.subscriptionID) else { + continue + } + let prefix = sanitizeToolPrefix(provider.id) + let toolSet = affordancesToTools(tree) + for tool in toolSet.tools { + guard let resolved = toolSet.resolve(tool.function.name) else { continue } + let name = reserveDynamicToolName("\(prefix)__\(tool.function.name)", used: &usedNames) + entries.append( + DynamicToolEntry( + name: name, + description: "[\(provider.name)] \(tool.function.description)", + inputSchema: tool.function.parameters, + providerID: provider.id, + path: resolved.path, + action: resolved.action, + targets: resolved.targets + ) + ) + resolveMap[name] = (provider.id, resolved.path, resolved.action, resolved.targets) + } + } + + return DynamicToolSet(tools: entries, resolveMap: resolveMap) +} + +private func sanitizeToolPrefix(_ value: String) -> String { + String(value.unicodeScalars.map { scalar -> Character in + let value = scalar.value + let isASCIIAlphaNumeric = (48...57).contains(value) || (65...90).contains(value) || (97...122).contains(value) + return isASCIIAlphaNumeric ? Character(String(scalar)) : "_" + }) +} + +private func reserveDynamicToolName(_ base: String, used: inout Set) -> String { + if used.insert(base).inserted { + return base + } + var suffix = 2 + while !used.insert("\(base)__\(suffix)").inserted { + suffix += 1 + } + return "\(base)__\(suffix)" +} + +private func isASCIILowercaseOrDigit(_ scalar: Unicode.Scalar) -> Bool { + (48...57).contains(scalar.value) || (97...122).contains(scalar.value) +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Helpers.swift b/packages/swift/slop-ai/Sources/SlopAI/Helpers.swift new file mode 100644 index 0000000..e2d6ff9 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Helpers.swift @@ -0,0 +1,49 @@ +import Foundation + +public func pick(_ object: [String: JSONValue], keys: some Sequence) -> [String: JSONValue] { + let keySet = Set(keys) + return object.filter { keySet.contains($0.key) } +} + +public func omit(_ object: [String: JSONValue], keys: some Sequence) -> [String: JSONValue] { + let keySet = Set(keys) + return object.filter { !keySet.contains($0.key) } +} + +public func action( + params: [String: ParamDef], + label: String? = nil, + description: String? = nil, + dangerous: Bool = false, + idempotent: Bool = false, + estimate: ActionEstimate? = nil, + handler: @escaping ([String: JSONValue]) async throws -> JSONValue? +) -> Action { + Action.value( + params: params, + label: label, + description: description, + dangerous: dangerous, + idempotent: idempotent, + estimate: estimate, + handler + ) +} + +public func action( + label: String? = nil, + description: String? = nil, + dangerous: Bool = false, + idempotent: Bool = false, + estimate: ActionEstimate? = nil, + handler: @escaping ([String: JSONValue]) async throws -> JSONValue? +) -> Action { + Action.value( + label: label, + description: description, + dangerous: dangerous, + idempotent: idempotent, + estimate: estimate, + handler + ) +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/JSONValue.swift b/packages/swift/slop-ai/Sources/SlopAI/JSONValue.swift new file mode 100644 index 0000000..86fe7e5 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/JSONValue.swift @@ -0,0 +1,162 @@ +import Foundation + +/// A type-safe representation of JSON values used at SLOP wire boundaries. +public enum JSONValue: Equatable, Codable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + 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(Int.self) { + self = .number(Double(value)) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: JSONValue].self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .bool(let value): + try container.encode(value) + case .number(let value): + guard value.isFinite else { + throw EncodingError.invalidValue( + value, + EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "JSON numbers must be finite") + ) + } + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } + + public var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } + + public var intValue: Int? { + if case .number(let value) = self { return Int(exactly: value) } + return nil + } + + public var doubleValue: Double? { + if case .number(let value) = self { return value } + return nil + } + + public var boolValue: Bool? { + if case .bool(let value) = self { return value } + return nil + } + + public var arrayValue: [JSONValue]? { + if case .array(let value) = self { return value } + return nil + } + + public var objectValue: [String: JSONValue]? { + if case .object(let value) = self { return value } + return nil + } + + var isEmptyObject: Bool { + if case .object(let value) = self { return value.isEmpty } + return false + } +} + +extension JSONValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } +} + +func encodeToJSONValue(_ value: T) throws -> JSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(JSONValue.self, from: data) +} + +func decodeJSONValue(_ value: JSONValue, as type: T.Type) throws -> T { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(type, from: data) +} + +func wireJSON(_ value: T) -> JSONValue { + (try? encodeToJSONValue(value)) ?? .null +} + +func canonicalJSON(_ value: JSONValue) -> String { + switch value { + case .null: + return "null" + case .bool(let value): + return value ? "true" : "false" + case .number(let value): + return Int(exactly: value).map(String.init) ?? String(value) + case .string(let value): + return String(data: try! JSONEncoder().encode(value), encoding: .utf8) ?? "\"\"" + case .array(let values): + return "[\(values.map(canonicalJSON).joined(separator: ","))]" + case .object(let object): + return "{\(object.keys.sorted().map { "\(canonicalJSON(.string($0))):\(canonicalJSON(object[$0]!))" }.joined(separator: ","))}" + } +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Paths.swift b/packages/swift/slop-ai/Sources/SlopAI/Paths.swift new file mode 100644 index 0000000..fe89f56 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Paths.swift @@ -0,0 +1,37 @@ +let reservedNodeIDs: Set = [ + "properties", + "children", + "affordances", + "meta", + "content_ref", + "id", + "type", +] + +public func escapeJSONPointerSegment(_ segment: String) -> String { + segment.replacingOccurrences(of: "~", with: "~0") + .replacingOccurrences(of: "/", with: "~1") +} + +public func unescapeJSONPointerSegment(_ segment: String) -> String { + segment.replacingOccurrences(of: "~1", with: "/") + .replacingOccurrences(of: "~0", with: "~") +} + +public func validateNodeID(_ id: String) throws { + if id.isEmpty { + throw SlopError.invalidNodeId("SLOP node id must be a non-empty string") + } + if reservedNodeIDs.contains(id) { + throw SlopError.invalidNodeId( + "SLOP node id \"\(id)\" collides with a reserved field keyword (properties, children, affordances, meta, content_ref, id, type)" + ) + } + if id.contains("/") || id.contains("~") { + throw SlopError.invalidNodeId("SLOP node id \"\(id)\" must not contain \"/\" or \"~\"; these are reserved in patch paths") + } +} + +public func isValidNodeID(_ id: String) -> Bool { + (try? validateNodeID(id)) != nil +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Scaling.swift b/packages/swift/slop-ai/Sources/SlopAI/Scaling.swift new file mode 100644 index 0000000..f723ff2 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Scaling.swift @@ -0,0 +1,193 @@ +import Foundation + +public struct SubscriptionFilter: Equatable { + public var types: [String]? + public var minSalience: Double? + + public init(types: [String]? = nil, minSalience: Double? = nil) { + self.types = types + self.minSalience = minSalience + } +} + +public struct OutputTreeOptions: Equatable { + public var maxDepth: Int? + public var maxNodes: Int? + public var minSalience: Double? + public var types: [String]? + + public init(maxDepth: Int? = nil, maxNodes: Int? = nil, minSalience: Double? = nil, types: [String]? = nil) { + self.maxDepth = maxDepth + self.maxNodes = maxNodes + self.minSalience = minSalience + self.types = types + } +} + +public struct OutputRequest: Equatable { + public var path: String? + public var depth: Int? + public var maxNodes: Int? + public var filter: SubscriptionFilter? + public var window: WindowRange? + + public init( + path: String? = nil, + depth: Int? = nil, + maxNodes: Int? = nil, + filter: SubscriptionFilter? = nil, + window: WindowRange? = nil + ) { + self.path = path + self.depth = depth + self.maxNodes = maxNodes + self.filter = filter + self.window = window + } +} + +public func prepareTree(_ root: SlopNode, options: OutputTreeOptions) -> SlopNode { + var tree = root + if options.minSalience != nil || options.types != nil { + tree = filterTree(tree, minSalience: options.minSalience, types: options.types) + } + if let maxDepth = options.maxDepth { + tree = truncateTree(tree, depth: maxDepth) + } + if let maxNodes = options.maxNodes { + tree = autoCompact(tree, maxNodes: maxNodes) + } + return tree +} + +public func getSubtree(_ root: SlopNode, path: String) -> SlopNode? { + if path.isEmpty || path == "/" { + return root + } + + let segments = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + .split(separator: "/") + .map(String.init) + var current = root + for segment in segments { + guard let child = current.children?.first(where: { $0.id == segment }) else { + return nil + } + current = child + } + return current +} + +public func truncateTree(_ node: SlopNode, depth: Int) -> SlopNode { + if depth <= 0, let children = node.children, !children.isEmpty { + var meta = node.meta ?? NodeMeta() + meta.totalChildren = children.count + return SlopNode(id: node.id, type: node.type, meta: meta) + } + guard let children = node.children else { + return node + } + var copy = node + copy.children = children.map { truncateTree($0, depth: depth - 1) } + return copy +} + +public func autoCompact(_ root: SlopNode, maxNodes: Int) -> SlopNode { + let total = countNodes(root) + guard total > maxNodes else { return root } + + var candidates: [CompactCandidate] = [] + if let children = root.children { + for index in children.indices { + collectCandidates(children[index], path: [index], candidates: &candidates, isRootChild: false) + } + } + candidates.sort { $0.score < $1.score } + + var tree = root + var nodeCount = total + for candidate in candidates where nodeCount > maxNodes { + let saved = collapseAtPath(&tree, path: candidate.path) + nodeCount -= saved + } + return tree +} + +public func filterTree(_ node: SlopNode, minSalience: Double? = nil, types: [String]? = nil) -> SlopNode { + guard let children = node.children else { return node } + let filtered = children + .filter { child in + if let minSalience { + let salience = child.meta?.salience ?? 0.5 + if salience < minSalience { return false } + } + if let types, !types.contains(child.type) { + return false + } + return true + } + .map { filterTree($0, minSalience: minSalience, types: types) } + + var copy = node + copy.children = filtered.isEmpty ? nil : filtered + return copy +} + +public func countNodes(_ node: SlopNode) -> Int { + 1 + (node.children?.reduce(0) { $0 + countNodes($1) } ?? 0) +} + +private struct CompactCandidate { + var path: [Int] + var score: Double +} + +private func collectCandidates(_ node: SlopNode, path: [Int], candidates: inout [CompactCandidate], isRootChild: Bool) { + guard let children = node.children else { return } + for index in children.indices { + let child = children[index] + let childPath = path + [index] + if let grandchildren = child.children, !grandchildren.isEmpty, !isRootChild, child.meta?.pinned != true { + let childCount = countNodes(child) - 1 + let salience = child.meta?.salience ?? 0.5 + let depth = Double(childPath.count) + let score = salience - depth * 0.01 - Double(childCount) * 0.001 + candidates.append(CompactCandidate(path: childPath, score: score)) + } + collectCandidates(child, path: childPath, candidates: &candidates, isRootChild: false) + } +} + +private func collapseAtPath(_ tree: inout SlopNode, path: [Int]) -> Int { + guard !path.isEmpty else { return 0 } + return collapseAtPath(&tree, path: path, depth: 0) +} + +private func collapseAtPath(_ node: inout SlopNode, path: [Int], depth: Int) -> Int { + guard var children = node.children, path.indices.contains(depth), children.indices.contains(path[depth]) else { + return 0 + } + let index = path[depth] + if depth == path.count - 1 { + let target = children[index] + let saved = countNodes(target) - 1 + var meta = target.meta ?? NodeMeta() + meta.totalChildren = target.children?.count ?? 0 + if meta.summary == nil { + meta.summary = "\(target.children?.count ?? 0) children" + } + children[index] = SlopNode( + id: target.id, + type: target.type, + properties: target.properties, + affordances: target.affordances, + meta: meta, + contentRef: target.contentRef + ) + node.children = children + return saved + } + let saved = collapseAtPath(&children[index], path: path, depth: depth + 1) + node.children = children + return saved +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Server.swift b/packages/swift/slop-ai/Sources/SlopAI/Server.swift new file mode 100644 index 0000000..3c92096 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Server.swift @@ -0,0 +1,539 @@ +import Foundation + +private struct ServerSubscription { + var id: String + var path: String + var depth: Int + var maxNodes: Int? + var filter: SubscriptionFilter? + var connectionID: ObjectIdentifier + var connection: SlopConnection + var lastTree: SlopNode + var seq: UInt64 +} + +private struct ServerOutboundMessage { + var connection: SlopConnection + var message: SlopMessage +} + +private struct ServerStateChange { + var outbound: [ServerOutboundMessage] = [] + var listeners: [() -> Void] = [] +} + +private final class ServerMessageQueue { + private let lock = NSLock() + private var tail: Task? + + func enqueue(_ operation: @escaping () async -> Void) { + lock.lock() + let previous = tail + let next = Task { + await previous?.value + await operation() + } + tail = next + lock.unlock() + } +} + +public final class SlopServer { + public let id: String + public let name: String + + private let lock = NSRecursiveLock() + private var providerSchema: JSONValue? + private var staticRegistrations: [String: NodeDescriptor] = [:] + private var dynamicRegistrations: [String: () -> NodeDescriptor] = [:] + private var decoratorActions: [String: [String: Action]] = [:] + private var currentTree: SlopNode + private var currentHandlers: [String: ActionHandler] = [:] + private var currentVersion: UInt64 = 0 + private var subscriptions: [ServerSubscription] = [] + private var connections: [ObjectIdentifier: SlopConnection] = [:] + private var connectionQueues: [ObjectIdentifier: ServerMessageQueue] = [:] + private var changeListeners: [UUID: () -> Void] = [:] + + public init(id: String, name: String, schema: JSONValue? = nil) { + self.id = id + self.name = name + providerSchema = schema + currentTree = SlopNode(id: id, type: "root") + } + + public var schema: JSONValue? { + get { locked { providerSchema } } + set { locked { providerSchema = newValue } } + } + + public var tree: SlopNode { + locked { currentTree } + } + + public var version: UInt64 { + locked { currentVersion } + } + + public func register(_ path: String, descriptor: NodeDescriptor) throws { + let change = try locked { + staticRegistrations[path] = mergeDecoratorActionsLocked(path: path, descriptor: descriptor) + dynamicRegistrations.removeValue(forKey: path) + return try rebuildLocked() + } + deliver(change) + } + + public func registerDynamic(_ path: String, descriptor: @escaping () -> NodeDescriptor) throws { + let change = try locked { + dynamicRegistrations[path] = descriptor + staticRegistrations.removeValue(forKey: path) + return try rebuildLocked() + } + deliver(change) + } + + public func action( + path: String, + name: String, + params: [String: ParamDef]? = nil, + label: String? = nil, + description: String? = nil, + dangerous: Bool = false, + idempotent: Bool = false, + estimate: ActionEstimate? = nil, + handler: @escaping ActionHandler + ) throws { + let change = try locked { + decoratorActions[path, default: [:]][name] = Action( + params: params, + label: label, + description: description, + dangerous: dangerous, + idempotent: idempotent, + estimate: estimate, + handler: handler + ) + guard let descriptor = staticRegistrations[path] else { + return ServerStateChange() + } + staticRegistrations[path] = mergeDecoratorActionsLocked(path: path, descriptor: descriptor) + return try rebuildLocked() + } + deliver(change) + } + + public func unregister(_ path: String, recursive: Bool = false) throws { + let change = try locked { + if recursive { + let prefix = "\(path)/" + for key in staticRegistrations.keys where key == path || key.hasPrefix(prefix) { + staticRegistrations.removeValue(forKey: key) + } + for key in dynamicRegistrations.keys where key == path || key.hasPrefix(prefix) { + dynamicRegistrations.removeValue(forKey: key) + } + } else { + staticRegistrations.removeValue(forKey: path) + dynamicRegistrations.removeValue(forKey: path) + } + return try rebuildLocked() + } + deliver(change) + } + + public func scope(_ prefix: String) -> ScopedSlopServer { + ScopedSlopServer(server: self, prefix: prefix) + } + + public func refresh() throws { + let change = try locked { + try rebuildLocked() + } + deliver(change) + } + + public func handleConnection(_ connection: SlopConnection) { + locked { + connections[ObjectIdentifier(connection)] = connection + } + connection.send(helloMessage()) + } + + public func attachConnection(_ connection: SlopConnection) { + let queue = ServerMessageQueue() + locked { + connectionQueues[ObjectIdentifier(connection)] = queue + } + handleConnection(connection) + connection.onMessage { [weak self, weak connection] message in + guard let self, let connection else { return } + queue.enqueue { + await self.handleMessage(message, from: connection) + } + } + connection.onClose { [weak self, weak connection] in + guard let self, let connection else { return } + self.handleDisconnect(connection) + } + } + + public func handleDisconnect(_ connection: SlopConnection) { + locked { + let id = ObjectIdentifier(connection) + connections.removeValue(forKey: id) + connectionQueues.removeValue(forKey: id) + subscriptions.removeAll { $0.connectionID == id } + } + } + + public func emitEvent(name: String, data: JSONValue? = nil) { + var message: SlopMessage = ["type": "event", "name": .string(name)] + if let data { + message["data"] = data + } + let targets = locked { Array(connections.values) } + for connection in targets { + connection.send(message) + } + } + + @discardableResult + public func onChange(_ callback: @escaping () -> Void) -> () -> Void { + let token = UUID() + locked { + changeListeners[token] = callback + } + return { [weak self] in + guard let self else { return } + _ = self.locked { + self.changeListeners.removeValue(forKey: token) + } + } + } + + public func stop() { + let targets = locked { () -> [SlopConnection] in + let targets = Array(connections.values) + connections.removeAll() + connectionQueues.removeAll() + subscriptions.removeAll() + return targets + } + for connection in targets { + connection.close() + } + } + + public func handleMessage(_ message: SlopMessage, from connection: SlopConnection) async { + switch messageString(message, "type") { + case "subscribe": + handleSubscribe(message, from: connection) + case "unsubscribe": + let subscriptionID = messageString(message, "id") ?? "" + let connectionID = ObjectIdentifier(connection) + locked { + subscriptions.removeAll { $0.id == subscriptionID && $0.connectionID == connectionID } + } + case "query": + handleQuery(message, from: connection) + case "invoke": + await handleInvoke(message, from: connection) + default: + connection.send(errorMessage(id: messageString(message, "id"), code: "bad_request", message: "Unknown message type: \(messageString(message, "type") ?? "nil")")) + } + } + + public func helloMessage() -> SlopMessage { + [ + "type": "hello", + "provider": .object([ + "id": .string(id), + "name": .string(name), + "slop_version": "0.1", + "capabilities": .array(["state", "patches", "affordances", "attention", "windowing", "async", "content_refs"].map(JSONValue.string)), + ]), + ] + } + + public func outputTree(_ request: OutputRequest = OutputRequest()) throws -> SlopNode { + try locked { + try outputTreeLocked(request) + } + } + + public func executeInvoke(id requestID: String, path: String, action: String, params: [String: JSONValue] = [:]) async -> SlopMessage { + let resolution = locked { () -> (ActionHandler?, JSONSchema?) in + (resolveHandlerLocked(path: path, action: action), resolveAffordanceLocked(path: path, action: action)?.params) + } + + guard let handler = resolution.0 else { + return resultMessage(id: requestID, status: "error", code: "not_found", message: "No handler for \(action) at \(path)") + } + + if let schema = resolution.1, let error = validateParams(schema: schema, params: .object(params)) { + return resultMessage(id: requestID, status: "error", code: "invalid_params", message: error) + } + + do { + let actionResult = try await handler(params) + refreshAfterInvoke() + + switch actionResult { + case .value(let value): + return resultMessage(id: requestID, status: "ok", data: value) + case .accepted(let taskID, let data): + var resultData = data + resultData["taskId"] = .string(taskID) + return resultMessage(id: requestID, status: "accepted", data: .object(resultData)) + } + } catch { + refreshAfterInvoke() + return resultMessage(id: requestID, status: "error", code: "internal", message: error.localizedDescription) + } + } + + private func refreshAfterInvoke() { + let change = try? locked { + try rebuildLocked() + } + if let change { + deliver(change) + } + } + + private func handleSubscribe(_ message: SlopMessage, from connection: SlopConnection) { + let subscriptionID = messageString(message, "id") ?? "" + let path = messageString(message, "path") ?? "/" + let depth = messageInt(message, "depth") ?? -1 + do { + let snapshot = try locked { () -> SlopMessage in + let output = try outputTreeLocked( + OutputRequest( + path: path, + depth: depth, + maxNodes: messageInt(message, "max_nodes"), + filter: messageFilter(message) + ) + ) + subscriptions.append( + ServerSubscription( + id: subscriptionID, + path: path, + depth: depth, + maxNodes: messageInt(message, "max_nodes"), + filter: messageFilter(message), + connectionID: ObjectIdentifier(connection), + connection: connection, + lastTree: output, + seq: 0 + ) + ) + return snapshotMessage(id: subscriptionID, version: currentVersion, seq: 0, tree: output) + } + connection.send(snapshot) + } catch { + connection.send(errorMessage(id: subscriptionID, code: "not_found", message: "Path \(path) does not exist in the state tree")) + } + } + + private func handleQuery(_ message: SlopMessage, from connection: SlopConnection) { + let requestID = messageString(message, "id") ?? "" + let path = messageString(message, "path") ?? "/" + do { + let snapshot = try locked { () -> SlopMessage in + let output = try outputTreeLocked( + OutputRequest( + path: path, + depth: messageInt(message, "depth"), + maxNodes: messageInt(message, "max_nodes"), + filter: messageFilter(message), + window: messageWindow(message, "window") + ) + ) + return snapshotMessage(id: requestID, version: currentVersion, tree: output) + } + connection.send(snapshot) + } catch { + connection.send(errorMessage(id: requestID, code: "not_found", message: "Path \(path) does not exist in the state tree")) + } + } + + private func handleInvoke(_ message: SlopMessage, from connection: SlopConnection) async { + let requestID = messageString(message, "id") ?? "" + let path = messageString(message, "path") ?? "/" + let action = messageString(message, "action") ?? "" + let params = messageObject(message, "params") ?? [:] + let result = await executeInvoke(id: requestID, path: path, action: action, params: params) + connection.send(result) + } + + private func rebuildLocked() throws -> ServerStateChange { + var registrations = staticRegistrations + for (path, descriptor) in dynamicRegistrations { + registrations[path] = mergeDecoratorActionsLocked(path: path, descriptor: descriptor()) + } + for (path, descriptor) in staticRegistrations { + registrations[path] = mergeDecoratorActionsLocked(path: path, descriptor: descriptor) + } + + let result = try assembleTree(registrations: registrations, rootID: id, rootName: name) + let ops = diffNodes(currentTree, result.tree) + currentHandlers = result.handlers + + if !ops.isEmpty { + currentTree = result.tree + currentVersion += 1 + return ServerStateChange(outbound: broadcastMessagesLocked(), listeners: Array(changeListeners.values)) + } + + if currentVersion == 0 { + currentTree = result.tree + currentVersion = 1 + } + return ServerStateChange() + } + + private func broadcastMessagesLocked() -> [ServerOutboundMessage] { + var outbound: [ServerOutboundMessage] = [] + for index in subscriptions.indices { + let subscription = subscriptions[index] + guard let output = try? outputTreeLocked( + OutputRequest( + path: subscription.path, + depth: subscription.depth, + maxNodes: subscription.maxNodes, + filter: subscription.filter + ) + ) else { + continue + } + let ops = diffNodes(subscription.lastTree, output) + subscriptions[index].lastTree = output + guard !ops.isEmpty else { continue } + subscriptions[index].seq += 1 + outbound.append( + ServerOutboundMessage( + connection: subscription.connection, + message: patchMessage( + subscription: subscription.id, + version: currentVersion, + seq: subscriptions[index].seq, + ops: ops + ) + ) + ) + } + return outbound + } + + private func outputTreeLocked(_ request: OutputRequest = OutputRequest()) throws -> SlopNode { + var output: SlopNode + if let path = request.path, !path.isEmpty, path != "/" { + guard let subtree = getSubtree(currentTree, path: path) else { + throw SlopError.notFound("Path \(path) does not exist in the state tree") + } + output = subtree + } else { + output = currentTree + } + + output = prepareTree( + output, + options: OutputTreeOptions( + maxDepth: request.depth != nil && request.depth! >= 0 ? request.depth : nil, + maxNodes: request.maxNodes, + minSalience: request.filter?.minSalience, + types: request.filter?.types + ) + ) + + if let window = request.window, let children = output.children { + let offset = max(0, min(window.offset, children.count)) + let count = max(0, min(window.count, children.count - offset)) + let end = offset + count + let sliced = Array(children[offset.. NodeDescriptor { + guard let actions = decoratorActions[path], !actions.isEmpty else { + return descriptor + } + var descriptor = descriptor + var merged = descriptor.actions ?? [:] + for (name, action) in actions { + merged[name] = action + } + descriptor.actions = merged + return descriptor + } + + private func resolveHandlerLocked(path: String, action: String) -> ActionHandler? { + var cleanPath = path + let rootPrefix = "/\(id)/" + if cleanPath.hasPrefix(rootPrefix) { + cleanPath = String(cleanPath.dropFirst(rootPrefix.count)) + } else if cleanPath.hasPrefix("/") { + cleanPath = String(cleanPath.dropFirst()) + } + let key = cleanPath.isEmpty ? action : "\(cleanPath)/\(action)" + return currentHandlers[key] + } + + private func resolveAffordanceLocked(path: String, action: String) -> Affordance? { + let rootPrefix = "/\(id)" + var treePath = path + if treePath == rootPrefix { + treePath = "/" + } else if treePath.hasPrefix("\(rootPrefix)/") { + treePath = String(treePath.dropFirst(rootPrefix.count)) + } + let node = treePath == "/" ? currentTree : getSubtree(currentTree, path: treePath) + return node?.affordances?.first { $0.action == action } + } + + private func deliver(_ change: ServerStateChange) { + for item in change.outbound { + item.connection.send(item.message) + } + for listener in change.listeners { + listener() + } + } + + private func locked(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body() + } +} + +public struct ScopedSlopServer { + private let server: SlopServer + private let prefix: String + + init(server: SlopServer, prefix: String) { + self.server = server + self.prefix = prefix + } + + public func register(_ path: String, descriptor: NodeDescriptor) throws { + try server.register("\(prefix)/\(path)", descriptor: descriptor) + } + + public func unregister(_ path: String, recursive: Bool = false) throws { + try server.unregister("\(prefix)/\(path)", recursive: recursive) + } + + public func scope(_ path: String) -> ScopedSlopServer { + server.scope("\(prefix)/\(path)") + } +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/StateMirror.swift b/packages/swift/slop-ai/Sources/SlopAI/StateMirror.swift new file mode 100644 index 0000000..967ce6d --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/StateMirror.swift @@ -0,0 +1,230 @@ +import Foundation + +private let nodeFieldSegments: Set = ["properties", "meta", "affordances", "content_ref"] + +public final class StateMirror { + private var tree: SlopNode + private var version: UInt64 + private var seq: UInt64 + + public init(snapshot: SnapshotMessage) throws { + guard snapshot.seq == 0 else { + throw SlopError.internalError("A state mirror requires a subscription snapshot with seq 0") + } + tree = snapshot.tree + version = snapshot.version + seq = 0 + } + + public func applyPatch(_ patch: PatchMessage) throws { + let originalTree = tree + let originalVersion = version + let originalSeq = seq + do { + let (expected, overflow) = seq.addingReportingOverflow(1) + guard !overflow, patch.seq == expected else { + throw SlopError.subscriptionGap(expected: overflow ? seq : expected, received: patch.seq) + } + seq = patch.seq + + for op in patch.ops { + try apply(op) + } + version = patch.version + } catch { + tree = originalTree + version = originalVersion + seq = originalSeq + throw error + } + } + + public func getTree() -> SlopNode { + tree + } + + public func getVersion() -> UInt64 { + version + } + + public func getSeq() -> UInt64 { + seq + } + + private func apply(_ op: PatchOp) throws { + let segments = op.path.split(separator: "/").map(String.init) + guard !segments.isEmpty else { return } + + if let fieldIndex = segments.firstIndex(where: { nodeFieldSegments.contains($0) }) { + let nodePath = Array(segments[.. Void) throws { + try withNode(at: path, in: &tree, body) + } + + private func withNode(at path: [String], in node: inout SlopNode, _ body: (inout SlopNode) throws -> Void) throws { + guard let first = path.first else { + try body(&node) + return + } + guard var children = node.children, let index = children.firstIndex(where: { $0.id == first }) else { + return + } + try withNode(at: Array(path.dropFirst()), in: &children[index], body) + node.children = children + } +} + +private func applyFieldOperation(_ op: PatchOp, field: String, fieldPath: [String], node: inout SlopNode) throws { + switch field { + case "properties": + var value: JSONValue = .object(node.properties ?? [:]) + mutateJSONValue(&value, operation: op.op, path: fieldPath, replacement: op.value) + node.properties = value.objectValue + case "meta": + if fieldPath.isEmpty { + node.meta = op.op == .remove ? nil : try op.value.map { try decodeJSONValue($0, as: NodeMeta.self) } + } else { + var value = node.meta.map(wireJSON) ?? .object([:]) + mutateJSONValue(&value, operation: op.op, path: fieldPath, replacement: op.value) + node.meta = try decodeJSONValue(value, as: NodeMeta.self) + } + case "affordances": + if fieldPath.isEmpty { + node.affordances = op.op == .remove ? nil : try op.value.map { try decodeJSONValue($0, as: [Affordance].self) } + } + case "content_ref": + if fieldPath.isEmpty { + node.contentRef = op.op == .remove ? nil : try op.value.map { try decodeJSONValue($0, as: ContentRef.self) } + } else { + var value = node.contentRef.map(wireJSON) ?? .object([:]) + mutateJSONValue(&value, operation: op.op, path: fieldPath, replacement: op.value) + node.contentRef = try decodeJSONValue(value, as: ContentRef.self) + } + default: + break + } +} + +private func mutateJSONValue(_ value: inout JSONValue, operation: PatchOperation, path: [String], replacement: JSONValue?) { + guard let first = path.first else { + switch operation { + case .add, .replace: + value = replacement ?? .null + case .remove: + value = .null + case .move: + break + } + return + } + + if path.count == 1 { + switch value { + case .object(var object): + switch operation { + case .add, .replace: + object[first] = replacement ?? .null + case .remove: + object.removeValue(forKey: first) + case .move: + break + } + value = .object(object) + case .array(var array): + guard let index = Int(first), array.indices.contains(index) else { return } + switch operation { + case .add, .replace: + array[index] = replacement ?? .null + case .remove: + array.remove(at: index) + case .move: + break + } + value = .array(array) + default: + break + } + return + } + + switch value { + case .object(var object): + var child = object[first] ?? .object([:]) + mutateJSONValue(&child, operation: operation, path: Array(path.dropFirst()), replacement: replacement) + object[first] = child + value = .object(object) + case .array(var array): + guard let index = Int(first), array.indices.contains(index) else { return } + var child = array[index] + mutateJSONValue(&child, operation: operation, path: Array(path.dropFirst()), replacement: replacement) + array[index] = child + value = .array(array) + default: + break + } +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/StdioProviderTransport.swift b/packages/swift/slop-ai/Sources/SlopAI/StdioProviderTransport.swift new file mode 100644 index 0000000..9a06a44 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/StdioProviderTransport.swift @@ -0,0 +1,169 @@ +import Foundation + +public final class StdioProviderTransport { + private let server: SlopServer + private let connection: StdioConnection + private var isRunning = false + + public init( + server: SlopServer, + input: FileHandle = .standardInput, + output: FileHandle = .standardOutput + ) { + self.server = server + connection = StdioConnection(input: input, output: output) + } + + public func start() { + guard !isRunning else { return } + isRunning = true + server.attachConnection(connection) + connection.startReading() + } + + public func stop() { + guard isRunning else { return } + isRunning = false + connection.close() + server.handleDisconnect(connection) + } + + deinit { + stop() + } +} + +public final class StdioConnection: SlopConnection { + private let input: FileHandle + private let output: FileHandle + private let lock = NSLock() + private let writeQueue = DispatchQueue(label: "dev.slop.stdio.write") + private let readQueue = DispatchQueue(label: "dev.slop.stdio.read", qos: .utility) + private var messageHandlers: [(SlopMessage) -> Void] = [] + private var closeHandlers: [() -> Void] = [] + private var pendingMessages: [SlopMessage] = [] + private var didClose = false + + public init(input: FileHandle = .standardInput, output: FileHandle = .standardOutput) { + self.input = input + self.output = output + } + + public func send(_ message: SlopMessage) { + writeQueue.sync { + do { + var data = try JSONEncoder().encode(JSONValue.object(message)) + data.append(0x0A) + output.write(data) + } catch { + fireClose() + } + } + } + + public func onMessage(_ handler: @escaping (SlopMessage) -> Void) { + lock.lock() + messageHandlers.append(handler) + let pending = pendingMessages + pendingMessages.removeAll() + lock.unlock() + + for message in pending { + handler(message) + } + } + + public func onClose(_ handler: @escaping () -> Void) { + lock.lock() + let alreadyClosed = didClose + if !alreadyClosed { + closeHandlers.append(handler) + } + lock.unlock() + if alreadyClosed { + handler() + } + } + + public func close() { + fireClose() + } + + func startReading() { + readQueue.async { [weak self] in + self?.readLoop() + } + } + + private func readLoop() { + var pending = Data() + while !isClosed { + let chunk = input.readData(ofLength: 1) + if chunk.isEmpty { + break + } + pending.append(chunk) + + while let newline = pending.firstIndex(of: 0x0A) { + let line = pending[.. StdioProviderTransport { + let transport = StdioProviderTransport(server: self, input: input, output: output) + transport.start() + return transport + } +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Store.swift b/packages/swift/slop-ai/Sources/SlopAI/Store.swift new file mode 100644 index 0000000..67d8b04 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Store.swift @@ -0,0 +1,158 @@ +import Foundation + +public protocol StateStore { + associatedtype State + + func getState() -> State + @discardableResult + func subscribe(_ listener: @escaping () -> Void) -> StoreSubscription +} + +public protocol StoreSubscription { + func unsubscribe() +} + +public protocol StoreTarget { + func register(_ path: String, descriptor: NodeDescriptor) throws + func unregister(_ path: String, recursive: Bool) throws +} + +extension SlopServer: StoreTarget {} + +public struct ExposeStoreOptions { + public var equals: ((State, State) -> Bool)? + public var debounceMilliseconds: Int + + public init(equals: ((State, State) -> Bool)? = nil, debounceMilliseconds: Int = 0) { + self.equals = equals + self.debounceMilliseconds = debounceMilliseconds + } +} + +public final class StoreExposure { + private let dispose: () -> Void + private let lock = NSLock() + private var disposed = false + + init(dispose: @escaping () -> Void) { + self.dispose = dispose + } + + public func unsubscribe() { + lock.lock() + guard !disposed else { + lock.unlock() + return + } + disposed = true + lock.unlock() + dispose() + } +} + +public enum StorePath { + case fixed(String) + case dynamic((State) -> String) + + func resolve(_ state: State) -> String { + switch self { + case .fixed(let path): + return path + case .dynamic(let resolve): + return resolve(state) + } + } +} + +@discardableResult +public func exposeStore( + target: Target, + path: StorePath, + store: S, + project: @escaping (S.State) -> NodeDescriptor, + options: ExposeStoreOptions = ExposeStoreOptions() +) throws -> StoreExposure { + let stateLock = NSRecursiveLock() + var currentPath: String? + var previousState: S.State? + var hasPreviousState = false + var disposed = false + var debounceTask: Task? + + func update() throws { + stateLock.lock() + defer { stateLock.unlock() } + guard !disposed else { return } + let state = store.getState() + if hasPreviousState, let previous = previousState, options.equals?(previous, state) == true { + return + } + let nextPath = path.resolve(state) + if let currentPath, currentPath != nextPath { + try target.register(nextPath, descriptor: project(state)) + do { + try target.unregister(currentPath, recursive: true) + } catch { + try? target.unregister(nextPath, recursive: true) + throw error + } + } else { + try target.register(nextPath, descriptor: project(state)) + } + currentPath = nextPath + previousState = state + hasPreviousState = true + } + + func scheduleUpdate() { + stateLock.lock() + defer { stateLock.unlock() } + guard !disposed else { return } + let delay = options.debounceMilliseconds + guard delay > 0 else { + try? update() + return + } + debounceTask?.cancel() + let (nanoseconds, overflow) = UInt64(delay).multipliedReportingOverflow(by: 1_000_000) + debounceTask = Task { + try? await Task.sleep(nanoseconds: overflow ? UInt64.max : nanoseconds) + guard !Task.isCancelled else { return } + try? update() + } + } + + let subscription = store.subscribe(scheduleUpdate) + do { + try update() + } catch { + stateLock.lock() + disposed = true + let pendingTask = debounceTask + debounceTask = nil + stateLock.unlock() + pendingTask?.cancel() + subscription.unsubscribe() + throw error + } + + return StoreExposure { + stateLock.lock() + guard !disposed else { + stateLock.unlock() + return + } + disposed = true + let pendingTask = debounceTask + debounceTask = nil + let registeredPath = currentPath + currentPath = nil + stateLock.unlock() + + pendingTask?.cancel() + subscription.unsubscribe() + if let registeredPath { + try? target.unregister(registeredPath, recursive: true) + } + } +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Tools.swift b/packages/swift/slop-ai/Sources/SlopAI/Tools.swift new file mode 100644 index 0000000..99dd653 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Tools.swift @@ -0,0 +1,258 @@ +import Foundation + +public struct LLMTool: Equatable { + public struct Function: Equatable { + public var name: String + public var description: String + public var parameters: JSONSchema + + public init(name: String, description: String, parameters: JSONSchema) { + self.name = name + self.description = description + self.parameters = parameters + } + } + + public var type: String + public var function: Function + + public init(type: String = "function", function: Function) { + self.type = type + self.function = function + } +} + +public struct ToolResolution: Equatable { + public var path: String? + public var action: String + public var targets: [String]? + + public init(path: String?, action: String, targets: [String]? = nil) { + self.path = path + self.action = action + self.targets = targets + } +} + +public struct ToolSet { + public var tools: [LLMTool] + private var resolveMap: [String: ToolResolution] + + public init(tools: [LLMTool], resolveMap: [String: ToolResolution]) { + self.tools = tools + self.resolveMap = resolveMap + } + + public func resolve(_ toolName: String) -> ToolResolution? { + resolveMap[toolName] + } +} + +private struct AffordanceEntry { + var nodeID: String + var nodeType: String + var path: String + var action: String + var affordance: Affordance + var schemaKey: String +} + +public func affordancesToTools(_ node: SlopNode, path: String = "") -> ToolSet { + var entries: [AffordanceEntry] = [] + collectAffordances(node, path: path, entries: &entries) + + let groups = Dictionary(grouping: entries) { "\($0.action)\u{0}\($0.schemaKey)" } + .values + .map { Array($0) } + + var actionNameCounts: [String: Int] = [:] + for group in groups { + guard let first = group.first else { continue } + let action = sanitize(first.action) + actionNameCounts[action, default: 0] += 1 + } + + var actionNameUsed: [String: Int] = [:] + var resolveMap: [String: ToolResolution] = [:] + var tools: [LLMTool] = [] + var usedToolNames: Set = [] + + for group in groups.sorted(by: { + let lhs = $0.first.map { "\($0.action)\u{0}\($0.schemaKey)\u{0}\($0.nodeID)" } ?? "" + let rhs = $1.first.map { "\($0.action)\u{0}\($0.schemaKey)\u{0}\($0.nodeID)" } ?? "" + return lhs < rhs + }) { + guard let first = group.first else { continue } + let safeAction = sanitize(first.action) + + if group.count == 1 { + let toolName = reserveToolName("\(sanitize(first.nodeID))__\(safeAction)", used: &usedToolNames) + resolveMap[toolName] = ToolResolution(path: first.path.isEmpty ? "/" : first.path, action: first.action) + tools.append( + LLMTool( + function: .init( + name: toolName, + description: buildDescription(first), + parameters: first.affordance.params ?? JSONSchema(type: "object", properties: [:]) + ) + ) + ) + continue + } + + var toolName = safeAction + if (actionNameCounts[safeAction] ?? 0) > 1 { + let used = actionNameUsed[safeAction] ?? 0 + actionNameUsed[safeAction] = used + 1 + if used > 0 { + toolName = "\(safeAction)__\(sanitize(first.nodeID))" + } + } + toolName = reserveToolName(toolName, used: &usedToolNames) + + let targets = group.map { $0.path.isEmpty ? "/" : $0.path } + let baseParams = cloneSchema(first.affordance.params ?? JSONSchema(type: "object", properties: [:])) + var properties = baseParams.properties ?? [:] + properties["target"] = JSONSchema( + type: "string", + description: "Path to the target node (e.g. \(targets[0])). See the state tree for valid paths." + ) + baseParams.properties = properties + baseParams.required = ["target"] + (baseParams.required ?? []) + + resolveMap[toolName] = ToolResolution(path: nil, action: first.action, targets: targets) + tools.append( + LLMTool( + function: .init( + name: toolName, + description: buildGroupDescription(group), + parameters: baseParams + ) + ) + ) + } + + return ToolSet(tools: tools, resolveMap: resolveMap) +} + +public func formatTree(_ node: SlopNode, indent: Int = 0) -> String { + let pad = String(repeating: " ", count: indent) + let properties = node.properties ?? [:] + let displayName = properties["label"]?.stringValue ?? properties["title"]?.stringValue + let header = displayName != nil && displayName != node.id ? "\(node.id): \(displayName!)" : node.id + let extra = properties.keys + .sorted() + .filter { $0 != "label" && $0 != "title" } + .map { "\($0)=\(canonicalJSON(properties[$0]!))" } + .joined(separator: ", ") + + let affordances = (node.affordances ?? []) + .map { affordance in + var string = affordance.action + if let properties = affordance.params?.properties { + let params = properties.keys.sorted().map { "\($0): \(properties[$0]!.type)" }.joined(separator: ", ") + string += "(\(params))" + } + return string + } + .joined(separator: ", ") + + var line = "\(pad)[\(node.type)] \(header)" + if !extra.isEmpty { + line += " (\(extra))" + } + if let summary = node.meta?.summary { + line += " — \"\(summary)\"" + } + if let salience = node.meta?.salience { + line += " salience=\((salience * 100).rounded() / 100)" + } + if !affordances.isEmpty { + line += " actions: {\(affordances)}" + } + + var lines = [line] + let childCount = node.children?.count ?? 0 + if let totalChildren = node.meta?.totalChildren, totalChildren > childCount { + if node.meta?.window != nil { + lines.append("\(pad) (showing \(childCount) of \(totalChildren))") + } else if childCount == 0 { + lines.append("\(pad) (\(totalChildren) \(totalChildren == 1 ? "child" : "children") not loaded)") + } + } + for child in node.children ?? [] { + lines.append(formatTree(child, indent: indent + 1)) + } + return lines.joined(separator: "\n") +} + +private func collectAffordances(_ node: SlopNode, path: String, entries: inout [AffordanceEntry]) { + for affordance in node.affordances ?? [] { + entries.append( + AffordanceEntry( + nodeID: node.id, + nodeType: node.type, + path: path, + action: affordance.action, + affordance: affordance, + schemaKey: canonicalSchemaKey(affordance.params) + ) + ) + } + for child in node.children ?? [] { + collectAffordances(child, path: "\(path)/\(child.id)", entries: &entries) + } +} + +private func sanitize(_ value: String) -> String { + String(value.unicodeScalars.map { scalar -> Character in + let value = scalar.value + let isASCIIAlphaNumeric = (48...57).contains(value) || (65...90).contains(value) || (97...122).contains(value) + return isASCIIAlphaNumeric ? Character(String(scalar)) : "_" + }) +} + +private func reserveToolName(_ base: String, used: inout Set) -> String { + if used.insert(base).inserted { + return base + } + var suffix = 2 + while !used.insert("\(base)__\(suffix)").inserted { + suffix += 1 + } + return "\(base)__\(suffix)" +} + +private func canonicalSchemaKey(_ schema: JSONSchema?) -> String { + guard let schema else { return "" } + return canonicalJSON(wireJSON(schema)) +} + +private func buildDescription(_ entry: AffordanceEntry) -> String { + var description = "\(entry.affordance.label ?? entry.affordance.action)" + if let detail = entry.affordance.description { + description += ": \(detail)" + } + description += " (on \(entry.path.isEmpty ? "/" : entry.path))" + if entry.affordance.dangerous == true { + description += " [DANGEROUS - confirm first]" + } + return description +} + +private func buildGroupDescription(_ group: [AffordanceEntry]) -> String { + guard let first = group.first else { return "" } + var description = first.affordance.label ?? first.affordance.action + if let detail = first.affordance.description { + description += ": \(detail)" + } + description += " (\(group.count) targets)" + if group.contains(where: { $0.affordance.dangerous == true }) { + description += " [DANGEROUS - confirm first]" + } + return description +} + +private func cloneSchema(_ schema: JSONSchema) -> JSONSchema { + (try? decodeJSONValue(wireJSON(schema), as: JSONSchema.self)) ?? JSONSchema(type: schema.type) +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Transport.swift b/packages/swift/slop-ai/Sources/SlopAI/Transport.swift new file mode 100644 index 0000000..6d9a3cf --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Transport.swift @@ -0,0 +1,357 @@ +import Foundation + +public typealias SlopMessage = [String: JSONValue] + +public protocol SlopConnection: AnyObject { + func send(_ message: SlopMessage) + func onMessage(_ handler: @escaping (SlopMessage) -> Void) + func onClose(_ handler: @escaping () -> Void) + func close() +} + +public protocol ClientTransport { + func connect() async throws -> SlopConnection +} + +public final class InMemoryConnection: SlopConnection, @unchecked Sendable { + private let lock = NSLock() + private var messageHandlers: [(SlopMessage) -> Void] = [] + private var closeHandlers: [() -> Void] = [] + private var pendingMessages: [SlopMessage] = [] + private var messageHandlerWaiters: [CheckedContinuation] = [] + private var didClose = false + private let sendHandler: (SlopMessage) -> Void + + public init(sendHandler: @escaping (SlopMessage) -> Void = { _ in }) { + self.sendHandler = sendHandler + } + + public func send(_ message: SlopMessage) { + sendHandler(message) + } + + public func receive(_ message: SlopMessage) { + lock.lock() + let handlers = messageHandlers + if handlers.isEmpty { + pendingMessages.append(message) + lock.unlock() + return + } + lock.unlock() + + for handler in handlers { + handler(message) + } + } + + public func onMessage(_ handler: @escaping (SlopMessage) -> Void) { + lock.lock() + messageHandlers.append(handler) + let pending = pendingMessages + pendingMessages.removeAll() + let waiters = messageHandlerWaiters + messageHandlerWaiters.removeAll() + lock.unlock() + + for message in pending { + handler(message) + } + for waiter in waiters { + waiter.resume() + } + } + + public func onClose(_ handler: @escaping () -> Void) { + lock.lock() + let alreadyClosed = didClose + if !alreadyClosed { + closeHandlers.append(handler) + } + lock.unlock() + if alreadyClosed { + handler() + } + } + + public func waitForMessageHandler() async { + await withCheckedContinuation { continuation in + lock.lock() + if !messageHandlers.isEmpty { + lock.unlock() + continuation.resume() + } else { + messageHandlerWaiters.append(continuation) + lock.unlock() + } + } + } + + public func close() { + lock.lock() + guard !didClose else { + lock.unlock() + return + } + didClose = true + let handlers = closeHandlers + lock.unlock() + + for handler in handlers { + handler() + } + } +} + +public final class InMemoryTransport: ClientTransport { + public let connection: InMemoryConnection + + public init(connection: InMemoryConnection = InMemoryConnection()) { + self.connection = connection + } + + public func connect() async throws -> SlopConnection { + connection + } +} + +public final class URLSessionWebSocketTransport: ClientTransport { + private let request: URLRequest + private let session: URLSession + + public convenience init(url: URL, session: URLSession = .shared) { + self.init(request: URLRequest(url: url), session: session) + } + + public init(request: URLRequest, session: URLSession = .shared) { + self.request = request + self.session = session + } + + public func connect() async throws -> SlopConnection { + let task = session.webSocketTask(with: request) + let connection = URLSessionWebSocketConnection(task: task) + task.resume() + connection.startReceiving() + return connection + } +} + +public final class URLSessionWebSocketConnection: SlopConnection { + private let task: URLSessionWebSocketTask + private let lock = NSLock() + private var messageHandlers: [(SlopMessage) -> Void] = [] + private var closeHandlers: [() -> Void] = [] + private var pendingMessages: [SlopMessage] = [] + private var didClose = false + private var sendTail: Task? + + init(task: URLSessionWebSocketTask) { + self.task = task + } + + public func send(_ message: SlopMessage) { + lock.lock() + let previous = sendTail + let next = Task { [weak self] in + await previous?.value + guard let self else { return } + do { + let data = try JSONEncoder().encode(JSONValue.object(message)) + let text = String(decoding: data, as: UTF8.self) + try await self.task.send(.string(text)) + } catch { + self.fireClose() + } + } + sendTail = next + lock.unlock() + } + + public func onMessage(_ handler: @escaping (SlopMessage) -> Void) { + lock.lock() + messageHandlers.append(handler) + let pending = pendingMessages + pendingMessages.removeAll() + lock.unlock() + + for message in pending { + handler(message) + } + } + + public func onClose(_ handler: @escaping () -> Void) { + lock.lock() + let alreadyClosed = didClose + if !alreadyClosed { + closeHandlers.append(handler) + } + lock.unlock() + if alreadyClosed { + handler() + } + } + + public func close() { + task.cancel(with: .normalClosure, reason: nil) + fireClose() + } + + func startReceiving() { + Task { + await receiveLoop() + } + } + + private func receiveLoop() async { + while !isClosed { + do { + let message = try await task.receive() + guard let slopMessage = try decode(message) else { + continue + } + fireMessage(slopMessage) + } catch { + fireClose() + return + } + } + } + + private var isClosed: Bool { + lock.lock() + defer { lock.unlock() } + return didClose + } + + private func decode(_ message: URLSessionWebSocketTask.Message) throws -> SlopMessage? { + let data: Data + switch message { + case .string(let text): + guard let stringData = text.data(using: .utf8) else { return nil } + data = stringData + case .data(let rawData): + data = rawData + @unknown default: + return nil + } + + guard case .object(let object) = try JSONDecoder().decode(JSONValue.self, from: data) else { + return nil + } + return object + } + + private func fireMessage(_ message: SlopMessage) { + lock.lock() + let handlers = messageHandlers + if handlers.isEmpty { + pendingMessages.append(message) + lock.unlock() + return + } + lock.unlock() + + for handler in handlers { + handler(message) + } + } + + private func fireClose() { + lock.lock() + guard !didClose else { + lock.unlock() + return + } + didClose = true + let handlers = closeHandlers + lock.unlock() + + for handler in handlers { + handler() + } + } +} + +func messageString(_ message: SlopMessage, _ key: String) -> String? { + message[key]?.stringValue +} + +func messageInt(_ message: SlopMessage, _ key: String) -> Int? { + message[key]?.intValue +} + +func messageUInt64(_ message: SlopMessage, _ key: String) -> UInt64? { + guard let int = message[key]?.intValue, int >= 0 else { return nil } + return UInt64(int) +} + +func messageObject(_ message: SlopMessage, _ key: String) -> [String: JSONValue]? { + message[key]?.objectValue +} + +func messageWindow(_ message: SlopMessage, _ key: String) -> WindowRange? { + guard let array = message[key]?.arrayValue, array.count == 2, let offset = array[0].intValue, let count = array[1].intValue else { + return nil + } + return WindowRange(offset, count) +} + +func messageFilter(_ message: SlopMessage) -> SubscriptionFilter? { + guard let object = messageObject(message, "filter") else { return nil } + let types = object["types"]?.arrayValue?.compactMap(\.stringValue) + let minSalience = object["min_salience"]?.doubleValue + return SubscriptionFilter(types: types, minSalience: minSalience) +} + +func snapshotMessage(id: String, version: UInt64, seq: UInt64? = nil, tree: SlopNode) -> SlopMessage { + var message: SlopMessage = [ + "type": "snapshot", + "id": .string(id), + "version": .number(Double(version)), + "tree": wireJSON(tree), + ] + if let seq { + message["seq"] = .number(Double(seq)) + } + return message +} + +func patchMessage(subscription: String, version: UInt64, seq: UInt64, ops: [PatchOp]) -> SlopMessage { + [ + "type": "patch", + "subscription": .string(subscription), + "version": .number(Double(version)), + "seq": .number(Double(seq)), + "ops": wireJSON(ops), + ] +} + +func errorMessage(id: String?, code: String, message: String) -> SlopMessage { + var result: SlopMessage = [ + "type": "error", + "error": .object([ + "code": .string(code), + "message": .string(message), + ]), + ] + if let id { + result["id"] = .string(id) + } + return result +} + +func resultMessage(id: String, status: String, data: JSONValue? = nil, code: String? = nil, message: String? = nil) -> SlopMessage { + var result: SlopMessage = [ + "type": "result", + "id": .string(id), + "status": .string(status), + ] + if let data { + result["data"] = data + } + if let code, let message { + result["error"] = .object(["code": .string(code), "message": .string(message)]) + } + return result +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/TreeAssembler.swift b/packages/swift/slop-ai/Sources/SlopAI/TreeAssembler.swift new file mode 100644 index 0000000..176f027 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/TreeAssembler.swift @@ -0,0 +1,133 @@ +import Foundation + +public struct AssemblyResult { + public var tree: SlopNode + public var handlers: [String: ActionHandler] +} + +public func assembleTree( + registrations: [String: NodeDescriptor], + rootID: String, + rootName: String +) throws -> AssemblyResult { + try validateNodeID(rootID) + for path in registrations.keys { + for segment in path.split(separator: "/").map(String.init) { + try validateNodeID(segment) + } + } + + var allHandlers: [String: ActionHandler] = [:] + var nodesByPath: [String: SlopNode] = [:] + let sortedPaths = registrations.keys.sorted { lhs, rhs in + let lhsDepth = lhs.split(separator: "/").count + let rhsDepth = rhs.split(separator: "/").count + return lhsDepth == rhsDepth ? lhs < rhs : lhsDepth < rhsDepth + } + + for path in sortedPaths { + guard let descriptor = registrations[path], let id = path.split(separator: "/").last.map(String.init) else { + continue + } + let result = try normalizeDescriptor(path: path, id: id, descriptor: descriptor) + nodesByPath[path] = result.node + allHandlers.merge(result.handlers) { _, new in new } + } + + var root = SlopNode(id: rootID, type: "root", properties: ["label": .string(rootName)], children: []) + for path in sortedPaths { + guard let node = nodesByPath[path] else { continue } + let parentPath = getParentPath(path) + if parentPath.isEmpty { + addChild(parent: &root, child: node) + } else { + _ = ensureNode(path: parentPath, nodesByPath: &nodesByPath, root: &root) + if var parent = nodesByPath[parentPath] { + addChild(parent: &parent, child: node) + nodesByPath[parentPath] = parent + replaceNode(path: parentPath, with: parent, in: &root) + } + } + } + + return AssemblyResult(tree: root, handlers: allHandlers) +} + +private func getParentPath(_ path: String) -> String { + guard let lastSlash = path.lastIndex(of: "/") else { return "" } + return String(path[.. SlopNode { + if let existing = nodesByPath[path] { + return existing + } + + let id = path.split(separator: "/").last.map(String.init) ?? path + var synthetic = SlopNode(id: id, type: "group", children: []) + nodesByPath[path] = synthetic + + let parentPath = getParentPath(path) + if parentPath.isEmpty { + addChild(parent: &root, child: synthetic) + } else { + _ = ensureNode(path: parentPath, nodesByPath: &nodesByPath, root: &root) + if var parent = nodesByPath[parentPath] { + addChild(parent: &parent, child: synthetic) + nodesByPath[parentPath] = parent + replaceNode(path: parentPath, with: parent, in: &root) + } + } + + synthetic = nodesByPath[path] ?? synthetic + return synthetic +} + +private func addChild(parent: inout SlopNode, child: SlopNode) { + if parent.children == nil { + parent.children = [] + } + + guard let existingIndex = parent.children?.firstIndex(where: { $0.id == child.id }) else { + parent.children?.append(child) + return + } + + var replacement = child + let existing = parent.children![existingIndex] + if existing.type == "group", existing.properties == nil { + if let existingChildren = existing.children, !existingChildren.isEmpty { + if replacement.children == nil || replacement.children?.isEmpty == true { + replacement.children = existingChildren + } else { + let childIDs = Set(replacement.children?.map(\.id) ?? []) + for existingChild in existingChildren where !childIDs.contains(existingChild.id) { + replacement.children?.append(existingChild) + } + } + } + } + parent.children![existingIndex] = replacement +} + +private func replaceNode(path: String, with replacement: SlopNode, in root: inout SlopNode) { + let segments = path.split(separator: "/").map(String.init) + replaceNode(segments: segments, with: replacement, in: &root) +} + +private func replaceNode(segments: [String], with replacement: SlopNode, in node: inout SlopNode) { + guard !segments.isEmpty else { + node = replacement + return + } + guard var children = node.children, let index = children.firstIndex(where: { $0.id == segments[0] }) else { + return + } + if segments.count == 1 { + children[index] = replacement + } else { + replaceNode(segments: Array(segments.dropFirst()), with: replacement, in: &children[index]) + } + node.children = children +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/Types.swift b/packages/swift/slop-ai/Sources/SlopAI/Types.swift new file mode 100644 index 0000000..81b9645 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/Types.swift @@ -0,0 +1,338 @@ +import Foundation + +public enum SlopError: Error, Equatable, LocalizedError { + case invalidNodeId(String) + case duplicateNodeId(String) + case notFound(String) + case invalidParams(String) + case subscriptionGap(expected: UInt64, received: UInt64) + case internalError(String) + + public var errorDescription: String? { + switch self { + case .invalidNodeId(let message), .duplicateNodeId(let message), .notFound(let message), .invalidParams(let message), .internalError(let message): + return message + case .subscriptionGap(let expected, let received): + return "SLOP subscription gap: expected seq \(expected), got \(received)" + } + } +} + +public enum Urgency: String, Codable, Equatable { + case none + case low + case medium + case high + case critical +} + +public enum ActionEstimate: String, Codable, Equatable { + case instant + case fast + case slow + case async +} + +public enum ContentRefType: String, Codable, Equatable { + case text + case binary + case stream +} + +public struct WindowRange: Codable, Equatable { + public var offset: Int + public var count: Int + + public init(_ offset: Int, _ count: Int) { + self.offset = offset + self.count = count + } + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + offset = try container.decode(Int.self) + count = try container.decode(Int.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(offset) + try container.encode(count) + } +} + +public struct NodeMeta: Codable, Equatable { + public var summary: String? + public var salience: Double? + public var pinned: Bool? + public var changed: Bool? + public var focus: Bool? + public var urgency: Urgency? + public var reason: String? + public var totalChildren: Int? + public var window: WindowRange? + public var created: String? + public var updated: String? + + enum CodingKeys: String, CodingKey { + case summary + case salience + case pinned + case changed + case focus + case urgency + case reason + case totalChildren = "total_children" + case window + case created + case updated + } + + public init( + summary: String? = nil, + salience: Double? = nil, + pinned: Bool? = nil, + changed: Bool? = nil, + focus: Bool? = nil, + urgency: Urgency? = nil, + reason: String? = nil, + totalChildren: Int? = nil, + window: WindowRange? = nil, + created: String? = nil, + updated: String? = nil + ) { + self.summary = summary + self.salience = salience + self.pinned = pinned + self.changed = changed + self.focus = focus + self.urgency = urgency + self.reason = reason + self.totalChildren = totalChildren + self.window = window + self.created = created + self.updated = updated + } + + var isEmpty: Bool { + summary == nil && + salience == nil && + pinned == nil && + changed == nil && + focus == nil && + urgency == nil && + reason == nil && + totalChildren == nil && + window == nil && + created == nil && + updated == nil + } +} + +public final class JSONSchema: Codable, Equatable { + public var type: String + public var properties: [String: JSONSchema]? + public var required: [String]? + public var items: JSONSchema? + public var description: String? + public var defaultValue: JSONValue? + public var enumValues: [JSONValue]? + + enum CodingKeys: String, CodingKey { + case type + case properties + case required + case items + case description + case defaultValue = "default" + case enumValues = "enum" + } + + public init( + type: String, + properties: [String: JSONSchema]? = nil, + required: [String]? = nil, + items: JSONSchema? = nil, + description: String? = nil, + defaultValue: JSONValue? = nil, + enumValues: [JSONValue]? = nil + ) { + self.type = type + self.properties = properties + self.required = required + self.items = items + self.description = description + self.defaultValue = defaultValue + self.enumValues = enumValues + } + + public static func == (lhs: JSONSchema, rhs: JSONSchema) -> Bool { + lhs.type == rhs.type && + lhs.properties == rhs.properties && + lhs.required == rhs.required && + lhs.items == rhs.items && + lhs.description == rhs.description && + lhs.defaultValue == rhs.defaultValue && + lhs.enumValues == rhs.enumValues + } +} + +public struct Affordance: Codable, Equatable { + public var action: String + public var label: String? + public var description: String? + public var params: JSONSchema? + public var dangerous: Bool? + public var idempotent: Bool? + public var estimate: ActionEstimate? + + public init( + action: String, + label: String? = nil, + description: String? = nil, + params: JSONSchema? = nil, + dangerous: Bool? = nil, + idempotent: Bool? = nil, + estimate: ActionEstimate? = nil + ) { + self.action = action + self.label = label + self.description = description + self.params = params + self.dangerous = dangerous + self.idempotent = idempotent + self.estimate = estimate + } +} + +public struct ContentRef: Codable, Equatable { + public var type: ContentRefType + public var mime: String + public var summary: String + public var size: Int? + public var uri: String? + public var preview: String? + public var encoding: String? + public var hash: String? + + public init( + type: ContentRefType, + mime: String, + summary: String, + size: Int? = nil, + uri: String? = nil, + preview: String? = nil, + encoding: String? = nil, + hash: String? = nil + ) { + self.type = type + self.mime = mime + self.summary = summary + self.size = size + self.uri = uri + self.preview = preview + self.encoding = encoding + self.hash = hash + } +} + +public struct SlopNode: Codable, Equatable { + public var id: String + public var type: String + public var properties: [String: JSONValue]? + public var children: [SlopNode]? + public var affordances: [Affordance]? + public var meta: NodeMeta? + public var contentRef: ContentRef? + + enum CodingKeys: String, CodingKey { + case id + case type + case properties + case children + case affordances + case meta + case contentRef = "content_ref" + } + + public init( + id: String, + type: String, + properties: [String: JSONValue]? = nil, + children: [SlopNode]? = nil, + affordances: [Affordance]? = nil, + meta: NodeMeta? = nil, + contentRef: ContentRef? = nil + ) { + self.id = id + self.type = type + self.properties = properties + self.children = children + self.affordances = affordances + self.meta = meta + self.contentRef = contentRef + } +} + +public enum PatchOperation: String, Codable, Equatable { + case add + case remove + case replace + case move +} + +public struct PatchOp: Codable, Equatable { + public var op: PatchOperation + public var path: String + public var value: JSONValue? + public var index: Int? + + public init(op: PatchOperation, path: String, value: JSONValue? = nil, index: Int? = nil) { + self.op = op + self.path = path + self.value = value + self.index = index + } +} + +public struct SnapshotMessage: Equatable { + public var id: String + public var version: UInt64 + public var seq: UInt64? + public var tree: SlopNode + + public init(id: String, version: UInt64, seq: UInt64? = nil, tree: SlopNode) { + self.id = id + self.version = version + self.seq = seq + self.tree = tree + } +} + +public struct PatchMessage: Equatable { + public var subscription: String + public var version: UInt64 + public var seq: UInt64 + public var ops: [PatchOp] + + public init(subscription: String, version: UInt64, seq: UInt64, ops: [PatchOp]) { + self.subscription = subscription + self.version = version + self.seq = seq + self.ops = ops + } +} + +public struct ResultMessage: Equatable { + public var id: String + public var status: String + public var data: JSONValue? + public var error: [String: JSONValue]? + + public init(id: String, status: String, data: JSONValue? = nil, error: [String: JSONValue]? = nil) { + self.id = id + self.status = status + self.data = data + self.error = error + } +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/UnixSocketClientTransport.swift b/packages/swift/slop-ai/Sources/SlopAI/UnixSocketClientTransport.swift new file mode 100644 index 0000000..4da84a7 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/UnixSocketClientTransport.swift @@ -0,0 +1,212 @@ +#if canImport(Darwin) +import Darwin +import Foundation + +public final class UnixSocketClientTransport: ClientTransport { + private let path: String + + public init(path: String) { + self.path = path + } + + public func connect() async throws -> SlopConnection { + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw SlopError.internalError("Unix socket create failed: \(unixErrnoDescription())") + } + + do { + try connect(fd: fd, path: path) + } catch { + Darwin.close(fd) + throw error + } + + let connection = UnixSocketConnection(fileDescriptor: fd) + connection.startReading() + return connection + } + + private func connect(fd: Int32, path: String) throws { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + + let pathBytes = Array(path.utf8) + [0] + let maxPathLength = MemoryLayout.size(ofValue: address.sun_path) + guard pathBytes.count <= maxPathLength else { + throw SlopError.internalError("Unix socket path is too long: \(path)") + } + + withUnsafeMutableBytes(of: &address.sun_path) { rawBuffer in + rawBuffer.copyBytes(from: pathBytes) + } + + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.connect(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + + guard result == 0 else { + throw SlopError.internalError("Unix socket connect failed at \(path): \(unixErrnoDescription())") + } + } +} + +public final class UnixSocketConnection: SlopConnection { + private let fd: Int32 + private let lock = NSLock() + private let ioLock = NSLock() + private let writeQueue = DispatchQueue(label: "dev.slop.unix-socket.write") + private var messageHandlers: [(SlopMessage) -> Void] = [] + private var closeHandlers: [() -> Void] = [] + private var pendingMessages: [SlopMessage] = [] + private var didClose = false + + init(fileDescriptor: Int32) { + fd = fileDescriptor + } + + public func send(_ message: SlopMessage) { + writeQueue.async { [weak self] in + guard let self else { return } + do { + var data = try JSONEncoder().encode(JSONValue.object(message)) + data.append(0x0A) + try self.writeAll(data) + } catch { + self.fireClose() + } + } + } + + public func onMessage(_ handler: @escaping (SlopMessage) -> Void) { + lock.lock() + messageHandlers.append(handler) + let pending = pendingMessages + pendingMessages.removeAll() + lock.unlock() + + for message in pending { + handler(message) + } + } + + public func onClose(_ handler: @escaping () -> Void) { + lock.lock() + let alreadyClosed = didClose + if !alreadyClosed { + closeHandlers.append(handler) + } + lock.unlock() + if alreadyClosed { + handler() + } + } + + public func close() { + fireClose() + } + + func startReading() { + DispatchQueue.global(qos: .utility).async { [weak self] in + self?.readLoop() + } + } + + private func readLoop() { + var pending = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + + while !isClosed { + let count = Darwin.read(fd, &buffer, buffer.count) + if count <= 0 { + break + } + pending.append(buffer, count: count) + + while let newline = pending.firstIndex(of: 0x0A) { + let line = pending[.. String { + String(cString: strerror(errno)) +} +#endif diff --git a/packages/swift/slop-ai/Sources/SlopAI/UnixSocketProviderTransport.swift b/packages/swift/slop-ai/Sources/SlopAI/UnixSocketProviderTransport.swift new file mode 100644 index 0000000..796fb13 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/UnixSocketProviderTransport.swift @@ -0,0 +1,248 @@ +#if canImport(Darwin) +import Darwin +import Foundation + +private struct UnixSocketFileIdentity: Equatable { + var device: UInt64 + var inode: UInt64 +} + +public final class UnixSocketProviderTransport { + private let server: SlopServer + private let path: String + private let acceptQueue = DispatchQueue(label: "dev.slop.unix-socket-provider.accept", qos: .utility) + private let stateLock = NSLock() + private let beforeQuarantineInspection: (() -> Void)? + private var listenerFD: Int32 = -1 + private var isRunning = false + private var discoveryRegistration: ProviderRegistration? + private var socketIdentity: UnixSocketFileIdentity? + + public init(server: SlopServer, path: String) { + self.server = server + self.path = path + beforeQuarantineInspection = nil + } + + init(server: SlopServer, path: String, beforeQuarantineInspection: @escaping () -> Void) { + self.server = server + self.path = path + self.beforeQuarantineInspection = beforeQuarantineInspection + } + + public func start(discover: Bool = false, discoveryDirectory: URL = Discovery.defaultProviderDirectories[0]) throws { + stateLock.lock() + defer { stateLock.unlock() } + guard !isRunning else { return } + let parentDirectory = URL(fileURLWithPath: path).deletingLastPathComponent() + try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true) + try validateSocketParentDirectory(parentDirectory) + + try requireAbsentSocketPath() + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw SlopError.internalError("Unix provider socket create failed: \(unixErrnoDescription())") + } + + var createdIdentity: UnixSocketFileIdentity? + var discoveryRegistration: ProviderRegistration? + do { + try bindAndListen(fd: fd, identity: &createdIdentity) + guard Darwin.chmod(path, 0o600) == 0 else { + throw SlopError.internalError("Unix provider socket permission hardening failed at \(path): \(unixErrnoDescription())") + } + guard let createdIdentity, try socketIdentityAtPath() == createdIdentity else { + throw SlopError.internalError("Unix provider socket path was replaced during startup at \(path)") + } + if discover { + discoveryRegistration = try Discovery.registerUnixProvider( + id: server.id, + name: server.name, + socketPath: path, + directory: discoveryDirectory + ) + } + } catch { + Darwin.close(fd) + if let createdIdentity { + unlinkSocketIfMatches(createdIdentity) + } + throw error + } + + listenerFD = fd + isRunning = true + socketIdentity = createdIdentity + self.discoveryRegistration = discoveryRegistration + + acceptQueue.async { [weak self] in + self?.acceptLoop() + } + } + + public func stop() { + stateLock.lock() + guard isRunning else { + stateLock.unlock() + return + } + isRunning = false + let fd = listenerFD + listenerFD = -1 + let discoveryRegistration = self.discoveryRegistration + self.discoveryRegistration = nil + let socketIdentity = self.socketIdentity + self.socketIdentity = nil + stateLock.unlock() + if fd >= 0 { + Darwin.shutdown(fd, SHUT_RDWR) + Darwin.close(fd) + } + if let socketIdentity { + unlinkSocketIfMatches(socketIdentity) + } + if let discoveryRegistration { + Discovery.unregisterProvider(discoveryRegistration) + } + } + + deinit { + stop() + } + + private func bindAndListen(fd: Int32, identity: inout UnixSocketFileIdentity?) throws { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + + let pathBytes = Array(path.utf8) + [0] + let maxPathLength = MemoryLayout.size(ofValue: address.sun_path) + guard pathBytes.count <= maxPathLength else { + throw SlopError.internalError("Unix provider socket path is too long: \(path)") + } + + withUnsafeMutableBytes(of: &address.sun_path) { rawBuffer in + rawBuffer.copyBytes(from: pathBytes) + } + + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + + guard bindResult == 0 else { + throw SlopError.internalError("Unix provider socket bind failed at \(path): \(unixErrnoDescription())") + } + identity = try socketIdentityAtPath() + guard Darwin.listen(fd, SOMAXCONN) == 0 else { + throw SlopError.internalError("Unix provider socket listen failed at \(path): \(unixErrnoDescription())") + } + } + + private func requireAbsentSocketPath() throws { + var statBuffer = stat() + if Darwin.lstat(path, &statBuffer) != 0 { + guard errno == ENOENT else { + throw SlopError.internalError("Could not inspect existing Unix provider socket path at \(path): \(unixErrnoDescription())") + } + return + } + throw SlopError.internalError("Refusing to replace an existing file at Unix provider socket path \(path)") + } + + private func socketIdentityAtPath() throws -> UnixSocketFileIdentity { + try socketIdentity(at: path) + } + + private func socketIdentity(at candidatePath: String) throws -> UnixSocketFileIdentity { + var statBuffer = stat() + guard Darwin.lstat(candidatePath, &statBuffer) == 0 else { + throw SlopError.internalError("Could not inspect Unix provider socket at \(candidatePath): \(unixErrnoDescription())") + } + let mode = Int(statBuffer.st_mode) + guard (mode & Int(S_IFMT)) == Int(S_IFSOCK), statBuffer.st_uid == Darwin.getuid() else { + throw SlopError.internalError("Unix provider socket path is not an owned socket at \(candidatePath)") + } + return UnixSocketFileIdentity(device: UInt64(statBuffer.st_dev), inode: UInt64(statBuffer.st_ino)) + } + + private func fileIdentity(at candidatePath: String) -> UnixSocketFileIdentity? { + var statBuffer = stat() + guard Darwin.lstat(candidatePath, &statBuffer) == 0 else { return nil } + return UnixSocketFileIdentity(device: UInt64(statBuffer.st_dev), inode: UInt64(statBuffer.st_ino)) + } + + private func unlinkSocketIfMatches(_ identity: UnixSocketFileIdentity) { + let quarantine = "\(path).slop-remove-\(UUID().uuidString)" + guard Darwin.rename(path, quarantine) == 0 else { return } + beforeQuarantineInspection?() + guard let movedIdentity = fileIdentity(at: quarantine) else { return } + if movedIdentity == identity { + Darwin.unlink(quarantine) + return + } + if Darwin.link(quarantine, path) == 0 { + Darwin.unlink(quarantine) + } else if errno == EEXIST { + Darwin.unlink(quarantine) + } + } + + private func validateSocketParentDirectory(_ directory: URL) throws { + let fd = Darwin.open(directory.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW) + guard fd >= 0 else { + throw SlopError.internalError("Could not safely open Unix provider socket directory at \(directory.path)") + } + defer { Darwin.close(fd) } + + var statBuffer = stat() + guard Darwin.fstat(fd, &statBuffer) == 0 else { + throw SlopError.internalError("Could not inspect Unix provider socket directory at \(directory.path)") + } + let mode = Int(statBuffer.st_mode) + guard (mode & Int(S_IFMT)) == Int(S_IFDIR), statBuffer.st_uid == Darwin.getuid() else { + throw SlopError.internalError("Unix provider socket directory must be an owned real directory at \(directory.path)") + } + guard mode & 0o022 == 0 else { + throw SlopError.internalError("Unix provider socket directory must not be group- or world-writable at \(directory.path)") + } + } + + private func acceptLoop() { + while true { + stateLock.lock() + let listenerFD = isRunning ? self.listenerFD : -1 + stateLock.unlock() + guard listenerFD >= 0 else { return } + + let fd = Darwin.accept(listenerFD, nil, nil) + if fd < 0 { + stateLock.lock() + let shouldContinue = isRunning + stateLock.unlock() + if shouldContinue { + continue + } + return + } + + let connection = UnixSocketConnection(fileDescriptor: fd) + server.attachConnection(connection) + connection.startReading() + } + } +} + +extension SlopServer { + @discardableResult + public func listenUnix( + path: String, + discover: Bool = false, + discoveryDirectory: URL = Discovery.defaultProviderDirectories[0] + ) throws -> UnixSocketProviderTransport { + let transport = UnixSocketProviderTransport(server: self, path: path) + try transport.start(discover: discover, discoveryDirectory: discoveryDirectory) + return transport + } +} +#endif diff --git a/packages/swift/slop-ai/Sources/SlopAI/ValidateParams.swift b/packages/swift/slop-ai/Sources/SlopAI/ValidateParams.swift new file mode 100644 index 0000000..07af890 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/ValidateParams.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Minimal JSON Schema validator for invoke params, matching the SDK subset. +public func validateParams(schema: JSONSchema?, params: JSONValue) -> String? { + guard let schema else { return nil } + return validate(schema, value: params, path: "params") +} + +private func validate(_ schema: JSONSchema, value: JSONValue, path: String) -> String? { + if let enumValues = schema.enumValues, !enumValues.contains(value) { + return "\(path) must be one of \(canonicalJSON(.array(enumValues)))" + } + + switch schema.type { + case "object": + guard case .object(let object) = value else { + return "\(path) must be an object" + } + for key in schema.required ?? [] where object[key] == nil { + return "\(path).\(key) is required" + } + for (key, propertySchema) in schema.properties ?? [:] { + if let propertyValue = object[key], let error = validate(propertySchema, value: propertyValue, path: "\(path).\(key)") { + return error + } + } + return nil + case "array": + guard case .array(let array) = value else { + return "\(path) must be an array" + } + if let itemSchema = schema.items { + for (index, item) in array.enumerated() { + if let error = validate(itemSchema, value: item, path: "\(path)[\(index)]") { + return error + } + } + } + return nil + case "string": + return value.stringValue == nil ? "\(path) must be a string" : nil + case "number": + return value.doubleValue == nil ? "\(path) must be a number" : nil + case "integer": + guard let number = value.doubleValue, number.rounded(.towardZero) == number else { + return "\(path) must be an integer" + } + return nil + case "boolean": + return value.boolValue == nil ? "\(path) must be a boolean" : nil + case "null": + return value == .null ? nil : "\(path) must be null" + default: + return nil + } +} diff --git a/packages/swift/slop-ai/Sources/SlopAI/WebSocketProviderTransport.swift b/packages/swift/slop-ai/Sources/SlopAI/WebSocketProviderTransport.swift new file mode 100644 index 0000000..0f07298 --- /dev/null +++ b/packages/swift/slop-ai/Sources/SlopAI/WebSocketProviderTransport.swift @@ -0,0 +1,475 @@ +import Foundation +import NIOCore +import NIOHTTP1 +import NIOPosix +import NIOWebSocket + +public typealias WebSocketUpgradeAuthenticator = (HTTPRequestHead, SocketAddress?) -> Bool + +public struct WebSocketProviderOptions { + public var host: String + public var port: Int + public var path: String + public var discovery: Bool + public var allowedOrigins: [String]? + public var authenticate: WebSocketUpgradeAuthenticator? + + public init( + host: String = "127.0.0.1", + port: Int = 0, + path: String = "/slop", + discovery: Bool = true, + allowedOrigins: [String]? = nil, + authenticate: WebSocketUpgradeAuthenticator? = nil + ) { + self.host = host + self.port = port + self.path = path + self.discovery = discovery + self.allowedOrigins = allowedOrigins + self.authenticate = authenticate + } +} + +public final class WebSocketProviderTransport { + private let server: SlopServer + private let options: WebSocketProviderOptions + private var group: EventLoopGroup? + private var channel: Channel? + private var isRunning = false + + public init(server: SlopServer, options: WebSocketProviderOptions = WebSocketProviderOptions()) { + self.server = server + self.options = options + } + + public var url: String? { + guard let port = channel?.localAddress?.port else { return nil } + return "ws://\(options.host):\(port)\(options.path)" + } + + public var port: Int? { + channel?.localAddress?.port + } + + public func start() throws { + guard !isRunning else { return } + let group = MultiThreadedEventLoopGroup(numberOfThreads: max(1, System.coreCount)) + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { [server, options] channel in + let discoveryHandlerName = "SlopWebSocketDiscoveryHTTPHandler" + let upgradeGuardName = "SlopWebSocketUpgradeGuardHandler" + let upgrader = NIOWebSocketServerUpgrader( + maxFrameSize: 1 << 20, + shouldUpgrade: { channel, _ in + channel.eventLoop.makeSucceededFuture(HTTPHeaders()) + }, + upgradePipelineHandler: { channel, _ in + channel.pipeline.removeHandler(name: discoveryHandlerName).flatMap { + channel.pipeline.removeHandler(name: upgradeGuardName) + }.flatMap { + do { + try channel.pipeline.syncOperations.addHandler(makeWebSocketFrameAggregator()) + return channel.pipeline.addHandler(WebSocketProviderHandler(server: server)) + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + } + } + ) + let upgradeConfig = NIOHTTPServerUpgradeConfiguration( + upgraders: [upgrader], + completionHandler: { _ in } + ) + return channel.pipeline.configureHTTPServerPipeline( + withPipeliningAssistance: false, + withServerUpgrade: upgradeConfig + ).flatMap { + do { + let upgradeContext = try channel.pipeline.syncOperations.context(handlerType: HTTPServerUpgradeHandler.self) + try channel.pipeline.syncOperations.addHandler( + WebSocketUpgradeGuardHandler( + path: options.path, + allowedOrigins: options.allowedOrigins, + authenticate: options.authenticate + ), + name: upgradeGuardName, + position: .before(upgradeContext.handler) + ) + return channel.eventLoop.makeSucceededVoidFuture() + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + }.flatMap { + channel.pipeline.addHandler(WebSocketDiscoveryHTTPHandler(server: server, options: options), name: discoveryHandlerName) + } + } + .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + + do { + channel = try bootstrap.bind(host: options.host, port: options.port).wait() + self.group = group + isRunning = true + } catch { + try? group.syncShutdownGracefully() + throw SlopError.internalError("WebSocket provider listen failed: \(error.localizedDescription)") + } + } + + public func stop() { + guard isRunning else { return } + isRunning = false + try? channel?.close().wait() + channel = nil + try? group?.syncShutdownGracefully() + group = nil + } + + deinit { + stop() + } +} + +public final class NIOWebSocketConnection: SlopConnection { + private let channel: Channel + private let lock = NSLock() + private var messageHandlers: [(SlopMessage) -> Void] = [] + private var closeHandlers: [() -> Void] = [] + private var pendingMessages: [SlopMessage] = [] + private var didClose = false + + init(channel: Channel) { + self.channel = channel + } + + public func send(_ message: SlopMessage) { + do { + let data = try JSONEncoder().encode(JSONValue.object(message)) + let text = String(decoding: data, as: UTF8.self) + let buffer = channel.allocator.buffer(string: text) + let frame = WebSocketFrame(fin: true, opcode: .text, data: buffer) + channel.writeAndFlush(frame, promise: nil) + } catch { + fireClose() + } + } + + public func onMessage(_ handler: @escaping (SlopMessage) -> Void) { + lock.lock() + messageHandlers.append(handler) + let pending = pendingMessages + pendingMessages.removeAll() + lock.unlock() + + for message in pending { + handler(message) + } + } + + public func onClose(_ handler: @escaping () -> Void) { + lock.lock() + let alreadyClosed = didClose + if !alreadyClosed { + closeHandlers.append(handler) + } + lock.unlock() + if alreadyClosed { + handler() + } + } + + public func close() { + lock.lock() + let alreadyClosed = didClose + lock.unlock() + guard !alreadyClosed else { return } + + let buffer = channel.allocator.buffer(capacity: 0) + let frame = WebSocketFrame(fin: true, opcode: .connectionClose, data: buffer) + channel.writeAndFlush(frame, promise: nil) + channel.close(promise: nil) + fireClose() + } + + func receive(_ message: SlopMessage) { + lock.lock() + let handlers = messageHandlers + if handlers.isEmpty { + pendingMessages.append(message) + lock.unlock() + return + } + lock.unlock() + + for handler in handlers { + handler(message) + } + } + + func fireClose() { + lock.lock() + guard !didClose else { + lock.unlock() + return + } + didClose = true + let handlers = closeHandlers + lock.unlock() + + for handler in handlers { + handler() + } + } +} + +final class WebSocketProviderHandler: ChannelDuplexHandler { + typealias InboundIn = WebSocketFrame + typealias OutboundIn = WebSocketFrame + typealias OutboundOut = WebSocketFrame + + private weak var server: SlopServer? + private var connection: NIOWebSocketConnection? + + init(server: SlopServer) { + self.server = server + } + + func handlerAdded(context: ChannelHandlerContext) { + let connection = NIOWebSocketConnection(channel: context.channel) + self.connection = connection + server?.attachConnection(connection) + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let frame = unwrapInboundIn(data) + switch frame.opcode { + case .ping: + let pong = WebSocketFrame(fin: true, opcode: .pong, data: frame.unmaskedData) + context.writeAndFlush(wrapOutboundOut(pong), promise: nil) + case .text: + var payload = frame.unmaskedData + guard + let text = payload.readString(length: payload.readableBytes), + let data = text.data(using: .utf8), + case .object(let message) = try? JSONDecoder().decode(JSONValue.self, from: data) + else { + return + } + connection?.receive(message) + case .binary: + var payload = frame.unmaskedData + guard + let bytes = payload.readBytes(length: payload.readableBytes), + case .object(let message) = try? JSONDecoder().decode(JSONValue.self, from: Data(bytes)) + else { + return + } + connection?.receive(message) + case .connectionClose: + connection?.fireClose() + context.close(promise: nil) + default: + break + } + } + + func channelInactive(context: ChannelHandlerContext) { + if let connection { + server?.handleDisconnect(connection) + connection.fireClose() + } + } +} + +final class WebSocketUpgradeGuardHandler: ChannelInboundHandler, RemovableChannelHandler { + typealias InboundIn = HTTPServerRequestPart + typealias OutboundOut = HTTPServerResponsePart + + private let path: String + private let allowedOrigins: [String]? + private let authenticate: WebSocketUpgradeAuthenticator? + private var rejecting = false + + init( + path: String, + allowedOrigins: [String]?, + authenticate: WebSocketUpgradeAuthenticator? + ) { + self.path = path + self.allowedOrigins = allowedOrigins + self.authenticate = authenticate + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let part = unwrapInboundIn(data) + switch part { + case .head(let head): + let isWebSocketUpgrade = head.headers.first(name: "Upgrade")?.lowercased() == "websocket" + if isWebSocketUpgrade, + !isAllowedWebSocketUpgrade( + head, + remoteAddress: context.channel.remoteAddress, + path: path, + allowedOrigins: allowedOrigins, + authenticate: authenticate + ) { + rejecting = true + let status: HTTPResponseStatus = requestPath(head.uri) == path ? .forbidden : .notFound + sendResponse(status: status, context: context) + return + } + context.fireChannelRead(data) + case .body, .end: + if !rejecting { + context.fireChannelRead(data) + } + } + } + + private func sendResponse(status: HTTPResponseStatus, context: ChannelHandlerContext) { + let body = status == .forbidden ? "Forbidden" : "Not Found" + let buffer = context.channel.allocator.buffer(string: body) + var headers = HTTPHeaders() + headers.add(name: "Content-Type", value: "text/plain; charset=utf-8") + headers.add(name: "Content-Length", value: "\(buffer.readableBytes)") + headers.add(name: "Connection", value: "close") + let head = HTTPResponseHead(version: .http1_1, status: status, headers: headers) + context.write(wrapOutboundOut(.head(head)), promise: nil) + context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) + context.writeAndFlush(wrapOutboundOut(.end(nil))).whenComplete { _ in + context.close(promise: nil) + } + } +} + +private final class WebSocketDiscoveryHTTPHandler: ChannelInboundHandler, RemovableChannelHandler { + typealias InboundIn = HTTPServerRequestPart + typealias OutboundOut = HTTPServerResponsePart + + private weak var server: SlopServer? + private let options: WebSocketProviderOptions + private var head: HTTPRequestHead? + + init(server: SlopServer, options: WebSocketProviderOptions) { + self.server = server + self.options = options + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch unwrapInboundIn(data) { + case .head(let head): + self.head = head + case .body: + break + case .end: + guard let head else { + sendResponse(status: .badRequest, body: "Bad Request", context: context) + return + } + if options.discovery, requestPath(head.uri) == "/.well-known/slop", let server { + let host = head.headers.first(name: "Host") ?? "\(options.host):\(context.channel.localAddress?.port ?? options.port)" + let descriptor = ProviderDescriptor( + id: server.id, + name: server.name, + slopVersion: "0.1", + transport: ProviderTransport(type: .ws, url: "ws://\(host)\(options.path)"), + pid: Int(ProcessInfo.processInfo.processIdentifier), + capabilities: ["state", "patches", "affordances", "attention", "windowing", "async", "content_refs"] + ) + let body = (try? JSONEncoder().encode(descriptor)) ?? Data() + sendResponse(status: .ok, body: body, contentType: "application/json", context: context) + } else if requestPath(head.uri) == options.path, head.headers.first(name: "Upgrade")?.lowercased() == "websocket" { + sendResponse(status: .forbidden, body: "Forbidden", context: context) + } else { + sendResponse(status: .notFound, body: "Not Found", context: context) + } + self.head = nil + } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + if error is NIOWebSocketUpgradeError { + sendResponse(status: .forbidden, body: "Forbidden", context: context) + } else { + context.close(promise: nil) + } + } + + private func sendResponse(status: HTTPResponseStatus, body: String, context: ChannelHandlerContext) { + sendResponse(status: status, body: Data(body.utf8), contentType: "text/plain; charset=utf-8", context: context) + } + + private func sendResponse(status: HTTPResponseStatus, body: Data, contentType: String, context: ChannelHandlerContext) { + var buffer = context.channel.allocator.buffer(capacity: body.count) + buffer.writeBytes(body) + + var headers = HTTPHeaders() + headers.add(name: "Content-Type", value: contentType) + headers.add(name: "Content-Length", value: "\(buffer.readableBytes)") + headers.add(name: "Connection", value: "close") + let responseHead = HTTPResponseHead(version: head?.version ?? .http1_1, status: status, headers: headers) + context.write(wrapOutboundOut(.head(responseHead)), promise: nil) + context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) + context.writeAndFlush(wrapOutboundOut(.end(nil))).whenComplete { _ in + context.close(promise: nil) + } + } +} + +func makeWebSocketFrameAggregator() -> NIOWebSocketFrameAggregator { + NIOWebSocketFrameAggregator( + minNonFinalFragmentSize: 1, + maxAccumulatedFrameCount: 128, + maxAccumulatedFrameSize: 1 << 20 + ) +} + +extension SlopServer { + @discardableResult + public func listenWebSocket(options: WebSocketProviderOptions = WebSocketProviderOptions()) throws -> WebSocketProviderTransport { + let transport = WebSocketProviderTransport(server: self, options: options) + try transport.start() + return transport + } +} + +func isAllowedWebSocketUpgrade( + _ head: HTTPRequestHead, + remoteAddress: SocketAddress?, + path: String, + allowedOrigins: [String]?, + authenticate: WebSocketUpgradeAuthenticator? +) -> Bool { + guard requestPath(head.uri) == path else { + return false + } + + if let origin = head.headers.first(name: "Origin") { + guard let allowedOrigins, allowedOrigins.contains(origin) else { + return false + } + } + + if let authenticate { + return authenticate(head, remoteAddress) + } + + return isLoopback(remoteAddress) +} + +private func isLoopback(_ address: SocketAddress?) -> Bool { + guard let ipAddress = address?.ipAddress else { return false } + return ipAddress == "127.0.0.1" + || ipAddress == "::1" + || ipAddress == "0:0:0:0:0:0:0:1" + || ipAddress == "::ffff:127.0.0.1" +} + +func requestPath(_ uri: String) -> String { + if let components = URLComponents(string: uri), !components.path.isEmpty { + return components.path + } + return String(uri.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first ?? "/") +} diff --git a/packages/swift/slop-ai/Tests/SlopAITests/AppIntentsAdapterTests.swift b/packages/swift/slop-ai/Tests/SlopAITests/AppIntentsAdapterTests.swift new file mode 100644 index 0000000..a2d386b --- /dev/null +++ b/packages/swift/slop-ai/Tests/SlopAITests/AppIntentsAdapterTests.swift @@ -0,0 +1,133 @@ +#if canImport(AppIntents) +import AppIntents +import XCTest +@testable import SlopAI + +@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +private struct AdapterTestEntity: AppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Adapter Todo" + static var defaultQuery = AdapterTestEntityQuery() + + var id: String + var title: String + var completed: Bool + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: LocalizedStringResource(stringLiteral: title), + subtitle: completed ? "Completed" : "Open" + ) + } +} + +@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +private struct AdapterTestEntityQuery: EntityQuery { + init() {} + + func entities(for identifiers: [String]) async throws -> [AdapterTestEntity] { + identifiers.map { AdapterTestEntity(id: $0, title: "Resolved \($0)", completed: false) } + } +} + +@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +private struct AdapterEchoIntent: AppIntent { + static var title: LocalizedStringResource = "Echo entity" + + var text: String + + init() { + text = "default" + } + + init(text: String) { + self.text = text + } + + func perform() async throws -> some IntentResult & ReturnsValue { + .result(value: text) + } +} + +@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +final class AppIntentsAdapterTests: XCTestCase { + func testEntityProjectionPreservesSemanticsAndProducesValidIDs() throws { + let entity = AdapterTestEntity(id: "todos/open~1", title: "Ship adapter", completed: false) + let adapter = AppEntityAdapter( + type: "todos:todo", + properties: { ["completed": .bool($0.completed)] }, + summary: { "\($0.title), \($0.completed ? "done" : "open")" } + ) + + let item = adapter.itemDescriptor(for: entity) + + XCTAssertEqual(item.type, "todos:todo") + XCTAssertEqual(item.props?["label"], "Ship adapter") + XCTAssertEqual(item.props?["description"], "Open") + XCTAssertEqual(item.props?["completed"], false) + XCTAssertEqual(item.props?["app_intents_identifier"], "todos/open~1") + XCTAssertEqual(item.summary, "Ship adapter, open") + XCTAssertFalse(item.id.contains("/")) + XCTAssertFalse(item.id.contains("~")) + XCTAssertNoThrow(try validateNodeID(item.id)) + } + + func testAppIntentActionExecutesTypedIntentAndMapsResult() async throws { + let action = Action.appIntent( + AdapterEchoIntent.self, + params: ["text": .type("string")], + makeIntent: { params in + AdapterEchoIntent(text: params["text"]?.stringValue ?? "") + }, + mapResult: { result in + result.value.map(JSONValue.string) + } + ) + + XCTAssertEqual(action.label, "Echo entity") + let result = try await action.handler(["text": "hello"]) + XCTAssertEqual(result, .value("hello")) + } + + func testServerRegistersOrderedAppEntityCollection() throws { + let entities = [ + AdapterTestEntity(id: "first", title: "First", completed: false), + AdapterTestEntity(id: "second", title: "Second", completed: true), + ] + let adapter = AppEntityAdapter(type: "todos:todo") + let server = SlopServer(id: "todos", name: "Todos") + + try server.registerAppEntities( + "list", + entities: entities, + adapter: adapter, + properties: ["count": 2] + ) + + let collection = try XCTUnwrap(server.tree.children?.first) + XCTAssertEqual(collection.type, "collection") + XCTAssertEqual(collection.properties?["count"], 2) + XCTAssertEqual(collection.children?.map(\.type), ["todos:todo", "todos:todo"]) + XCTAssertEqual(collection.children?.compactMap { $0.properties?["label"]?.stringValue }, ["First", "Second"]) + } + +#if compiler(>=6.4) + @available(macOS 27.0, iOS 27.0, watchOS 27.0, tvOS 27.0, *) + func testEntityCollectionRegistrationResolvesIdentifiers() async throws { + let adapter = AppEntityAdapter(type: "todos:todo") + let server = SlopServer(id: "todos", name: "Todos") + let collection = EntityCollection(identifiers: ["one", "two"]) + + try await server.registerAppEntityCollection( + "resolved", + collection: collection, + adapter: adapter + ) + + let labels = server.tree.children?.first?.children?.compactMap { + $0.properties?["label"]?.stringValue + } + XCTAssertEqual(labels, ["Resolved one", "Resolved two"]) + } +#endif +} +#endif diff --git a/packages/swift/slop-ai/Tests/SlopAITests/SlopAITests.swift b/packages/swift/slop-ai/Tests/SlopAITests/SlopAITests.swift new file mode 100644 index 0000000..0769f9c --- /dev/null +++ b/packages/swift/slop-ai/Tests/SlopAITests/SlopAITests.swift @@ -0,0 +1,2489 @@ +import XCTest +@testable import SlopAI +import NIOCore +import NIOEmbedded +import NIOHTTP1 +import NIOWebSocket + +#if canImport(Darwin) +import Darwin +#endif + +final class SlopAITests: XCTestCase { + func testDescriptorNormalizationExtractsHandlersAndWireShape() async throws { + let descriptor = NodeDescriptor( + type: "collection", + props: ["count": 1], + summary: "Open todos", + items: [ + ItemDescriptor(id: "first", props: ["title": "Ship Swift"], summary: "Top priority") + ], + children: [ + "stats": NodeDescriptor(type: "panel", props: ["done": 0]) + ], + actions: [ + "add": .value(params: ["title": .type("string")], label: "Add todo") { params in + .object(["title": params["title"] ?? .null]) + } + ] + ) + + let result = try normalizeDescriptor(path: "todos", id: "todos", descriptor: descriptor) + + XCTAssertEqual(result.node.id, "todos") + XCTAssertEqual(result.node.type, "collection") + XCTAssertEqual(result.node.properties?["count"], 1) + XCTAssertEqual(result.node.meta?.summary, "Open todos") + XCTAssertEqual(result.node.children?.map(\.id), ["first", "stats"]) + XCTAssertEqual(result.node.affordances?.first?.action, "add") + XCTAssertEqual(result.node.affordances?.first?.params?.required, ["title"]) + XCTAssertNotNil(result.handlers["todos/add"]) + } + + func testDescriptorNormalizationRejectsDuplicateSiblingIDs() throws { + let duplicateItems = NodeDescriptor( + type: "collection", + items: [ItemDescriptor(id: "same"), ItemDescriptor(id: "same")] + ) + XCTAssertThrowsError(try normalizeDescriptor(path: "items", id: "items", descriptor: duplicateItems)) { error in + guard case .duplicateNodeId = error as? SlopError else { + return XCTFail("Expected duplicateNodeId, got \(error)") + } + } + + let itemChildCollision = NodeDescriptor( + type: "collection", + items: [ItemDescriptor(id: "same")], + children: ["same": NodeDescriptor(type: "panel")] + ) + XCTAssertThrowsError(try normalizeDescriptor(path: "items", id: "items", descriptor: itemChildCollision)) { error in + guard case .duplicateNodeId = error as? SlopError else { + return XCTFail("Expected duplicateNodeId, got \(error)") + } + } + } + + func testTreeAssemblyCreatesSyntheticAncestorsAndMergesRealNodes() throws { + let result = try assembleTree( + registrations: [ + "inbox/messages": NodeDescriptor(type: "collection", props: ["count": 2]), + "inbox": NodeDescriptor(type: "panel", props: ["label": "Inbox"]), + ], + rootID: "app", + rootName: "Mail" + ) + + let inbox = try XCTUnwrap(result.tree.children?.first) + XCTAssertEqual(inbox.id, "inbox") + XCTAssertEqual(inbox.type, "panel") + XCTAssertEqual(inbox.children?.first?.id, "messages") + XCTAssertEqual(inbox.children?.first?.properties?["count"], 2) + } + + func testDiffOpsApplyThroughStateMirror() throws { + let old = SlopNode( + id: "root", + type: "root", + children: [ + SlopNode(id: "a", type: "item", properties: ["title": "A"]), + SlopNode(id: "b", type: "item", properties: ["title": "B"]), + ] + ) + let new = SlopNode( + id: "root", + type: "root", + children: [ + SlopNode(id: "b", type: "item", properties: ["title": "B"]), + SlopNode(id: "a", type: "item", properties: ["title": "A+"] ), + SlopNode(id: "c", type: "item", properties: ["title": "C"]), + ] + ) + + let ops = diffNodes(old, new) + XCTAssertTrue(ops.contains { $0.op == .add && $0.path == "/c" && $0.index == 2 }) + XCTAssertTrue(ops.contains { $0.op == .move && $0.path == "/b" && $0.index == 0 }) + XCTAssertTrue(ops.contains { $0.op == .replace && $0.path == "/a/properties/title" }) + + let mirror = try StateMirror(snapshot: SnapshotMessage(id: "sub-1", version: 1, seq: 0, tree: old)) + try mirror.applyPatch(PatchMessage(subscription: "sub-1", version: 2, seq: 1, ops: ops)) + XCTAssertEqual(mirror.getTree(), new) + XCTAssertEqual(mirror.getVersion(), 2) + XCTAssertThrowsError(try mirror.applyPatch(PatchMessage(subscription: "sub-1", version: 3, seq: 3, ops: []))) + } + + func testStateMirrorRollsBackMalformedPatches() throws { + let original = SlopNode(id: "root", type: "root", properties: ["title": "Original"]) + let mirror = try StateMirror(snapshot: SnapshotMessage(id: "sub", version: 1, seq: 0, tree: original)) + let patch = PatchMessage( + subscription: "sub", + version: 2, + seq: 1, + ops: [ + PatchOp(op: .replace, path: "/properties/title", value: "Changed"), + PatchOp(op: .replace, path: "/meta", value: "invalid-meta"), + ] + ) + + XCTAssertThrowsError(try mirror.applyPatch(patch)) + XCTAssertEqual(mirror.getTree(), original) + XCTAssertEqual(mirror.getVersion(), 1) + XCTAssertEqual(mirror.getSeq(), 0) + } + + func testStateMirrorRequiresSubscriptionSnapshotSequenceZero() { + let tree = SlopNode(id: "root", type: "root") + XCTAssertThrowsError( + try StateMirror(snapshot: SnapshotMessage(id: "query-1", version: 1, tree: tree)) + ) + XCTAssertThrowsError( + try StateMirror(snapshot: SnapshotMessage(id: "sub-1", version: 1, seq: 2, tree: tree)) + ) + } + + func testValidateParamsMatchesSchemaSubset() { + let schema = normalizeParams([ + "title": .type("string"), + "priority": .schema(JSONSchema(type: "integer", enumValues: [1, 2, 3])), + ]) + + XCTAssertNil(validateParams(schema: schema, params: .object(["title": "Write", "priority": 2]))) + XCTAssertEqual(validateParams(schema: schema, params: .object(["priority": 2])), "params.title is required") + XCTAssertEqual(validateParams(schema: schema, params: .object(["title": "Write", "priority": 4])), "params.priority must be one of [1,2,3]") + } + + func testJSONIntegerConversionRejectsOutOfRangeAndFractionalNumbers() { + XCTAssertNil(JSONValue.number(1e300).intValue) + XCTAssertNil(JSONValue.number(1.5).intValue) + XCTAssertEqual(JSONValue.number(42).intValue, 42) + XCTAssertEqual(canonicalJSON(.number(42)), "42") + XCTAssertFalse(canonicalJSON(.number(1e300)).isEmpty) + } + + func testScalingFormatsAndGroupsAffordances() { + let params = normalizeParams(["title": .type("string")]) + let root = SlopNode( + id: "app", + type: "root", + children: [ + SlopNode(id: "card-1", type: "card", affordances: [Affordance(action: "edit", params: params)]), + SlopNode(id: "card-2", type: "card", affordances: [Affordance(action: "edit", params: params)]), + ] + ) + + let tools = affordancesToTools(root) + XCTAssertEqual(tools.tools.count, 1) + XCTAssertEqual(tools.tools.first?.function.name, "edit") + XCTAssertEqual(tools.resolve("edit")?.path, nil) + XCTAssertEqual(tools.resolve("edit")?.targets, ["/card-1", "/card-2"]) + + let truncated = truncateTree(root, depth: 0) + XCTAssertNil(truncated.children) + XCTAssertEqual(truncated.meta?.totalChildren, 2) + XCTAssertTrue(formatTree(root).contains("[card] card-1")) + } + + func testScalingProducesUniqueASCIIOnlyToolNamesAfterSanitizing() { + let root = SlopNode( + id: "app", + type: "root", + children: [ + SlopNode(id: "你", type: "item", affordances: [Affordance(action: "做")]), + SlopNode(id: "_", type: "item", affordances: [Affordance(action: "_")]), + ] + ) + + let tools = affordancesToTools(root) + let names = tools.tools.map(\.function.name) + XCTAssertEqual(names.count, 2) + XCTAssertEqual(Set(names).count, 2) + XCTAssertTrue(names.allSatisfy { name in + name.unicodeScalars.allSatisfy { scalar in + let value = scalar.value + return (48...57).contains(value) || (65...90).contains(value) || (97...122).contains(value) || value == 95 + } + }) + XCTAssertEqual(Set(names.compactMap { tools.resolve($0)?.action }), Set(["做", "_"])) + } + + func testDynamicToolNamesAreGloballyUniqueASCIIAndOrderIndependent() { + func connectedProvider(id: String) -> ConnectedProvider { + let consumer = SlopConsumer(transport: InMemoryTransport()) + let tree = SlopNode( + id: id, + type: "root", + children: [SlopNode(id: "button", type: "control", affordances: [Affordance(action: "press")])] + ) + consumer.receive(snapshotMessage(id: "sub-1", version: 1, seq: 0, tree: tree)) + let descriptor = ProviderDescriptor( + id: id, + name: id, + slopVersion: "0.1", + transport: ProviderTransport(type: .ws, url: "ws://memory/\(id)"), + capabilities: ["state", "affordances"] + ) + return ConnectedProvider( + id: id, + name: id, + descriptor: descriptor, + consumer: consumer, + subscriptionID: "sub-1", + status: "connected" + ) + } + + let providers = [connectedProvider(id: "foo-bar"), connectedProvider(id: "foo_bar"), connectedProvider(id: "你")] + let forward = createDynamicTools(providers: providers) + let reversed = createDynamicTools(providers: providers.reversed()) + let names = forward.tools.map(\.name) + + XCTAssertEqual(names, reversed.tools.map(\.name)) + XCTAssertEqual(names.count, Set(names).count) + XCTAssertTrue(names.allSatisfy { name in + name.unicodeScalars.allSatisfy { scalar in + let value = scalar.value + return (48...57).contains(value) || (65...90).contains(value) || (97...122).contains(value) || value == 95 + } + }) + XCTAssertEqual(Set(names.compactMap { forward.resolve($0)?.providerID }), Set(["foo-bar", "foo_bar", "你"])) + } + + func testServerSubscribeInvokeAndPatchBroadcast() async throws { + let server = SlopServer(id: "todos", name: "Todos") + try server.register( + "list", + descriptor: NodeDescriptor( + type: "collection", + props: ["count": 1], + actions: [ + "add": .value(params: ["title": .type("string")]) { params in + .object(["created": params["title"] ?? .null]) + } + ] + ) + ) + + let connection = RecordingConnection() + server.handleConnection(connection) + XCTAssertEqual(connection.messages.first?["type"], "hello") + + await server.handleMessage(["type": "subscribe", "id": "sub-1", "path": "/list", "depth": 1], from: connection) + XCTAssertEqual(connection.messages.last?["type"], "snapshot") + XCTAssertEqual(connection.messages.last?["seq"], 0) + + await server.handleMessage(["type": "invoke", "id": "bad", "path": "/list", "action": "add", "params": [:]], from: connection) + XCTAssertEqual(connection.messages.last?["status"], "error") + XCTAssertEqual(connection.messages.last?["error"]?.objectValue?["code"], "invalid_params") + + await server.handleMessage(["type": "invoke", "id": "ok", "path": "/list", "action": "add", "params": ["title": "New"]], from: connection) + XCTAssertEqual(connection.messages.last?["status"], "ok") + XCTAssertEqual(connection.messages.last?["data"]?.objectValue?["created"], "New") + + try server.register("list", descriptor: NodeDescriptor(type: "collection", props: ["count": 2])) + XCTAssertEqual(connection.messages.last?["type"], "patch") + XCTAssertEqual(connection.messages.last?["subscription"], "sub-1") + } + + func testServerRefreshesDynamicStateWhenActionThrows() async throws { + let server = SlopServer(id: "dynamic", name: "Dynamic") + var count = 0 + try server.registerDynamic("status") { + NodeDescriptor( + type: "status", + props: ["count": .number(Double(count))], + actions: [ + "mutateThenFail": .value { _ in + count += 1 + throw TestActionError.failed + } + ] + ) + } + + let connection = RecordingConnection() + server.handleConnection(connection) + await server.handleMessage(["type": "subscribe", "id": "sub-dynamic", "path": "/status", "depth": 1], from: connection) + let initialMessageCount = connection.messages.count + + await server.handleMessage( + ["type": "invoke", "id": "inv-dynamic", "path": "/status", "action": "mutateThenFail", "params": [:]], + from: connection + ) + + let messages = Array(connection.messages.dropFirst(initialMessageCount)) + XCTAssertEqual(messages.map { $0["type"]?.stringValue }, ["patch", "result"]) + XCTAssertEqual(messages.first?["ops"]?.arrayValue?.first?.objectValue?["value"], 1) + XCTAssertEqual(messages.last?["status"], "error") + } + + func testServerClampsHostileWindowRangesWithoutOverflow() throws { + let server = SlopServer(id: "window", name: "Window") + try server.register( + "list", + descriptor: NodeDescriptor( + type: "list", + items: [ItemDescriptor(id: "a"), ItemDescriptor(id: "b"), ItemDescriptor(id: "c")] + ) + ) + + let large = try server.outputTree(OutputRequest(path: "/list", window: WindowRange(1, Int.max))) + XCTAssertEqual(large.children?.map(\.id), ["b", "c"]) + let negative = try server.outputTree(OutputRequest(path: "/list", window: WindowRange(1, -1))) + XCTAssertEqual(negative.children, []) + } + + func testAttachedServerProcessesMessagesInWireOrder() async throws { + let server = SlopServer(id: "ordered", name: "Ordered") + let gate = TestAsyncGate() + let actionStarted = TestFlag() + try server.register( + "status", + descriptor: NodeDescriptor( + type: "status", + actions: [ + "wait": .value { _ in + actionStarted.set() + await gate.wait() + return .null + } + ] + ) + ) + let outbound = MessageList() + let connection = InMemoryConnection { outbound.append($0) } + server.attachConnection(connection) + + connection.receive(["type": "invoke", "id": "first", "path": "/status", "action": "wait", "params": [:]]) + connection.receive(["type": "query", "id": "second", "path": "/status"]) + try await waitUntil { actionStarted.value } + XCTAssertFalse(outbound.values().contains { $0["id"] == "second" }) + + await gate.open() + try await waitUntil { outbound.values().contains { $0["id"] == "second" } } + let responses = outbound.values().filter { $0["id"] == "first" || $0["id"] == "second" } + XCTAssertEqual(responses.map { $0["id"]?.stringValue }, ["first", "second"]) + } + + func testConsumerConnectWiresIncomingMessagesWithoutRetainingUntrackedSnapshots() async throws { + let connection = InMemoryConnection { _ in } + let consumer = SlopConsumer(transport: InMemoryTransport(connection: connection)) + let receivedEvent = TestFlag() + consumer.onEvent { name, _ in + if name == "ready" { + receivedEvent.set() + } + } + + let helloTask = Task { try await consumer.connect() } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object(["id": "app", "name": "App", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + + let message = try await helloTask.value + XCTAssertEqual(message["type"], "hello") + + let snapshot = SlopNode(id: "app", type: "root") + connection.receive(snapshotMessage(id: "sub-1", version: 1, seq: 0, tree: snapshot)) + connection.receive(["type": "event", "name": "ready"]) + XCTAssertNil(consumer.getTree(subscriptionID: "sub-1")) + XCTAssertTrue(receivedEvent.value) + } + + func testConsumerRejectsMalformedHelloIdentity() async throws { + let connection = InMemoryConnection { _ in } + let consumer = SlopConsumer(transport: InMemoryTransport(connection: connection)) + let connectTask = Task { try await consumer.connect() } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object(["id": "app", "name": "App", "slop_version": "0.1", "capabilities": []]), + ]) + + do { + _ = try await connectTask.value + XCTFail("Expected hello without the required state capability to be rejected") + } catch { + XCTAssertTrue(error.localizedDescription.contains("state capability")) + } + } + + func testConsumerCallbacksRunOutsideStateLock() async throws { + let connection = InMemoryConnection { _ in } + let consumer = SlopConsumer(transport: InMemoryTransport(connection: connection)) + let sendableConsumer = UncheckedSendable(consumer) + consumer.onEvent { _, _ in + let completed = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + _ = sendableConsumer.value.getTree(subscriptionID: "missing") + completed.signal() + } + XCTAssertEqual(completed.wait(timeout: .now() + 1), .success) + } + + let helloTask = Task { try await consumer.connect() } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object(["id": "app", "name": "App", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + _ = try await helloTask.value + + connection.receive(["type": "event", "name": "ready"]) + } + + func testConsumerCallbacksCanUnsubscribeAndDisconnectFires() async throws { + let connection = InMemoryConnection { _ in } + let consumer = SlopConsumer(transport: InMemoryTransport(connection: connection)) + var events: [String] = [] + let unsubscribe = consumer.onEvent { name, _ in events.append(name) } + var disconnects = 0 + consumer.onDisconnect { disconnects += 1 } + + let helloTask = Task { try await consumer.connect() } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object(["id": "app", "name": "App", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + _ = try await helloTask.value + + connection.receive(["type": "event", "name": "ready"]) + unsubscribe() + connection.receive(["type": "event", "name": "ignored"]) + connection.close() + + XCTAssertEqual(events, ["ready"]) + XCTAssertEqual(disconnects, 1) + } + + func testConsumerCallbackUnsubscribesUseStableTokens() { + let consumer = SlopConsumer(transport: InMemoryTransport()) + var events: [String] = [] + let unsubscribeFirst = consumer.onEvent { _, _ in events.append("first") } + let unsubscribeSecond = consumer.onEvent { _, _ in events.append("second") } + + unsubscribeFirst() + consumer.receive(["type": "event", "name": "one"]) + unsubscribeSecond() + consumer.receive(["type": "event", "name": "two"]) + + XCTAssertEqual(events, ["second"]) + } + + func testConsumerReportsMalformedPatchWithoutTrapping() { + let consumer = SlopConsumer(transport: InMemoryTransport()) + let errors = MessageList() + consumer.onError { error, id in + var message = error + if let id { + message["id"] = .string(id) + } + errors.append(message) + } + consumer.receive(snapshotMessage(id: "sub-invalid", version: 1, seq: 0, tree: SlopNode(id: "root", type: "root"))) + + consumer.receive([ + "type": "patch", + "subscription": "sub-invalid", + "version": 2, + "seq": 1, + "ops": .array([ + .object(["op": "replace", "path": "/meta", "value": "invalid-meta"]), + ]), + ]) + + XCTAssertEqual(errors.values().first?["code"], "invalid_patch") + XCTAssertEqual(errors.values().first?["id"], "sub-invalid") + XCTAssertNil(consumer.getTree(subscriptionID: "sub-invalid")) + } + + func testConsumerRejectsSubscriptionPatchWithoutSequence() { + let consumer = SlopConsumer(transport: InMemoryTransport()) + let errors = MessageList() + consumer.onError { error, id in + var message = error + if let id { + message["id"] = .string(id) + } + errors.append(message) + } + consumer.receive(snapshotMessage(id: "sub-missing-seq", version: 1, seq: 0, tree: SlopNode(id: "root", type: "root"))) + + consumer.receive([ + "type": "patch", + "subscription": "sub-missing-seq", + "version": 2, + "ops": .array([]), + ]) + + XCTAssertEqual(errors.values().first?["code"], "invalid_patch") + XCTAssertEqual(errors.values().first?["id"], "sub-missing-seq") + XCTAssertNil(consumer.getTree(subscriptionID: "sub-missing-seq")) + } + + func testConsumerRejectsInitialSubscriptionSnapshotWithoutSequenceZero() async throws { + var connection: InMemoryConnection! + connection = InMemoryConnection { message in + guard message["type"] == "subscribe", let id = message["id"]?.stringValue else { return } + connection.receive(snapshotMessage(id: id, version: 1, seq: 1, tree: SlopNode(id: "root", type: "root"))) + } + let consumer = SlopConsumer(transport: InMemoryTransport(connection: connection)) + let helloTask = Task { try await consumer.connect() } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object(["id": "app", "name": "App", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + _ = try await helloTask.value + + do { + _ = try await consumer.subscribe() + XCTFail("Expected subscription snapshot with seq 1 to be rejected") + } catch { + XCTAssertTrue(error.localizedDescription.contains("seq 0")) + } + XCTAssertNil(consumer.getTree(subscriptionID: "sub-1")) + } + + func testConsumerRejectsMalformedQuerySnapshotInsteadOfSuspending() async throws { + var connection: InMemoryConnection! + connection = InMemoryConnection { message in + guard message["type"] == "query", let id = message["id"]?.stringValue else { return } + connection.receive([ + "type": "snapshot", + "id": .string(id), + "version": 1, + "tree": "not-a-tree", + ]) + } + let consumer = SlopConsumer(transport: InMemoryTransport(connection: connection)) + let helloTask = Task { try await consumer.connect() } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object(["id": "app", "name": "App", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + _ = try await helloTask.value + + do { + _ = try await consumer.query() + XCTFail("Expected malformed query snapshot to be rejected") + } catch { + XCTAssertTrue(error.localizedDescription.contains("invalid tree")) + } + } + + func testConsumerRecoversFromMalformedPatchBody() { + let consumer = SlopConsumer(transport: InMemoryTransport()) + let errors = MessageList() + consumer.onError { error, id in + var message = error + if let id { + message["id"] = .string(id) + } + errors.append(message) + } + consumer.receive(snapshotMessage(id: "sub-invalid-ops", version: 1, seq: 0, tree: SlopNode(id: "root", type: "root"))) + + consumer.receive([ + "type": "patch", + "subscription": "sub-invalid-ops", + "version": 2, + "seq": 1, + "ops": "not-operations", + ]) + + XCTAssertEqual(errors.values().first?["code"], "invalid_patch") + XCTAssertEqual(errors.values().first?["id"], "sub-invalid-ops") + XCTAssertNil(consumer.getTree(subscriptionID: "sub-invalid-ops")) + } + + func testConsumerRejectsPendingRequestsWhenConnectionCloses() async throws { + let sent = MessageList() + let connection = InMemoryConnection { message in sent.append(message) } + let consumer = SlopConsumer(transport: InMemoryTransport(connection: connection)) + let helloTask = Task { try await consumer.connect() } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object(["id": "app", "name": "App", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + _ = try await helloTask.value + + let queryTask = Task { try await consumer.query() } + try await waitUntil { + sent.values().contains { $0["type"] == "query" } + } + connection.close() + + do { + _ = try await queryTask.value + XCTFail("Expected the pending query to fail when the connection closed") + } catch let error as SlopError { + guard case .internalError(let message) = error else { + return XCTFail("Expected internalError, got \(error)") + } + XCTAssertEqual(message, "SLOP connection closed") + } + } + + func testConsumerDisconnectRejectsLateTransportConnection() async throws { + let transport = SuspendingClientTransport() + let consumer = SlopConsumer(transport: transport) + let connectTask = Task { try await consumer.connect() } + try await waitUntil { transport.connectCount == 1 } + + consumer.disconnect() + await transport.release() + + do { + _ = try await connectTask.value + XCTFail("Expected the stale connection to be rejected") + } catch {} + XCTAssertTrue(transport.connectionClosed) + } + + func testConsumerCancellationRemovesPendingRequestsAndSubscriptions() async throws { + let sent = MessageList() + let connection = InMemoryConnection { sent.append($0) } + let consumer = SlopConsumer(transport: InMemoryTransport(connection: connection)) + let connectTask = Task { try await consumer.connect() } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object(["id": "app", "name": "App", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + _ = try await connectTask.value + + let queryTask = Task { try await consumer.query() } + try await waitUntil { sent.values().contains { $0["type"] == "query" } } + let queryID = try XCTUnwrap(sent.values().first { $0["type"] == "query" }?["id"]?.stringValue) + queryTask.cancel() + do { + _ = try await queryTask.value + XCTFail("Expected query cancellation") + } catch is CancellationError {} + connection.receive(snapshotMessage(id: queryID, version: 1, tree: SlopNode(id: "late", type: "root"))) + XCTAssertNil(consumer.getTree(subscriptionID: queryID)) + + let subscribeTask = Task { try await consumer.subscribe() } + try await waitUntil { sent.values().contains { $0["type"] == "subscribe" } } + let subscriptionID = try XCTUnwrap(sent.values().first { $0["type"] == "subscribe" }?["id"]?.stringValue) + subscribeTask.cancel() + do { + _ = try await subscribeTask.value + XCTFail("Expected subscription cancellation") + } catch is CancellationError {} + connection.receive(snapshotMessage(id: subscriptionID, version: 1, seq: 0, tree: SlopNode(id: "late", type: "root"))) + XCTAssertNil(consumer.getTree(subscriptionID: subscriptionID)) + } + + func testConsumerSerializesConcurrentRequestIDsAndResponses() async throws { + let sent = MessageList() + var connection: InMemoryConnection! + connection = InMemoryConnection { message in + guard message["type"] == "query", let id = message["id"]?.stringValue else { return } + sent.append(message) + DispatchQueue.global(qos: .userInitiated).async { + connection.receive( + snapshotMessage(id: id, version: 1, tree: SlopNode(id: "app", type: "root")) + ) + } + } + let consumer = SlopConsumer(transport: InMemoryTransport(connection: connection)) + let helloTask = Task { try await consumer.connect() } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object(["id": "app", "name": "App", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + _ = try await helloTask.value + + try await withThrowingTaskGroup(of: SlopNode.self) { group in + for _ in 0..<100 { + group.addTask { + try await consumer.query() + } + } + for try await tree in group { + XCTAssertEqual(tree.id, "app") + } + } + + let ids = sent.values().compactMap { $0["id"]?.stringValue } + XCTAssertEqual(ids.count, 100) + XCTAssertEqual(Set(ids).count, 100) + } + + func testConsumerSubscribeQueryAndInvokeThroughTransport() async throws { + let sent = MessageList() + var connection: InMemoryConnection! + connection = InMemoryConnection { message in + sent.append(message) + guard let type = message["type"]?.stringValue, let id = message["id"]?.stringValue else { return } + switch type { + case "subscribe", "query": + connection.receive( + snapshotMessage( + id: id, + version: 1, + seq: type == "subscribe" ? 0 : nil, + tree: SlopNode(id: "app", type: "root", children: [SlopNode(id: "panel", type: "view")]) + ) + ) + case "invoke": + connection.receive([ + "type": "result", + "id": .string(id), + "status": "ok", + "data": .object(["pressed": true]), + ]) + default: + break + } + } + + let consumer = SlopConsumer(transport: InMemoryTransport(connection: connection)) + let helloTask = Task { try await consumer.connect() } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object(["id": "app", "name": "App", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + _ = try await helloTask.value + + let subscription = try await consumer.subscribe(path: "/", depth: -1) + XCTAssertEqual(subscription.snapshot.children?.first?.id, "panel") + XCTAssertEqual(consumer.getTree(subscriptionID: subscription.id)?.id, "app") + + let queried = try await consumer.query(path: "/", depth: 1) + XCTAssertEqual(queried.children?.first?.type, "view") + let queryID = try XCTUnwrap(sent.values().first { $0["type"] == "query" }?["id"]?.stringValue) + XCTAssertNil(consumer.getTree(subscriptionID: queryID)) + + let result = try await consumer.invoke(path: "/panel", action: "press") + XCTAssertEqual(result["status"], "ok") + XCTAssertEqual(result["data"]?.objectValue?["pressed"], true) + + XCTAssertEqual(sent.values().map { $0["type"]?.stringValue }, ["subscribe", "query", "invoke"]) + } + + func testHelpersPickOmitAndAction() async throws { + let object: [String: JSONValue] = ["a": 1, "b": 2, "c": "three"] + XCTAssertEqual(pick(object, keys: ["a", "c"]), ["a": 1, "c": "three"]) + XCTAssertEqual(omit(object, keys: ["b"]), ["a": 1, "c": "three"]) + + let add = action(params: ["title": .type("string")], label: "Add") { params in + .object(["title": params["title"] ?? .null]) + } + XCTAssertEqual(add.label, "Add") + XCTAssertEqual(add.params, ["title": .type("string")]) + let result = try await add.handler(["title": "Swift"]) + XCTAssertEqual(result, .value(.object(["title": "Swift"]))) + } + + func testAsyncActionRegistersTaskAndReturnsAccepted() async throws { + let server = SlopServer(id: "app", name: "App") + let action = server.asyncAction(params: ["name": .type("string")], label: "Run") { params, task in + task.update(progress: 0.5, message: "Halfway") + return .object(["name": params["name"] ?? .null]) + } + try server.register("runner", descriptor: NodeDescriptor(type: "control", actions: ["run": action])) + + let result = await server.executeInvoke(id: "inv-1", path: "/runner", action: "run", params: ["name": "job"]) + XCTAssertEqual(result["status"], "accepted") + let taskID = try XCTUnwrap(result["data"]?.objectValue?["taskId"]?.stringValue) + XCTAssertNotNil(getSubtree(server.tree, path: "/tasks/\(taskID)")) + } + + func testTaskHandleHonorsCancellationBeforeAttach() async throws { + let server = SlopServer(id: "tasks", name: "Tasks") + let handle = TaskHandle(id: "race", server: server, label: nil, cancelable: true) + let observedCancellation = TestFlag() + handle.cancel() + let task = Task { + while !Task.isCancelled { + await Task.yield() + } + observedCancellation.set() + } + + handle.attach(task) + + try await waitUntil { observedCancellation.value } + handle.update(progress: 0.5, message: "Must stay cancelled") + XCTAssertEqual(getSubtree(server.tree, path: "/tasks/race")?.properties?["status"], "cancelled") + } + + func testExposeStoreSerializesUpdatesAndDisposal() async throws { + let store = TestIntStore() + let target = TestStoreTarget() + let exposure = try exposeStore( + target: target, + path: .dynamic { "value-\($0)" }, + store: store, + project: { NodeDescriptor(type: "value", props: ["value": .number(Double($0))]) }, + options: ExposeStoreOptions(debounceMilliseconds: 1) + ) + + await withTaskGroup(of: Void.self) { group in + for value in 1...50 { + group.addTask { store.set(value) } + } + } + exposure.unsubscribe() + let operationsAfterDispose = target.operationCount + store.set(999) + try await Task.sleep(nanoseconds: 5_000_000) + + XCTAssertEqual(target.operationCount, operationsAfterDispose) + } + + func testExposeStoreSubscribesBeforeInitialSnapshot() throws { + let store = TransitionDuringSubscribeStore() + let target = TestStoreTarget() + let exposure = try exposeStore( + target: target, + path: .dynamic { "value-\($0)" }, + store: store, + project: { _ in NodeDescriptor(type: "value") } + ) + defer { exposure.unsubscribe() } + + XCTAssertEqual(target.lastRegisteredPath, "value-1") + } + + func testExposeStorePropagatesInitialRegistrationFailure() { + XCTAssertThrowsError( + try exposeStore( + target: FailingStoreTarget(), + path: .fixed("invalid"), + store: TestIntStore(), + project: { _ in NodeDescriptor(type: "value") } + ) + ) + } + + func testExposeStoreKeepsOldDynamicPathWhenNewRegistrationFails() throws { + let store = TestIntStore() + let target = TransitionFailingStoreTarget(failingPath: "value-1") + let exposure = try exposeStore( + target: target, + path: .dynamic { "value-\($0)" }, + store: store, + project: { _ in NodeDescriptor(type: "value") } + ) + defer { exposure.unsubscribe() } + + store.set(1) + + XCTAssertTrue(target.contains("value-0")) + XCTAssertFalse(target.contains("value-1")) + } + + func testStoreAndBridgeDelayConversionsClampLargeValues() throws { + let store = TestIntStore() + let exposure = try exposeStore( + target: TestStoreTarget(), + path: .fixed("value"), + store: store, + project: { _ in NodeDescriptor(type: "value") }, + options: ExposeStoreOptions(debounceMilliseconds: Int.max) + ) + store.set(1) + exposure.unsubscribe() + + _ = BridgeClient(reconnectDelay: .infinity) + _ = BridgeClient(reconnectDelay: .nan) + } + + func testDiscoveryRegisterAndReadProvider() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + + try Discovery.registerProvider( + id: "swift-app", + name: "Swift App", + transport: ProviderTransport(type: .ws, url: "ws://127.0.0.1:7777/slop"), + directory: directory, + pid: 123 + ) + + let descriptors = Discovery.readDescriptors(from: [directory]) + XCTAssertEqual(descriptors.count, 1) + XCTAssertEqual(descriptors.first?.id, "swift-app") + XCTAssertEqual(descriptors.first?.transport.type, .ws) + + Discovery.unregisterProvider(id: "swift-app", directory: directory) + XCTAssertTrue(Discovery.readDescriptors(from: [directory]).isEmpty) + } + + func testConcurrentDescriptorUnregistersPreserveNewestRegistration() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let registrations = ProviderRegistrationList() + + DispatchQueue.concurrentPerform(iterations: 40) { index in + if let registration = try? Discovery.registerProvider( + id: "shared-app", + name: "Concurrent \(index)", + transport: ProviderTransport(type: .ws, url: "ws://memory/\(index)"), + directory: directory + ) { + registrations.append(registration) + } + } + let newest = try Discovery.registerProvider( + id: "shared-app", + name: "Newest", + transport: ProviderTransport(type: .ws, url: "ws://memory/newest"), + directory: directory + ) + + let oldRegistrations = registrations.values() + DispatchQueue.concurrentPerform(iterations: oldRegistrations.count) { index in + Discovery.unregisterProvider(oldRegistrations[index]) + } + + let descriptor = try XCTUnwrap(Discovery.readDescriptors(from: [directory]).first) + XCTAssertEqual(descriptor.name, "Newest") + XCTAssertEqual(descriptor.transport.url, "ws://memory/newest") + Discovery.unregisterProvider(newest) + } + + func testDescriptorQuarantineIsRemovedWhenNewerRegistrationWinsRestore() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let old = try Discovery.registerProvider( + id: "shared-app", + name: "Old", + transport: ProviderTransport(type: .ws, url: "ws://memory/old"), + directory: directory + ) + _ = try Discovery.registerProvider( + id: "shared-app", + name: "Superseded", + transport: ProviderTransport(type: .ws, url: "ws://memory/superseded"), + directory: directory + ) + var newest: ProviderRegistration? + + Discovery.unregisterProvider(old) { + newest = try? Discovery.registerProvider( + id: "shared-app", + name: "Newest", + transport: ProviderTransport(type: .ws, url: "ws://memory/newest"), + directory: directory + ) + } + + let descriptor = try XCTUnwrap(Discovery.readDescriptors(from: [directory]).first) + XCTAssertEqual(descriptor.name, "Newest") + let files = try FileManager.default.contentsOfDirectory(atPath: directory.path) + XCTAssertFalse(files.contains { $0.hasPrefix(".slop-unregister-") }) + if let newest { + Discovery.unregisterProvider(newest) + } + } + + func testDiscoveryRejectsSymlinkProviderDirectory() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let realDirectory = root.appendingPathComponent("real") + let linkedDirectory = root.appendingPathComponent("linked") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: realDirectory, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: realDirectory.path) + try FileManager.default.createSymbolicLink(at: linkedDirectory, withDestinationURL: realDirectory) + let permissionsBefore = try XCTUnwrap( + FileManager.default.attributesOfItem(atPath: realDirectory.path)[.posixPermissions] as? NSNumber + ) + + XCTAssertThrowsError( + try Discovery.registerProvider( + id: "swift-app", + name: "Swift App", + transport: ProviderTransport(type: .ws, url: "ws://127.0.0.1:7777/slop"), + directory: linkedDirectory + ) + ) + XCTAssertFalse(FileManager.default.fileExists(atPath: linkedDirectory.appendingPathComponent("swift-app.json").path)) + let permissionsAfter = try XCTUnwrap( + FileManager.default.attributesOfItem(atPath: realDirectory.path)[.posixPermissions] as? NSNumber + ) + XCTAssertEqual(permissionsAfter, permissionsBefore) + } + + #if canImport(Darwin) + func testDiscoveryDefaultFactorySupportsUnixDescriptorsOnDarwin() { + let descriptor = ProviderDescriptor( + id: "mac-app", + name: "Mac App", + slopVersion: "0.1", + transport: ProviderTransport(type: .unix, path: "/tmp/slop/mac-app.sock"), + capabilities: [] + ) + + XCTAssertTrue(Discovery.defaultTransportFactory(descriptor) is UnixSocketClientTransport) + } + + func testUnixProviderStartRollsBackWhenDiscoveryRegistrationFails() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let socketPath = directory.appendingPathComponent("provider.sock").path + let invalidDiscoveryDirectory = directory.appendingPathComponent("not-a-directory") + try Data().write(to: invalidDiscoveryDirectory) + let server = SlopServer(id: "rollback-provider", name: "Rollback Provider") + let transport = UnixSocketProviderTransport(server: server, path: socketPath) + + XCTAssertThrowsError(try transport.start(discover: true, discoveryDirectory: invalidDiscoveryDirectory)) + XCTAssertFalse(FileManager.default.fileExists(atPath: socketPath)) + + try transport.start() + XCTAssertTrue(FileManager.default.fileExists(atPath: socketPath)) + transport.stop() + } + + func testUnixProviderRejectsWorldWritableSocketDirectory() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o777], ofItemAtPath: directory.path) + defer { try? FileManager.default.removeItem(at: directory) } + + let socketPath = directory.appendingPathComponent("provider.sock").path + let transport = UnixSocketProviderTransport( + server: SlopServer(id: "unsafe-provider", name: "Unsafe Provider"), + path: socketPath + ) + + XCTAssertThrowsError(try transport.start()) + XCTAssertFalse(FileManager.default.fileExists(atPath: socketPath)) + } + + func testUnixProviderPreservesRegularFileAtSocketPath() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let socketURL = directory.appendingPathComponent("provider.sock") + let original = Data("keep me".utf8) + try original.write(to: socketURL) + let transport = UnixSocketProviderTransport( + server: SlopServer(id: "safe-provider", name: "Safe Provider"), + path: socketURL.path + ) + + XCTAssertThrowsError(try transport.start()) + XCTAssertEqual(try Data(contentsOf: socketURL), original) + } + + func testUnixProviderStopPreservesReplacementPath() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let socketURL = directory.appendingPathComponent("provider.sock") + let movedSocketURL = directory.appendingPathComponent("provider-old.sock") + let replacement = Data("replacement".utf8) + let transport = UnixSocketProviderTransport( + server: SlopServer(id: "replacement-provider", name: "Replacement Provider"), + path: socketURL.path + ) + try transport.start() + try FileManager.default.moveItem(at: socketURL, to: movedSocketURL) + try replacement.write(to: socketURL) + + transport.stop() + + XCTAssertEqual(try Data(contentsOf: socketURL), replacement) + } + + func testUnixProviderPreservesReplacementCreatedDuringConditionalCleanup() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let socketURL = directory.appendingPathComponent("provider.sock") + let replacement = Data("replacement-during-cleanup".utf8) + let transport = UnixSocketProviderTransport( + server: SlopServer(id: "racing-provider", name: "Racing Provider"), + path: socketURL.path, + beforeQuarantineInspection: { + try? replacement.write(to: socketURL) + } + ) + try transport.start() + + transport.stop() + + XCTAssertEqual(try Data(contentsOf: socketURL), replacement) + } + + func testUnixProviderRemovesSupersededQuarantineWhenNewerPathWinsRestore() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let socketURL = directory.appendingPathComponent("provider.sock") + let movedSocketURL = directory.appendingPathComponent("provider-old.sock") + let superseded = Data("superseded".utf8) + let newest = Data("newest".utf8) + let transport = UnixSocketProviderTransport( + server: SlopServer(id: "quarantine-provider", name: "Quarantine Provider"), + path: socketURL.path, + beforeQuarantineInspection: { + try? newest.write(to: socketURL) + } + ) + try transport.start() + try FileManager.default.moveItem(at: socketURL, to: movedSocketURL) + try superseded.write(to: socketURL) + + transport.stop() + + XCTAssertEqual(try Data(contentsOf: socketURL), newest) + let files = try FileManager.default.contentsOfDirectory(atPath: directory.path) + XCTAssertFalse(files.contains { $0.contains(".slop-remove-") }) + } + + func testUnixProviderRefusesToReplaceLiveSocket() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let socketPath = directory.appendingPathComponent("provider.sock").path + let first = UnixSocketProviderTransport( + server: SlopServer(id: "first-provider", name: "First Provider"), + path: socketPath + ) + let second = UnixSocketProviderTransport( + server: SlopServer(id: "second-provider", name: "Second Provider"), + path: socketPath + ) + try first.start() + defer { first.stop() } + + XCTAssertThrowsError(try second.start()) + XCTAssertTrue(FileManager.default.fileExists(atPath: socketPath)) + } + + func testUnixProviderStopDoesNotRemoveNewerDescriptorRegistration() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let discoveryDirectory = directory.appendingPathComponent("providers") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let socketPath = directory.appendingPathComponent("old.sock").path + let transport = UnixSocketProviderTransport( + server: SlopServer(id: "shared-provider", name: "Old Provider"), + path: socketPath + ) + try transport.start(discover: true, discoveryDirectory: discoveryDirectory) + try Discovery.registerUnixProvider( + id: "shared-provider", + name: "New Provider", + socketPath: directory.appendingPathComponent("new.sock").path, + directory: discoveryDirectory + ) + + transport.stop() + + let descriptor = try XCTUnwrap(Discovery.readDescriptors(from: [discoveryDirectory]).first) + XCTAssertEqual(descriptor.name, "New Provider") + XCTAssertTrue(descriptor.transport.path?.hasSuffix("new.sock") == true) + } + #endif + + func testDiscoveryServiceConnectsWithInjectedTransport() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try Discovery.registerProvider( + id: "memory-app", + name: "Memory App", + transport: ProviderTransport(type: .ws, url: "ws://memory/slop"), + directory: directory, + pid: 123 + ) + + var connection: InMemoryConnection! + connection = InMemoryConnection { message in + if message["type"] == "subscribe", let id = message["id"]?.stringValue { + let tree = SlopNode( + id: "memory-app", + type: "root", + children: [ + SlopNode(id: "button", type: "control", affordances: [Affordance(action: "press")]) + ] + ) + connection.receive(snapshotMessage(id: id, version: 1, seq: 0, tree: tree)) + } + } + + let factoryCalls = TestCounter() + let service = DiscoveryService( + options: DiscoveryOptions(providerDirectories: [directory]) { _ in + factoryCalls.increment() + return InMemoryTransport(connection: connection) + } + ) + let firstConnectTask = Task { try await service.ensureConnected("memory-app") } + let secondConnectTask = Task { try await service.ensureConnected("Memory App") } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object(["id": "memory-app", "name": "Memory App", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + let firstConnectedProvider = try await firstConnectTask.value + let secondConnectedProvider = try await secondConnectTask.value + let provider = try XCTUnwrap(firstConnectedProvider) + let secondProvider = try XCTUnwrap(secondConnectedProvider) + + XCTAssertEqual(provider.id, "memory-app") + XCTAssertTrue(provider.consumer === secondProvider.consumer) + XCTAssertEqual(factoryCalls.value, 1) + XCTAssertEqual(service.getProviders().count, 1) + let dynamic = createDynamicTools(providers: service.getProviders()) + XCTAssertEqual(dynamic.tools.first?.name, "memory_app__button__press") + XCTAssertTrue(service.disconnect("Memory App")) + } + + func testDiscoveryUsesAuthoritativeHelloIdentity() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try Discovery.registerProvider( + id: "advertised-app", + name: "Advertised App", + transport: ProviderTransport(type: .ws, url: "ws://memory/slop"), + directory: directory + ) + + var connection: InMemoryConnection! + connection = InMemoryConnection { message in + guard message["type"] == "subscribe", let id = message["id"]?.stringValue else { return } + connection.receive( + snapshotMessage(id: id, version: 1, seq: 0, tree: SlopNode(id: "actual-app", type: "root")) + ) + } + let service = DiscoveryService( + options: DiscoveryOptions(providerDirectories: [directory]) { _ in + InMemoryTransport(connection: connection) + } + ) + let connectTask = Task { try await service.ensureConnected("advertised-app") } + await connection.waitForMessageHandler() + connection.receive([ + "type": "hello", + "provider": .object([ + "id": "actual-app", + "name": "Actual App", + "slop_version": "0.1", + "capabilities": ["state", "patches"], + ]), + ]) + + let connectedProvider = try await connectTask.value + let provider = try XCTUnwrap(connectedProvider) + XCTAssertEqual(provider.id, "actual-app") + XCTAssertEqual(provider.name, "Actual App") + XCTAssertEqual(provider.descriptor.id, "actual-app") + XCTAssertEqual(provider.descriptor.capabilities, ["state", "patches"]) + XCTAssertTrue(service.getProvider("advertised-app")?.consumer === provider.consumer) + XCTAssertTrue(service.getProvider("actual-app")?.consumer === provider.consumer) + let secondProvider = try await service.ensureConnected("advertised-app") + XCTAssertTrue(secondProvider?.consumer === provider.consumer) + XCTAssertTrue(service.disconnect("advertised-app")) + XCTAssertNil(service.getProvider("actual-app")) + } + + func testDiscoveryCanonicalIDTakesPrecedenceOverDescriptorAlias() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try Discovery.registerProvider( + id: "foo", + name: "Foo Advertisement", + transport: ProviderTransport(type: .ws, url: "ws://memory/foo"), + directory: directory + ) + try Discovery.registerProvider( + id: "baz", + name: "Baz Advertisement", + transport: ProviderTransport(type: .ws, url: "ws://memory/baz"), + directory: directory + ) + + var fooConnection: InMemoryConnection! + fooConnection = InMemoryConnection { message in + guard message["type"] == "subscribe", let id = message["id"]?.stringValue else { return } + fooConnection.receive(snapshotMessage(id: id, version: 1, seq: 0, tree: SlopNode(id: "bar", type: "root"))) + } + var bazConnection: InMemoryConnection! + bazConnection = InMemoryConnection { message in + guard message["type"] == "subscribe", let id = message["id"]?.stringValue else { return } + bazConnection.receive(snapshotMessage(id: id, version: 1, seq: 0, tree: SlopNode(id: "foo", type: "root"))) + } + let service = DiscoveryService( + options: DiscoveryOptions(providerDirectories: [directory]) { descriptor in + InMemoryTransport(connection: descriptor.id == "foo" ? fooConnection : bazConnection) + } + ) + + let aliasTask = Task { try await service.ensureConnected("foo") } + await fooConnection.waitForMessageHandler() + fooConnection.receive([ + "type": "hello", + "provider": .object(["id": "bar", "name": "Bar", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + let aliasProvider = try await aliasTask.value + + let canonicalTask = Task { try await service.ensureConnected("baz") } + await bazConnection.waitForMessageHandler() + bazConnection.receive([ + "type": "hello", + "provider": .object(["id": "foo", "name": "Canonical Foo", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + let canonicalProvider = try await canonicalTask.value + + XCTAssertTrue(service.getProvider("foo")?.consumer === canonicalProvider?.consumer) + XCTAssertTrue(service.getProvider("bar")?.consumer === aliasProvider?.consumer) + XCTAssertTrue(service.disconnect("foo")) + XCTAssertTrue(service.getProvider("bar")?.consumer === aliasProvider?.consumer) + } + + func testDiscoveryStateCallbacksUseStableTokens() { + let service = DiscoveryService(options: DiscoveryOptions(providerDirectories: [])) + var events: [String] = [] + let unsubscribeFirst = service.onStateChange { events.append("first") } + let unsubscribeSecond = service.onStateChange { events.append("second") } + + unsubscribeFirst() + service.scan() + unsubscribeSecond() + service.scan() + + XCTAssertEqual(events, ["second"]) + } + + func testServerSupportsConcurrentRegistrationAndReads() async throws { + let server = SlopServer(id: "concurrent", name: "Concurrent") + + try await withThrowingTaskGroup(of: Void.self) { group in + for index in 0..<50 { + group.addTask { + try server.register( + "items/\(index)", + descriptor: NodeDescriptor(type: "item", props: ["index": .number(Double(index))]) + ) + _ = try server.outputTree(OutputRequest(path: "/", depth: 2)) + } + } + try await group.waitForAll() + } + + let tree = try server.outputTree(OutputRequest(path: "/", depth: -1)) + XCTAssertEqual(tree.children?.first { $0.id == "items" }?.children?.count, 50) + } + + func testStdioProviderTransportSpeaksNDJSON() async throws { + let server = SlopServer(id: "stdio-app", name: "Stdio App") + try server.register("status", descriptor: NodeDescriptor(type: "status", props: ["ready": true])) + + let input = Pipe() + let output = Pipe() + let reader = PipeJSONReader(output.fileHandleForReading) + let transport = server.listenStdio(input: input.fileHandleForReading, output: output.fileHandleForWriting) + defer { + transport.stop() + try? input.fileHandleForWriting.close() + try? output.fileHandleForReading.close() + } + + let hello = try XCTUnwrap(reader.next()) + XCTAssertEqual(hello["type"], "hello") + XCTAssertEqual(hello["provider"]?.objectValue?["id"], "stdio-app") + + writePipeJSON( + input.fileHandleForWriting, + ["type": "query", "id": "q-stdio", "path": "/", "depth": 1] + ) + let snapshot = try XCTUnwrap(reader.next()) + XCTAssertEqual(snapshot["type"], "snapshot") + XCTAssertEqual(snapshot["id"], "q-stdio") + XCTAssertEqual(snapshot["tree"]?.objectValue?["id"], "stdio-app") + } + + func testStdioConnectionFiresLateCloseHandlers() { + let connection = StdioConnection(input: Pipe().fileHandleForReading, output: Pipe().fileHandleForWriting) + connection.close() + var closeCount = 0 + + connection.onClose { closeCount += 1 } + + XCTAssertEqual(closeCount, 1) + } + + func testWebSocketProviderTransportServesConsumerAndDiscovery() async throws { + let server = SlopServer(id: "ws-provider", name: "WS Provider") + try server.register( + "status", + descriptor: NodeDescriptor( + type: "status", + props: ["online": true], + actions: ["ping": .value { _ in .object(["pong": true]) }] + ) + ) + + let transport = try server.listenWebSocket( + options: WebSocketProviderOptions(host: "127.0.0.1", port: 0, path: "/slop") + ) + defer { transport.stop() } + + let url = try XCTUnwrap(transport.url.flatMap(URL.init(string:))) + let discoveryURL = URL(string: "http://127.0.0.1:\(try XCTUnwrap(transport.port))/.well-known/slop")! + let (data, _) = try await URLSession.shared.data(from: discoveryURL) + let descriptor = try JSONDecoder().decode(ProviderDescriptor.self, from: data) + XCTAssertEqual(descriptor.id, "ws-provider") + XCTAssertEqual(descriptor.transport.type, .ws) + + let consumer = SlopConsumer(transport: URLSessionWebSocketTransport(url: url)) + let hello = try await consumer.connect() + XCTAssertEqual(hello["provider"]?.objectValue?["id"], "ws-provider") + + let queried = try await consumer.query(path: "/status", depth: 1) + XCTAssertEqual(queried.properties?["online"], true) + + let result = try await consumer.invoke(path: "/status", action: "ping") + XCTAssertEqual(result["status"], "ok") + XCTAssertEqual(result["data"]?.objectValue?["pong"], true) + + var rejectedRequest = URLRequest(url: url) + rejectedRequest.setValue("https://evil.example", forHTTPHeaderField: "Origin") + let rejectedConsumer = SlopConsumer(transport: URLSessionWebSocketTransport(request: rejectedRequest)) + do { + _ = try await rejectedConsumer.connect() + XCTFail("Expected the provider to reject an untrusted browser origin") + } catch { + XCTAssertNotNil(error as? SlopError) + } + } + + func testWebSocketHandlersPongAndAggregateFragments() throws { + let server = SlopServer(id: "embedded", name: "Embedded") + let pingChannel = EmbeddedChannel(handler: WebSocketProviderHandler(server: server)) + let hello: WebSocketFrame? = try pingChannel.readOutbound() + XCTAssertEqual(hello?.opcode, .text) + + let pingData = pingChannel.allocator.buffer(string: "heartbeat") + XCTAssertTrue(try pingChannel.writeInbound(WebSocketFrame(fin: true, opcode: .ping, data: pingData)).isEmpty) + let pong: WebSocketFrame? = try pingChannel.readOutbound() + XCTAssertEqual(pong?.opcode, .pong) + var pongData = try XCTUnwrap(pong).unmaskedData + XCTAssertEqual(pongData.readString(length: pongData.readableBytes), "heartbeat") + XCTAssertNoThrow(try pingChannel.finish()) + + let aggregateChannel = EmbeddedChannel(handler: makeWebSocketFrameAggregator()) + let first = aggregateChannel.allocator.buffer(string: "{\"type\":") + let second = aggregateChannel.allocator.buffer(string: "\"query\"}") + XCTAssertTrue(try aggregateChannel.writeInbound(WebSocketFrame(fin: false, opcode: .text, data: first)).isEmpty) + XCTAssertTrue(try aggregateChannel.writeInbound(WebSocketFrame(fin: true, opcode: .continuation, data: second)).isFull) + let aggregated: WebSocketFrame? = try aggregateChannel.readInbound() + XCTAssertEqual(aggregated?.opcode, .text) + var aggregatedData = try XCTUnwrap(aggregated).unmaskedData + XCTAssertEqual(aggregatedData.readString(length: aggregatedData.readableBytes), "{\"type\":\"query\"}") + XCTAssertNoThrow(try aggregateChannel.finish()) + } + + func testDiscoveryRejectsInsecureLocalDescriptors() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + + try Discovery.registerProvider( + id: "secure-app", + name: "Secure App", + transport: ProviderTransport(type: .ws, url: "ws://127.0.0.1/slop"), + directory: directory + ) + XCTAssertEqual(Discovery.readDescriptors(from: [directory]).count, 1) + + let descriptorFile = directory.appendingPathComponent("secure-app.json") + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: descriptorFile.path) + XCTAssertTrue(Discovery.readDescriptors(from: [directory]).isEmpty) + + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: descriptorFile.path) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path) + XCTAssertTrue(Discovery.readDescriptors(from: [directory]).isEmpty) + } + + func testBridgeRelayTransportAndDiscoveryService() async throws { + let bridge = FakeBridge() + let service = DiscoveryService( + options: DiscoveryOptions(providerDirectories: [], bridges: [bridge]) + ) + + let discovered = service.getDiscovered() + XCTAssertEqual(discovered.first?.id, "tab-1") + XCTAssertEqual(discovered.first?.transport.type, .relay) + + let connected = try await service.ensureConnected("tab-1") + let provider = try XCTUnwrap(connected) + XCTAssertEqual(provider.id, "tab-1") + XCTAssertEqual(provider.consumer.getTree(subscriptionID: provider.subscriptionID)?.id, "tab-1") + XCTAssertTrue(bridge.sentTypes().contains("relay-open")) + XCTAssertTrue(bridge.sentTypes().contains("slop-relay")) + } + + func testBridgeDisconnectClosesRelayConsumerAndPendingRequests() async throws { + let bridge = FakeBridge() + let consumer = SlopConsumer(transport: BridgeRelayTransport(bridge: bridge, providerKey: "tab-1")) + _ = try await consumer.connect() + let queryTask = Task { try await consumer.query() } + try await waitUntil { + bridge.sentInnerTypes().contains("query") + } + + bridge.simulateDisconnect() + + do { + _ = try await queryTask.value + XCTFail("Expected bridge loss to fail the pending relay query") + } catch { + XCTAssertNotNil(error as? SlopError) + } + } + + func testBridgeRelayConnectionSerializesSuspendedSends() async throws { + let bridge = SuspendingBridge() + let connection = BridgeRelayConnection(bridge: bridge, providerKey: "tab-1", unsubscribe: {}) + + connection.send(["type": "first"]) + connection.send(["type": "second"]) + try await waitUntil { bridge.sentInnerTypes() == ["first"] } + XCTAssertEqual(bridge.sentInnerTypes(), ["first"]) + + await bridge.releaseFirstSend() + try await waitUntil { bridge.sentInnerTypes() == ["first", "second"] } + } + + func testBridgeServerStopNotifiesRelayConnections() throws { + let bridge = BridgeServer(port: 0) + let connection = BridgeRelayConnection(bridge: bridge, providerKey: "tab-1", unsubscribe: {}) + let closed = TestFlag() + let unsubscribe = bridge.onDisconnect { + connection.bridgeDidDisconnect() + } + defer { unsubscribe() } + connection.onClose { closed.set() } + + try bridge.start() + bridge.stop() + + XCTAssertTrue(closed.value) + } + + func testBridgeClientStopNotifiesDisconnectOnce() async throws { + let server = BridgeServer(port: 0) + try server.start() + defer { server.stop() } + let client = BridgeClient(url: try XCTUnwrap(server.url.flatMap(URL.init(string:)))) + try await client.connectOnce() + let disconnects = TestCounter() + let unsubscribe = client.onDisconnect { disconnects.increment() } + defer { unsubscribe() } + + client.stop() + try await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(disconnects.value, 1) + } + + func testBridgeClientSingleFlightsConnectAndRejectsPostStopResult() async throws { + let transport = SuspendingClientTransport() + let client = BridgeClient(transportFactory: { transport }) + let disconnects = TestCounter() + client.onDisconnect { disconnects.increment() } + let first = Task { try await client.connectOnce() } + let second = Task { try await client.connectOnce() } + try await waitUntil { transport.connectCount == 1 } + + client.stop() + client.stop() + await transport.release() + + do { + try await first.value + XCTFail("Expected stopped connection attempt to fail") + } catch {} + do { + try await second.value + XCTFail("Expected stopped connection attempt to fail") + } catch {} + XCTAssertEqual(transport.connectCount, 1) + XCTAssertEqual(disconnects.value, 1) + XCTAssertTrue(transport.connectionClosed) + } + + func testBridgeClientIgnoresMessagesFromStoppedConnection() async throws { + let connection = InMemoryConnection() + let client = BridgeClient(transportFactory: { InMemoryTransport(connection: connection) }) + try await client.connectOnce() + client.stop() + + connection.receive([ + "type": "provider-available", + "providerKey": "stale", + "provider": .object(["id": "stale", "name": "Stale", "transport": "postmessage"]), + ]) + + XCTAssertTrue(client.providers().isEmpty) + } + + func testBridgeServerTracksProvidersAndDispatchesRelay() async throws { + let bridge = BridgeServer( + port: 0, + allowedOrigins: ["https://trusted.example"], + authenticate: { head, _ in + head.headers.first(name: "Authorization") == "Bearer bridge-secret" + } + ) + try bridge.start() + defer { bridge.stop() } + + let url = try XCTUnwrap(bridge.url.flatMap(URL.init(string:))) + var rejectedRequest = URLRequest(url: url) + rejectedRequest.setValue("https://evil.example", forHTTPHeaderField: "Origin") + rejectedRequest.setValue("Bearer bridge-secret", forHTTPHeaderField: "Authorization") + let rejectedConsumer = SlopConsumer(transport: URLSessionWebSocketTransport(request: rejectedRequest)) + do { + _ = try await rejectedConsumer.connect() + XCTFail("Expected the bridge to reject an untrusted browser origin") + } catch { + XCTAssertNotNil(error as? SlopError) + } + + var authorizedRequest = URLRequest(url: url) + authorizedRequest.setValue("https://trusted.example", forHTTPHeaderField: "Origin") + authorizedRequest.setValue("Bearer bridge-secret", forHTTPHeaderField: "Authorization") + let connection = try await URLSessionWebSocketTransport(request: authorizedRequest).connect() + defer { connection.close() } + + connection.send([ + "type": "provider-available", + "tabId": 7, + "providerKey": "tab-bridge", + "provider": .object(["id": "app", "name": "App", "transport": "postmessage"]), + ]) + try await waitUntil { + bridge.providers().first?.providerKey == "tab-bridge" + } + + let relay = MessageBox() + let unsubscribe = bridge.subscribeRelay(providerKey: "tab-bridge") { message in + relay.set(message) + } + defer { unsubscribe() } + + connection.send([ + "type": "slop-relay", + "providerKey": "tab-bridge", + "message": .object(["type": "hello"]), + ]) + try await waitUntil { + relay.value()?["type"] == "hello" + } + + let secondConnection = try await URLSessionWebSocketTransport(request: authorizedRequest).connect() + defer { secondConnection.close() } + let secondMessages = MessageList() + secondConnection.onMessage { secondMessages.append($0) } + secondConnection.send([ + "type": "provider-available", + "providerKey": "tab-second", + "provider": .object(["id": "second", "name": "Second", "transport": "postmessage"]), + ]) + try await waitUntil { bridge.providers().count == 2 } + + connection.close() + try await waitUntil { + bridge.providers().map(\.providerKey) == ["tab-second"] + } + try await waitUntil { + secondMessages.values().contains { + $0["type"] == "provider-unavailable" && $0["providerKey"] == "tab-bridge" + } + } + } + + func testBridgeWebSocketPolicyRejectsUnauthorizedUpgrades() throws { + let remoteAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 9000) + var untrusted = HTTPRequestHead(version: .http1_1, method: .GET, uri: defaultBridgePath) + untrusted.headers.add(name: "Origin", value: "https://evil.example") + untrusted.headers.add(name: "Authorization", value: "Bearer bridge-secret") + + XCTAssertFalse( + isAllowedWebSocketUpgrade( + untrusted, + remoteAddress: remoteAddress, + path: defaultBridgePath, + allowedOrigins: ["https://trusted.example"], + authenticate: { head, _ in + head.headers.first(name: "Authorization") == "Bearer bridge-secret" + } + ) + ) + + var unauthenticated = HTTPRequestHead(version: .http1_1, method: .GET, uri: defaultBridgePath) + unauthenticated.headers.add(name: "Origin", value: "https://trusted.example") + XCTAssertFalse( + isAllowedWebSocketUpgrade( + unauthenticated, + remoteAddress: remoteAddress, + path: defaultBridgePath, + allowedOrigins: ["https://trusted.example"], + authenticate: { head, _ in + head.headers.first(name: "Authorization") == "Bearer bridge-secret" + } + ) + ) + } + + #if canImport(Darwin) + func testUnixSocketConnectFailsWhenPeerClosesBeforeHandlersRegister() async throws { + let socketPath = "/tmp/slop-close-\(UUID().uuidString.prefix(8)).sock" + let listener = try makeUnixListener(path: socketPath) + defer { + Darwin.close(listener) + Darwin.unlink(socketPath) + } + DispatchQueue.global(qos: .utility).async { + let fd = Darwin.accept(listener, nil, nil) + guard fd >= 0 else { return } + Darwin.close(fd) + } + + let consumer = SlopConsumer(transport: UnixSocketClientTransport(path: socketPath)) + do { + _ = try await consumer.connect() + XCTFail("Expected connect to fail when the peer closed before hello") + } catch { + XCTAssertNotNil(error as? SlopError) + } + } + + func testUnixSocketClientTransportConnectsOverNDJSON() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let socketPath = directory.appendingPathComponent("slop.sock").path + let listener = try makeUnixListener(path: socketPath) + defer { + Darwin.close(listener) + Darwin.unlink(socketPath) + } + + let received = MessageBox() + DispatchQueue.global(qos: .utility).async { + let fd = Darwin.accept(listener, nil, nil) + guard fd >= 0 else { return } + defer { Darwin.close(fd) } + setReadTimeout(fd, seconds: 5) + + writeUnixJSON( + fd, + [ + "type": "hello", + "provider": .object([ + "id": "mac-app", + "name": "Mac App", + "slop_version": "0.1", + "capabilities": ["state"], + ]), + ] + ) + + guard let query = readUnixJSON(fd) else { return } + received.set(query) + let id = query["id"]?.stringValue ?? "q-1" + writeUnixJSON( + fd, + snapshotMessage( + id: id, + version: 1, + tree: SlopNode(id: "mac-app", type: "root", children: [SlopNode(id: "window", type: "view")]) + ) + ) + } + + let consumer = SlopConsumer(transport: UnixSocketClientTransport(path: socketPath)) + let hello = try await consumer.connect() + XCTAssertEqual(hello["type"], "hello") + XCTAssertEqual(hello["provider"]?.objectValue?["id"], "mac-app") + + let tree = try await consumer.query(path: "/", depth: 1) + XCTAssertEqual(tree.id, "mac-app") + XCTAssertEqual(tree.children?.first?.id, "window") + XCTAssertEqual(received.value()?["type"], "query") + } + + func testSwiftAppCanActAsProviderOverUnixSocketAndDiscovery() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let socketPath = directory.appendingPathComponent("provider.sock").path + let discoveryDirectory = directory.appendingPathComponent("providers") + + let server = SlopServer(id: "swift-provider", name: "Swift Provider") + try server.register( + "status", + descriptor: NodeDescriptor( + type: "status", + props: ["healthy": true], + actions: [ + "ping": .value { _ in + .object(["pong": true]) + } + ] + ) + ) + + let listener = try server.listenUnix(path: socketPath, discover: true, discoveryDirectory: discoveryDirectory) + defer { listener.stop() } + + let descriptor = try XCTUnwrap(Discovery.readDescriptors(from: [discoveryDirectory]).first) + XCTAssertEqual(descriptor.id, "swift-provider") + XCTAssertEqual(descriptor.transport.type, .unix) + + let consumer = try XCTUnwrap(SlopConsumer(descriptor: descriptor)) + let hello = try await consumer.connect() + XCTAssertEqual(hello["provider"]?.objectValue?["id"], "swift-provider") + + let subscription = try await consumer.subscribe(path: "/", depth: -1) + XCTAssertEqual(subscription.snapshot.children?.first?.id, "status") + + let queried = try await consumer.query(path: "/status", depth: 1) + XCTAssertEqual(queried.properties?["healthy"], true) + + let result = try await consumer.invoke(path: "/status", action: "ping") + XCTAssertEqual(result["status"], "ok") + XCTAssertEqual(result["data"]?.objectValue?["pong"], true) + } + #endif + + func testDiscoveryDescriptorFilenameValidation() { + XCTAssertTrue(Discovery.isValidDescriptorFilename("my-app_1.json")) + XCTAssertFalse(Discovery.isValidDescriptorFilename("../bad.json")) + XCTAssertFalse(Discovery.isValidDescriptorFilename("Bad.json")) + XCTAssertFalse(Discovery.isValidDescriptorFilename("é.json")) + XCTAssertFalse(Discovery.isValidDescriptorFilename("١.json")) + } +} + +private enum TestActionError: Error { + case failed +} + +private final class UncheckedSendable: @unchecked Sendable { + let value: Value + + init(_ value: Value) { + self.value = value + } +} + +private final class RecordingConnection: SlopConnection { + var messages: [SlopMessage] = [] + + func send(_ message: SlopMessage) { + messages.append(message) + } + + func onMessage(_ handler: @escaping (SlopMessage) -> Void) {} + + func onClose(_ handler: @escaping () -> Void) {} + + func close() {} +} + +private final class MessageList: @unchecked Sendable { + private let lock = NSLock() + private var messages: [SlopMessage] = [] + + func append(_ message: SlopMessage) { + lock.lock() + messages.append(message) + lock.unlock() + } + + func values() -> [SlopMessage] { + lock.lock() + let values = messages + lock.unlock() + return values + } +} + +private final class MessageBox: @unchecked Sendable { + private let lock = NSLock() + private var message: SlopMessage? + + func set(_ message: SlopMessage) { + lock.lock() + self.message = message + lock.unlock() + } + + func value() -> SlopMessage? { + lock.lock() + let value = message + lock.unlock() + return value + } +} + +private final class ProviderRegistrationList: @unchecked Sendable { + private let lock = NSLock() + private var registrations: [ProviderRegistration] = [] + + func append(_ registration: ProviderRegistration) { + lock.lock() + registrations.append(registration) + lock.unlock() + } + + func values() -> [ProviderRegistration] { + lock.lock() + defer { lock.unlock() } + return registrations + } +} + +private final class TestFlag: @unchecked Sendable { + private let lock = NSLock() + private var storage = false + + var value: Bool { + lock.lock() + defer { lock.unlock() } + return storage + } + + func set() { + lock.lock() + storage = true + lock.unlock() + } +} + +private final class TestCounter: @unchecked Sendable { + private let lock = NSLock() + private var storage = 0 + + var value: Int { + lock.lock() + defer { lock.unlock() } + return storage + } + + func increment() { + lock.lock() + storage += 1 + lock.unlock() + } +} + +private actor TestAsyncGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func open() { + isOpen = true + let pending = waiters + waiters.removeAll() + for continuation in pending { + continuation.resume() + } + } +} + +private final class TestStoreSubscription: StoreSubscription, @unchecked Sendable { + private let lock = NSLock() + private var callback: (() -> Void)? + + init(_ callback: @escaping () -> Void) { + self.callback = callback + } + + func unsubscribe() { + lock.lock() + let callback = callback + self.callback = nil + lock.unlock() + callback?() + } +} + +private final class TestIntStore: StateStore, @unchecked Sendable { + private let lock = NSLock() + private var state = 0 + private var listeners: [UUID: () -> Void] = [:] + + func getState() -> Int { + lock.lock() + defer { lock.unlock() } + return state + } + + @discardableResult + func subscribe(_ listener: @escaping () -> Void) -> StoreSubscription { + let token = UUID() + lock.lock() + listeners[token] = listener + lock.unlock() + return TestStoreSubscription { [weak self] in + self?.lock.lock() + self?.listeners.removeValue(forKey: token) + self?.lock.unlock() + } + } + + func set(_ value: Int) { + lock.lock() + state = value + let callbacks = Array(listeners.values) + lock.unlock() + for callback in callbacks { + callback() + } + } +} + +private final class TransitionDuringSubscribeStore: StateStore { + private var state = 0 + + func getState() -> Int { + state + } + + @discardableResult + func subscribe(_ listener: @escaping () -> Void) -> StoreSubscription { + state = 1 + return TestStoreSubscription {} + } +} + +private final class TestStoreTarget: StoreTarget, @unchecked Sendable { + private let lock = NSLock() + private var operations = 0 + private var registeredPath: String? + + var operationCount: Int { + lock.lock() + defer { lock.unlock() } + return operations + } + + var lastRegisteredPath: String? { + lock.lock() + defer { lock.unlock() } + return registeredPath + } + + func register(_ path: String, descriptor: NodeDescriptor) throws { + lock.lock() + operations += 1 + registeredPath = path + lock.unlock() + } + + func unregister(_ path: String, recursive: Bool) throws { + lock.lock() + operations += 1 + lock.unlock() + } +} + +private struct FailingStoreTarget: StoreTarget { + func register(_ path: String, descriptor: NodeDescriptor) throws { + throw SlopError.invalidNodeId("Rejected test path") + } + + func unregister(_ path: String, recursive: Bool) throws {} +} + +private final class TransitionFailingStoreTarget: StoreTarget, @unchecked Sendable { + private let lock = NSLock() + private let failingPath: String + private var paths: Set = [] + + init(failingPath: String) { + self.failingPath = failingPath + } + + func register(_ path: String, descriptor: NodeDescriptor) throws { + guard path != failingPath else { + throw SlopError.invalidNodeId("Rejected test path") + } + lock.lock() + paths.insert(path) + lock.unlock() + } + + func unregister(_ path: String, recursive: Bool) throws { + lock.lock() + paths.remove(path) + lock.unlock() + } + + func contains(_ path: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return paths.contains(path) + } +} + +private final class PipeJSONReader: @unchecked Sendable { + private let handle: FileHandle + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var messages: [SlopMessage] = [] + + init(_ handle: FileHandle) { + self.handle = handle + DispatchQueue.global(qos: .utility).async { [weak self] in + self?.readLoop() + } + } + + func next(timeout: TimeInterval = 3) -> SlopMessage? { + guard semaphore.wait(timeout: .now() + timeout) == .success else { + return nil + } + lock.lock() + let message = messages.isEmpty ? nil : messages.removeFirst() + lock.unlock() + return message + } + + private func readLoop() { + var pending = Data() + while true { + let chunk = handle.readData(ofLength: 1) + if chunk.isEmpty { + return + } + if chunk.first == 0x0A { + guard !pending.isEmpty else { continue } + if case .object(let message) = try? JSONDecoder().decode(JSONValue.self, from: pending) { + lock.lock() + messages.append(message) + lock.unlock() + semaphore.signal() + } + pending.removeAll() + } else { + pending.append(chunk) + } + } + } +} + +private final class FakeBridge: DiscoveryBridge, @unchecked Sendable { + private let lock = NSLock() + private var relaySubscribers: [String: [BridgeRelayHandler]] = [:] + private var disconnectCallbacks: [UUID: () -> Void] = [:] + private var sent: [SlopMessage] = [] + private var connected = true + + var running: Bool { true } + + func providers() -> [BridgeProvider] { + [BridgeProvider(providerKey: "tab-1", id: "tab-app", name: "Tab App")] + } + + @discardableResult + func onProviderChange(_ callback: @escaping () -> Void) -> () -> Void { + {} + } + + @discardableResult + func onDisconnect(_ callback: @escaping () -> Void) -> () -> Void { + let token = UUID() + locked { + disconnectCallbacks[token] = callback + } + return { [weak self] in + _ = self?.locked { + self?.disconnectCallbacks.removeValue(forKey: token) + } + } + } + + @discardableResult + func subscribeRelay(providerKey: String, handler: @escaping BridgeRelayHandler) -> () -> Void { + lock.lock() + relaySubscribers[providerKey, default: []].append(handler) + lock.unlock() + return { [weak self] in + self?.lock.lock() + self?.relaySubscribers[providerKey]?.removeAll() + self?.lock.unlock() + } + } + + func send(_ message: SlopMessage) async throws { + let providerKey = message["providerKey"]?.stringValue ?? "" + let subscribers = try locked { () throws -> [BridgeRelayHandler] in + guard connected else { + throw SlopError.internalError("Bridge disconnected") + } + sent.append(message) + return relaySubscribers[providerKey] ?? [] + } + + guard message["type"]?.stringValue == "slop-relay", let inner = message["message"]?.objectValue else { + return + } + + switch inner["type"]?.stringValue { + case "connect": + for subscriber in subscribers { + subscriber([ + "type": "hello", + "provider": .object(["id": "tab-1", "name": "Tab App", "slop_version": "0.1", "capabilities": ["state"]]), + ]) + } + case "subscribe": + let id = inner["id"]?.stringValue ?? "sub-1" + for subscriber in subscribers { + subscriber(snapshotMessage(id: id, version: 1, seq: 0, tree: SlopNode(id: "tab-1", type: "root"))) + } + default: + break + } + } + + func stop() {} + + func sentTypes() -> [String] { + locked { + sent.compactMap { $0["type"]?.stringValue } + } + } + + func sentInnerTypes() -> [String] { + locked { + sent.compactMap { $0["message"]?.objectValue?["type"]?.stringValue } + } + } + + func simulateDisconnect() { + let callbacks = locked { () -> [() -> Void] in + connected = false + return Array(disconnectCallbacks.values) + } + for callback in callbacks { + callback() + } + } + + private func locked(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body() + } +} + +private final class SuspendingClientTransport: ClientTransport, @unchecked Sendable { + private let gate = TestAsyncGate() + private let counter = TestCounter() + private let closed = TestFlag() + private let connection: InMemoryConnection + + init() { + connection = InMemoryConnection() + connection.onClose { [closed] in closed.set() } + } + + var connectCount: Int { counter.value } + var connectionClosed: Bool { closed.value } + + func connect() async throws -> SlopConnection { + counter.increment() + await gate.wait() + return connection + } + + func release() async { + await gate.open() + } +} + +private final class SuspendingBridge: DiscoveryBridge, @unchecked Sendable { + private let lock = NSLock() + private let firstSendGate = TestAsyncGate() + private var innerTypes: [String] = [] + + var running: Bool { true } + + func providers() -> [BridgeProvider] { + [BridgeProvider(providerKey: "tab-1")] + } + + @discardableResult + func onProviderChange(_ callback: @escaping () -> Void) -> () -> Void { + {} + } + + @discardableResult + func onDisconnect(_ callback: @escaping () -> Void) -> () -> Void { + {} + } + + @discardableResult + func subscribeRelay(providerKey: String, handler: @escaping BridgeRelayHandler) -> () -> Void { + {} + } + + func send(_ message: SlopMessage) async throws { + let count: Int = locked { + if let type = message["message"]?.objectValue?["type"]?.stringValue { + innerTypes.append(type) + } + return innerTypes.count + } + if count == 1 { + await firstSendGate.wait() + } + } + + func stop() {} + + func sentInnerTypes() -> [String] { + locked { innerTypes } + } + + func releaseFirstSend() async { + await firstSendGate.open() + } + + private func locked(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } +} + +private func writePipeJSON(_ handle: FileHandle, _ message: SlopMessage) { + guard var data = try? JSONEncoder().encode(JSONValue.object(message)) else { return } + data.append(0x0A) + handle.write(data) +} + +private func waitUntil( + timeout: TimeInterval = 3, + file: StaticString = #filePath, + line: UInt = #line, + condition: @escaping () -> Bool +) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return + } + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTFail("Timed out waiting for condition", file: file, line: line) +} + +#if canImport(Darwin) +private func makeUnixListener(path: String) throws -> Int32 { + Darwin.unlink(path) + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw SlopError.internalError("socket failed: \(testErrnoDescription())") + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(path.utf8) + [0] + guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + Darwin.close(fd) + throw SlopError.internalError("socket path too long") + } + withUnsafeMutableBytes(of: &address.sun_path) { rawBuffer in + rawBuffer.copyBytes(from: pathBytes) + } + + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + Darwin.close(fd) + throw SlopError.internalError("bind failed: \(testErrnoDescription())") + } + + guard Darwin.listen(fd, 1) == 0 else { + Darwin.close(fd) + throw SlopError.internalError("listen failed: \(testErrnoDescription())") + } + + return fd +} + +private func setReadTimeout(_ fd: Int32, seconds: Int) { + var timeout = timeval(tv_sec: seconds, tv_usec: 0) + withUnsafePointer(to: &timeout) { pointer in + _ = pointer.withMemoryRebound(to: UInt8.self, capacity: MemoryLayout.size) { rawPointer in + Darwin.setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, rawPointer, socklen_t(MemoryLayout.size)) + } + } +} + +private func writeUnixJSON(_ fd: Int32, _ message: SlopMessage) { + guard var data = try? JSONEncoder().encode(JSONValue.object(message)) else { return } + data.append(0x0A) + data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.baseAddress else { return } + var written = 0 + while written < data.count { + let count = Darwin.write(fd, base.advanced(by: written), data.count - written) + if count <= 0 { + return + } + written += count + } + } +} + +private func readUnixJSON(_ fd: Int32) -> SlopMessage? { + var data = Data() + var byte: UInt8 = 0 + while true { + let count = Darwin.read(fd, &byte, 1) + guard count == 1 else { return nil } + if byte == 0x0A { + break + } + data.append(byte) + } + guard case .object(let message) = try? JSONDecoder().decode(JSONValue.self, from: data) else { + return nil + } + return message +} + +private func testErrnoDescription() -> String { + String(cString: strerror(errno)) +} +#endif