Establish watch relay contracts and platform package wiring

This commit is contained in:
2026-09-18 09:34:26 +04:00
parent a92698e1e6
commit 9b87299bf5
22 changed files with 778 additions and 159 deletions
@@ -0,0 +1,139 @@
# WatchBridge integration proposal
Status: accepted with integration decisions below. This proposal is retained as
design history; `docs/CONTRACTS.md` and RemoteCore/WatchSession.swift are the
authoritative final API. The original suggested signatures below are not the
implementation contract.
## Integration decisions
- Shared DTOs are implemented, including RelayFailure conforming to Error.
- RelayJSON centrally enforces millisecond dates and the 4096-byte payload bound.
- PhoneCommandCoordinating is an Actor protocol with explicit request values and
a local WatchPeerGeneration argument on every operation, plus activation and
deactivation callbacks. This resolves stale counterpart/lifecycle events.
- The bounded three-key container remains adapter-owned; RelayMessageKind freezes
its v1 routing vocabulary. Only A's single actor consumes command admission.
- An adjacent local Swift package provides Common/iPhone/Watch source and test
targets. Xcode links its platform products instead of duplicating source files
directly in the app targets. New B source files require no project regeneration.
- B may use a fake injected actor now. A's production conformance and physical
device behavior remain later integration gates, not blockers to B's coding.
## Original proposal (historical)
## Shared RemoteCore additions requested
Add typed, `Codable`, `Sendable`, `Equatable` control-plane values to
`RemoteCore`. Names are suggestions; the wire values and semantics need to be
frozen by integration before B implements them.
```swift
public struct BeginRemoteSessionRequest: Codable, Sendable, Equatable {
public let schemaVersion: Int
}
public struct RemoteSessionGrant: Codable, Sendable, Equatable {
public let schemaVersion: Int
public let sessionID: UUID
public let televisionID: UUID
public let issuedAt: Date
public let maximumCommandLifetimeMilliseconds: UInt64
}
public struct EndRemoteSessionRequest: Codable, Sendable, Equatable {
public let schemaVersion: Int
public let sessionID: UUID
}
public struct RemoteSessionEnded: Codable, Sendable, Equatable {
public let schemaVersion: Int
public let sessionID: UUID
}
public struct RelayFailure: Codable, Sendable, Equatable {
public enum Code: String, Codable, Sendable {
case unsupportedSchema
case malformedRequest
case noSelectedTelevision
case unavailable
case sessionInvalidated
}
public let schemaVersion: Int
public let code: Code
}
```
`maximumCommandLifetimeMilliseconds` must be positive and no greater than
`CommandAdmission.maximumLifetime * 1_000`. `issuedAt` is encoded as JSON
milliseconds since Unix epoch. A grant authorizes no command by itself and must
be bound by the phone actor to the currently activated paired WCSession and the
phone's currently selected TV.
The bridge container remains adapter-owned and has exactly three keys:
```text
schemaVersion: Int == 1
kind: String
payload: Data (maximum 4 KiB)
```
Freeze these v1 `kind` values: `beginSession`, `session`, `endSession`, `ended`,
`command`, `receipt`, and `error`. Every request receives one reply container.
Unsupported versions, unknown kinds, missing/extra or incorrectly typed keys,
oversized payloads, and structural decode failures fail closed with an `error`
reply. Semantic command rejection remains solely in the phone command actor.
## Injected phone actor seam requested
Integration/A should provide one actor conforming to a narrow protocol such as:
```swift
public protocol PhoneCommandCoordinating: Sendable {
func beginWatchSession() async -> Result<RemoteSessionGrant, RelayFailure>
func endWatchSession(_ request: EndRemoteSessionRequest) async
-> Result<RemoteSessionEnded, RelayFailure>
func submitWatchCommand(_ envelope: CommandEnvelope) async -> CommandReceipt
func watchBridgeDidDeactivate() async
}
```
That actor remains the only owner of the authorized session and
`CommandAdmission`. `WatchBridge` will retain only the injected protocol
reference on iPhone. It will not instantiate `CommandAdmission`, perform
semantic admission, select a TV, or issue session UUIDs.
Please confirm whether a changed paired-watch identity can be surfaced to the
actor as `watchBridgeDidDeactivate()` or needs an explicit opaque peer
generation parameter. No persistent hardware identifier should cross this seam.
## Project/test wiring requested
Update the integrator-owned project generator and generated project to:
- compile `Adapters/WatchBridge/Sources/Common` into both app targets;
- compile `Sources/iPhone` only into the iPhone target and `Sources/Watch` only
into the watchOS target;
- link `WatchConnectivity` through normal SDK imports (no third-party runtime);
- add adjacent fake-driven test targets for common, iPhone, and watch behavior;
- keep the adapter outside the pure algorithm package.
The test target must be able to inject a session facade rather than subclassing
or manufacturing `WCSession`. Planned coverage: reachable/unreachable delivery,
activation/deactivation, phone/watch reinstall or counterpart switch,
duplicate callback suppression, reply timeout, lost reply, invalid containers,
4 KiB payload boundary, millisecond date round trips, and application-context
sanitization.
## Migration and impact
This is additive to contract v1 but introduces the first control-plane wire
format. Existing demo apps remain demos until integration explicitly injects the
live adapter. Mixed installations fail closed on schema mismatch and expose an
update-required/unavailable UI state; they do not fall back to queued commands.
No permission string, background mode, credential path, analytics, raw motion,
or third-party dependency is requested. Physical iPhone/Watch testing remains
required for locked-phone wake, relaunch, counterpart switching, and actual
reachability behavior without Xcode attached.
+27
View File
@@ -0,0 +1,27 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "WatchBridge",
platforms: [.iOS(.v17), .watchOS(.v10), .macOS(.v14)],
products: [
.library(name: "WatchBridgeCommon", targets: ["WatchBridgeCommon"]),
.library(name: "WatchBridgePhone", targets: ["WatchBridgePhone"]),
.library(name: "WatchBridgeWatch", targets: ["WatchBridgeWatch"])
],
dependencies: [.package(path: "../../Packages/WristivoKit")],
targets: [
.target(name: "WatchBridgeCommon", dependencies: [
.product(name: "RemoteCore", package: "WristivoKit")
], path: "Sources/Common"),
.target(name: "WatchBridgePhone", dependencies: [
"WatchBridgeCommon", .product(name: "RemoteCore", package: "WristivoKit")
], path: "Sources/iPhone"),
.target(name: "WatchBridgeWatch", dependencies: [
"WatchBridgeCommon", .product(name: "RemoteCore", package: "WristivoKit")
], path: "Sources/Watch"),
.testTarget(name: "WatchBridgeCommonTests", dependencies: ["WatchBridgeCommon", .product(name: "RemoteCore", package: "WristivoKit")], path: "Tests/Common"),
.testTarget(name: "WatchBridgePhoneTests", dependencies: ["WatchBridgePhone", .product(name: "RemoteCore", package: "WristivoKit")], path: "Tests/iPhone"),
.testTarget(name: "WatchBridgeWatchTests", dependencies: ["WatchBridgeWatch", "WatchBridgeCommon", .product(name: "RemoteCore", package: "WristivoKit")], path: "Tests/Watch")
]
)
+12 -1
View File
@@ -1,3 +1,14 @@
# WatchBridge
Planned adapter: versioned live WatchConnectivity relay. No live implementation is present. Owner: workstream B in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules.
Versioned live WatchConnectivity relay, owned by workstream B. Integration prerequisites are now available; live WCSession behavior is still to be implemented.
- Shared DTOs, RelayJSON and PhoneCommandCoordinating: `Packages/WristivoKit/Sources/RemoteCore/WatchSession.swift`.
- Adjacent Swift package: `Adapters/WatchBridge/Package.swift`.
- Common source/test target: `Sources/Common` and `Tests/Common`.
- Phone source/test target: `Sources/iPhone` and `Tests/iPhone`.
- Watch source/test target: `Sources/Watch` and `Tests/Watch`.
- Xcode links WatchBridgePhone only to iPhone and WatchBridgeWatch only to watchOS; both depend on WatchBridgeCommon. Source files added under these package paths need no project regeneration.
Run `bash Scripts/check.sh` from the repository root for both packages' fake-driven tests; add `--apps` for unsigned native compilation. These are SwiftPM test targets, not hosted app UI tests. Adding actual WCSession code requires conditional imports/platform boundaries so fake-driven package tests continue to run on macOS. WCSession cannot run there as a substitute for iPhone/watch hardware.
Implement against an injected fake phone actor now; A's live transport is not a prerequisite for B's unit implementation. Keep one consuming CommandAdmission gate in A's phone actor. Use WatchPeerGeneration as a local activation epoch so stale callbacks cannot invalidate the current session. Read docs/CONTRACTS.md and docs/agents/WATCH-RELAY-HANDOFF.md before coding. No live connection is injected into the demo apps by this integration change.
@@ -0,0 +1,15 @@
import Foundation
import RemoteCore
/// Typed outgoing container seam. Workstream B owns WCSession dictionary parsing,
/// strict incoming key/type validation, routing and exactly-once replies.
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)
}
}
@@ -0,0 +1,10 @@
import RemoteCore
import WatchBridgeCommon
/// Pure outgoing container construction: does not activate WCSession, grant a
/// session, queue a command, or transmit it. Live delivery belongs to B.
public enum WatchCommandPayload {
public static func container(for envelope: CommandEnvelope) throws -> RelayContainer {
try RelayContainer(kind: .command, value: envelope)
}
}
@@ -0,0 +1,11 @@
import RemoteCore
/// An injection seam, not a live relay. App composition supplies the same actor
/// used for phone commands; this holder creates no admission or session state.
public struct PhoneRelayDependency: Sendable {
public let coordinator: any PhoneCommandCoordinating
public init(coordinator: any PhoneCommandCoordinating) {
self.coordinator = coordinator
}
}
@@ -0,0 +1,26 @@
import Foundation
import Testing
import RemoteCore
import WatchBridgeCommon
@Test func payloadUsesUnixMillisecondsInBothDirections() throws {
let issued = Date(timeIntervalSince1970: 1_700_000_000.125)
let envelope = CommandEnvelope(
sessionID: UUID(), televisionID: UUID(), sequence: 1,
issuedAt: issued, expiresAt: issued.addingTimeInterval(0.5), command: .right
)
let container = try RelayContainer(kind: .command, value: envelope)
let data = container.payload
#expect(container.schemaVersion == RelayWire.schemaVersion)
#expect(container.kind == .command)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect((object["issuedAt"] as? Double) == 1_700_000_000_125)
#expect((object["expiresAt"] as? Double) == 1_700_000_000_625)
#expect(try RelayJSON.decode(CommandEnvelope.self, from: data) == envelope)
}
@Test func outgoingContainerRejectsOversizedPayload() {
#expect(throws: RelayContractError.payloadTooLarge) {
try RelayContainer(kind: .error, value: String(repeating: "x", count: RelayWire.maximumPayloadBytes))
}
}
@@ -0,0 +1,19 @@
import Foundation
import Testing
import RemoteCore
import WatchBridgeCommon
import WatchBridgeWatch
@Test func watchPayloadPreservesCommandIdentityAndExpiry() throws {
let issued = Date(timeIntervalSince1970: 1_700_000_000.125)
let envelope = CommandEnvelope(
sessionID: UUID(), televisionID: UUID(), sequence: 42,
issuedAt: issued, expiresAt: issued.addingTimeInterval(0.5), command: .left
)
let container = try WatchCommandPayload.container(for: envelope)
#expect(container.kind == .command)
let data = container.payload
#expect(try RelayJSON.decode(CommandEnvelope.self, from: data) == envelope)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect((object["expiresAt"] as? Double) == 1_700_000_000_625)
}
@@ -0,0 +1,41 @@
import Foundation
import Testing
import RemoteCore
import WatchBridgePhone
private actor FakePhoneCoordinator: PhoneCommandCoordinating {
private(set) var receivedCommands: [CommandEnvelope] = []
private(set) var receivedPeers: [WatchPeerGeneration] = []
func watchBridgeDidActivate(peer: WatchPeerGeneration) {}
func watchBridgeDidDeactivate(peer: WatchPeerGeneration) {}
func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration)
-> Result<RemoteSessionGrant, RelayFailure> {
.failure(RelayFailure(code: .unavailable))
}
func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration)
-> Result<RemoteSessionEnded, RelayFailure> {
.failure(RelayFailure(code: .sessionInvalidated))
}
func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) -> CommandReceipt {
receivedCommands.append(envelope)
receivedPeers.append(peer)
return CommandReceipt(id: envelope.id, status: .rejected)
}
}
@Test func phoneDependencyUsesInjectedActorAndPreservesItsDecision() async {
let actor = FakePhoneCoordinator()
let dependency = PhoneRelayDependency(coordinator: actor)
#expect(dependency.coordinator === actor)
let peer = WatchPeerGeneration()
let now = Date(timeIntervalSince1970: 1_700_000_000)
let command = CommandEnvelope(
sessionID: UUID(), televisionID: UUID(), sequence: 1,
issuedAt: now, expiresAt: now.addingTimeInterval(0.5), command: .right
)
let receipt = await dependency.coordinator.submitWatchCommand(command, peer: peer)
#expect(receipt == CommandReceipt(id: command.id, status: .rejected))
#expect(await actor.receivedCommands == [command])
#expect(await actor.receivedPeers == [peer])
}