Implement WatchConnectivity relay bridge
This commit is contained in:
@@ -1,8 +1,28 @@
|
||||
import Foundation
|
||||
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
|
||||
@@ -12,4 +32,156 @@ public struct RelayContainer: Sendable {
|
||||
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 init(selectedTelevisionID: UUID?, displayName: String?) {
|
||||
self.schemaVersion = RelayWire.schemaVersion
|
||||
self.selectedTelevisionID = selectedTelevisionID
|
||||
self.displayName = displayName
|
||||
}
|
||||
|
||||
public func dictionary() throws -> [String: Any] {
|
||||
var result: [String: Any] = ["schemaVersion": schemaVersion]
|
||||
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"]
|
||||
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
|
||||
}
|
||||
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 {
|
||||
public let message: [String: Any]
|
||||
public let reply: RelayReplyOnce
|
||||
|
||||
public init(message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
self.message = 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()
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user