198 lines
7.8 KiB
Swift
198 lines
7.8 KiB
Swift
import Foundation
|
|
import CoreFoundation
|
|
import RemoteCore
|
|
|
|
private func relayNSNumberIsBoolean(_ value: NSNumber) -> Bool {
|
|
String(cString: value.objCType) == "c"
|
|
}
|
|
|
|
private func relayNSNumberIsInteger(_ value: NSNumber) -> Bool {
|
|
["c", "s", "i", "l", "q", "C", "S", "I", "L", "Q"].contains(
|
|
String(cString: value.objCType))
|
|
}
|
|
|
|
/// Typed outgoing container seam. Workstream B owns WCSession dictionary parsing,
|
|
/// strict incoming key/type validation, routing and exactly-once replies.
|
|
public enum RelayContainerError: Error, Sendable, Equatable {
|
|
case missingKeys
|
|
case extraKeys
|
|
case invalidSchemaType
|
|
case unsupportedSchema(Int)
|
|
case invalidKind
|
|
case invalidPayload
|
|
case unexpectedKind(RelayMessageKind)
|
|
case malformedPayload
|
|
}
|
|
|
|
public struct RelayContainer: Sendable {
|
|
public let schemaVersion = RelayWire.schemaVersion
|
|
public let kind: RelayMessageKind
|
|
public let payload: Data
|
|
|
|
public init<Value: Encodable>(kind: RelayMessageKind, value: Value) throws {
|
|
self.kind = kind
|
|
self.payload = try RelayJSON.encode(value)
|
|
}
|
|
|
|
/// The exact WCSession dictionary shape. Parsing is structural only; the
|
|
/// caller must decode the payload for the expected request kind.
|
|
public init(dictionary: [String: Any]) throws {
|
|
let expected: Set<String> = ["schemaVersion", "kind", "payload"]
|
|
let actual = Set(dictionary.keys)
|
|
guard actual == expected else {
|
|
if actual.isSubset(of: expected) { throw RelayContainerError.missingKeys }
|
|
throw RelayContainerError.extraKeys
|
|
}
|
|
guard let version = dictionary["schemaVersion"] as? NSNumber,
|
|
CFGetTypeID(version) == CFNumberGetTypeID(),
|
|
relayNSNumberIsInteger(version),
|
|
version.intValue >= 0,
|
|
!relayNSNumberIsBoolean(version) else {
|
|
throw RelayContainerError.invalidSchemaType
|
|
}
|
|
let schema = version.intValue
|
|
guard schema == RelayWire.schemaVersion else {
|
|
throw RelayContainerError.unsupportedSchema(schema)
|
|
}
|
|
guard let rawKind = dictionary["kind"] as? String,
|
|
let kind = RelayMessageKind(rawValue: rawKind) else {
|
|
throw RelayContainerError.invalidKind
|
|
}
|
|
guard let payload = dictionary["payload"] as? Data else {
|
|
throw RelayContainerError.invalidPayload
|
|
}
|
|
guard payload.count <= RelayWire.maximumPayloadBytes else {
|
|
throw RelayContractError.payloadTooLarge
|
|
}
|
|
self.kind = kind
|
|
self.payload = payload
|
|
}
|
|
|
|
public func dictionary() -> [String: Any] {
|
|
["schemaVersion": schemaVersion, "kind": kind.rawValue, "payload": payload]
|
|
}
|
|
|
|
public func decode<Value: Decodable>(_ type: Value.Type,
|
|
expectedKind: RelayMessageKind) throws -> Value {
|
|
guard kind == expectedKind else { throw RelayContainerError.unexpectedKind(kind) }
|
|
do {
|
|
return try RelayJSON.decode(type, from: payload)
|
|
} catch {
|
|
throw RelayContainerError.malformedPayload
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Only allowlisted, latest-state configuration belongs in application
|
|
/// context. Commands, tokens and executable work are intentionally absent.
|
|
public struct RelayApplicationContext: Sendable, Equatable {
|
|
public let schemaVersion: Int
|
|
public let selectedTelevisionID: UUID?
|
|
public let displayName: String?
|
|
public let isConnectionReady: Bool
|
|
|
|
public init(selectedTelevisionID: UUID?, displayName: String?, isConnectionReady: Bool = false) {
|
|
self.schemaVersion = RelayWire.schemaVersion
|
|
self.selectedTelevisionID = selectedTelevisionID
|
|
self.displayName = displayName
|
|
self.isConnectionReady = isConnectionReady && selectedTelevisionID != nil
|
|
}
|
|
|
|
public func dictionary() throws -> [String: Any] {
|
|
var result: [String: Any] = ["schemaVersion": schemaVersion, "isConnectionReady": isConnectionReady]
|
|
if let selectedTelevisionID { result["selectedTelevisionID"] = selectedTelevisionID.uuidString }
|
|
if let displayName { result["displayName"] = String(displayName.prefix(128)) }
|
|
return result
|
|
}
|
|
|
|
public init(dictionary: [String: Any]) throws {
|
|
let allowed: Set<String> = ["schemaVersion", "selectedTelevisionID", "displayName", "isConnectionReady"]
|
|
guard Set(dictionary.keys).isSubset(of: allowed) else { throw RelayContainerError.extraKeys }
|
|
guard let version = dictionary["schemaVersion"] as? NSNumber,
|
|
relayNSNumberIsInteger(version),
|
|
!relayNSNumberIsBoolean(version),
|
|
version.intValue == RelayWire.schemaVersion else {
|
|
throw RelayContainerError.invalidSchemaType
|
|
}
|
|
if let raw = dictionary["selectedTelevisionID"] {
|
|
guard let string = raw as? String, UUID(uuidString: string) != nil else {
|
|
throw RelayContainerError.invalidPayload
|
|
}
|
|
self.selectedTelevisionID = UUID(uuidString: string)
|
|
} else {
|
|
self.selectedTelevisionID = nil
|
|
}
|
|
if let raw = dictionary["displayName"] {
|
|
guard let string = raw as? String, string.count <= 128 else {
|
|
throw RelayContainerError.invalidPayload
|
|
}
|
|
self.displayName = string
|
|
} else {
|
|
self.displayName = nil
|
|
}
|
|
if let raw = dictionary["isConnectionReady"] {
|
|
guard let number = raw as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else {
|
|
throw RelayContainerError.invalidPayload
|
|
}
|
|
self.isConnectionReady = number.boolValue && selectedTelevisionID != nil
|
|
} else { self.isConnectionReady = false }
|
|
self.schemaVersion = version.intValue
|
|
}
|
|
}
|
|
|
|
public enum RelayActivationState: Sendable, Equatable {
|
|
case notActivated, activating, activated, inactive, deactivated
|
|
}
|
|
|
|
/// A small injectable facade around WCSession. It deliberately exposes only
|
|
/// immediate messages and latest-state context, never transferUserInfo.
|
|
@MainActor
|
|
public protocol RelaySessionSending: AnyObject {
|
|
var activationState: RelayActivationState { get }
|
|
var isReachable: Bool { get }
|
|
func activate()
|
|
func sendMessage(_ message: [String: Any],
|
|
replyHandler: @escaping ([String: Any]) -> Void,
|
|
errorHandler: @escaping (Error) -> Void)
|
|
func updateApplicationContext(_ context: [String: Any]) throws
|
|
}
|
|
|
|
public final class RelayReplyOnce: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var completed = false
|
|
private let handler: ([String: Any]) -> Void
|
|
|
|
public init(_ handler: @escaping ([String: Any]) -> Void) { self.handler = handler }
|
|
|
|
public func reply(_ value: [String: Any]) {
|
|
lock.lock()
|
|
guard !completed else { lock.unlock(); return }
|
|
completed = true
|
|
lock.unlock()
|
|
handler(value)
|
|
}
|
|
}
|
|
|
|
/// WCSession delivers Foundation dictionaries and non-Sendable callbacks from
|
|
/// a delegate context. This explicit boundary lets the native delegate hop to
|
|
/// the main actor without pretending the dictionary itself is Sendable.
|
|
public final class RelayInboundMessage: @unchecked Sendable {
|
|
private let container: RelayContainer?
|
|
public var message: [String: Any] { container?.dictionary() ?? [:] }
|
|
public let reply: RelayReplyOnce
|
|
|
|
public init(message: [String: Any],
|
|
replyHandler: @escaping ([String: Any]) -> Void) {
|
|
self.container = try? RelayContainer(dictionary: message)
|
|
self.reply = RelayReplyOnce(replyHandler)
|
|
}
|
|
}
|
|
|
|
public func relayErrorReply(_ failure: RelayFailure) -> [String: Any] {
|
|
(try? RelayContainer(kind: .error, value: failure).dictionary()) ?? [
|
|
"schemaVersion": RelayWire.schemaVersion,
|
|
"kind": RelayMessageKind.error.rawValue,
|
|
"payload": Data()
|
|
]
|
|
}
|