Files
wristivo/Adapters/WatchBridge/Sources/Common/RelayContainer.swift
T

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()
]
}