diff --git a/Adapters/WatchBridge/Sources/Common/RelayContainer.swift b/Adapters/WatchBridge/Sources/Common/RelayContainer.swift index 277a5b4..63cbb6e 100644 --- a/Adapters/WatchBridge/Sources/Common/RelayContainer.swift +++ b/Adapters/WatchBridge/Sources/Common/RelayContainer.swift @@ -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 = ["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(_ 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 = ["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() + ] } diff --git a/Adapters/WatchBridge/Sources/Watch/WatchRelayBridge.swift b/Adapters/WatchBridge/Sources/Watch/WatchRelayBridge.swift new file mode 100644 index 0000000..d831d38 --- /dev/null +++ b/Adapters/WatchBridge/Sources/Watch/WatchRelayBridge.swift @@ -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 + } +} diff --git a/Adapters/WatchBridge/Sources/Watch/WatchWCSessionDelegate.swift b/Adapters/WatchBridge/Sources/Watch/WatchWCSessionDelegate.swift new file mode 100644 index 0000000..504e0a6 --- /dev/null +++ b/Adapters/WatchBridge/Sources/Watch/WatchWCSessionDelegate.swift @@ -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 diff --git a/Adapters/WatchBridge/Sources/iPhone/PhoneRelayBridge.swift b/Adapters/WatchBridge/Sources/iPhone/PhoneRelayBridge.swift new file mode 100644 index 0000000..fb10a27 --- /dev/null +++ b/Adapters/WatchBridge/Sources/iPhone/PhoneRelayBridge.swift @@ -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(kind: RelayMessageKind, + value: Value) -> [String: Any] { + (try? RelayContainer(kind: kind, value: value).dictionary()) ?? + relayErrorReply(RelayFailure(code: .malformedRequest)) + } + + private func withTimeout( + _ 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 + } + } +} diff --git a/Adapters/WatchBridge/Sources/iPhone/PhoneWCSessionDelegate.swift b/Adapters/WatchBridge/Sources/iPhone/PhoneWCSessionDelegate.swift new file mode 100644 index 0000000..c6aa2ad --- /dev/null +++ b/Adapters/WatchBridge/Sources/iPhone/PhoneWCSessionDelegate.swift @@ -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 diff --git a/Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift b/Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift index 620604e..963669c 100644 --- a/Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift +++ b/Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift @@ -24,3 +24,49 @@ import WatchBridgeCommon 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) + } +} diff --git a/Adapters/WatchBridge/Tests/Watch/WatchRelayBridgeTests.swift b/Adapters/WatchBridge/Tests/Watch/WatchRelayBridgeTests.swift new file mode 100644 index 0000000..01e21a2 --- /dev/null +++ b/Adapters/WatchBridge/Tests/Watch/WatchRelayBridgeTests.swift @@ -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) +} diff --git a/Adapters/WatchBridge/Tests/iPhone/PhoneRelayBridgeTests.swift b/Adapters/WatchBridge/Tests/iPhone/PhoneRelayBridgeTests.swift new file mode 100644 index 0000000..9fc4571 --- /dev/null +++ b/Adapters/WatchBridge/Tests/iPhone/PhoneRelayBridgeTests.swift @@ -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 { + .success(RemoteSessionGrant(sessionID: sessionID, televisionID: televisionID, + issuedAt: Date(timeIntervalSince1970: 1_700_000_000))) + } + func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) + -> Result { + .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) +}