Establish watch relay contracts and platform package wiring
This commit is contained in:
@@ -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.
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user