diff --git a/CHANGELOG.md b/CHANGELOG.md index f223270..14a9168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to PatternSpaceSDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows semantic versioning. +## [1.1.0] - 2026-10-09 + +### Added +- Optional `device.signalSnapshot` read route and `DeviceNamespace.signalSnapshot()` returning schema-1 immutable evidence separately from connection-bound probe authorization. The delegate receives the authenticated `OutputRequestContext`; reads remain available while the JSON source is inactive. +- Optional `pattern.displayProbe` route and `PatternNamespace.displayProbe(_:)` carrying the existing patch shape plus a bounded opaque `expectedContextGuard`. The SDK reuses ordinary patch validation and forwards guard/context to the host for atomic authorization and admission. It never falls back to `displayPatch`. +- Typed source samples, scalar stage observations, separate accepted/published identities, draw/mapping/presentation identities, capture intervals, explicit unknown/omission reasons, and forward-compatible open tokens. Evidence excludes guard and packed frame buffers. Encoded responses are bounded to 256 KiB; each stage retains at most 64 samples with explicit omitted counts. Oversized optional details are marked omitted; oversized identity fails safely. +- Default unsupported delegate hooks and `supportsSignalSnapshot` / `supportsSignalProbe` opt-ins preserve 1.0.0 conformers. Optional `CapabilityFeatures.signalSnapshot` / `signalProbe` have trailing initializer defaults and decode absent from older hosts. + +### Changed +- `capabilities.list` filters only unsupported new routes/flags, preserving all existing namespace entries. Unsupported new methods return method-not-found. +- `PatternSpaceProtocolMetadata.sdkVersion` is now `1.1.0`; JSON protocol remains `1.3`. Existing routes, device bit-depth meaning, ACK behavior and global session timeout behavior remain unchanged. + +### Fixed +- Both client transports now accept the full 256 KiB SDK response bound, so valid signal snapshots larger than 64 KiB work on native Network.framework endpoints as well as URLSession endpoints. Server incoming request limits remain unchanged. +- Invalid mandatory capture timestamps fail validation rather than being replaced with zero. Unsupported evidence schema versions fail typed validation before evidence or probe authorization is decoded. + +### Host integration +- Guards belong to the authenticated connection and stable expected target/configuration/ownership context, independent of snapshot revisions and new patches. Historical responses must have no authorization; stable Refresh must not rotate a current guard. Stale context uses `displayError`, a different connection uses `notAuthorized`, inactive source uses `sourceNotActive`, malformed guard uses `invalidParams`. +- Share only evidence and apply host export redaction to identifiers/optional descriptions. The SDK does not inspect OS profiles, calculate SDI packing, attest physical transport, or supply a per-request timeout. + ## [1.0.0] - 2026-10-03 ### Added diff --git a/Documentation/SignalTransparency.md b/Documentation/SignalTransparency.md new file mode 100644 index 0000000..a5b2a8a --- /dev/null +++ b/Documentation/SignalTransparency.md @@ -0,0 +1,107 @@ +# Signal transparency (SDK 1.1.0) + +These optional additions retain PatternSpace JSON protocol 1.3. Evidence has its own schema version, currently 1. No new listener, credential, dependency or physical output behavior is introduced. + +## Discovery and compatibility + +`CapabilityFeatures.signalSnapshot` and `signalProbe` are optional Boolean fields with nil defaults. Older hosts omit them. Clients enable a feature only when its flag is true and handle method-not-found safely. The dispatcher filters the two unsupported methods from the host-supplied namespace manifest and removes their flags. Every existing namespace entry stays unchanged. `JSONRPCDispatcher.routeManifest` is the complete SDK surface, including optional methods. + +Existing server conformers need no additions: `supportsSignalSnapshot` and `supportsSignalProbe` default to false, and the associated methods default to throwing `methodNotFound`. Opt-in hosts implement the flag, method and advertised capability together. No cases are added to existing public enums. + +## `device.signalSnapshot` + +Accepts absent parameters, `{}` or `[]`. Nonempty or scalar parameters, including null, return `invalidParams`. The authenticated connection identity is supplied by the server, never accepted from JSON. Reading does not require the JSON source to be active and must not admit output or change ownership. + +The delegate is: + +```swift +func signalSnapshot(context: OutputRequestContext) async throws -> SignalSnapshotResponse +``` + +The result is a response envelope: + +```json +{ + "evidence": { + "schemaVersion": 1, + "identity": { + "diagnosticsEpoch": "export-local-epoch", + "revision": 7, + "targetRevision": 2, + "configurationRevision": 4, + "ownershipRevision": 3 + }, + "capture": { "startedAt": 1791450000, "completedAt": 1791450000.2 }, + "freshness": "current", + "source": { "status": "unknown", "fields": [], "samples": [], "totalSampleCount": 0, "omittedSampleCount": 0 }, + "appMapping": { "status": "unknown", "fields": [], "samples": [], "totalSampleCount": 0, "omittedSampleCount": 0 }, + "sdiFrame": { "status": "notApplicable", "fields": [], "samples": [], "totalSampleCount": 0, "omittedSampleCount": 0 }, + "physicalSignal": { "status": "unknown", "fields": [], "samples": [], "totalSampleCount": 0, "omittedSampleCount": 0 }, + "omissions": [] + } +} +``` + +Optional `probeAuthorization` is omitted from this evidence-only example. It contains `expectedContextGuard` and `context`, where context declares the diagnostics epoch, optional target lifetime, target revision, configuration revision and ownership revision. A diagnostics revision or new same-owner patch does not change this context. Never export the response envelope or authorization. + +## Evidence fields + +- `identity` separates latest accepted content from currently published/coalesced content using optional `acceptedContentID` / `acceptedSequence` and `publishedContentID` / `publishedSequence`. It also carries optional `sourceLifetime` and `targetLifetime`. Revision is ordered only within one diagnostics epoch, minted independently of JSON server startup. +- `capture` is an interval in Unix seconds. `freshness` uses `current`, `historical` or `collecting`. Profile/headroom field observations may carry their own intervals; no atomic OS-settings observation is implied. +- Four stages are `source`, `appMapping`, `sdiFrame`, and `physicalSignal`. A stage declares `status`, optional `provenance` and `reason`, typed `fields`, typed `samples`, total sample count and omitted sample count. +- A stage may carry the actual `contentID`, `configurationIdentity`, `mappingIdentity`, `presentationIdentity`, `targetLifetime`, monotonic `drawSequence`, `submittedAt`, `presentedAt`, and separate capture interval. Selected configuration and an older confirmed presentation must remain separately identifiable. Callback time is an app observation, not a measured panel response time. +- `SignalFieldEvidence` uses machine-key `name`, `provenance`, optional `number`, `integer`, `token`, `flag` or `text`, optional `units`, `reason`, and `capture`. Field names, units and tokens are not localized labels. Hosts must declare concrete stage semantics and avoid interpreting one stage's numbers as another stage's physical codes. +- `SignalSampleEvidence` identifies background/rectangle `role` and zero-based `ordinal`. It separates `receivedIntegers` from `receivedValues`, `effectiveRendererInput` and `calculatedCodes`, with optional representation, declared bit depth, geometry, fields and reason. Component structs use `first`, `second`, `third`; containing observations declare RGB versus Y/Cb/Cr and units/layout. Received codes must come from ingress, not a reconstruction of normalized values. Library pattern identifiers/parameters and blank state belong in explicit fields rather than invented RGB samples. + +Provenance includes received, calculated, appReported, packed, unknown and notApplicable. Reasons include nonFinite, superseded, readerBusy, timedOut, rectangleLimit and payloadLimit. Physical HDMI link depth, cable codes, dithering and receiver behavior remain unknown without external capture. SDI packed-byte evidence establishes software encoding only. There is no bit-perfect certification. + +`SignalToken` is an open Codable raw string. Unknown tokens are preserved on round trip and must be displayed as unknown/unavailable, never mapped to a recognized success. Unknown additive object fields are ignored. `decodeValidated` and client reads reject an unsupported major schema with `SignalSnapshotValidationError.unsupportedSchema(version)` before decoding stage fields or authorization. `validatedData` likewise rejects unsupported schemas. The `isSupportedSchema` convenience remains available for locally constructed evidence; it is not a substitute for validated wire decoding. Existing device-info bit depth keeps its prior meaning and cannot substitute for physical signal evidence. + +## Bounds and encoding + +Every stage retains at most 64 sample records, in order, with `totalSampleCount` and `omittedSampleCount`. The initializer bounds records; validated decoding rejects oversized or inconsistent wire counts. Missing records are explicitly omitted and must not appear silently absent. No frame buffer field exists. + +`SignalSnapshotResponse.validatedData()` verifies schema version, finite numbers and authorization consistency, then encodes sorted-key UTF-8 JSON. It caps the payload at 256 KiB. If optional stage fields/samples exceed the bound, it removes those details, preserves immutable identities/counts, adds top-level `payloadLimit`, and marks affected stages with that omission reason. If the retained identity/envelope still exceeds the limit, encoding throws. The dispatcher also checks the complete JSON-RPC success response bound. `decodeValidated` checks bytes and the schema header before decoding and validates finite fields/counts afterward. Both client transports accept complete incoming SDK messages up to 256 KiB, including the JSON-RPC envelope; server incoming request limits are unchanged. Malformed or oversized host evidence becomes an error, not plausible truncated evidence. + +Nonfinite optional scalar/sample inputs become unavailable with `nonFinite`. Mandatory capture intervals retain their original timestamp inputs; NaN/infinity or reversed intervals fail validation as `invalidEvidence` rather than inventing an observation time. Other invalid geometry values fail validated encoding. Consumers should use validated envelope APIs instead of directly decoding untrusted evidence. The SDK does not perform user export redaction: hosts must replace potentially identifying lifetimes/IDs with export-local opaque IDs and remove sensitive names, paths, IPs, serials and sessions from optional descriptions. Share `evidence` only, using a frozen redacted copy. + +## `pattern.displayProbe` + +Parameters are the flat `PatchParams` shape plus `expectedContextGuard`: + +```json +{ + "background": { "r": 0, "g": 0, "b": 0 }, + "rectangles": [ + { "color": { "r": 1, "g": 0, "b": 0 }, "x": 0, "y": 0, "width": 1, "height": 1 } + ], + "bitDepth": 10, + "expectedContextGuard": "opaque-connection-context-token" +} +``` + +Swift wraps these in `SignalProbeParams(patch:expectedContextGuard:)`. The guard must contain 1–512 bytes of printable non-whitespace ASCII; contents are opaque and have no SDK semantics. Missing, wrong-type, empty, oversized or malformed guards return `invalidParams`. Patch color, geometry, count and bit depth pass the exact shared ordinary patch parser. Unsupported probes return method-not-found even while the JSON source is inactive; supported probes require an active source. + +The delegate is: + +```swift +func displayProbe(_ params: SignalProbeParams, context: OutputRequestContext) async throws +``` + +Hosts must compare the authenticated connection, guard and current expected context synchronously on the admission actor immediately before existing output admission, with no suspension between check and commit. The dispatcher cannot validate app-owned context. Stale target/configuration/ownership context throws `displayError`; a guard bound to a different connection throws `notAuthorized`; inactive source throws `sourceNotActive`. Rejected probes must not mutate output, advance ownership or consume render allowance. Never implement unsupported or stale probes using unguarded `displayPatch` fallback. + +Issue one stable guard for the eligible authenticated connection and exact context. Refresh, newer diagnostics revisions, same-owner patches, reader timeout and cancelled reads must not rotate it. Real context/eligibility transitions or connection/service replacement retire it. Changes A→B→A must not revive old guards. Capture evidence first, reconcile eligibility/context during response assembly, and omit authorization from historical, superseded or old-connection results. A current partial timeout may retain authorization only when readiness and eligibility are independently established. Validated envelopes reject authorization whose context differs from evidence identity or whose evidence is not current. + +The client API sends one explicit sample: + +```swift +let response = try await client.device.signalSnapshot() +if response.evidence.isSupportedSchema, + let authorization = response.probeAuthorization { + try await client.pattern.displayProbe( + SignalProbeParams(patch: sample, expectedContextGuard: authorization.expectedContextGuard) + ) +} +``` + +Display success remains ordinary admission, not physical presentation. Submitted SDI work follows the host's existing confirmation contract. SDK methods add no timeout, retry, automatic replay or transport teardown. Cancel cannot recall dispatched/admitted samples. Apps may settle a local UI deadline as outcome unknown while retaining the SDK continuation and serial output queue slot until the real response or disconnect; they must not free that slot, replace the worker or claim the sample was cancelled. Existing output blank/resume and composer ordering stays unchanged. diff --git a/README.md b/README.md index 44867d6..3556534 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ let package = Package( name: "MyTool", platforms: [.macOS(.v12), .iOS(.v15)], dependencies: [ - .package(url: "https://github.com/caplaz/PatternSpaceSDK.git", from: "1.0.0") + .package(url: "https://github.com/caplaz/PatternSpaceSDK.git", from: "1.1.0") ], targets: [ .executableTarget( @@ -446,6 +446,16 @@ See [Documentation](Documentation/Protocol.md) for the wire-format overview. The `sourceActive` field is a race-condition guard. In normal PatternSpace operation, changing away from the JSON source stops the server and closes the socket rather than keeping a client connected with `sourceActive: false`. +## Signal transparency (SDK 1.1.0) + +Check `capabilities.features.signalSnapshot == true` before reading `client.device.signalSnapshot()`. The response separates immutable `evidence` from optional connection-bound `probeAuthorization`. Share or export evidence only. Schema 1 records source request provenance, app mapping, SDI software encoding and physical-signal unknowns; software observations do not certify a physical cable or panel. + +A host opts in with `supportsSignalSnapshot` / `supportsSignalProbe` and implements `signalSnapshot(context:)` / `displayProbe(_:context:)`. Existing delegates compile unchanged: new hooks default to unsupported and new capability fields default to nil. Unsupported hosts omit the new namespace methods and feature flags and return method-not-found. Advertise each flag only for supported functionality; `JSONRPCDispatcher.routeManifest` includes optional routes and dispatch filters them for the host. + +When `features.signalProbe == true` and the snapshot provides authorization, an explicit sample uses `client.pattern.displayProbe(SignalProbeParams(patch: patch, expectedContextGuard: authorization.expectedContextGuard))`. The host must validate the authenticated connection and exact expected context atomically with its existing patch admission. The SDK shares ordinary patch validation but never substitutes an unguarded patch write. A success follows the existing admission contract; cancelling a submitted request cannot prevent a sample from appearing. The SDK adds no RPC timeout, automatic retry, or transport changes. + +See [Signal transparency](Documentation/SignalTransparency.md) for the schema, bounds, forward compatibility and host obligations. JSON protocol remains 1.3; the evidence schema is independently versioned. + ## Development ```bash diff --git a/Sources/PatternSpaceSDKClient/Namespaces/DeviceNamespace.swift b/Sources/PatternSpaceSDKClient/Namespaces/DeviceNamespace.swift index fac5984..50e0d7c 100644 --- a/Sources/PatternSpaceSDKClient/Namespaces/DeviceNamespace.swift +++ b/Sources/PatternSpaceSDKClient/Namespaces/DeviceNamespace.swift @@ -26,4 +26,12 @@ public final class DeviceNamespace: Sendable { let data = try JSONEncoder().encode(result) return try JSONDecoder().decode(DeviceStatus.self, from: data) } + /// Captures bounded immutable host evidence. Unsupported hosts return methodNotFound. + /// Share `response.evidence` only; the optional authorization is connection-specific. + public func signalSnapshot() async throws -> SignalSnapshotResponse { + struct Params: Encodable {} + let result = try await session.send(method: "device.signalSnapshot", params: Params(), via: transport) + return try SignalSnapshotResponse.decodeValidated(JSONEncoder().encode(result)) + } + } diff --git a/Sources/PatternSpaceSDKClient/Namespaces/PatternNamespace.swift b/Sources/PatternSpaceSDKClient/Namespaces/PatternNamespace.swift index f193cfe..3b72528 100644 --- a/Sources/PatternSpaceSDKClient/Namespaces/PatternNamespace.swift +++ b/Sources/PatternSpaceSDKClient/Namespaces/PatternNamespace.swift @@ -76,6 +76,16 @@ public final class PatternNamespace: Sendable { ) } + /// Explicit guarded diagnostic sample; success retains the host's normal admission contract. + /// This method never falls back to displayPatch. Check the signalProbe capability first. + /// Cancellation cannot recall an already submitted sample; no per-request timeout is added. + public func displayProbe(_ params: SignalProbeParams) async throws { + guard SignalProbeParams.isValidGuard(params.expectedContextGuard) else { + throw PSDispatchError(.invalidParams, message: "Malformed expectedContextGuard") + } + _ = try await session.send(method: "pattern.displayProbe", params: params, via: transport) + } + /// Clears the current JSON protocol pattern from the display. public func clear() async throws { struct Params: Encodable {} diff --git a/Sources/PatternSpaceSDKClient/Transport/WebSocketTransport.swift b/Sources/PatternSpaceSDKClient/Transport/WebSocketTransport.swift index ea7bf1e..c8ab6f5 100644 --- a/Sources/PatternSpaceSDKClient/Transport/WebSocketTransport.swift +++ b/Sources/PatternSpaceSDKClient/Transport/WebSocketTransport.swift @@ -1,4 +1,5 @@ import Foundation +import PatternSpaceSDKCore import Network /// WebSocket transport with a serialized delivery fence. @@ -154,6 +155,7 @@ final class WebSocketTransport: @unchecked Sendable { } let task = URLSession.shared.webSocketTask(with: request) + task.maximumMessageSize = SignalSnapshotResponse.maximumPayloadBytes socket = .urlSession(task) task.resume() receive(from: task, epoch: epoch) @@ -162,7 +164,7 @@ final class WebSocketTransport: @unchecked Sendable { private func openNativeWebSocket(to endpoint: NWEndpoint, token: String?, epoch: UInt64) { let webSocketOptions = NWProtocolWebSocket.Options() webSocketOptions.autoReplyPing = true - webSocketOptions.maximumMessageSize = 65_536 + webSocketOptions.maximumMessageSize = SignalSnapshotResponse.maximumPayloadBytes if let token { webSocketOptions.setAdditionalHeaders([("Authorization", "Bearer \(token)")]) } diff --git a/Sources/PatternSpaceSDKCore/Models/CapabilitiesSchemas.swift b/Sources/PatternSpaceSDKCore/Models/CapabilitiesSchemas.swift index 17e3f13..89713ca 100644 --- a/Sources/PatternSpaceSDKCore/Models/CapabilitiesSchemas.swift +++ b/Sources/PatternSpaceSDKCore/Models/CapabilitiesSchemas.swift @@ -23,6 +23,8 @@ public struct CapabilityFeatures: Codable, Sendable, Equatable { public let catalogPatterns: Bool public let customICCBuilder: Bool public let httpBridge: Bool + public let signalSnapshot: Bool? + public let signalProbe: Bool? public init( events: Bool, @@ -32,7 +34,9 @@ public struct CapabilityFeatures: Codable, Sendable, Equatable { measurementRange: Bool, catalogPatterns: Bool, customICCBuilder: Bool, - httpBridge: Bool + httpBridge: Bool, + signalSnapshot: Bool? = nil, + signalProbe: Bool? = nil ) { self.events = events self.displayInventory = displayInventory @@ -42,6 +46,8 @@ public struct CapabilityFeatures: Codable, Sendable, Equatable { self.catalogPatterns = catalogPatterns self.customICCBuilder = customICCBuilder self.httpBridge = httpBridge + self.signalSnapshot = signalSnapshot + self.signalProbe = signalProbe } } diff --git a/Sources/PatternSpaceSDKCore/Models/ProtocolMetadata.swift b/Sources/PatternSpaceSDKCore/Models/ProtocolMetadata.swift index 9741f93..e99f47e 100644 --- a/Sources/PatternSpaceSDKCore/Models/ProtocolMetadata.swift +++ b/Sources/PatternSpaceSDKCore/Models/ProtocolMetadata.swift @@ -6,5 +6,5 @@ public enum PatternSpaceProtocolMetadata { public static let protocolVersion = "1.3" /// SDK package version exposing this protocol surface. - public static let sdkVersion = "1.0.0" + public static let sdkVersion = "1.1.0" } diff --git a/Sources/PatternSpaceSDKCore/Models/SignalEvidenceSchemas.swift b/Sources/PatternSpaceSDKCore/Models/SignalEvidenceSchemas.swift new file mode 100644 index 0000000..e47c1b4 --- /dev/null +++ b/Sources/PatternSpaceSDKCore/Models/SignalEvidenceSchemas.swift @@ -0,0 +1,161 @@ +import Foundation + +/// Open machine-readable vocabulary. Unknown tokens are preserved, never interpreted as success. +/// Consumers must use an unknown/unavailable presentation for tokens they do not recognize. +public struct SignalToken: RawRepresentable, Codable, Sendable, Equatable, Hashable { + public let rawValue: String + public init(rawValue: String) { self.rawValue = rawValue } + public init(from decoder: Decoder) throws { rawValue = try decoder.singleValueContainer().decode(String.self) } + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + public static let current = Self(rawValue: "current") + public static let historical = Self(rawValue: "historical") + public static let collecting = Self(rawValue: "collecting") + public static let available = Self(rawValue: "available") + public static let unknown = Self(rawValue: "unknown") + public static let notApplicable = Self(rawValue: "notApplicable") + public static let received = Self(rawValue: "received") + public static let calculated = Self(rawValue: "calculated") + public static let appReported = Self(rawValue: "appReported") + public static let packed = Self(rawValue: "packed") + public static let confirmed = Self(rawValue: "confirmed") + public static let pending = Self(rawValue: "pending") + public static let failed = Self(rawValue: "failed") + public static let superseded = Self(rawValue: "superseded") + public static let readerBusy = Self(rawValue: "readerBusy") + public static let timedOut = Self(rawValue: "timedOut") + public static let nonFinite = Self(rawValue: "nonFinite") + public static let payloadLimit = Self(rawValue: "payloadLimit") + public static let rectangleLimit = Self(rawValue: "rectangleLimit") + public static let background = Self(rawValue: "background") + public static let rectangle = Self(rawValue: "rectangle") + public static let receivedInteger = Self(rawValue: "receivedInteger") + public static let receivedNormalized = Self(rawValue: "receivedNormalized") + public static let receivedCodeValue = Self(rawValue: "receivedCodeValue") +} + +/// Unix seconds for the independently captured interval. No atomic OS-settings claim is implied. +public struct SignalCaptureInterval: Codable, Sendable, Equatable { + public let startedAt: Double + public let completedAt: Double + public init(startedAt: Double, completedAt: Double) { + self.startedAt = startedAt + self.completedAt = completedAt + } +} + +/// A typed scalar observation; `name` and tokens are stable machine keys, not localized text. +public struct SignalFieldEvidence: Codable, Sendable, Equatable { + public let name: String + public let provenance: SignalToken + public let number: Double? + public let integer: Int64? + public let token: SignalToken? + public let flag: Bool? + public let text: String? + public let units: SignalToken? + public let reason: SignalToken? + public let capture: SignalCaptureInterval? + public init(name: String, provenance: SignalToken, number: Double? = nil, + integer: Int64? = nil, token: SignalToken? = nil, flag: Bool? = nil, + text: String? = nil, units: SignalToken? = nil, reason: SignalToken? = nil, + capture: SignalCaptureInterval? = nil) { + self.name = name; self.provenance = provenance + self.number = number.flatMap { $0.isFinite ? $0 : nil } + self.integer = integer; self.token = token; self.flag = flag; self.text = text + self.units = units; self.capture = capture + self.reason = number.map { !$0.isFinite } == true ? .nonFinite : reason + } +} + +/// RGB or Y/Cb/Cr components. Units/layout are declared by the containing observation. +public struct SignalComponents: Codable, Sendable, Equatable { + public let first: Double + public let second: Double + public let third: Double + public var isFinite: Bool { first.isFinite && second.isFinite && third.isFinite } + public init(first: Double, second: Double, third: Double) { + self.first = first; self.second = second; self.third = third + } +} + +public struct SignalIntegerComponents: Codable, Sendable, Equatable { + public let first: Int64 + public let second: Int64 + public let third: Int64 + public init(first: Int64, second: Int64, third: Int64) { + self.first = first; self.second = second; self.third = third + } +} + +/// Received values and calculated equivalents are separate fields. Never reconstruct received codes. +public struct SignalSampleEvidence: Codable, Sendable, Equatable { + public let role: SignalToken + public let ordinal: Int + public let representation: SignalToken? + public let declaredBitDepth: Int? + public let receivedIntegers: SignalIntegerComponents? + public let receivedValues: SignalComponents? + public let effectiveRendererInput: SignalComponents? + public let calculatedCodes: SignalIntegerComponents? + public let geometry: NormalizedRectangle? + public let fields: [SignalFieldEvidence] + public let reason: SignalToken? + public init(role: SignalToken, ordinal: Int, representation: SignalToken? = nil, + declaredBitDepth: Int? = nil, receivedIntegers: SignalIntegerComponents? = nil, + receivedValues: SignalComponents? = nil, effectiveRendererInput: SignalComponents? = nil, + calculatedCodes: SignalIntegerComponents? = nil, geometry: NormalizedRectangle? = nil, + fields: [SignalFieldEvidence] = [], reason: SignalToken? = nil) { + self.role = role; self.ordinal = ordinal; self.representation = representation + self.declaredBitDepth = declaredBitDepth; self.receivedIntegers = receivedIntegers + self.receivedValues = receivedValues.flatMap { $0.isFinite ? $0 : nil } + self.effectiveRendererInput = effectiveRendererInput.flatMap { $0.isFinite ? $0 : nil } + self.calculatedCodes = calculatedCodes; self.geometry = geometry; self.fields = fields + self.reason = receivedValues?.isFinite == false || effectiveRendererInput?.isFinite == false ? .nonFinite : reason + } +} + +/// One of source, app mapping, SDI frame or physical signal. Stages retain their own captured identities. +/// `fields` can describe layout/range/rounding, actual layer policy, headroom, and boundary availability. +public struct SignalStageEvidence: Codable, Sendable, Equatable { + public let status: SignalToken + public let provenance: SignalToken? + public let reason: SignalToken? + public let contentID: String? + public let configurationIdentity: String? + public let mappingIdentity: String? + public let presentationIdentity: String? + public let targetLifetime: String? + public let drawSequence: UInt64? + public let submittedAt: Double? + public let presentedAt: Double? + public let capture: SignalCaptureInterval? + public let fields: [SignalFieldEvidence] + public let samples: [SignalSampleEvidence] + public let totalSampleCount: Int + public let omittedSampleCount: Int + public init(status: SignalToken = .unknown, provenance: SignalToken? = nil, reason: SignalToken? = nil, + contentID: String? = nil, configurationIdentity: String? = nil, mappingIdentity: String? = nil, + presentationIdentity: String? = nil, targetLifetime: String? = nil, drawSequence: UInt64? = nil, + submittedAt: Double? = nil, presentedAt: Double? = nil, capture: SignalCaptureInterval? = nil, + fields: [SignalFieldEvidence] = [], samples: [SignalSampleEvidence] = [], totalSampleCount: Int? = nil) { + self.status = status; self.provenance = provenance; self.reason = reason + self.contentID = contentID; self.configurationIdentity = configurationIdentity + self.mappingIdentity = mappingIdentity; self.presentationIdentity = presentationIdentity + self.targetLifetime = targetLifetime; self.drawSequence = drawSequence + self.submittedAt = submittedAt.flatMap { $0.isFinite ? $0 : nil } + self.presentedAt = presentedAt.flatMap { $0.isFinite ? $0 : nil }; self.capture = capture + self.fields = fields; self.samples = Array(samples.prefix(64)) + self.totalSampleCount = max(totalSampleCount ?? samples.count, samples.count) + self.omittedSampleCount = self.totalSampleCount - self.samples.count + } + func omittingDetails() -> Self { + Self(status: status, provenance: provenance, reason: fields.isEmpty && samples.isEmpty ? reason : .payloadLimit, contentID: contentID, + configurationIdentity: configurationIdentity, mappingIdentity: mappingIdentity, + presentationIdentity: presentationIdentity, targetLifetime: targetLifetime, drawSequence: drawSequence, + submittedAt: submittedAt, presentedAt: presentedAt, capture: capture, + totalSampleCount: totalSampleCount) + } +} diff --git a/Sources/PatternSpaceSDKCore/Models/SignalProbeSchemas.swift b/Sources/PatternSpaceSDKCore/Models/SignalProbeSchemas.swift new file mode 100644 index 0000000..66ae677 --- /dev/null +++ b/Sources/PatternSpaceSDKCore/Models/SignalProbeSchemas.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Stable expected host context; diagnostics snapshot revision and patch sequence are deliberately absent. +public struct SignalProbeContext: Codable, Sendable, Equatable { + public let diagnosticsEpoch: String + public let targetLifetime: String? + public let targetRevision: UInt64 + public let configurationRevision: UInt64 + public let ownershipRevision: UInt64 + public init(diagnosticsEpoch: String, targetLifetime: String? = nil, targetRevision: UInt64, + configurationRevision: UInt64, ownershipRevision: UInt64) { + self.diagnosticsEpoch = diagnosticsEpoch; self.targetLifetime = targetLifetime + self.targetRevision = targetRevision; self.configurationRevision = configurationRevision + self.ownershipRevision = ownershipRevision + } +} + +/// An opaque guard valid for one authenticated connection and exact host context. +public struct SignalProbeAuthorization: Codable, Sendable, Equatable { + public let expectedContextGuard: String + public let context: SignalProbeContext + public init(expectedContextGuard: String, context: SignalProbeContext) { + self.expectedContextGuard = expectedContextGuard; self.context = context + } +} + +/// The same patch wire shape as `displayPatch`, plus a required opaque context guard. +public struct SignalProbeParams: Codable, Sendable, Equatable { + public let patch: PatchParams + public let expectedContextGuard: String + public init(patch: PatchParams, expectedContextGuard: String) { + self.patch = patch; self.expectedContextGuard = expectedContextGuard + } + enum CodingKeys: String, CodingKey { case background, rectangles, bitDepth, expectedContextGuard } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + patch = PatchParams(background: try c.decode(PSColor.self, forKey: .background), + rectangles: try c.decode([PatchRectangle].self, forKey: .rectangles), + bitDepth: try c.decode(BitDepth.self, forKey: .bitDepth)) + expectedContextGuard = try c.decode(String.self, forKey: .expectedContextGuard) + } + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(patch.background, forKey: .background) + try c.encode(patch.rectangles, forKey: .rectangles) + try c.encode(patch.bitDepth, forKey: .bitDepth) + try c.encode(expectedContextGuard, forKey: .expectedContextGuard) + } + /// Guards are bounded printable ASCII with no whitespace; their contents have no SDK semantics. + public static func isValidGuard(_ guardValue: String) -> Bool { + !guardValue.isEmpty && guardValue.utf8.count <= 512 && guardValue.utf8.allSatisfy { (33...126).contains($0) } + } +} diff --git a/Sources/PatternSpaceSDKCore/Models/SignalSnapshotSchemas.swift b/Sources/PatternSpaceSDKCore/Models/SignalSnapshotSchemas.swift new file mode 100644 index 0000000..6e9d0af --- /dev/null +++ b/Sources/PatternSpaceSDKCore/Models/SignalSnapshotSchemas.swift @@ -0,0 +1,153 @@ +import Foundation + +public struct SignalSnapshotIdentity: Codable, Sendable, Equatable { + public let diagnosticsEpoch: String + public let revision: UInt64 + public let targetLifetime: String? + public let targetRevision: UInt64 + public let configurationRevision: UInt64 + public let ownershipRevision: UInt64 + public let sourceLifetime: String? + public let acceptedContentID: String? + public let acceptedSequence: UInt64? + public let publishedContentID: String? + public let publishedSequence: UInt64? + public init(diagnosticsEpoch: String, revision: UInt64, targetLifetime: String? = nil, + targetRevision: UInt64 = 0, configurationRevision: UInt64 = 0, ownershipRevision: UInt64 = 0, + sourceLifetime: String? = nil, acceptedContentID: String? = nil, acceptedSequence: UInt64? = nil, + publishedContentID: String? = nil, publishedSequence: UInt64? = nil) { + self.diagnosticsEpoch = diagnosticsEpoch; self.revision = revision; self.targetLifetime = targetLifetime + self.targetRevision = targetRevision; self.configurationRevision = configurationRevision + self.ownershipRevision = ownershipRevision; self.sourceLifetime = sourceLifetime + self.acceptedContentID = acceptedContentID; self.acceptedSequence = acceptedSequence + self.publishedContentID = publishedContentID; self.publishedSequence = publishedSequence + } +} + +/// Immutable schema-1 evidence. This type has no authorization or packed frame buffer field. +public struct SignalSnapshotEvidence: Codable, Sendable, Equatable { + public let schemaVersion: Int + public let identity: SignalSnapshotIdentity + public let capture: SignalCaptureInterval + public let freshness: SignalToken + public let source: SignalStageEvidence + public let appMapping: SignalStageEvidence + public let sdiFrame: SignalStageEvidence + public let physicalSignal: SignalStageEvidence + public let omissions: [SignalToken] + public var isSupportedSchema: Bool { schemaVersion == 1 } + public init(schemaVersion: Int = 1, identity: SignalSnapshotIdentity, capture: SignalCaptureInterval, + freshness: SignalToken, source: SignalStageEvidence = .init(), appMapping: SignalStageEvidence = .init(), + sdiFrame: SignalStageEvidence = .init(), physicalSignal: SignalStageEvidence = .init(), + omissions: [SignalToken] = []) { + self.schemaVersion = schemaVersion; self.identity = identity; self.capture = capture; self.freshness = freshness + self.source = source; self.appMapping = appMapping; self.sdiFrame = sdiFrame; self.physicalSignal = physicalSignal + self.omissions = omissions + } + func omittingDetails() -> Self { + Self(schemaVersion: schemaVersion, identity: identity, capture: capture, freshness: freshness, + source: source.omittingDetails(), appMapping: appMapping.omittingDetails(), + sdiFrame: sdiFrame.omittingDetails(), physicalSignal: physicalSignal.omittingDetails(), + omissions: omissions.contains(.payloadLimit) ? omissions : omissions + [.payloadLimit]) + } +} + +public enum SignalSnapshotValidationError: Error, Sendable, Equatable { + case payloadTooLarge + case tooManySamples + case invalidEvidence + case invalidAuthorization + case unsupportedSchema(Int) +} + +/// Connection-specific read response. Share/export only `evidence`, never this envelope. +public struct SignalSnapshotResponse: Codable, Sendable, Equatable { + public static let maximumPayloadBytes = 256 * 1024 + public let evidence: SignalSnapshotEvidence + public let probeAuthorization: SignalProbeAuthorization? + public init(evidence: SignalSnapshotEvidence, probeAuthorization: SignalProbeAuthorization? = nil) { + self.evidence = evidence; self.probeAuthorization = probeAuthorization + } + + /// Encodes finite bounded evidence; oversized optional details become explicitly omitted. + /// If authoritative identity alone exceeds the bound, encoding fails instead of truncating identity. + public func validatedData() throws -> Data { + try validate() + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(self) + if data.count <= Self.maximumPayloadBytes { return data } + let reduced = Self(evidence: evidence.omittingDetails(), probeAuthorization: probeAuthorization) + let bounded = try encoder.encode(reduced) + guard bounded.count <= Self.maximumPayloadBytes else { throw SignalSnapshotValidationError.payloadTooLarge } + return bounded + } + + /// Checks the wire bound and schema before decoding any evidence or authorization. + public static func decodeValidated(_ data: Data) throws -> Self { + guard data.count <= maximumPayloadBytes else { throw SignalSnapshotValidationError.payloadTooLarge } + struct Header: Decodable { + struct Evidence: Decodable { let schemaVersion: Int } + let evidence: Evidence + } + let schema = try JSONDecoder().decode(Header.self, from: data).evidence.schemaVersion + guard schema == 1 else { throw SignalSnapshotValidationError.unsupportedSchema(schema) } + let response = try JSONDecoder().decode(Self.self, from: data) + try response.validate() + return response + } + + public func validate() throws { + guard evidence.isSupportedSchema else { + throw SignalSnapshotValidationError.unsupportedSchema(evidence.schemaVersion) + } + guard !evidence.identity.diagnosticsEpoch.isEmpty, + evidence.capture.startedAt.isFinite, evidence.capture.completedAt.isFinite, + evidence.capture.completedAt >= evidence.capture.startedAt else { + throw SignalSnapshotValidationError.invalidEvidence + } + for stage in [evidence.source, evidence.appMapping, evidence.sdiFrame, evidence.physicalSignal] { + guard stage.samples.count <= 64 else { throw SignalSnapshotValidationError.tooManySamples } + guard stage.totalSampleCount >= stage.samples.count, + stage.omittedSampleCount == stage.totalSampleCount - stage.samples.count else { + throw SignalSnapshotValidationError.invalidEvidence + } + try validateCapture(stage.capture) + guard stage.submittedAt?.isFinite != false, stage.presentedAt?.isFinite != false else { + throw SignalSnapshotValidationError.invalidEvidence + } + for field in stage.fields { try validateField(field) } + for sample in stage.samples { + guard sample.ordinal >= 0, sample.receivedValues?.isFinite != false, + sample.effectiveRendererInput?.isFinite != false else { + throw SignalSnapshotValidationError.invalidEvidence + } + if let geometry = sample.geometry { + guard [geometry.x, geometry.y, geometry.width, geometry.height].allSatisfy(\.isFinite) else { + throw SignalSnapshotValidationError.invalidEvidence + } + } + for field in sample.fields { try validateField(field) } + } + } + if let auth = probeAuthorization { + guard SignalProbeParams.isValidGuard(auth.expectedContextGuard), + auth.context.diagnosticsEpoch == evidence.identity.diagnosticsEpoch, + auth.context.targetLifetime == evidence.identity.targetLifetime, + auth.context.targetRevision == evidence.identity.targetRevision, + auth.context.configurationRevision == evidence.identity.configurationRevision, + auth.context.ownershipRevision == evidence.identity.ownershipRevision, + evidence.freshness == .current else { throw SignalSnapshotValidationError.invalidAuthorization } + } + } + private func validateField(_ field: SignalFieldEvidence) throws { + guard field.number?.isFinite != false else { throw SignalSnapshotValidationError.invalidEvidence } + try validateCapture(field.capture) + } + private func validateCapture(_ interval: SignalCaptureInterval?) throws { + if let interval { + guard interval.startedAt.isFinite, interval.completedAt.isFinite, + interval.completedAt >= interval.startedAt else { throw SignalSnapshotValidationError.invalidEvidence } + } + } +} diff --git a/Sources/PatternSpaceSDKServer/Delegate/PatternSpaceServerDelegate.swift b/Sources/PatternSpaceSDKServer/Delegate/PatternSpaceServerDelegate.swift index c32f355..9a86fb1 100644 --- a/Sources/PatternSpaceSDKServer/Delegate/PatternSpaceServerDelegate.swift +++ b/Sources/PatternSpaceSDKServer/Delegate/PatternSpaceServerDelegate.swift @@ -85,6 +85,18 @@ public protocol PatternSpaceServerDelegate: AnyObject, Sendable { /// Display configuration writes do not require the JSON source to be active. func setMeasurementRange(_ params: SetMeasurementRangeParams) async throws -> SetMeasurementRangeResult + /// Opt-in support; defaults preserve existing 1.0.0 conformers. + var supportsSignalSnapshot: Bool { get } + var supportsSignalProbe: Bool { get } + + /// Read-only immutable evidence with authorization for this authenticated connection, if eligible. + func signalSnapshot(context: OutputRequestContext) async throws -> SignalSnapshotResponse + + /// Guarded patch admission. Validate connection/guard/context atomically with normal output admission. + /// A stale context throws displayError; a different connection throws notAuthorized. + /// Never implement this by falling back to an unguarded displayPatch call before checking context. + func displayProbe(_ params: SignalProbeParams, context: OutputRequestContext) async throws + // MARK: Source state /// Returns true when the JSON source is the currently selected PatternSourceSelection. @@ -93,6 +105,15 @@ public protocol PatternSpaceServerDelegate: AnyObject, Sendable { } public extension PatternSpaceServerDelegate { + var supportsSignalSnapshot: Bool { false } + var supportsSignalProbe: Bool { false } + func signalSnapshot(context: OutputRequestContext) async throws -> SignalSnapshotResponse { + throw PSDispatchError(.methodNotFound) + } + func displayProbe(_ params: SignalProbeParams, context: OutputRequestContext) async throws { + throw PSDispatchError(.methodNotFound) + } + func listOutputColorPresets(displayId: String) async throws -> OutputColorPresetList { OutputColorPresetList( displayId: displayId, diff --git a/Sources/PatternSpaceSDKServer/Dispatch/JSONRPCDispatcher.swift b/Sources/PatternSpaceSDKServer/Dispatch/JSONRPCDispatcher.swift index f3bc760..30b4ed1 100644 --- a/Sources/PatternSpaceSDKServer/Dispatch/JSONRPCDispatcher.swift +++ b/Sources/PatternSpaceSDKServer/Dispatch/JSONRPCDispatcher.swift @@ -6,11 +6,13 @@ enum JSONRPCRoute: String, CaseIterable { case capabilitiesList = "capabilities.list" case deviceInfo = "device.info" case deviceStatus = "device.status" + case deviceSignalSnapshot = "device.signalSnapshot" case patternList = "pattern.list" case patternGet = "pattern.get" case patternDisplay = "pattern.display" case patternDisplayColor = "pattern.displayColor" case patternDisplayPatch = "pattern.displayPatch" + case patternDisplayProbe = "pattern.displayProbe" case patternClear = "pattern.clear" case displayList = "display.list" case displaySetPeakWhite = "display.setPeakWhite" @@ -35,7 +37,7 @@ enum JSONRPCRoute: String, CaseIterable { /// This type owns protocol-level request validation, method routing, and /// JSON-RPC error response construction. public final class JSONRPCDispatcher: @unchecked Sendable { - private weak var delegate: (any PatternSpaceServerDelegate)? + weak var delegate: (any PatternSpaceServerDelegate)? /// Creates a dispatcher for a server delegate. public init(delegate: any PatternSpaceServerDelegate) { @@ -86,7 +88,11 @@ public final class JSONRPCDispatcher: @unchecked Sendable { let params = obj["params"] do { let result = try await route(method: method, params: params, context: context) - return encode(JSONRPCSuccessResponse(id: id, result: result)) + let response = encode(JSONRPCSuccessResponse(id: id, result: result)) + if method == "device.signalSnapshot", response.count > SignalSnapshotResponse.maximumPayloadBytes { + return errorResponse(id: id, code: .internalError, message: "Signal snapshot exceeds payload limit") + } + return response } catch let e as PSDispatchError { return errorResponse(id: id, code: e.code, message: e.message, data: e.data) } catch { @@ -119,6 +125,8 @@ public final class JSONRPCDispatcher: @unchecked Sendable { case .patternGet: return try await handleGet(params) case .deviceInfo: return try await handleDeviceInfo() case .deviceStatus: return try await handleDeviceStatus() + case .deviceSignalSnapshot: return try await handleSignalSnapshot(params, context: context) + case .patternDisplayProbe: return try await handleSignalProbe(params, context: context) case .displayList: return try await handleDisplayList() case .displaySetPeakWhite: return try await handleSetPeakWhite(params) case .displayListOutputColorPresets: return try await handleListOutputColorPresets(params) @@ -132,7 +140,7 @@ public final class JSONRPCDispatcher: @unchecked Sendable { // MARK: - Write handlers - private func requireSourceActive() throws { + func requireSourceActive() throws { guard delegate?.isSourceActive == true else { throw PSDispatchError(.sourceNotActive) } } @@ -171,41 +179,9 @@ public final class JSONRPCDispatcher: @unchecked Sendable { } private func handleDisplayPatch(_ params: JSONValue?, context: OutputRequestContext) async throws -> JSONValue { - let obj = params?.object ?? [:] - guard let bg = obj["background"]?.object, - let br = bg["r"]?.number, let bg_g = bg["g"]?.number, let bb = bg["b"]?.number, - let rectValues = obj["rectangles"]?.array, - let bdInt = obj["bitDepth"]?.int else { - throw PSDispatchError(.invalidParams, message: "background, rectangles, and bitDepth are required") - } - try InputValidator.validateColor(r: br, g: bg_g, b: bb) - try InputValidator.validateBitDepth(bdInt) - try InputValidator.validateRectangleCount(rectValues.count) - guard let bitDepth = BitDepth(rawValue: bdInt) else { throw PSDispatchError(.invalidBitDepth) } - - let rectangles = try rectValues.map { value -> PatchRectangle in - guard let obj = value.object, - let colorObject = obj["color"]?.object, - let r = colorObject["r"]?.number, - let g = colorObject["g"]?.number, - let b = colorObject["b"]?.number, - let x = obj["x"]?.number, - let y = obj["y"]?.number, - let width = obj["width"]?.number, - let height = obj["height"]?.number else { - throw PSDispatchError(.invalidParams, message: "each rectangle requires color, x, y, width, and height") - } - try InputValidator.validateColor(r: r, g: g, b: b) - try InputValidator.validateRectangle(x: x, y: y, width: width, height: height) - return PatchRectangle(color: PSColor(r: r, g: g, b: b), x: x, y: y, width: width, height: height) - } - + let patch = try InputValidator.patch(params) try requireSourceActive() - try await delegate?.displayPatch(PatchParams( - background: PSColor(r: br, g: bg_g, b: bb), - rectangles: rectangles, - bitDepth: bitDepth - ), context: context) + try await delegate?.displayPatch(patch, context: context) return .object([:]) } @@ -218,7 +194,7 @@ public final class JSONRPCDispatcher: @unchecked Sendable { // MARK: - Output handlers /// Output methods take no params: accept absent, `{}` or `[]` only. - private func requireNoParams(_ params: JSONValue?) throws { + func requireNoParams(_ params: JSONValue?) throws { switch params { case nil: return case .object(let object) where object.isEmpty: return @@ -289,7 +265,7 @@ public final class JSONRPCDispatcher: @unchecked Sendable { guard let capabilities = try await delegate?.capabilities() else { throw PSDispatchError(.internalError) } - return try encodeToJSONValue(capabilities) + return try filteredSignalCapabilities(capabilities) } // MARK: - Display handlers diff --git a/Sources/PatternSpaceSDKServer/Dispatch/PatchRequestParser.swift b/Sources/PatternSpaceSDKServer/Dispatch/PatchRequestParser.swift new file mode 100644 index 0000000..978d1ba --- /dev/null +++ b/Sources/PatternSpaceSDKServer/Dispatch/PatchRequestParser.swift @@ -0,0 +1,36 @@ +import PatternSpaceSDKCore + +extension InputValidator { + static func patch(_ params: JSONValue?) throws -> PatchParams { + let obj = params?.object ?? [:] + guard let bg = obj["background"]?.object, + let br = bg["r"]?.number, let bg_g = bg["g"]?.number, let bb = bg["b"]?.number, + let rectValues = obj["rectangles"]?.array, + let bdInt = obj["bitDepth"]?.int else { + throw PSDispatchError(.invalidParams, message: "background, rectangles, and bitDepth are required") + } + try InputValidator.validateColor(r: br, g: bg_g, b: bb) + try InputValidator.validateBitDepth(bdInt) + try InputValidator.validateRectangleCount(rectValues.count) + guard let bitDepth = BitDepth(rawValue: bdInt) else { throw PSDispatchError(.invalidBitDepth) } + + let rectangles = try rectValues.map { value -> PatchRectangle in + guard let obj = value.object, + let colorObject = obj["color"]?.object, + let r = colorObject["r"]?.number, + let g = colorObject["g"]?.number, + let b = colorObject["b"]?.number, + let x = obj["x"]?.number, + let y = obj["y"]?.number, + let width = obj["width"]?.number, + let height = obj["height"]?.number else { + throw PSDispatchError(.invalidParams, message: "each rectangle requires color, x, y, width, and height") + } + try InputValidator.validateColor(r: r, g: g, b: b) + try InputValidator.validateRectangle(x: x, y: y, width: width, height: height) + return PatchRectangle(color: PSColor(r: r, g: g, b: b), x: x, y: y, width: width, height: height) + } + + return PatchParams(background: PSColor(r: br, g: bg_g, b: bb), rectangles: rectangles, bitDepth: bitDepth) + } +} diff --git a/Sources/PatternSpaceSDKServer/Dispatch/SignalRequestHandlers.swift b/Sources/PatternSpaceSDKServer/Dispatch/SignalRequestHandlers.swift new file mode 100644 index 0000000..731db04 --- /dev/null +++ b/Sources/PatternSpaceSDKServer/Dispatch/SignalRequestHandlers.swift @@ -0,0 +1,43 @@ +import Foundation +import PatternSpaceSDKCore + +extension JSONRPCDispatcher { + func handleSignalSnapshot(_ params: JSONValue?, context: OutputRequestContext) async throws -> JSONValue { + guard let delegate, delegate.supportsSignalSnapshot else { throw PSDispatchError(.methodNotFound) } + try requireNoParams(params) + let response = try await delegate.signalSnapshot(context: context) + return try JSONDecoder().decode(JSONValue.self, from: response.validatedData()) + } + + func handleSignalProbe(_ params: JSONValue?, context: OutputRequestContext) async throws -> JSONValue { + guard let delegate, delegate.supportsSignalProbe else { throw PSDispatchError(.methodNotFound) } + guard let guardValue = params?.object?["expectedContextGuard"]?.string, + SignalProbeParams.isValidGuard(guardValue) else { + throw PSDispatchError(.invalidParams, message: "expectedContextGuard must be a bounded nonempty printable token") + } + let patch = try InputValidator.patch(params) + try requireSourceActive() + try await delegate.displayProbe(.init(patch: patch, expectedContextGuard: guardValue), context: context) + return .object([:]) + } + + /// Removes only unsupported new entries; the host's existing namespace and feature contracts remain intact. + func filteredSignalCapabilities(_ capabilities: CapabilitiesResult) throws -> JSONValue { + var object = try JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(capabilities)).object ?? [:] + var namespaces = object["namespaces"]?.object ?? [:] + var features = object["features"]?.object ?? [:] + for (supported, namespace, method, feature) in [ + (delegate?.supportsSignalSnapshot == true, "device", "signalSnapshot", "signalSnapshot"), + (delegate?.supportsSignalProbe == true, "pattern", "displayProbe", "signalProbe") + ] { + if supported { continue } + if let methods = namespaces[namespace]?.array { + namespaces[namespace] = .array(methods.filter { $0 != .string(method) }) + } + features.removeValue(forKey: feature) + } + object["namespaces"] = .object(namespaces) + object["features"] = .object(features) + return .object(object) + } +} diff --git a/Tests/PatternSpaceSDKClientTests/SignalSnapshotClientTests.swift b/Tests/PatternSpaceSDKClientTests/SignalSnapshotClientTests.swift new file mode 100644 index 0000000..2a181a4 --- /dev/null +++ b/Tests/PatternSpaceSDKClientTests/SignalSnapshotClientTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Network +import Testing +import PatternSpaceSDKCore +@testable import PatternSpaceSDKClient + +@Suite(.serialized) struct SignalSnapshotClientTests { + @Test(arguments: WebSocketTransportEpochTests.TransportPath.allCases) + func bothTransportsReceiveSnapshotLargerThan64KiB(_ path: WebSocketTransportEpochTests.TransportPath) async throws { + let response = SignalSnapshotResponse(evidence: .init(identity: .init(diagnosticsEpoch: "epoch", revision: 1), + capture: .init(startedAt: 1, completedAt: 2), freshness: .current, + appMapping: .init(status: .available, fields: [.init(name: "description", provenance: .appReported, + text: String(repeating: "a", count: 100_000))]))) + let decoded = try await roundTrip(response, path: path) + #expect(decoded == response) + } + + @Test(arguments: WebSocketTransportEpochTests.TransportPath.allCases) + func actualClientRejectsUnsupportedMajorSchema(_ path: WebSocketTransportEpochTests.TransportPath) async throws { + let response = SignalSnapshotResponse(evidence: .init(schemaVersion: 2, + identity: .init(diagnosticsEpoch: "epoch", revision: 1), capture: .init(startedAt: 1, completedAt: 2), freshness: .current)) + await #expect(throws: SignalSnapshotValidationError.unsupportedSchema(2)) { + try await roundTrip(response, path: path) + } + } + + private func roundTrip(_ response: SignalSnapshotResponse, path: WebSocketTransportEpochTests.TransportPath) async throws -> SignalSnapshotResponse { + let server = try await TestWebSocketServer.start() + defer { server.stop() } + let client = PatternSpaceClient(service: .init(name: "signal-test", endpoint: server.endpoint(path), port: server.port), token: nil) + client.connect(); defer { client.disconnect() } + let request = Task { try await client.device.signalSnapshot() } + let deadline = ContinuousClock.now + .seconds(3) + while server.requestIDs(on: 0).isEmpty { + guard ContinuousClock.now < deadline else { throw EpochTestError.timedOut } + try await Task.sleep(for: .milliseconds(10)) + } + let id = try #require(server.requestIDs(on: 0).first) + let result = try JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(response)) + let data = try JSONEncoder().encode(JSONValue.object(["jsonrpc": .string("2.0"), "id": .string(id), "result": result])) + if response.evidence.schemaVersion == 1 { + #expect(data.count > 65_536) + #expect(data.count < SignalSnapshotResponse.maximumPayloadBytes) + } + server.send(String(decoding: data, as: UTF8.self), to: 0) + return try await withCheckedThrowingContinuation { continuation in + let once = ResumeOnce(continuation) + Task { once.resume(with: await Result(catching: { try await request.value })) } + Task { + try? await Task.sleep(for: .seconds(3)) + once.resume(with: .failure(EpochTestError.timedOut)) + } + } + } +} diff --git a/Tests/PatternSpaceSDKCoreTests/SignalSnapshotSchemasTests.swift b/Tests/PatternSpaceSDKCoreTests/SignalSnapshotSchemasTests.swift new file mode 100644 index 0000000..5660868 --- /dev/null +++ b/Tests/PatternSpaceSDKCoreTests/SignalSnapshotSchemasTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +import PatternSpaceSDKCore + +@Suite struct SignalSnapshotSchemasTests { + @Test func minorSDKReleaseRetainsProtocolVersion() { + #expect(PatternSpaceProtocolMetadata.sdkVersion == "1.1.0") + #expect(PatternSpaceProtocolMetadata.protocolVersion == "1.3") + } + + @Test func forwardTokensAndUnknownFieldsRoundTrip() throws { + let token = try JSONDecoder().decode(SignalToken.self, from: Data(#""futureToken""#.utf8)) + #expect(token.rawValue == "futureToken") + #expect(String(decoding: try JSONEncoder().encode(token), as: UTF8.self) == #""futureToken""#) + } + + @Test func nonFiniteFieldsAreExplicitlyUnavailable() throws { + let field = SignalFieldEvidence(name: "headroom", provenance: .appReported, number: .infinity) + #expect(field.number == nil) + #expect(field.reason == .nonFinite) + _ = try JSONEncoder().encode(field) + } + + @Test func evidenceBoundsRectanglesAndExcludesGuard() throws { + let rectangles = (0..<65).map { SignalSampleEvidence(role: .rectangle, ordinal: $0) } + let evidence = SignalSnapshotEvidence(identity: .init(diagnosticsEpoch: "epoch", revision: 1), + capture: .init(startedAt: 1, completedAt: 2), freshness: .current, + source: .init(status: .available, samples: rectangles)) + #expect(evidence.source.samples.count == 64) + #expect(evidence.source.totalSampleCount == 65) + #expect(evidence.source.omittedSampleCount == 1) + let authorization = SignalProbeAuthorization(expectedContextGuard: "secret-guard", context: + .init(diagnosticsEpoch: "epoch", targetRevision: 1, configurationRevision: 2, ownershipRevision: 3)) + let response = SignalSnapshotResponse(evidence: evidence, probeAuthorization: authorization) + #expect(String(decoding: try JSONEncoder().encode(response), as: UTF8.self).contains("secret-guard")) + #expect(!String(decoding: try JSONEncoder().encode(response.evidence), as: UTF8.self).contains("secret-guard")) + } + + @Test func payloadLimitAndUnsupportedSchemaFailSafely() throws { + let evidence = SignalSnapshotEvidence(identity: .init(diagnosticsEpoch: String(repeating: "x", count: 270_000), revision: 1), + capture: .init(startedAt: 1, completedAt: 2), freshness: .current) + #expect(throws: SignalSnapshotValidationError.self) { try SignalSnapshotResponse(evidence: evidence).validatedData() } + let future = SignalSnapshotEvidence(schemaVersion: 2, identity: .init(diagnosticsEpoch: "e", revision: 1), + capture: .init(startedAt: 1, completedAt: 2), freshness: .current) + #expect(!future.isSupportedSchema) + } + + @Test func optionalDetailCanBeOmittedToFitLimit() throws { + let fields = [SignalFieldEvidence(name: "profile", provenance: .appReported, text: String(repeating: "p", count: 270_000))] + let evidence = SignalSnapshotEvidence(identity: .init(diagnosticsEpoch: "e", revision: 1), + capture: .init(startedAt: 1, completedAt: 2), freshness: .current, + appMapping: .init(status: .available, fields: fields)) + let response = try SignalSnapshotResponse.decodeValidated(SignalSnapshotResponse(evidence: evidence).validatedData()) + #expect(response.evidence.appMapping.fields.isEmpty) + #expect(response.evidence.omissions.contains(.payloadLimit)) + } + + @Test func oldCapabilitiesDecodeWithAbsentNewFlags() throws { + let json = #"{"events":true,"displayInventory":true,"peakWhiteControl":true,"outputColorPresets":true,"measurementRange":true,"catalogPatterns":true,"customICCBuilder":false,"httpBridge":false}"# + let features = try JSONDecoder().decode(CapabilityFeatures.self, from: Data(json.utf8)) + #expect(features.signalSnapshot == nil) + #expect(features.signalProbe == nil) + } + @Test func decodedMalformedCountsAndHistoricalAuthorizationAreRejected() throws { + let base = SignalSnapshotResponse(evidence: .init(identity: .init(diagnosticsEpoch: "e", revision: 1), + capture: .init(startedAt: 1, completedAt: 2), freshness: .current)) + var object = try JSONDecoder().decode(JSONValue.self, from: base.validatedData()).object! + var evidence = object["evidence"]!.object! + var source = evidence["source"]!.object! + let sample = try JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(SignalSampleEvidence(role: .rectangle, ordinal: 0))) + source["samples"] = .array(Array(repeating: sample, count: 65)) + source["totalSampleCount"] = .int(65) + evidence["source"] = .object(source); object["evidence"] = .object(evidence) + #expect(throws: SignalSnapshotValidationError.self) { + try SignalSnapshotResponse.decodeValidated(JSONEncoder().encode(JSONValue.object(object))) + } + let historical = SignalSnapshotResponse(evidence: .init(identity: .init(diagnosticsEpoch: "e", revision: 1), + capture: .init(startedAt: 1, completedAt: 2), freshness: .historical), + probeAuthorization: .init(expectedContextGuard: "guard", context: .init(diagnosticsEpoch: "e", + targetRevision: 0, configurationRevision: 0, ownershipRevision: 0))) + #expect(throws: SignalSnapshotValidationError.self) { try historical.validatedData() } + } + + @Test func unknownFieldsDoNotBreakSchemaOneAndProbeWireIsFlat() throws { + let base = SignalSnapshotResponse(evidence: .init(identity: .init(diagnosticsEpoch: "e", revision: 1), + capture: .init(startedAt: 1, completedAt: 2), freshness: .current)) + var object = try JSONDecoder().decode(JSONValue.self, from: base.validatedData()).object! + object["futureEnvelopeField"] = .string("ignored") + let decoded = try SignalSnapshotResponse.decodeValidated(JSONEncoder().encode(JSONValue.object(object))) + #expect(decoded == base) + let patch = PatchParams(background: .init(r: 0, g: 0, b: 0), rectangles: [], bitDepth: .ten) + let probe = SignalProbeParams(patch: patch, expectedContextGuard: "guard") + let probeData = try JSONEncoder().encode(probe) + let wire = try JSONDecoder().decode(JSONValue.self, from: probeData).object! + #expect(wire["patch"] == nil) + #expect(wire["bitDepth"] == .int(10)) + #expect(try JSONDecoder().decode(SignalProbeParams.self, from: probeData) == probe) + } + + @Test func oversizedDetailMarksAffectedStageOmitted() throws { + let base = SignalSnapshotResponse(evidence: .init(identity: .init(diagnosticsEpoch: "e", revision: 1), + capture: .init(startedAt: 1, completedAt: 2), freshness: .current, + source: .init(status: .available, fields: [.init(name: "detail", provenance: .received, + text: String(repeating: "x", count: 270_000))]))) + let response = try SignalSnapshotResponse.decodeValidated(base.validatedData()) + #expect(response.evidence.source.reason == .payloadLimit) + } + + @Test(arguments: [Double.nan, Double.infinity, -Double.infinity]) + func invalidCaptureInitializerIsRejected(_ timestamp: Double) throws { + for interval in [SignalCaptureInterval(startedAt: timestamp, completedAt: 2), + SignalCaptureInterval(startedAt: 1, completedAt: timestamp)] { + let response = SignalSnapshotResponse(evidence: .init(identity: .init(diagnosticsEpoch: "e", revision: 1), + capture: interval, freshness: .current)) + #expect(throws: SignalSnapshotValidationError.invalidEvidence) { try response.validate() } + } + } + + @Test func unsupportedSchemaCannotCarryAuthorizationOrDecodeAsUsableEvidence() throws { + let response = SignalSnapshotResponse(evidence: .init(schemaVersion: 2, + identity: .init(diagnosticsEpoch: "e", revision: 1), capture: .init(startedAt: 1, completedAt: 2), freshness: .current), + probeAuthorization: .init(expectedContextGuard: "guard", context: .init(diagnosticsEpoch: "e", + targetRevision: 0, configurationRevision: 0, ownershipRevision: 0))) + #expect(throws: SignalSnapshotValidationError.unsupportedSchema(2)) { try response.validate() } + #expect(throws: SignalSnapshotValidationError.unsupportedSchema(2)) { + try SignalSnapshotResponse.decodeValidated(JSONEncoder().encode(response)) + } + } + + @Test func incompatibleFutureShapeReportsUnsupportedBeforeFieldDecoding() throws { + let data = Data(#"{"evidence":{"schemaVersion":9,"newShape":true},"probeAuthorization":{"unsafeFutureField":true}}"#.utf8) + #expect(throws: SignalSnapshotValidationError.unsupportedSchema(9)) { + try SignalSnapshotResponse.decodeValidated(data) + } + } + +} diff --git a/Tests/PatternSpaceSDKServerTests/DispatcherTestSupport.swift b/Tests/PatternSpaceSDKServerTests/DispatcherTestSupport.swift new file mode 100644 index 0000000..7da3806 --- /dev/null +++ b/Tests/PatternSpaceSDKServerTests/DispatcherTestSupport.swift @@ -0,0 +1,303 @@ +// Tests/PatternSpaceSDKServerTests/JSONRPCDispatcherTests.swift +import Testing +import Foundation +@testable import PatternSpaceSDKServer +import PatternSpaceSDKCore + +// MARK: - Mock delegate + +final class MockDelegate: PatternSpaceServerDelegate, @unchecked Sendable { + var supportsSignalSnapshot = false + var supportsSignalProbe = false + var signalReadContext: OutputRequestContext? + var signalProbeContext: OutputRequestContext? + var signalProbeParams: SignalProbeParams? + var signalError: PSDispatchError? + var signalSnapshotResponse: SignalSnapshotResponse? + func signalSnapshot(context: OutputRequestContext) async throws -> SignalSnapshotResponse { + signalReadContext = context + if let signalError { throw signalError } + if let signalSnapshotResponse { return signalSnapshotResponse } + return .init(evidence: .init(identity: .init(diagnosticsEpoch: "epoch", revision: 1), + capture: .init(startedAt: 1, completedAt: 2), freshness: .current)) + } + func displayProbe(_ params: SignalProbeParams, context: OutputRequestContext) async throws { + if let signalError { throw signalError } + signalProbeParams = params; signalProbeContext = context + } + var isSourceActive: Bool = true + var currentResolution: Resolution = Resolution(width: 3840, height: 2160) + + var displayedPatternId: String? + var displayedColor: PSColor? + var displayedBitDepth: BitDepth? + var displayedPatch: PatchParams? + var clearCalled = false + var shouldThrowNotFound = false + + var outputStatus = OutputStatus( + owner: .jsonClient, ownerConnected: true, blankRequester: .jsonClient, + blanked: .remote, blankPending: false, failure: nil, idleBlankSeconds: nil, + epoch: "6F9619FF-8B86-D011-B42D-00C04FC964FF", revision: 5 + ) + var outputError: PSDispatchError? + + // Request contexts received by each output-write method, in call order. + private let contextLock = NSLock() + private var recordedContexts: [String: [OutputRequestContext]] = [:] + private func record(_ method: String, _ context: OutputRequestContext) { + contextLock.lock(); defer { contextLock.unlock() } + recordedContexts[method, default: []].append(context) + } + private func contexts(_ method: String) -> [OutputRequestContext] { + contextLock.lock(); defer { contextLock.unlock() } + return recordedContexts[method] ?? [] + } + var patternContexts: [OutputRequestContext] { contexts("pattern") } + var colorContexts: [OutputRequestContext] { contexts("color") } + var patchContexts: [OutputRequestContext] { contexts("patch") } + var clearContexts: [OutputRequestContext] { contexts("clear") } + var blankContexts: [OutputRequestContext] { contexts("blank") } + var resumeContexts: [OutputRequestContext] { contexts("resume") } + + func displayPattern(id: String, context: OutputRequestContext) async throws { + record("pattern", context) + if shouldThrowNotFound { throw PSDispatchError(.patternNotFound) } + displayedPatternId = id + } + func displayColor(_ color: PSColor, bitDepth: BitDepth, context: OutputRequestContext) async throws { + record("color", context) + displayedColor = color; displayedBitDepth = bitDepth + } + func displayPatch(_ params: PatchParams, context: OutputRequestContext) async throws { + record("patch", context) + displayedPatch = params + } + func clearDisplay(context: OutputRequestContext) async throws { + record("clear", context) + clearCalled = true + } + func blankOutput(context: OutputRequestContext) async throws -> OutputStatus { + record("blank", context) + if let outputError { throw outputError } + return outputStatus + } + func resumeOutput(context: OutputRequestContext) async throws -> OutputStatus { + record("resume", context) + if let outputError { throw outputError } + return outputStatus + } + func listPatterns(category: String?, subcategory: String?) async throws -> [PatternInfo] { + [PatternInfo(id: "Color-One-Red", name: "Red", category: "Color", subcategory: "Solid")] + } + func getPattern(id: String) async throws -> PatternInfo { + if id == "unknown" { throw PSDispatchError(.patternNotFound) } + return PatternInfo(id: id, name: "Red", category: "Color", subcategory: "Solid") + } + func deviceInfo() async throws -> DeviceInfo { + DeviceInfo(name: "Test", resolution: currentResolution, colorFormat: "RGB", + bitDepth: 10, hdrMode: "SDR", refreshRate: 60, outputRange: "full") + } + func deviceStatus() async throws -> DeviceStatus { + DeviceStatus(currentPatternId: nil, sourceActive: isSourceActive) + } + + // MARK: - Display API + + var displayList = DisplayListResult( + platform: .macOS, + selectedDisplayId: "69734272", + displays: [ + DisplayEntry( + id: "69734272", + name: "Studio Display", + selected: true, + connection: .wired, + resolution: Resolution(width: 5120, height: 2880), + refreshRate: 60, + colorSpaceName: "Display P3", + cgColorSpaceName: "kCGColorSpaceDisplayP3", + maximumPotentialEDR: 4.0, + maximumCurrentEDR: 2.0, + peakWhite: 4.0, + effectivePeakWhite: 2.0, + peakWhiteRange: PeakWhiteRange(maximum: 4.0), + supportsPeakWhiteControl: true + ) + ] + ) + var setPeakWhiteCalls: [SetPeakWhiteParams] = [] + var setOutputColorPresetCalls: [SetOutputColorPresetParams] = [] + var setMeasurementRangeCalls: [SetMeasurementRangeParams] = [] + var getOutputColorPresetCalls: [GetOutputColorPresetParams] = [] + var unknownOutputPresetIds: Set = [] + var unsupportedOutputPresetConfig: OutputColorPresetConfig? + var capabilitiesResult = CapabilitiesResult( + protocolVersion: PatternSpaceProtocolMetadata.protocolVersion, + app: AppMetadata(name: "PatternSpace", version: "1.1.0", build: "123"), + sdkVersion: PatternSpaceProtocolMetadata.sdkVersion, + platform: .macOS, + authRequired: true, + namespaces: ["capabilities": ["list"]], + features: CapabilityFeatures( + events: true, + displayInventory: true, + peakWhiteControl: true, + outputColorPresets: true, + measurementRange: false, + catalogPatterns: true, + customICCBuilder: false, + httpBridge: false + ) + ) + + func listDisplays() async throws -> DisplayListResult { displayList } + + func setPeakWhite(_ params: SetPeakWhiteParams) async throws -> DisplayEntry { + setPeakWhiteCalls.append(params) + guard let display = displayList.displays.first(where: { $0.id == params.displayId }) else { + throw PSDispatchError(.displayNotFound, data: .object(["displayId": .string(params.displayId)])) + } + guard params.peakWhite >= display.peakWhiteRange.minimum, + params.peakWhite <= display.peakWhiteRange.maximum else { + throw PSDispatchError( + .peakWhiteOutOfRange, + data: .object([ + "displayId": .string(params.displayId), + "peakWhite": .double(params.peakWhite), + "minimum": .double(display.peakWhiteRange.minimum), + "maximum": .double(display.peakWhiteRange.maximum) + ]) + ) + } + return DisplayEntry( + id: display.id, + name: display.name, + selected: display.selected, + connection: display.connection, + resolution: display.resolution, + refreshRate: display.refreshRate, + colorSpaceName: display.colorSpaceName, + cgColorSpaceName: display.cgColorSpaceName, + maximumPotentialEDR: display.maximumPotentialEDR, + maximumCurrentEDR: display.maximumCurrentEDR, + peakWhite: params.peakWhite, + effectivePeakWhite: min(params.peakWhite, display.maximumCurrentEDR), + peakWhiteRange: display.peakWhiteRange, + supportsPeakWhiteControl: display.supportsPeakWhiteControl + ) + } + + func capabilities() async throws -> CapabilitiesResult { capabilitiesResult } + + func listOutputColorPresets(displayId: String) async throws -> OutputColorPresetList { + OutputColorPresetList( + displayId: displayId, + selectedPresetId: .hdrBT2020PQ, + scope: .host, + catalogRevision: "test-catalog", + presets: [ + OutputColorPresetSummary( + id: .hdrBT2020PQ, + label: "BT.2020 PQ", + group: "hdr", + family: .hdrReference, + supported: true, + requiresPro: true, + implementationStatus: .native + ) + ] + ) + } + + func getOutputColorPreset(_ params: GetOutputColorPresetParams) async throws -> GetOutputColorPresetResult { + getOutputColorPresetCalls.append(params) + if unknownOutputPresetIds.contains(params.presetId) { + throw PSDispatchError( + .outputColorPresetUnsupported, + data: .object([ + "requestedPresetId": .string(params.presetId.rawValue), + "supportedPresetIds": .array([.string(OutputColorPresetID.hdrBT2020PQ.rawValue)]), + "scope": .string(ColorManagementScope.host.rawValue), + "reason": .string("unknownPreset") + ]) + ) + } + return GetOutputColorPresetResult( + displayId: params.displayId, + catalogRevision: "test-catalog", + preset: unsupportedOutputPresetConfig ?? outputPresetConfig( + id: params.presetId, + supported: true, + implementationStatus: .native + ) + ) + } + + func setOutputColorPreset(_ params: SetOutputColorPresetParams) async throws -> SetOutputColorPresetResult { + setOutputColorPresetCalls.append(params) + let display = displayList.displays[0] + return SetOutputColorPresetResult( + scope: .host, + selectedPresetId: params.presetId, + selectedDisplayId: display.id, + display: display + ) + } + + func setMeasurementRange(_ params: SetMeasurementRangeParams) async throws -> SetMeasurementRangeResult { + setMeasurementRangeCalls.append(params) + let display = displayList.displays[0] + return SetMeasurementRangeResult( + scope: .host, + selectedMeasurementRange: params.measurementRange, + selectedDisplayId: display.id, + display: display + ) + } + + private func outputPresetConfig( + id: OutputColorPresetID, + supported: Bool, + implementationStatus: OutputColorPresetImplementationStatus + ) -> OutputColorPresetConfig { + OutputColorPresetConfig( + id: id, + label: "BT.2020 PQ", + group: "hdr", + family: .hdrReference, + gamut: .bt2020, + whitePoint: .d65, + transfer: .pqSt2084, + dynamicRange: .hdr, + toneMapping: .none, + inputEncoding: .pqSt2084, + implementationStatus: implementationStatus, + supported: supported, + requiresPro: true, + unsupportedReason: supported ? nil : "insufficientHeadroom", + edrHeadroomRequired: 2.0, + edrHeadroomPotential: 1.2, + edrHeadroomCurrent: 1.0, + edrHeadroomReference: 1.0, + referenceWhiteNits: 100, + referenceWhiteNitsSource: "configured", + peakLuminanceNits: 1000, + clipOnsetNits: 120, + clipOnsetPQSignal: 0.508 + ) + } +} + +// MARK: - Helpers + +let testContext = OutputRequestContext(clientID: UUID()) + +func request(method: String, params: String = "{}") -> Data { + Data(#"{"jsonrpc":"2.0","id":"1","method":"\#(method)","params":\#(params)}"#.utf8) +} + +func responseObject(from data: Data) throws -> JSONValue { + try JSONDecoder().decode(JSONValue.self, from: data) +} + diff --git a/Tests/PatternSpaceSDKServerTests/JSONRPCDispatcherTests.swift b/Tests/PatternSpaceSDKServerTests/JSONRPCDispatcherTests.swift index 74caf6a..3d73cce 100644 --- a/Tests/PatternSpaceSDKServerTests/JSONRPCDispatcherTests.swift +++ b/Tests/PatternSpaceSDKServerTests/JSONRPCDispatcherTests.swift @@ -1,288 +1,8 @@ -// Tests/PatternSpaceSDKServerTests/JSONRPCDispatcherTests.swift import Testing import Foundation @testable import PatternSpaceSDKServer import PatternSpaceSDKCore -// MARK: - Mock delegate - -final class MockDelegate: PatternSpaceServerDelegate, @unchecked Sendable { - var isSourceActive: Bool = true - var currentResolution: Resolution = Resolution(width: 3840, height: 2160) - - var displayedPatternId: String? - var displayedColor: PSColor? - var displayedBitDepth: BitDepth? - var displayedPatch: PatchParams? - var clearCalled = false - var shouldThrowNotFound = false - - var outputStatus = OutputStatus( - owner: .jsonClient, ownerConnected: true, blankRequester: .jsonClient, - blanked: .remote, blankPending: false, failure: nil, idleBlankSeconds: nil, - epoch: "6F9619FF-8B86-D011-B42D-00C04FC964FF", revision: 5 - ) - var outputError: PSDispatchError? - - // Request contexts received by each output-write method, in call order. - private let contextLock = NSLock() - private var recordedContexts: [String: [OutputRequestContext]] = [:] - private func record(_ method: String, _ context: OutputRequestContext) { - contextLock.lock(); defer { contextLock.unlock() } - recordedContexts[method, default: []].append(context) - } - private func contexts(_ method: String) -> [OutputRequestContext] { - contextLock.lock(); defer { contextLock.unlock() } - return recordedContexts[method] ?? [] - } - var patternContexts: [OutputRequestContext] { contexts("pattern") } - var colorContexts: [OutputRequestContext] { contexts("color") } - var patchContexts: [OutputRequestContext] { contexts("patch") } - var clearContexts: [OutputRequestContext] { contexts("clear") } - var blankContexts: [OutputRequestContext] { contexts("blank") } - var resumeContexts: [OutputRequestContext] { contexts("resume") } - - func displayPattern(id: String, context: OutputRequestContext) async throws { - record("pattern", context) - if shouldThrowNotFound { throw PSDispatchError(.patternNotFound) } - displayedPatternId = id - } - func displayColor(_ color: PSColor, bitDepth: BitDepth, context: OutputRequestContext) async throws { - record("color", context) - displayedColor = color; displayedBitDepth = bitDepth - } - func displayPatch(_ params: PatchParams, context: OutputRequestContext) async throws { - record("patch", context) - displayedPatch = params - } - func clearDisplay(context: OutputRequestContext) async throws { - record("clear", context) - clearCalled = true - } - func blankOutput(context: OutputRequestContext) async throws -> OutputStatus { - record("blank", context) - if let outputError { throw outputError } - return outputStatus - } - func resumeOutput(context: OutputRequestContext) async throws -> OutputStatus { - record("resume", context) - if let outputError { throw outputError } - return outputStatus - } - func listPatterns(category: String?, subcategory: String?) async throws -> [PatternInfo] { - [PatternInfo(id: "Color-One-Red", name: "Red", category: "Color", subcategory: "Solid")] - } - func getPattern(id: String) async throws -> PatternInfo { - if id == "unknown" { throw PSDispatchError(.patternNotFound) } - return PatternInfo(id: id, name: "Red", category: "Color", subcategory: "Solid") - } - func deviceInfo() async throws -> DeviceInfo { - DeviceInfo(name: "Test", resolution: currentResolution, colorFormat: "RGB", - bitDepth: 10, hdrMode: "SDR", refreshRate: 60, outputRange: "full") - } - func deviceStatus() async throws -> DeviceStatus { - DeviceStatus(currentPatternId: nil, sourceActive: isSourceActive) - } - - // MARK: - Display API - - var displayList = DisplayListResult( - platform: .macOS, - selectedDisplayId: "69734272", - displays: [ - DisplayEntry( - id: "69734272", - name: "Studio Display", - selected: true, - connection: .wired, - resolution: Resolution(width: 5120, height: 2880), - refreshRate: 60, - colorSpaceName: "Display P3", - cgColorSpaceName: "kCGColorSpaceDisplayP3", - maximumPotentialEDR: 4.0, - maximumCurrentEDR: 2.0, - peakWhite: 4.0, - effectivePeakWhite: 2.0, - peakWhiteRange: PeakWhiteRange(maximum: 4.0), - supportsPeakWhiteControl: true - ) - ] - ) - var setPeakWhiteCalls: [SetPeakWhiteParams] = [] - var setOutputColorPresetCalls: [SetOutputColorPresetParams] = [] - var setMeasurementRangeCalls: [SetMeasurementRangeParams] = [] - var getOutputColorPresetCalls: [GetOutputColorPresetParams] = [] - var unknownOutputPresetIds: Set = [] - var unsupportedOutputPresetConfig: OutputColorPresetConfig? - var capabilitiesResult = CapabilitiesResult( - protocolVersion: PatternSpaceProtocolMetadata.protocolVersion, - app: AppMetadata(name: "PatternSpace", version: "1.1.0", build: "123"), - sdkVersion: PatternSpaceProtocolMetadata.sdkVersion, - platform: .macOS, - authRequired: true, - namespaces: ["capabilities": ["list"]], - features: CapabilityFeatures( - events: true, - displayInventory: true, - peakWhiteControl: true, - outputColorPresets: true, - measurementRange: false, - catalogPatterns: true, - customICCBuilder: false, - httpBridge: false - ) - ) - - func listDisplays() async throws -> DisplayListResult { displayList } - - func setPeakWhite(_ params: SetPeakWhiteParams) async throws -> DisplayEntry { - setPeakWhiteCalls.append(params) - guard let display = displayList.displays.first(where: { $0.id == params.displayId }) else { - throw PSDispatchError(.displayNotFound, data: .object(["displayId": .string(params.displayId)])) - } - guard params.peakWhite >= display.peakWhiteRange.minimum, - params.peakWhite <= display.peakWhiteRange.maximum else { - throw PSDispatchError( - .peakWhiteOutOfRange, - data: .object([ - "displayId": .string(params.displayId), - "peakWhite": .double(params.peakWhite), - "minimum": .double(display.peakWhiteRange.minimum), - "maximum": .double(display.peakWhiteRange.maximum) - ]) - ) - } - return DisplayEntry( - id: display.id, - name: display.name, - selected: display.selected, - connection: display.connection, - resolution: display.resolution, - refreshRate: display.refreshRate, - colorSpaceName: display.colorSpaceName, - cgColorSpaceName: display.cgColorSpaceName, - maximumPotentialEDR: display.maximumPotentialEDR, - maximumCurrentEDR: display.maximumCurrentEDR, - peakWhite: params.peakWhite, - effectivePeakWhite: min(params.peakWhite, display.maximumCurrentEDR), - peakWhiteRange: display.peakWhiteRange, - supportsPeakWhiteControl: display.supportsPeakWhiteControl - ) - } - - func capabilities() async throws -> CapabilitiesResult { capabilitiesResult } - - func listOutputColorPresets(displayId: String) async throws -> OutputColorPresetList { - OutputColorPresetList( - displayId: displayId, - selectedPresetId: .hdrBT2020PQ, - scope: .host, - catalogRevision: "test-catalog", - presets: [ - OutputColorPresetSummary( - id: .hdrBT2020PQ, - label: "BT.2020 PQ", - group: "hdr", - family: .hdrReference, - supported: true, - requiresPro: true, - implementationStatus: .native - ) - ] - ) - } - - func getOutputColorPreset(_ params: GetOutputColorPresetParams) async throws -> GetOutputColorPresetResult { - getOutputColorPresetCalls.append(params) - if unknownOutputPresetIds.contains(params.presetId) { - throw PSDispatchError( - .outputColorPresetUnsupported, - data: .object([ - "requestedPresetId": .string(params.presetId.rawValue), - "supportedPresetIds": .array([.string(OutputColorPresetID.hdrBT2020PQ.rawValue)]), - "scope": .string(ColorManagementScope.host.rawValue), - "reason": .string("unknownPreset") - ]) - ) - } - return GetOutputColorPresetResult( - displayId: params.displayId, - catalogRevision: "test-catalog", - preset: unsupportedOutputPresetConfig ?? outputPresetConfig( - id: params.presetId, - supported: true, - implementationStatus: .native - ) - ) - } - - func setOutputColorPreset(_ params: SetOutputColorPresetParams) async throws -> SetOutputColorPresetResult { - setOutputColorPresetCalls.append(params) - let display = displayList.displays[0] - return SetOutputColorPresetResult( - scope: .host, - selectedPresetId: params.presetId, - selectedDisplayId: display.id, - display: display - ) - } - - func setMeasurementRange(_ params: SetMeasurementRangeParams) async throws -> SetMeasurementRangeResult { - setMeasurementRangeCalls.append(params) - let display = displayList.displays[0] - return SetMeasurementRangeResult( - scope: .host, - selectedMeasurementRange: params.measurementRange, - selectedDisplayId: display.id, - display: display - ) - } - - private func outputPresetConfig( - id: OutputColorPresetID, - supported: Bool, - implementationStatus: OutputColorPresetImplementationStatus - ) -> OutputColorPresetConfig { - OutputColorPresetConfig( - id: id, - label: "BT.2020 PQ", - group: "hdr", - family: .hdrReference, - gamut: .bt2020, - whitePoint: .d65, - transfer: .pqSt2084, - dynamicRange: .hdr, - toneMapping: .none, - inputEncoding: .pqSt2084, - implementationStatus: implementationStatus, - supported: supported, - requiresPro: true, - unsupportedReason: supported ? nil : "insufficientHeadroom", - edrHeadroomRequired: 2.0, - edrHeadroomPotential: 1.2, - edrHeadroomCurrent: 1.0, - edrHeadroomReference: 1.0, - referenceWhiteNits: 100, - referenceWhiteNitsSource: "configured", - peakLuminanceNits: 1000, - clipOnsetNits: 120, - clipOnsetPQSignal: 0.508 - ) - } -} - -// MARK: - Helpers - -let testContext = OutputRequestContext(clientID: UUID()) - -func request(method: String, params: String = "{}") -> Data { - Data(#"{"jsonrpc":"2.0","id":"1","method":"\#(method)","params":\#(params)}"#.utf8) -} - -func responseObject(from data: Data) throws -> JSONValue { - try JSONDecoder().decode(JSONValue.self, from: data) -} - // MARK: - Tests @Suite struct JSONRPCDispatcherTests { @@ -692,7 +412,7 @@ func responseObject(from data: Data) throws -> JSONValue { } @Test func routeManifestMethodsDispatchWithoutMethodNotFound() async throws { - for method in JSONRPCRoute.allCases.map(\.rawValue) { + for method in JSONRPCRoute.allCases.map(\.rawValue).filter({ !["device.signalSnapshot", "pattern.displayProbe"].contains($0) }) { let mock = MockDelegate() let d = JSONRPCDispatcher(delegate: mock) let resp = await d.dispatch(request(method: method, params: params(for: method)), context: testContext) diff --git a/Tests/PatternSpaceSDKServerTests/SignalDispatchTests.swift b/Tests/PatternSpaceSDKServerTests/SignalDispatchTests.swift new file mode 100644 index 0000000..3557860 --- /dev/null +++ b/Tests/PatternSpaceSDKServerTests/SignalDispatchTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing +import PatternSpaceSDKCore +@testable import PatternSpaceSDKServer + +@Suite struct SignalDispatchTests { + private let patch = #""background":{"r":0,"g":0,"b":0},"rectangles":[{"color":{"r":1,"g":0,"b":0},"x":0,"y":0,"width":1,"height":1}],"bitDepth":10"# + @Test(arguments: ["{}", "[]"]) + func snapshotReadsWhileInactiveAndForwardsContext(_ params: String) async throws { + let host = MockDelegate(); host.supportsSignalSnapshot = true; host.isSourceActive = false + let response = await JSONRPCDispatcher(delegate: host).dispatch(request(method: "device.signalSnapshot", params: params), context: testContext) + #expect(try responseObject(from: response).object?["result"]?.object?["evidence"] != nil) + #expect(host.signalReadContext == testContext) + #expect(host.patchContexts.isEmpty) + } + @Test(arguments: ["null", "[1]", #"{"extra":1}"#, "true"]) + func snapshotRejectsNonEmptyParams(_ params: String) async throws { + let host = MockDelegate(); host.supportsSignalSnapshot = true + let response = await JSONRPCDispatcher(delegate: host).dispatch(request(method: "device.signalSnapshot", params: params), context: testContext) + #expect(try responseObject(from: response).object?["error"]?.object?["code"] == .int(-32602)) + #expect(host.signalReadContext == nil) + } + @Test func unsupportedRoutesRemainMethodNotFoundAndOmitted() async throws { + let host = MockDelegate() + host.capabilitiesResult = CapabilitiesResult(protocolVersion: "1.3", app: .init(name: "Old", version: "1", build: "1"), + sdkVersion: "1.0.0", platform: .macOS, authRequired: true, namespaces: JSONRPCDispatcher.routeManifest, + features: .init(events: true, displayInventory: true, peakWhiteControl: true, outputColorPresets: true, + measurementRange: true, catalogPatterns: true, customICCBuilder: false, httpBridge: false, + signalSnapshot: true, signalProbe: true)) + let dispatcher = JSONRPCDispatcher(delegate: host) + for method in ["device.signalSnapshot", "pattern.displayProbe"] { + let response = await dispatcher.dispatch(request(method: method), context: testContext) + #expect(try responseObject(from: response).object?["error"]?.object?["code"] == .int(-32601)) + } + let response = await dispatcher.dispatch(request(method: "capabilities.list"), context: testContext) + let result = try #require(try responseObject(from: response).object?["result"]?.object) + #expect(result["namespaces"]?.object?["device"]?.array?.contains(.string("signalSnapshot")) == false) + #expect(result["namespaces"]?.object?["pattern"]?.array?.contains(.string("displayProbe")) == false) + #expect(result["namespaces"]?.object?["device"]?.array?.contains(.string("info")) == true) + #expect(result["features"]?.object?["signalSnapshot"] == nil) + #expect(result["features"]?.object?["signalProbe"] == nil) + } + @Test func probeForwardsValidatedPatchAndGuardWithoutFallback() async throws { + let host = MockDelegate(); host.supportsSignalProbe = true + let response = await JSONRPCDispatcher(delegate: host).dispatch(request(method: "pattern.displayProbe", params: "{\(patch),\"expectedContextGuard\":\"opaque-guard\"}"), context: testContext) + #expect(try responseObject(from: response).object?["result"] != nil) + #expect(host.signalProbeContext == testContext) + #expect(host.signalProbeParams?.patch.bitDepth == .ten) + #expect(host.signalProbeParams?.expectedContextGuard == "opaque-guard") + #expect(host.patchContexts.isEmpty) + } + @Test(arguments: ["", "white space", "\n", String(repeating: "a", count: 513)]) + func malformedGuardsRejectedBeforeDelegate(_ guardValue: String) async throws { + let host = MockDelegate(); host.supportsSignalProbe = true + let encoded = String(decoding: try JSONEncoder().encode(guardValue), as: UTF8.self) + let response = await JSONRPCDispatcher(delegate: host).dispatch(request(method: "pattern.displayProbe", params: "{\(patch),\"expectedContextGuard\":\(encoded)}"), context: testContext) + #expect(try responseObject(from: response).object?["error"]?.object?["code"] == .int(-32602)) + #expect(host.signalProbeParams == nil) + } + @Test func probeRequiresSourceAndPreservesHostGuardErrors() async throws { + let host = MockDelegate(); host.supportsSignalProbe = true; host.isSourceActive = false + let dispatcher = JSONRPCDispatcher(delegate: host) + let params = "{\(patch),\"expectedContextGuard\":\"guard\"}" + let inactive = await dispatcher.dispatch(request(method: "pattern.displayProbe", params: params), context: testContext) + #expect(try responseObject(from: inactive).object?["error"]?.object?["code"] == .int(-32005)) + #expect(host.signalProbeParams == nil) + host.isSourceActive = true + for code in [PSErrorCode.displayError, .notAuthorized] { + host.signalError = PSDispatchError(code) + let response = await dispatcher.dispatch(request(method: "pattern.displayProbe", params: params), context: testContext) + #expect(try responseObject(from: response).object?["error"]?.object?["code"] == .int(code.rawValue)) + } + } + @Test func probeAndPatchShareValidation() async throws { + let host = MockDelegate(); host.supportsSignalProbe = true + let dispatcher = JSONRPCDispatcher(delegate: host) + let invalid = patch.replacingOccurrences(of: "\"width\":1", with: "\"width\":2") + let patchResponse = await dispatcher.dispatch(request(method: "pattern.displayPatch", params: "{\(invalid)}"), context: testContext) + let probeResponse = await dispatcher.dispatch(request(method: "pattern.displayProbe", params: "{\(invalid),\"expectedContextGuard\":\"guard\"}"), context: testContext) + #expect(try responseObject(from: patchResponse).object?["error"]?.object?["code"] == responseObject(from: probeResponse).object?["error"]?.object?["code"]) + #expect(host.signalProbeParams == nil && host.patchContexts.isEmpty) + } + @Test func snapshotAcceptsAbsentParamsAndBoundsCompleteRPCResponse() async throws { + let host = MockDelegate(); host.supportsSignalSnapshot = true + let dispatcher = JSONRPCDispatcher(delegate: host) + let absent = Data(#"{"jsonrpc":"2.0","id":1,"method":"device.signalSnapshot"}"#.utf8) + let read = await dispatcher.dispatch(absent, context: testContext) + #expect(try responseObject(from: read).object?["result"] != nil) + host.signalSnapshotResponse = .init(evidence: .init(identity: .init( + diagnosticsEpoch: String(repeating: "e", count: 250_000), revision: 1), + capture: .init(startedAt: 1, completedAt: 2), freshness: .current)) + let longID = String(repeating: "i", count: 20_000) + let packet = JSONValue.object(["jsonrpc": .string("2.0"), "id": .string(longID), + "method": .string("device.signalSnapshot")]) + let oversized = await dispatcher.dispatch(try JSONEncoder().encode(packet), context: testContext) + #expect(oversized.count <= SignalSnapshotResponse.maximumPayloadBytes) + #expect(try responseObject(from: oversized).object?["error"]?.object?["code"] == .int(-32603)) + } + + @Test func unchangedOldDelegateUsesDefaultUnsupportedHooks() async throws { + let oldHost = UnsupportedBlankDelegate() + #expect(!oldHost.supportsSignalSnapshot && !oldHost.supportsSignalProbe) + let dispatcher = JSONRPCDispatcher(delegate: oldHost) + for method in ["device.signalSnapshot", "pattern.displayProbe"] { + let response = await dispatcher.dispatch(request(method: method), context: testContext) + #expect(try responseObject(from: response).object?["error"]?.object?["code"] == .int(-32601)) + } + } + +} diff --git a/Tests/PatternSpaceSDKServerTests/WebSocketClientIntegrationTests.swift b/Tests/PatternSpaceSDKServerTests/WebSocketClientIntegrationTests.swift index ec8fefd..a2ead52 100644 --- a/Tests/PatternSpaceSDKServerTests/WebSocketClientIntegrationTests.swift +++ b/Tests/PatternSpaceSDKServerTests/WebSocketClientIntegrationTests.swift @@ -236,6 +236,46 @@ import PatternSpaceSDKCore #expect(recorder.events == ["connected:A", "disconnected:A:evicted"]) } + @Test func signalClientMethodsRoundTripWithoutPatchFallback() async throws { + let port: UInt16 = 18_797 + let delegate = MockDelegate() + delegate.supportsSignalSnapshot = true; delegate.supportsSignalProbe = true + let server = makeServer(delegate: delegate) + try server.start(port: port, deviceName: "Signal test") + defer { server.stop() } + let client = makeClient(port: port, token: "test-token") + client.connect(); defer { client.disconnect() } + let snapshot = try await withinTwoSeconds(client) { try await client.device.signalSnapshot() } + #expect(snapshot.evidence.identity.diagnosticsEpoch == "epoch") + let patch = PatchParams(background: .init(r: 0, g: 0, b: 0), rectangles: [ + .init(color: .init(r: 1, g: 0, b: 0), x: 0, y: 0, width: 1, height: 1) + ], bitDepth: .ten) + try await withinTwoSeconds(client) { + try await client.pattern.displayProbe(.init(patch: patch, expectedContextGuard: "guard")) + } + #expect(delegate.signalProbeParams?.patch == patch) + #expect(delegate.signalProbeContext == delegate.signalReadContext) + #expect(delegate.patchContexts.isEmpty) + } + + @Test func newSignalClientReceivesMethodNotFoundFromOldHost() async throws { + let port: UInt16 = 18_798 + let delegate = MockDelegate() + let server = makeServer(delegate: delegate) + try server.start(port: port, deviceName: "Old host test") + defer { server.stop() } + let client = makeClient(port: port, token: "test-token") + client.connect(); defer { client.disconnect() } + do { + _ = try await withinTwoSeconds(client) { try await client.device.signalSnapshot() } + Issue.record("Old host unexpectedly supported signal snapshots") + } catch let error as PSDispatchError { + #expect(error.code == .methodNotFound) + } + let status = try await withinTwoSeconds(client) { try await client.device.status() } + #expect(status.sourceActive) + } + private func upgradeRequest(token: String) -> String { "GET /patternspace HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\n" + "Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"