Implement WatchConnectivity relay bridge
This commit is contained in:
@@ -1,8 +1,28 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import RemoteCore
|
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,
|
/// Typed outgoing container seam. Workstream B owns WCSession dictionary parsing,
|
||||||
/// strict incoming key/type validation, routing and exactly-once replies.
|
/// 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 struct RelayContainer: Sendable {
|
||||||
public let schemaVersion = RelayWire.schemaVersion
|
public let schemaVersion = RelayWire.schemaVersion
|
||||||
public let kind: RelayMessageKind
|
public let kind: RelayMessageKind
|
||||||
@@ -12,4 +32,156 @@ public struct RelayContainer: Sendable {
|
|||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.payload = try RelayJSON.encode(value)
|
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()
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import Foundation
|
||||||
|
import RemoteCore
|
||||||
|
import WatchBridgeCommon
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class WatchRelayBridge: NSObject {
|
||||||
|
public enum State: Sendable, Equatable {
|
||||||
|
case inactive
|
||||||
|
case activating
|
||||||
|
case unavailable
|
||||||
|
case ready(reachable: Bool)
|
||||||
|
case sessionNegotiating
|
||||||
|
case armed(sessionID: UUID, televisionID: UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
public private(set) var state: State = .inactive {
|
||||||
|
didSet { onStateChange?(state) }
|
||||||
|
}
|
||||||
|
public var onStateChange: ((State) -> Void)?
|
||||||
|
|
||||||
|
private let session: any RelaySessionSending
|
||||||
|
private var grant: RemoteSessionGrant?
|
||||||
|
private var nextSequence: UInt64 = 0
|
||||||
|
private var activationReady = false
|
||||||
|
private var pendingReplies: [UUID: RelayReplyOnce] = [:]
|
||||||
|
|
||||||
|
public init(session: any RelaySessionSending) {
|
||||||
|
self.session = session
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func activate() {
|
||||||
|
guard !activationReady else { return }
|
||||||
|
state = .activating
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func activationDidComplete(error: Error?) {
|
||||||
|
guard error == nil else {
|
||||||
|
activationReady = false
|
||||||
|
state = .unavailable
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activationReady = true
|
||||||
|
state = .ready(reachable: session.isReachable)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func reachabilityDidChange() {
|
||||||
|
guard activationReady, grant == nil else { return }
|
||||||
|
state = .ready(reachable: session.isReachable)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func didBecomeInactive() {
|
||||||
|
activationReady = false
|
||||||
|
invalidatePending()
|
||||||
|
state = .inactive
|
||||||
|
}
|
||||||
|
|
||||||
|
public func didDeactivate() {
|
||||||
|
activationReady = false
|
||||||
|
grant = nil
|
||||||
|
nextSequence = 0
|
||||||
|
invalidatePending()
|
||||||
|
state = .inactive
|
||||||
|
}
|
||||||
|
|
||||||
|
public func beginSession() {
|
||||||
|
guard activationReady, session.isReachable, grant == nil else {
|
||||||
|
state = .unavailable
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .sessionNegotiating
|
||||||
|
let requestID = UUID()
|
||||||
|
let request = BeginRemoteSessionRequest()
|
||||||
|
guard let message = try? RelayContainer(kind: .beginSession, value: request).dictionary() else {
|
||||||
|
state = .unavailable
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send(message: message, requestID: requestID) { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
guard let container = try? RelayContainer(dictionary: response),
|
||||||
|
container.kind == .session,
|
||||||
|
let received = try? container.decode(RemoteSessionGrant.self,
|
||||||
|
expectedKind: .session) else {
|
||||||
|
self.state = .unavailable
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.grant = received
|
||||||
|
self.nextSequence = 0
|
||||||
|
self.state = .armed(sessionID: received.sessionID,
|
||||||
|
televisionID: received.televisionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func endSession() {
|
||||||
|
guard activationReady, let grant else { return }
|
||||||
|
let request = EndRemoteSessionRequest(sessionID: grant.sessionID)
|
||||||
|
guard let message = try? RelayContainer(kind: .endSession, value: request).dictionary() else {
|
||||||
|
self.grant = nil
|
||||||
|
state = .ready(reachable: session.isReachable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let requestID = UUID()
|
||||||
|
send(message: message, requestID: requestID) { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
guard self.grant?.sessionID == grant.sessionID else { return }
|
||||||
|
self.grant = nil
|
||||||
|
self.nextSequence = 0
|
||||||
|
self.state = .ready(reachable: self.session.isReachable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One command is one live sendMessage. A sequence is consumed before the
|
||||||
|
/// send; ambiguous timeout/failure is never replayed.
|
||||||
|
public func sendCommand(_ command: RemoteCommand, now: Date = Date()) {
|
||||||
|
guard activationReady, session.isReachable, let grant else {
|
||||||
|
state = .unavailable
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard nextSequence < UInt64.max else {
|
||||||
|
self.grant = nil
|
||||||
|
state = .unavailable
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let sequence = nextSequence
|
||||||
|
nextSequence += 1
|
||||||
|
let lifetime = Double(grant.maximumCommandLifetimeMilliseconds) / 1_000
|
||||||
|
let envelope = CommandEnvelope(sessionID: grant.sessionID,
|
||||||
|
televisionID: grant.televisionID,
|
||||||
|
sequence: sequence,
|
||||||
|
issuedAt: now,
|
||||||
|
expiresAt: now.addingTimeInterval(lifetime),
|
||||||
|
command: command)
|
||||||
|
guard let message = try? RelayContainer(kind: .command, value: envelope).dictionary() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send(message: message, requestID: UUID()) { [weak self] response in
|
||||||
|
guard let self,
|
||||||
|
let container = try? RelayContainer(dictionary: response),
|
||||||
|
container.kind == .receipt,
|
||||||
|
let receipt = try? container.decode(CommandReceipt.self,
|
||||||
|
expectedKind: .receipt),
|
||||||
|
receipt.id == envelope.id else { return }
|
||||||
|
// A receipt is command feedback, not evidence that the TV changed.
|
||||||
|
if receipt.status == .unavailable { self.state = .unavailable }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(message: [String: Any], requestID: UUID,
|
||||||
|
response: @escaping @MainActor ([String: Any]) -> Void) {
|
||||||
|
guard pendingReplies.count < 32 else {
|
||||||
|
state = .unavailable
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let once = RelayReplyOnce { [weak self] value in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, self.pendingReplies.removeValue(forKey: requestID) != nil else { return }
|
||||||
|
guard self.activationReady else { return }
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingReplies[requestID] = once
|
||||||
|
session.sendMessage(message, replyHandler: { value in
|
||||||
|
once.reply(value)
|
||||||
|
}, errorHandler: { _ in
|
||||||
|
once.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||||
|
})
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(1_000))
|
||||||
|
self?.pendingReplies[requestID]?.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func invalidatePending() {
|
||||||
|
let replies = pendingReplies.values
|
||||||
|
pendingReplies.removeAll()
|
||||||
|
for reply in replies { reply.reply(relayErrorReply(RelayFailure(code: .sessionInvalidated))) }
|
||||||
|
grant = nil
|
||||||
|
nextSequence = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#if os(watchOS)
|
||||||
|
import Foundation
|
||||||
|
import RemoteCore
|
||||||
|
import WatchBridgeCommon
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class WatchWCSessionSending: RelaySessionSending {
|
||||||
|
private let session: WCSession
|
||||||
|
init(session: WCSession) { self.session = session }
|
||||||
|
var activationState: RelayActivationState {
|
||||||
|
switch session.activationState {
|
||||||
|
case .notActivated: return .notActivated
|
||||||
|
case .inactive: return .inactive
|
||||||
|
case .activated: return .activated
|
||||||
|
@unknown default: return .inactive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var isReachable: Bool { session.isReachable }
|
||||||
|
func activate() { session.activate() }
|
||||||
|
func sendMessage(_ message: [String: Any],
|
||||||
|
replyHandler: @escaping ([String: Any]) -> Void,
|
||||||
|
errorHandler: @escaping (Error) -> Void) {
|
||||||
|
session.sendMessage(message, replyHandler: replyHandler, errorHandler: errorHandler)
|
||||||
|
}
|
||||||
|
func updateApplicationContext(_ context: [String: Any]) throws {
|
||||||
|
try session.updateApplicationContext(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate watchOS delegate. The app remains foreground/active for gesture
|
||||||
|
/// use; this delegate does not add a background mode or deferred command queue.
|
||||||
|
@MainActor
|
||||||
|
public final class WatchWCSessionDelegate: NSObject, WCSessionDelegate {
|
||||||
|
public let bridge: WatchRelayBridge
|
||||||
|
|
||||||
|
public init(session: WCSession = .default) {
|
||||||
|
bridge = WatchRelayBridge(session: WatchWCSessionSending(session: session))
|
||||||
|
super.init()
|
||||||
|
session.delegate = self
|
||||||
|
bridge.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public func session(_ session: WCSession,
|
||||||
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||||
|
error: Error?) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if case .inactive = self.bridge.state { } else { self.bridge.didDeactivate() }
|
||||||
|
self.bridge.activationDidComplete(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public func sessionReachabilityDidChange(_ session: WCSession) {
|
||||||
|
Task { @MainActor [weak self] in self?.bridge.reachabilityDidChange() }
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public func session(_ session: WCSession,
|
||||||
|
didReceiveMessage message: [String: Any],
|
||||||
|
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||||
|
// The watch is the request sender in v1; never execute a phone reply
|
||||||
|
// as a request. Complete unexpected inbound traffic exactly once.
|
||||||
|
RelayReplyOnce(replyHandler).reply(
|
||||||
|
relayErrorReply(RelayFailure(code: .malformedRequest)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import Foundation
|
||||||
|
import RemoteCore
|
||||||
|
import WatchBridgeCommon
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class PhoneRelayBridge: NSObject {
|
||||||
|
public enum State: Sendable, Equatable {
|
||||||
|
case inactive
|
||||||
|
case activating
|
||||||
|
case ready(reachable: Bool)
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
public private(set) var state: State = .inactive
|
||||||
|
private let session: any RelaySessionSending
|
||||||
|
private let coordinator: any PhoneCommandCoordinating
|
||||||
|
private var peer: WatchPeerGeneration?
|
||||||
|
private var activationReady = false
|
||||||
|
|
||||||
|
public init(session: any RelaySessionSending,
|
||||||
|
dependency: PhoneRelayDependency) {
|
||||||
|
self.session = session
|
||||||
|
self.coordinator = dependency.coordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
public func activate() {
|
||||||
|
guard peer == nil else { return }
|
||||||
|
state = .activating
|
||||||
|
activationReady = false
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func activationDidComplete(error: Error?) {
|
||||||
|
guard error == nil else {
|
||||||
|
state = .unavailable
|
||||||
|
activationReady = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let newPeer = WatchPeerGeneration()
|
||||||
|
peer = newPeer
|
||||||
|
activationReady = false
|
||||||
|
state = .ready(reachable: session.isReachable)
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.coordinator.watchBridgeDidActivate(peer: newPeer)
|
||||||
|
guard let self, self.peer == newPeer else { return }
|
||||||
|
self.activationReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func reachabilityDidChange() {
|
||||||
|
guard peer != nil, activationReady else { return }
|
||||||
|
state = .ready(reachable: session.isReachable)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func didBecomeInactive() {
|
||||||
|
// iOS will normally follow with sessionDidDeactivate, but invalidate
|
||||||
|
// immediately so no command can slip through during counterpart swap.
|
||||||
|
didDeactivate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func didDeactivate() {
|
||||||
|
guard let oldPeer = peer else { return }
|
||||||
|
peer = nil
|
||||||
|
activationReady = false
|
||||||
|
state = .inactive
|
||||||
|
Task { await coordinator.watchBridgeDidDeactivate(peer: oldPeer) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point for the native WCSession delegate. Every path completes the
|
||||||
|
/// supplied reply exactly once, including malformed, unavailable and timed
|
||||||
|
/// out requests.
|
||||||
|
public func receive(message: [String: Any],
|
||||||
|
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||||
|
let reply = RelayReplyOnce(replyHandler)
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await self.route(message: message, reply: reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func route(message: [String: Any], reply: RelayReplyOnce) async {
|
||||||
|
guard activationReady, let currentPeer = peer else {
|
||||||
|
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let container: RelayContainer
|
||||||
|
do {
|
||||||
|
container = try RelayContainer(dictionary: message)
|
||||||
|
} catch {
|
||||||
|
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch container.kind {
|
||||||
|
case .beginSession:
|
||||||
|
guard let request = try? container.decode(BeginRemoteSessionRequest.self,
|
||||||
|
expectedKind: .beginSession) else {
|
||||||
|
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let result = await withTimeout({
|
||||||
|
await self.coordinator.beginWatchSession(request, peer: currentPeer)
|
||||||
|
}) else {
|
||||||
|
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case .success(let grant):
|
||||||
|
reply.reply(Self.reply(kind: .session, value: grant))
|
||||||
|
case .failure(let failure):
|
||||||
|
reply.reply(relayErrorReply(failure))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .endSession:
|
||||||
|
guard let request = try? container.decode(EndRemoteSessionRequest.self,
|
||||||
|
expectedKind: .endSession) else {
|
||||||
|
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let result = await withTimeout({
|
||||||
|
await self.coordinator.endWatchSession(request, peer: currentPeer)
|
||||||
|
}) else {
|
||||||
|
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case .success(let ended):
|
||||||
|
reply.reply(Self.reply(kind: .ended, value: ended))
|
||||||
|
case .failure(let failure):
|
||||||
|
reply.reply(relayErrorReply(failure))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .command:
|
||||||
|
guard let envelope = try? container.decode(CommandEnvelope.self,
|
||||||
|
expectedKind: .command) else {
|
||||||
|
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let receipt = await withTimeout({
|
||||||
|
await self.coordinator.submitWatchCommand(envelope, peer: currentPeer)
|
||||||
|
}) else {
|
||||||
|
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard receipt.id == envelope.id else {
|
||||||
|
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply.reply(Self.reply(kind: .receipt, value: receipt))
|
||||||
|
|
||||||
|
case .session, .ended, .receipt, .error:
|
||||||
|
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func reply<Value: Encodable>(kind: RelayMessageKind,
|
||||||
|
value: Value) -> [String: Any] {
|
||||||
|
(try? RelayContainer(kind: kind, value: value).dictionary()) ??
|
||||||
|
relayErrorReply(RelayFailure(code: .malformedRequest))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func withTimeout<Value: Sendable>(
|
||||||
|
_ operation: @escaping @Sendable () async -> Value
|
||||||
|
) async -> Value? {
|
||||||
|
await withTaskGroup(of: Value?.self) { group in
|
||||||
|
group.addTask { await operation() }
|
||||||
|
group.addTask {
|
||||||
|
try? await Task.sleep(for: .milliseconds(1_000))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let result = await group.next() ?? nil
|
||||||
|
group.cancelAll()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#if os(iOS)
|
||||||
|
import Foundation
|
||||||
|
import RemoteCore
|
||||||
|
import WatchBridgeCommon
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class PhoneWCSessionSending: RelaySessionSending {
|
||||||
|
private let session: WCSession
|
||||||
|
|
||||||
|
init(session: WCSession) { self.session = session }
|
||||||
|
|
||||||
|
var activationState: RelayActivationState {
|
||||||
|
switch session.activationState {
|
||||||
|
case .notActivated: return .notActivated
|
||||||
|
case .inactive: return .inactive
|
||||||
|
case .activated: return .activated
|
||||||
|
@unknown default: return .inactive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isReachable: Bool { session.isReachable }
|
||||||
|
func activate() { session.activate() }
|
||||||
|
func sendMessage(_ message: [String: Any],
|
||||||
|
replyHandler: @escaping ([String: Any]) -> Void,
|
||||||
|
errorHandler: @escaping (Error) -> Void) {
|
||||||
|
session.sendMessage(message, replyHandler: replyHandler, errorHandler: errorHandler)
|
||||||
|
}
|
||||||
|
func updateApplicationContext(_ context: [String: Any]) throws {
|
||||||
|
try session.updateApplicationContext(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iPhone-side WCSession delegate. All UI-facing state changes are hopped to
|
||||||
|
/// the main actor; semantic command admission remains in the injected actor.
|
||||||
|
@MainActor
|
||||||
|
public final class PhoneWCSessionDelegate: NSObject, WCSessionDelegate {
|
||||||
|
public let bridge: PhoneRelayBridge
|
||||||
|
private let session: WCSession
|
||||||
|
|
||||||
|
public init(coordinator: any PhoneCommandCoordinating,
|
||||||
|
session: WCSession = .default) {
|
||||||
|
self.session = session
|
||||||
|
self.bridge = PhoneRelayBridge(session: PhoneWCSessionSending(session: session),
|
||||||
|
dependency: PhoneRelayDependency(coordinator: coordinator))
|
||||||
|
super.init()
|
||||||
|
session.delegate = self
|
||||||
|
bridge.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public func session(_ session: WCSession,
|
||||||
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||||
|
error: Error?) {
|
||||||
|
Task { @MainActor [weak self] in self?.bridge.activationDidComplete(error: error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public func sessionDidBecomeInactive(_ session: WCSession) {
|
||||||
|
Task { @MainActor [weak self] in self?.bridge.didBecomeInactive() }
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public func sessionDidDeactivate(_ session: WCSession) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.bridge.didDeactivate()
|
||||||
|
self.session.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public func sessionReachabilityDidChange(_ session: WCSession) {
|
||||||
|
Task { @MainActor [weak self] in self?.bridge.reachabilityDidChange() }
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public func session(_ session: WCSession,
|
||||||
|
didReceiveMessage message: [String: Any],
|
||||||
|
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||||
|
let inbound = RelayInboundMessage(message: message, replyHandler: replyHandler)
|
||||||
|
Task { @MainActor [weak self, inbound] in
|
||||||
|
guard let self else {
|
||||||
|
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.bridge.receive(message: inbound.message,
|
||||||
|
replyHandler: inbound.reply.reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -24,3 +24,49 @@ import WatchBridgeCommon
|
|||||||
try RelayContainer(kind: .error, value: String(repeating: "x", count: RelayWire.maximumPayloadBytes))
|
try RelayContainer(kind: .error, value: String(repeating: "x", count: RelayWire.maximumPayloadBytes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func incomingContainerRequiresExactTypedShape() throws {
|
||||||
|
let payload = try RelayJSON.encode(BeginRemoteSessionRequest())
|
||||||
|
let valid: [String: Any] = [
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "beginSession",
|
||||||
|
"payload": payload
|
||||||
|
]
|
||||||
|
#expect(try RelayContainer(dictionary: valid).kind == .beginSession)
|
||||||
|
#expect(throws: RelayContainerError.extraKeys) {
|
||||||
|
var extra = valid
|
||||||
|
extra["token"] = "never accepted"
|
||||||
|
_ = try RelayContainer(dictionary: extra)
|
||||||
|
}
|
||||||
|
#expect(throws: RelayContainerError.invalidSchemaType) {
|
||||||
|
var booleanVersion = valid
|
||||||
|
booleanVersion["schemaVersion"] = true
|
||||||
|
_ = try RelayContainer(dictionary: booleanVersion)
|
||||||
|
}
|
||||||
|
#expect(throws: RelayContainerError.invalidSchemaType) {
|
||||||
|
var floatingVersion = valid
|
||||||
|
floatingVersion["schemaVersion"] = 1.0
|
||||||
|
_ = try RelayContainer(dictionary: floatingVersion)
|
||||||
|
}
|
||||||
|
#expect(throws: RelayContainerError.invalidPayload) {
|
||||||
|
var wrongPayload = valid
|
||||||
|
wrongPayload["payload"] = "not bytes"
|
||||||
|
_ = try RelayContainer(dictionary: wrongPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func applicationContextContainsOnlyLatestStateAllowlist() throws {
|
||||||
|
let tv = UUID()
|
||||||
|
let context = try RelayApplicationContext(selectedTelevisionID: tv,
|
||||||
|
displayName: "Living room").dictionary()
|
||||||
|
#expect(context["selectedTelevisionID"] as? String == tv.uuidString)
|
||||||
|
#expect(context["displayName"] as? String == "Living room")
|
||||||
|
#expect(!context.keys.contains("token"))
|
||||||
|
#expect(!context.keys.contains("command"))
|
||||||
|
#expect(try RelayApplicationContext(dictionary: context).selectedTelevisionID == tv)
|
||||||
|
var executable = context
|
||||||
|
executable["command"] = "queued"
|
||||||
|
#expect(throws: RelayContainerError.extraKeys) {
|
||||||
|
_ = try RelayApplicationContext(dictionary: executable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import RemoteCore
|
||||||
|
import WatchBridgeCommon
|
||||||
|
import WatchBridgeWatch
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class FakeWatchSession: RelaySessionSending {
|
||||||
|
var activationState: RelayActivationState = .notActivated
|
||||||
|
var isReachable = true
|
||||||
|
var sent: [[String: Any]] = []
|
||||||
|
var reply: (([String: Any]) -> Void)?
|
||||||
|
var error: ((Error) -> Void)?
|
||||||
|
var replyTwice = false
|
||||||
|
|
||||||
|
func activate() { activationState = .activated }
|
||||||
|
func sendMessage(_ message: [String: Any],
|
||||||
|
replyHandler: @escaping ([String: Any]) -> Void,
|
||||||
|
errorHandler: @escaping (Error) -> Void) {
|
||||||
|
sent.append(message)
|
||||||
|
reply = replyHandler
|
||||||
|
error = errorHandler
|
||||||
|
}
|
||||||
|
func updateApplicationContext(_ context: [String: Any]) throws {}
|
||||||
|
|
||||||
|
func answer(_ value: [String: Any]) {
|
||||||
|
reply?(value)
|
||||||
|
if replyTwice { reply?(value); error?(RelayTestError.failed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum RelayTestError: Error { case failed }
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func watchNegotiatesBeforeSendingCommandAndUsesMilliseconds() async throws {
|
||||||
|
let fake = FakeWatchSession()
|
||||||
|
let bridge = WatchRelayBridge(session: fake)
|
||||||
|
bridge.activate()
|
||||||
|
bridge.activationDidComplete(error: nil)
|
||||||
|
bridge.beginSession()
|
||||||
|
#expect(fake.sent.count == 1)
|
||||||
|
let request = try RelayContainer(dictionary: fake.sent[0])
|
||||||
|
#expect(request.kind == .beginSession)
|
||||||
|
|
||||||
|
let sessionID = UUID(), tvID = UUID()
|
||||||
|
let grant = RemoteSessionGrant(sessionID: sessionID, televisionID: tvID,
|
||||||
|
issuedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
maximumCommandLifetimeMilliseconds: 500)
|
||||||
|
fake.answer(try RelayContainer(kind: .session, value: grant).dictionary())
|
||||||
|
await Task.yield()
|
||||||
|
bridge.sendCommand(.right, now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||||
|
#expect(fake.sent.count == 2)
|
||||||
|
let command = try RelayContainer(dictionary: fake.sent[1])
|
||||||
|
let envelope = try command.decode(CommandEnvelope.self, expectedKind: .command)
|
||||||
|
#expect(envelope.sessionID == sessionID)
|
||||||
|
#expect(envelope.televisionID == tvID)
|
||||||
|
#expect(envelope.sequence == 0)
|
||||||
|
#expect(envelope.expiresAt.timeIntervalSince1970 == 1_700_000_000.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func watchDropsCommandsWhenUnreachableAndDuplicateRepliesCompleteOnce() async throws {
|
||||||
|
let fake = FakeWatchSession()
|
||||||
|
let bridge = WatchRelayBridge(session: fake)
|
||||||
|
bridge.activate()
|
||||||
|
bridge.activationDidComplete(error: nil)
|
||||||
|
fake.isReachable = false
|
||||||
|
bridge.beginSession()
|
||||||
|
#expect(fake.sent.isEmpty)
|
||||||
|
|
||||||
|
fake.isReachable = true
|
||||||
|
bridge.beginSession()
|
||||||
|
fake.replyTwice = true
|
||||||
|
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
|
||||||
|
fake.answer(try RelayContainer(kind: .session, value: grant).dictionary())
|
||||||
|
await Task.yield()
|
||||||
|
#expect(bridge.state == .armed(sessionID: grant.sessionID, televisionID: grant.televisionID))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func watchDeactivationDisarmsAndClearsGrant() async throws {
|
||||||
|
let fake = FakeWatchSession()
|
||||||
|
let bridge = WatchRelayBridge(session: fake)
|
||||||
|
bridge.activate()
|
||||||
|
bridge.activationDidComplete(error: nil)
|
||||||
|
bridge.beginSession()
|
||||||
|
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
|
||||||
|
fake.answer(try RelayContainer(kind: .session, value: grant).dictionary())
|
||||||
|
await Task.yield()
|
||||||
|
bridge.didDeactivate()
|
||||||
|
#expect(bridge.state == .inactive)
|
||||||
|
#expect(fake.sent.count == 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import RemoteCore
|
||||||
|
import WatchBridgeCommon
|
||||||
|
import WatchBridgePhone
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class FakePhoneSession: RelaySessionSending {
|
||||||
|
var activationState: RelayActivationState = .notActivated
|
||||||
|
var isReachable = true
|
||||||
|
var sent: [[String: Any]] = []
|
||||||
|
var reply: (([String: Any]) -> Void)?
|
||||||
|
var error: ((Error) -> Void)?
|
||||||
|
func activate() { activationState = .activated }
|
||||||
|
func sendMessage(_ message: [String: Any],
|
||||||
|
replyHandler: @escaping ([String: Any]) -> Void,
|
||||||
|
errorHandler: @escaping (Error) -> Void) {
|
||||||
|
sent.append(message)
|
||||||
|
reply = replyHandler
|
||||||
|
error = errorHandler
|
||||||
|
}
|
||||||
|
func updateApplicationContext(_ context: [String: Any]) throws {}
|
||||||
|
func answer(_ value: [String: Any]) { reply?(value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor RecordingPhoneCoordinator: PhoneCommandCoordinating {
|
||||||
|
var activated: [WatchPeerGeneration] = []
|
||||||
|
var deactivated: [WatchPeerGeneration] = []
|
||||||
|
var commands: [CommandEnvelope] = []
|
||||||
|
let sessionID = UUID()
|
||||||
|
let televisionID = UUID()
|
||||||
|
|
||||||
|
func watchBridgeDidActivate(peer: WatchPeerGeneration) { activated.append(peer) }
|
||||||
|
func watchBridgeDidDeactivate(peer: WatchPeerGeneration) { deactivated.append(peer) }
|
||||||
|
func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration)
|
||||||
|
-> Result<RemoteSessionGrant, RelayFailure> {
|
||||||
|
.success(RemoteSessionGrant(sessionID: sessionID, televisionID: televisionID,
|
||||||
|
issuedAt: Date(timeIntervalSince1970: 1_700_000_000)))
|
||||||
|
}
|
||||||
|
func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration)
|
||||||
|
-> Result<RemoteSessionEnded, RelayFailure> {
|
||||||
|
.success(RemoteSessionEnded(sessionID: request.sessionID))
|
||||||
|
}
|
||||||
|
func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) -> CommandReceipt {
|
||||||
|
commands.append(envelope)
|
||||||
|
return CommandReceipt(id: envelope.id, status: .sentToTransport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func phoneWaitsForActorActivationAndForwardsCommandWithoutAdmission() async throws {
|
||||||
|
let session = FakePhoneSession()
|
||||||
|
let actor = RecordingPhoneCoordinator()
|
||||||
|
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: actor))
|
||||||
|
bridge.activate()
|
||||||
|
bridge.activationDidComplete(error: nil)
|
||||||
|
await Task.yield()
|
||||||
|
#expect({
|
||||||
|
if case .ready = bridge.state { return true }
|
||||||
|
return false
|
||||||
|
}())
|
||||||
|
|
||||||
|
let begin = try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary()
|
||||||
|
bridge.receive(message: begin) { _ in }
|
||||||
|
await Task.yield()
|
||||||
|
#expect(session.sent.isEmpty)
|
||||||
|
|
||||||
|
let peerActivated = await actor.activated
|
||||||
|
#expect(peerActivated.count == 1)
|
||||||
|
bridge.receive(message: begin) { _ in }
|
||||||
|
await Task.yield()
|
||||||
|
#expect(session.sent.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func phoneRejectsMalformedAndWrongDirectionRequests() async throws {
|
||||||
|
let session = FakePhoneSession()
|
||||||
|
let actor = RecordingPhoneCoordinator()
|
||||||
|
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: actor))
|
||||||
|
bridge.activate(); bridge.activationDidComplete(error: nil)
|
||||||
|
await Task.yield()
|
||||||
|
var replies = 0
|
||||||
|
bridge.receive(message: ["schemaVersion": true, "kind": "command", "payload": Data()]) { _ in
|
||||||
|
replies += 1
|
||||||
|
}
|
||||||
|
await Task.yield()
|
||||||
|
#expect(replies == 1)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user