Implement WatchConnectivity relay bridge

This commit is contained in:
2026-09-18 09:59:38 +04:00
parent 9b87299bf5
commit 3e787543c0
8 changed files with 913 additions and 0 deletions
@@ -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)
}
}
@@ -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)
}