From 9b87299bf513e8332237a5e495380bc14bee7d51 Mon Sep 17 00:00:00 2001 From: samialabri Date: Fri, 18 Sep 2026 09:34:26 +0400 Subject: [PATCH] Establish watch relay contracts and platform package wiring --- AGENTS.md | 2 +- Adapters/WatchBridge/INTEGRATION-PROPOSAL.md | 139 ++++++++ Adapters/WatchBridge/Package.swift | 27 ++ Adapters/WatchBridge/README.md | 13 +- .../Sources/Common/RelayContainer.swift | 15 + .../Sources/Watch/WatchCommandPayload.swift | 10 + .../Sources/iPhone/PhoneRelayDependency.swift | 11 + .../Tests/Common/RelayPayloadCodecTests.swift | 26 ++ .../Watch/WatchCommandPayloadTests.swift | 19 ++ .../iPhone/PhoneRelayDependencyTests.swift | 41 +++ .../Sources/RemoteCore/WatchSession.swift | 166 ++++++++++ .../RemoteCoreTests/WatchSessionTests.swift | 88 +++++ README.md | 2 +- Scripts/check.sh | 5 +- Scripts/generate-project.rb | 6 + Wristivo.xcodeproj/project.pbxproj | 307 ++++++++++-------- .../xcshareddata/xcschemes/Wristivo.xcscheme | 8 +- .../xcschemes/WristivoWatch.xcscheme | 8 +- docs/ARCHITECTURE.md | 2 +- docs/CONTRACTS.md | 12 +- docs/agents/README.md | 2 + docs/agents/WATCH-RELAY-HANDOFF.md | 28 ++ 22 files changed, 778 insertions(+), 159 deletions(-) create mode 100644 Adapters/WatchBridge/INTEGRATION-PROPOSAL.md create mode 100644 Adapters/WatchBridge/Package.swift create mode 100644 Adapters/WatchBridge/Sources/Common/RelayContainer.swift create mode 100644 Adapters/WatchBridge/Sources/Watch/WatchCommandPayload.swift create mode 100644 Adapters/WatchBridge/Sources/iPhone/PhoneRelayDependency.swift create mode 100644 Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift create mode 100644 Adapters/WatchBridge/Tests/Watch/WatchCommandPayloadTests.swift create mode 100644 Adapters/WatchBridge/Tests/iPhone/PhoneRelayDependencyTests.swift create mode 100644 Packages/WristivoKit/Sources/RemoteCore/WatchSession.swift create mode 100644 Packages/WristivoKit/Tests/RemoteCoreTests/WatchSessionTests.swift create mode 100644 docs/agents/WATCH-RELAY-HANDOFF.md diff --git a/AGENTS.md b/AGENTS.md index f992fb9..a392318 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Read README.md, docs/ARCHITECTURE.md, docs/CONTRACTS.md, your brief in docs/agen ## Shared ownership -The integrating engineer owns Package.swift, RemoteCore, Xcode project/schemes, Config, Scripts, AGENTS.md, root docs and integration composition. Propose a contract change with migration and test impact; do not silently edit shared types or fork duplicate types locally. Changes to source file membership must be handed to the integrator through the project generator. Do not regenerate the Xcode project from another agent's branch. +The integrating engineer owns all Package.swift manifests, RemoteCore, Xcode project/schemes, Config, Scripts, AGENTS.md, root docs and integration composition. Propose a contract change with migration and test impact; do not silently edit shared types or fork duplicate types locally. New source/test files inside an existing Swift package target are automatically included and may be added by the owning feature agent. New targets/dependencies or directly compiled app-source membership require the integrator's generator changes. Do not regenerate the Xcode project from another agent's branch. Never switch a checkout used by another running agent; use a separate worktree. ## Engineering rules diff --git a/Adapters/WatchBridge/INTEGRATION-PROPOSAL.md b/Adapters/WatchBridge/INTEGRATION-PROPOSAL.md new file mode 100644 index 0000000..6e272d3 --- /dev/null +++ b/Adapters/WatchBridge/INTEGRATION-PROPOSAL.md @@ -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 + func endWatchSession(_ request: EndRemoteSessionRequest) async + -> Result + 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. diff --git a/Adapters/WatchBridge/Package.swift b/Adapters/WatchBridge/Package.swift new file mode 100644 index 0000000..5183e01 --- /dev/null +++ b/Adapters/WatchBridge/Package.swift @@ -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") + ] +) diff --git a/Adapters/WatchBridge/README.md b/Adapters/WatchBridge/README.md index 26e5da0..9a8fe2e 100644 --- a/Adapters/WatchBridge/README.md +++ b/Adapters/WatchBridge/README.md @@ -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. diff --git a/Adapters/WatchBridge/Sources/Common/RelayContainer.swift b/Adapters/WatchBridge/Sources/Common/RelayContainer.swift new file mode 100644 index 0000000..277a5b4 --- /dev/null +++ b/Adapters/WatchBridge/Sources/Common/RelayContainer.swift @@ -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(kind: RelayMessageKind, value: Value) throws { + self.kind = kind + self.payload = try RelayJSON.encode(value) + } +} diff --git a/Adapters/WatchBridge/Sources/Watch/WatchCommandPayload.swift b/Adapters/WatchBridge/Sources/Watch/WatchCommandPayload.swift new file mode 100644 index 0000000..5c85dba --- /dev/null +++ b/Adapters/WatchBridge/Sources/Watch/WatchCommandPayload.swift @@ -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) + } +} diff --git a/Adapters/WatchBridge/Sources/iPhone/PhoneRelayDependency.swift b/Adapters/WatchBridge/Sources/iPhone/PhoneRelayDependency.swift new file mode 100644 index 0000000..816d4d1 --- /dev/null +++ b/Adapters/WatchBridge/Sources/iPhone/PhoneRelayDependency.swift @@ -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 + } +} diff --git a/Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift b/Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift new file mode 100644 index 0000000..620604e --- /dev/null +++ b/Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift @@ -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)) + } +} diff --git a/Adapters/WatchBridge/Tests/Watch/WatchCommandPayloadTests.swift b/Adapters/WatchBridge/Tests/Watch/WatchCommandPayloadTests.swift new file mode 100644 index 0000000..a7220b8 --- /dev/null +++ b/Adapters/WatchBridge/Tests/Watch/WatchCommandPayloadTests.swift @@ -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) +} diff --git a/Adapters/WatchBridge/Tests/iPhone/PhoneRelayDependencyTests.swift b/Adapters/WatchBridge/Tests/iPhone/PhoneRelayDependencyTests.swift new file mode 100644 index 0000000..ca03901 --- /dev/null +++ b/Adapters/WatchBridge/Tests/iPhone/PhoneRelayDependencyTests.swift @@ -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 { + .failure(RelayFailure(code: .unavailable)) + } + func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) + -> Result { + .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]) +} diff --git a/Packages/WristivoKit/Sources/RemoteCore/WatchSession.swift b/Packages/WristivoKit/Sources/RemoteCore/WatchSession.swift new file mode 100644 index 0000000..cdfed7a --- /dev/null +++ b/Packages/WristivoKit/Sources/RemoteCore/WatchSession.swift @@ -0,0 +1,166 @@ +import Foundation + +/// Frozen v1 routing vocabulary. Dictionary parsing and request/reply routing +/// belong to WatchBridge; these constants are not an authorization mechanism. +public enum RelayMessageKind: String, Codable, Sendable, CaseIterable { + case beginSession, session, endSession, ended, command, receipt, error +} + +public enum RelayWire { + public static let schemaVersion = 1 + public static let maximumPayloadBytes = 4_096 + public static let maximumCommandLifetimeMilliseconds: UInt64 = 1_000 +} + +public enum RelayContractError: Error, Sendable, Equatable { + case unsupportedSchema(Int) + case invalidLifetime + case invalidTimestamp + case payloadTooLarge +} + +/// Control-plane structural invariants only. Command admission remains in the +/// phone actor and must never be consumed by a bridge decoder. +public protocol RelayControlPayload: Codable, Sendable { + func validate() throws +} + +private func validateSchema(_ version: Int) throws { + guard version == RelayWire.schemaVersion else { + throw RelayContractError.unsupportedSchema(version) + } +} + +public struct BeginRemoteSessionRequest: RelayControlPayload, Equatable { + public let schemaVersion: Int + public init(schemaVersion: Int = RelayWire.schemaVersion) { + self.schemaVersion = schemaVersion + } + public func validate() throws { try validateSchema(schemaVersion) } +} + +public struct RemoteSessionGrant: RelayControlPayload, Equatable { + public let schemaVersion: Int + public let sessionID: UUID + public let televisionID: UUID + public let issuedAt: Date + public let maximumCommandLifetimeMilliseconds: UInt64 + + public init(sessionID: UUID, televisionID: UUID, issuedAt: Date, + maximumCommandLifetimeMilliseconds: UInt64 = RelayWire.maximumCommandLifetimeMilliseconds, + schemaVersion: Int = RelayWire.schemaVersion) { + self.schemaVersion = schemaVersion + self.sessionID = sessionID + self.televisionID = televisionID + self.issuedAt = issuedAt + self.maximumCommandLifetimeMilliseconds = maximumCommandLifetimeMilliseconds + } + + public func validate() throws { + try validateSchema(schemaVersion) + guard issuedAt.timeIntervalSince1970.isFinite else { + throw RelayContractError.invalidTimestamp + } + guard maximumCommandLifetimeMilliseconds > 0, + maximumCommandLifetimeMilliseconds <= RelayWire.maximumCommandLifetimeMilliseconds else { + throw RelayContractError.invalidLifetime + } + } +} + +public struct EndRemoteSessionRequest: RelayControlPayload, Equatable { + public let schemaVersion: Int + public let sessionID: UUID + public init(sessionID: UUID, schemaVersion: Int = RelayWire.schemaVersion) { + self.schemaVersion = schemaVersion + self.sessionID = sessionID + } + public func validate() throws { try validateSchema(schemaVersion) } +} + +public struct RemoteSessionEnded: RelayControlPayload, Equatable { + public let schemaVersion: Int + public let sessionID: UUID + public init(sessionID: UUID, schemaVersion: Int = RelayWire.schemaVersion) { + self.schemaVersion = schemaVersion + self.sessionID = sessionID + } + public func validate() throws { try validateSchema(schemaVersion) } +} + +public struct RelayFailure: Error, RelayControlPayload, Equatable { + public enum Code: String, Codable, Sendable { + case unsupportedSchema, malformedRequest, noSelectedTelevision + case unavailable, sessionInvalidated + } + public let schemaVersion: Int + public let code: Code + public init(code: Code, schemaVersion: Int = RelayWire.schemaVersion) { + self.schemaVersion = schemaVersion + self.code = code + } + public func validate() throws { try validateSchema(schemaVersion) } +} + +/// Every call creates a local coder, avoiding shared mutable coder concurrency. +/// Dates are numeric milliseconds since Unix epoch, never Foundation's default +/// reference-date seconds. A decoded command still requires actor admission. +public enum RelayJSON { + public static func encode(_ value: Value) throws -> Data { + if let control = value as? any RelayControlPayload { try control.validate() } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + let data = try encoder.encode(value) + guard data.count <= RelayWire.maximumPayloadBytes else { + throw RelayContractError.payloadTooLarge + } + return data + } + + public static func decode(_ type: Value.Type, from data: Data) throws -> Value { + guard data.count <= RelayWire.maximumPayloadBytes else { + throw RelayContractError.payloadTooLarge + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + let value = try decoder.decode(type, from: data) + if let control = value as? any RelayControlPayload { try control.validate() } + return value + } +} + +/// Local activation generation, not a hardware identifier or wire value. +/// Create a fresh value each time the phone bridge activates a counterpart. +public struct WatchPeerGeneration: Sendable, Hashable { + public let id: UUID + public init(id: UUID = UUID()) { self.id = id } +} + +/// A/integration implements this on the SAME actor that owns CommandAdmission +/// and TV dispatch. B injects it and can use a fake while A is implemented. +/// Never add a second consuming gate behind a forwarding implementation. +public protocol PhoneCommandCoordinating: Actor { + /// B serializes lifecycle events and awaits this before accepting requests. + /// Re-activating the same peer is idempotent; a new peer invalidates old + /// grants and pending work. This method must not pair or send a TV key. + func watchBridgeDidActivate(peer: WatchPeerGeneration) async + + /// Validate the request and current peer, require a ready selected TV, and + /// issue a fresh session (invalidating any previous one for this peer). + func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration) + async -> Result + + /// End only the named current session for this peer. Stale ends must not + /// invalidate a newer session; report sessionInvalidated instead. + func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) + async -> Result + + /// Sole semantic admission and TV dispatch boundary. Reject old peers and + /// recheck peer/session/TV/deadline after suspension before writing bytes. + func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) + async -> CommandReceipt + + /// Ignore a stale peer's delayed deactivation. For the current peer, + /// invalidate sessions and pending commands; do not replay on activation. + func watchBridgeDidDeactivate(peer: WatchPeerGeneration) async +} diff --git a/Packages/WristivoKit/Tests/RemoteCoreTests/WatchSessionTests.swift b/Packages/WristivoKit/Tests/RemoteCoreTests/WatchSessionTests.swift new file mode 100644 index 0000000..a42b2c2 --- /dev/null +++ b/Packages/WristivoKit/Tests/RemoteCoreTests/WatchSessionTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import RemoteCore + +@Test func wireDatesUseUnixMillisecondsForGrantAndCommand() throws { + let session = UUID(), tv = UUID() + let issued = Date(timeIntervalSince1970: 1_700_000_000.125) + let grant = RemoteSessionGrant(sessionID: session, televisionID: tv, issuedAt: issued) + let data = try RelayJSON.encode(grant) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(object["issuedAt"] as? Double == 1_700_000_000_125) + #expect(try RelayJSON.decode(RemoteSessionGrant.self, from: data) == grant) + let command = CommandEnvelope(sessionID: session, televisionID: tv, sequence: 1, + issuedAt: issued, expiresAt: issued.addingTimeInterval(0.5), command: .right) + let commandData = try RelayJSON.encode(command) + let commandObject = try #require(JSONSerialization.jsonObject(with: commandData) as? [String: Any]) + #expect(commandObject["expiresAt"] as? Double == 1_700_000_000_625) + #expect(try RelayJSON.decode(CommandEnvelope.self, from: commandData) == command) +} + +@Test func controlPayloadsRoundTripAndFailureIsAnError() throws { + let session = UUID() + let begin = BeginRemoteSessionRequest() + let end = EndRemoteSessionRequest(sessionID: session) + let ended = RemoteSessionEnded(sessionID: session) + let failure = RelayFailure(code: .sessionInvalidated) + #expect(try RelayJSON.decode(BeginRemoteSessionRequest.self, from: RelayJSON.encode(begin)) == begin) + #expect(try RelayJSON.decode(EndRemoteSessionRequest.self, from: RelayJSON.encode(end)) == end) + #expect(try RelayJSON.decode(RemoteSessionEnded.self, from: RelayJSON.encode(ended)) == ended) + #expect(try RelayJSON.decode(RelayFailure.self, from: RelayJSON.encode(failure)) == failure) + let result: Result = .failure(failure) + #expect(throws: failure) { try result.get() } +} + +@Test func malformedOrUnsupportedControlPayloadsFailClosed() throws { + #expect(throws: RelayContractError.unsupportedSchema(2)) { + try RelayJSON.decode(BeginRemoteSessionRequest.self, from: Data(#"{"schemaVersion":2}"#.utf8)) + } + #expect(throws: (any Error).self) { + try RelayJSON.decode(EndRemoteSessionRequest.self, from: Data(#"{"schemaVersion":1,"sessionID":"bad"}"#.utf8)) + } + #expect(throws: (any Error).self) { + try RelayJSON.decode(BeginRemoteSessionRequest.self, from: Data(#"{"schemaVersion":true}"#.utf8)) + } + #expect(throws: RelayContractError.unsupportedSchema(9)) { + try RelayJSON.encode(RemoteSessionEnded(sessionID: UUID(), schemaVersion: 9)) + } +} + +@Test func invalidGrantLifetimeRejectedOnEncodeAndDecode() throws { + for lifetime: UInt64 in [0, 1_001, .max] { + let invalid = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), + issuedAt: Date(timeIntervalSince1970: 1_000), maximumCommandLifetimeMilliseconds: lifetime) + #expect(throws: RelayContractError.invalidLifetime) { try RelayJSON.encode(invalid) } + // Bypass the validating encoder to simulate an untrusted peer. + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + let raw = try encoder.encode(invalid) + #expect(throws: RelayContractError.invalidLifetime) { + try RelayJSON.decode(RemoteSessionGrant.self, from: raw) + } + } + let invalidDate = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), + issuedAt: Date(timeIntervalSince1970: .infinity)) + #expect(throws: RelayContractError.invalidTimestamp) { try RelayJSON.encode(invalidDate) } +} + +@Test func byteLimitIsCheckedBeforeDecodingAndAtEncoding() throws { + // JSON string quotes use 2 bytes. Exactly 4096 bytes is permitted. + let boundary = String(repeating: "a", count: RelayWire.maximumPayloadBytes - 2) + let encoded = try RelayJSON.encode(boundary) + #expect(encoded.count == RelayWire.maximumPayloadBytes) + #expect(try RelayJSON.decode(String.self, from: encoded) == boundary) + #expect(throws: RelayContractError.payloadTooLarge) { try RelayJSON.encode(boundary + "a") } + #expect(throws: RelayContractError.payloadTooLarge) { + try RelayJSON.decode(BeginRemoteSessionRequest.self, from: Data(repeating: 0, count: 4_097)) + } +} + +@Test func commandDecodeDoesNotConsumeOrReplaceAdmission() throws { + let session = UUID(), tv = UUID(), now = Date(timeIntervalSince1970: 1_000) + let command = CommandEnvelope(sessionID: session, televisionID: tv, sequence: 1, + issuedAt: now, expiresAt: now.addingTimeInterval(0.5), command: .left) + let data = try RelayJSON.encode(command) + var admission = CommandAdmission(sessionID: session, televisionID: tv) + #expect(admission.admit(try RelayJSON.decode(CommandEnvelope.self, from: data), now: now) == .accepted) + #expect(admission.admit(try RelayJSON.decode(CommandEnvelope.self, from: data), now: now) == .rejected(.duplicate)) +} diff --git a/README.md b/README.md index 53020ce..adfcf2c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A native iPhone and Apple Watch remote foundation for compatible Samsung TVs run ## Current state -This is an engineering starter repository, not a finished TV remote. It includes two native SwiftUI demo apps, a tested shared Swift package, an Xcode project, and implementation briefs for parallel agents. The demo buttons update a local label only. Live TV pairing, WatchConnectivity relay, Keychain storage, Core Motion capture/classification, and App Store assets remain planned work. No physical-device compatibility is claimed. +This is an engineering starter repository, not a finished TV remote. It includes two native SwiftUI demo apps, tested domain and adapter Swift packages, an Xcode project, and implementation briefs for parallel agents. WatchBridge now has shared control-plane contracts, an injected phone-actor seam, and platform source/test membership; see [B's handoff](docs/agents/WATCH-RELAY-HANDOFF.md). The demo buttons update a local label only. Live TV pairing, WatchConnectivity relay, Keychain storage, Core Motion capture/classification, and App Store assets remain planned work. No physical-device compatibility is claimed. The intended path is **Apple Watch → WatchConnectivity → iPhone → local TV WebSocket**. The watch is the control surface; the iPhone is required as a relay. Gestures operate in an explicit active watch session. The app must stop sensing and disarm when inactive. No promise of all-day, wrist-down, or phone-free control. diff --git a/Scripts/check.sh b/Scripts/check.sh index 291f688..762e772 100755 --- a/Scripts/check.sh +++ b/Scripts/check.sh @@ -5,11 +5,14 @@ scratch=$(mktemp -d "${TMPDIR:-/private/tmp}/wristivo-check.XXXXXX") trap 'rm -rf "$scratch"' EXIT export CLANG_MODULE_CACHE_PATH="$scratch/clang-cache" export SWIFTPM_MODULECACHE_OVERRIDE="$scratch/module-cache" -# This dependency-free package has no plugins. Disable nested SwiftPM sandboxing +# These local packages have no third-party dependencies or plugins. Disable nested SwiftPM sandboxing # for agent environments; Swift Testing does not need the XCTest bundle runner. swift test --package-path Packages/WristivoKit --scratch-path "$scratch/swiftpm" \ --cache-path "$scratch/cache" --config-path "$scratch/config" \ --security-path "$scratch/security" --disable-sandbox --disable-xctest +swift test --package-path Adapters/WatchBridge --scratch-path "$scratch/watchbridge" \ + --cache-path "$scratch/cache" --config-path "$scratch/config" \ + --security-path "$scratch/security" --disable-sandbox --disable-xctest plutil -lint Resources/PrivacyInfo.xcprivacy if [ "${1:-}" = "--apps" ]; then extra_args=() diff --git a/Scripts/generate-project.rb b/Scripts/generate-project.rb index ad16633..8bbcfa3 100755 --- a/Scripts/generate-project.rb +++ b/Scripts/generate-project.rb @@ -25,6 +25,10 @@ package = project.new(Xcodeproj::Project::Object::XCLocalSwiftPackageReference) package.relative_path = 'Packages/WristivoKit' project.root_object.package_references << package +bridge_package = project.new(Xcodeproj::Project::Object::XCLocalSwiftPackageReference) +bridge_package.relative_path = 'Adapters/WatchBridge' +project.root_object.package_references << bridge_package + def add_sources(project, target, pattern) Dir.glob(pattern).sort.each do |path| ref = project.main_group.find_file_by_path(path) || project.main_group.new_file(path) @@ -63,6 +67,8 @@ add_sources(project, phone, 'Apps/iPhone/*.swift') add_sources(project, watch, 'Apps/Watch/*.swift') link_product(project, phone, package, 'SamsungProtocol') link_product(project, watch, package, 'GestureCore') +link_product(project, phone, bridge_package, 'WatchBridgePhone') +link_product(project, watch, bridge_package, 'WatchBridgeWatch') phone.build_configurations.each do |c| c.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = '$(WRISTIVO_BUNDLE_PREFIX)' c.build_settings['TARGETED_DEVICE_FAMILY'] = '1,2' diff --git a/Wristivo.xcodeproj/project.pbxproj b/Wristivo.xcodeproj/project.pbxproj index 630ca0a..1cf2024 100644 --- a/Wristivo.xcodeproj/project.pbxproj +++ b/Wristivo.xcodeproj/project.pbxproj @@ -7,41 +7,43 @@ objects = { /* Begin PBXBuildFile section */ - 07891F143146642C0266FD875C601D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8EAD0E81A4073BB85995F21AC2B4AC6 /* Foundation.framework */; }; - 170DFF6AAFB8152BD51A4973BB5DF00F /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9D3CF76227B20B7C3540FB24BC876B3 /* Foundation.framework */; }; - 1B4C090099185A0D5A798AF87CDC8F23 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */; }; - 1B9C65E31D990E762267462A933553BA /* RemoteCore in Frameworks */ = {isa = PBXBuildFile; productRef = 17F7FFE0C5B5A43B2969CB383115F529 /* RemoteCore */; }; - 2F3BAA5EB973440A232134A3BD895BE1 /* RemoteCore in Frameworks */ = {isa = PBXBuildFile; productRef = 8F92019D5A61504D315E161021F43F1A /* RemoteCore */; }; - 39CF36028D51F14FB3C052331BB95089 /* RemoteDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D461BAF88CA54866E0E56A2DD436B4A /* RemoteDemoView.swift */; }; - 3D66763F79D71C717673D84379DB1797 /* WristivoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 890F3EFC8807C413145F604429BF91F6 /* WristivoApp.swift */; }; - 41EC6157B4AF81E9C5964425A318DDD8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */; }; - 72715A7D6A205D60414F56FB18E0782C /* GestureCore in Frameworks */ = {isa = PBXBuildFile; productRef = 340123967C59555926E737A0F34AB1D8 /* GestureCore */; }; - 90D853831CC9607665EAA8B470A1FAFD /* CommandPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */; }; - 941586BDC5633D57DAEF324C5CAB5D14 /* SamsungProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 2D9485594FC92EDB674203BAD168EDA4 /* SamsungProtocol */; }; - A0F2F790688A596403FE392F949F865E /* CommandPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */; }; - AFF041DC49AC1BAF0C719558A44B2955 /* WatchRemoteDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8D4E53699D4E49FA01E69284C27AF0 /* WatchRemoteDemoView.swift */; }; - B774CEF1DAE7A9F591B141CD46AA6840 /* WristivoWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 4A082972BE30D2A9CA9B01AEDA159CD5 /* WristivoWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - D49E922B08FA5BB884D5F396CB911770 /* WristivoWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96FEDE35530887CE7794CE846AE4AB84 /* WristivoWatchApp.swift */; }; + 0E2482967C119992EFC9731BD93990F7 /* RemoteDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D461BAF88CA54866E0E56A2DD436B4A /* RemoteDemoView.swift */; }; + 1E53F8BEEE6D61CE71E682F5F7CF2147 /* RemoteCore in Frameworks */ = {isa = PBXBuildFile; productRef = E8753247C07C7B446324EE3A71951236 /* RemoteCore */; }; + 25F9B24B070225E1A1636D9985324EA6 /* WristivoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 890F3EFC8807C413145F604429BF91F6 /* WristivoApp.swift */; }; + 48FCE21F445E23DBA4A5DAD86D481954 /* GestureCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4BDCC823CAE7AF85DEB80F49EA2FA408 /* GestureCore */; }; + 641657FFCFCFB81A75762E575E913AE5 /* WristivoWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 4A082972BE30D2A9CA9B01AEDA159CD5 /* WristivoWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 665A97BD7636C338F128AF1499BAFECB /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9D3CF76227B20B7C3540FB24BC876B3 /* Foundation.framework */; }; + 6BB86F38E5C018B41C6D4E71893A23CE /* RemoteCore in Frameworks */ = {isa = PBXBuildFile; productRef = 9E6C269953D3C4013B98995A8D81F8F4 /* RemoteCore */; }; + 75D76893CE6B6841CEA19A8434778F3B /* WristivoWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96FEDE35530887CE7794CE846AE4AB84 /* WristivoWatchApp.swift */; }; + 85FBE8F84E0AD27608500C0D032A71F4 /* CommandPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */; }; + 94FE13C392F93E0209CA935E99858801 /* WatchBridgeWatch in Frameworks */ = {isa = PBXBuildFile; productRef = D39AAD66EB3940123D81B61C66173DE9 /* WatchBridgeWatch */; }; + A9EFDE4F5319C6E31CBC00B6066B1816 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */; }; + BEADBF605154E2E19723E0E815F8606B /* CommandPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */; }; + C93002E459E3E8DFA2E2D6947143B1F2 /* WatchRemoteDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8D4E53699D4E49FA01E69284C27AF0 /* WatchRemoteDemoView.swift */; }; + DB0584C988D0B59B83BFDC5328504D78 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8EAD0E81A4073BB85995F21AC2B4AC6 /* Foundation.framework */; }; + E6D8811706FA6D163D5383674346C819 /* WatchBridgePhone in Frameworks */ = {isa = PBXBuildFile; productRef = 680557163CFB560AC3E3060AB13041E5 /* WatchBridgePhone */; }; + EC287AAF374D4034FFC06B54CFCE298C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */; }; + EC830E832AD41359BA3A5A23EA4C59A7 /* SamsungProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = ABFF37EAE43C10491026D0A9DA7A4D16 /* SamsungProtocol */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 2FD8B8B2F015FB57526A9D1F7D97014F /* PBXContainerItemProxy */ = { + AD16755ABE4DE4DC55D93AE00CC51988 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = A6B9AD07CCD6814B2B51DF0509865B11 /* Project object */; proxyType = 1; - remoteGlobalIDString = 92B9FFB5D4CDC0A46FD43667A67D7CDA; + remoteGlobalIDString = E847B268313C04CBDC6F406F02397F95; remoteInfo = WristivoWatch; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 1E513BDB1D02C7D7D28F78DF25F44A97 /* Embed Watch Content */ = { + 204A5DA2221ABB055B8F5E4427992562 /* Embed Watch Content */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; dstSubfolderSpec = 16; files = ( - B774CEF1DAE7A9F591B141CD46AA6840 /* WristivoWatch.app in Embed Watch Content */, + 641657FFCFCFB81A75762E575E913AE5 /* WristivoWatch.app in Embed Watch Content */, ); name = "Embed Watch Content"; runOnlyForDeploymentPostprocessing = 0; @@ -63,23 +65,25 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - B88C11584DAE8E615A17FC886D69DCB5 /* Frameworks */ = { + 7E27479456E7603104693DA6C9A3E8DC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 170DFF6AAFB8152BD51A4973BB5DF00F /* Foundation.framework in Frameworks */, - 1B9C65E31D990E762267462A933553BA /* RemoteCore in Frameworks */, - 941586BDC5633D57DAEF324C5CAB5D14 /* SamsungProtocol in Frameworks */, + DB0584C988D0B59B83BFDC5328504D78 /* Foundation.framework in Frameworks */, + 6BB86F38E5C018B41C6D4E71893A23CE /* RemoteCore in Frameworks */, + 48FCE21F445E23DBA4A5DAD86D481954 /* GestureCore in Frameworks */, + 94FE13C392F93E0209CA935E99858801 /* WatchBridgeWatch in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - BA7EC3DC2B6177E7589C757502941595 /* Frameworks */ = { + EF34AAE38B8E8F5C777ABCE512830344 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 07891F143146642C0266FD875C601D6C /* Foundation.framework in Frameworks */, - 2F3BAA5EB973440A232134A3BD895BE1 /* RemoteCore in Frameworks */, - 72715A7D6A205D60414F56FB18E0782C /* GestureCore in Frameworks */, + 665A97BD7636C338F128AF1499BAFECB /* Foundation.framework in Frameworks */, + 1E53F8BEEE6D61CE71E682F5F7CF2147 /* RemoteCore in Frameworks */, + EC830E832AD41359BA3A5A23EA4C59A7 /* SamsungProtocol in Frameworks */, + E6D8811706FA6D163D5383674346C819 /* WatchBridgePhone in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -138,36 +142,37 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 1EE0B2EC50CDC1E93E13DDE8D3D0B85F /* Wristivo */ = { + 51E84BBEC4583A7F7C052F58F2EAAF78 /* Wristivo */ = { isa = PBXNativeTarget; - buildConfigurationList = 53BE8879069346EEFCBB651C0F32164D /* Build configuration list for PBXNativeTarget "Wristivo" */; + buildConfigurationList = 93F0F7B38CDEC6A75FDEC3AFA9C730CE /* Build configuration list for PBXNativeTarget "Wristivo" */; buildPhases = ( - 3EF63CDCD6DED7DA7BAD8FBFF2490EF1 /* Sources */, - B88C11584DAE8E615A17FC886D69DCB5 /* Frameworks */, - B376C59BB378C28B89BD38291F2AF29F /* Resources */, - 1E513BDB1D02C7D7D28F78DF25F44A97 /* Embed Watch Content */, + 2866B4A68DF0BA6A92BB55E3913CCFCE /* Sources */, + EF34AAE38B8E8F5C777ABCE512830344 /* Frameworks */, + 376501AED0FE93CCB5CD8877460F8CA6 /* Resources */, + 204A5DA2221ABB055B8F5E4427992562 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( - B012FEF8A7F534D96D288D1D3E951AB1 /* PBXTargetDependency */, + 75F3F1FC01E91504E0D509169C05FF60 /* PBXTargetDependency */, ); name = Wristivo; packageProductDependencies = ( - 17F7FFE0C5B5A43B2969CB383115F529 /* RemoteCore */, - 2D9485594FC92EDB674203BAD168EDA4 /* SamsungProtocol */, + E8753247C07C7B446324EE3A71951236 /* RemoteCore */, + ABFF37EAE43C10491026D0A9DA7A4D16 /* SamsungProtocol */, + 680557163CFB560AC3E3060AB13041E5 /* WatchBridgePhone */, ); productName = Wristivo; productReference = A2F3C9F86650749EDA3E56A620D8ED43 /* Wristivo.app */; productType = "com.apple.product-type.application"; }; - 92B9FFB5D4CDC0A46FD43667A67D7CDA /* WristivoWatch */ = { + E847B268313C04CBDC6F406F02397F95 /* WristivoWatch */ = { isa = PBXNativeTarget; - buildConfigurationList = 8603BA37D79B0E833C7EF148546C35FB /* Build configuration list for PBXNativeTarget "WristivoWatch" */; + buildConfigurationList = FA77B001FC3F57F319FC00E23843C2E4 /* Build configuration list for PBXNativeTarget "WristivoWatch" */; buildPhases = ( - 57DA0310021CDADB3C00D1CAAB54F986 /* Sources */, - BA7EC3DC2B6177E7589C757502941595 /* Frameworks */, - 7C2AD6858FE884341025B34CE43D029E /* Resources */, + 9C18AE3B1CD5BEED88FD5DDB178CA25A /* Sources */, + 7E27479456E7603104693DA6C9A3E8DC /* Frameworks */, + BFD51CDC523FB0EDE831D838E1110BB2 /* Resources */, ); buildRules = ( ); @@ -175,8 +180,9 @@ ); name = WristivoWatch; packageProductDependencies = ( - 8F92019D5A61504D315E161021F43F1A /* RemoteCore */, - 340123967C59555926E737A0F34AB1D8 /* GestureCore */, + 9E6C269953D3C4013B98995A8D81F8F4 /* RemoteCore */, + 4BDCC823CAE7AF85DEB80F49EA2FA408 /* GestureCore */, + D39AAD66EB3940123D81B61C66173DE9 /* WatchBridgeWatch */, ); productName = WristivoWatch; productReference = 4A082972BE30D2A9CA9B01AEDA159CD5 /* WristivoWatch.app */; @@ -202,71 +208,95 @@ mainGroup = AA6CD4C83BB8EE679A0550AAAF6CA77D; minimizedProjectReferenceProxies = 0; packageReferences = ( - 80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */, + 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */, + C392D09E694FC793E9D75786813ADCCC /* XCLocalSwiftPackageReference "WatchBridge" */, ); preferredProjectObjectVersion = 77; productRefGroup = E852DA970C83730E757E48147D2344A5 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 1EE0B2EC50CDC1E93E13DDE8D3D0B85F /* Wristivo */, - 92B9FFB5D4CDC0A46FD43667A67D7CDA /* WristivoWatch */, + 51E84BBEC4583A7F7C052F58F2EAAF78 /* Wristivo */, + E847B268313C04CBDC6F406F02397F95 /* WristivoWatch */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 7C2AD6858FE884341025B34CE43D029E /* Resources */ = { + 376501AED0FE93CCB5CD8877460F8CA6 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1B4C090099185A0D5A798AF87CDC8F23 /* PrivacyInfo.xcprivacy in Resources */, + EC287AAF374D4034FFC06B54CFCE298C /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - B376C59BB378C28B89BD38291F2AF29F /* Resources */ = { + BFD51CDC523FB0EDE831D838E1110BB2 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 41EC6157B4AF81E9C5964425A318DDD8 /* PrivacyInfo.xcprivacy in Resources */, + A9EFDE4F5319C6E31CBC00B6066B1816 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 3EF63CDCD6DED7DA7BAD8FBFF2490EF1 /* Sources */ = { + 2866B4A68DF0BA6A92BB55E3913CCFCE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - A0F2F790688A596403FE392F949F865E /* CommandPresentation.swift in Sources */, - 39CF36028D51F14FB3C052331BB95089 /* RemoteDemoView.swift in Sources */, - 3D66763F79D71C717673D84379DB1797 /* WristivoApp.swift in Sources */, + 85FBE8F84E0AD27608500C0D032A71F4 /* CommandPresentation.swift in Sources */, + 0E2482967C119992EFC9731BD93990F7 /* RemoteDemoView.swift in Sources */, + 25F9B24B070225E1A1636D9985324EA6 /* WristivoApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 57DA0310021CDADB3C00D1CAAB54F986 /* Sources */ = { + 9C18AE3B1CD5BEED88FD5DDB178CA25A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 90D853831CC9607665EAA8B470A1FAFD /* CommandPresentation.swift in Sources */, - AFF041DC49AC1BAF0C719558A44B2955 /* WatchRemoteDemoView.swift in Sources */, - D49E922B08FA5BB884D5F396CB911770 /* WristivoWatchApp.swift in Sources */, + BEADBF605154E2E19723E0E815F8606B /* CommandPresentation.swift in Sources */, + C93002E459E3E8DFA2E2D6947143B1F2 /* WatchRemoteDemoView.swift in Sources */, + 75D76893CE6B6841CEA19A8434778F3B /* WristivoWatchApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - B012FEF8A7F534D96D288D1D3E951AB1 /* PBXTargetDependency */ = { + 75F3F1FC01E91504E0D509169C05FF60 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = WristivoWatch; - target = 92B9FFB5D4CDC0A46FD43667A67D7CDA /* WristivoWatch */; - targetProxy = 2FD8B8B2F015FB57526A9D1F7D97014F /* PBXContainerItemProxy */; + target = E847B268313C04CBDC6F406F02397F95 /* WristivoWatch */; + targetProxy = AD16755ABE4DE4DC55D93AE00CC51988 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 0AE74C51A5980FF7C345A772BFB29C55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Wristivo; + INFOPLIST_KEY_WKApplication = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(WRISTIVO_BUNDLE_PREFIX)"; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "$(WRISTIVO_BUNDLE_PREFIX).watchkitapp"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; 0B8C0DFEB77BFF8CE1C1F368E2F7E051 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 72EF8E42887FFEC5B4C2590143572AC2 /* Base.xcconfig */; @@ -322,7 +352,7 @@ }; name = Release; }; - 51D640C39FC1A3D291D46E18FEDC030A /* Debug */ = { + 142429C6DF0CAB80FB8127E0D4DAAE55 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -345,31 +375,7 @@ }; name = Debug; }; - 553F6EB42092FB1108A50A2653994F7D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Wristivo; - INFOPLIST_KEY_WKApplication = YES; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(WRISTIVO_BUNDLE_PREFIX)"; - INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "$(WRISTIVO_BUNDLE_PREFIX).watchkitapp"; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 4; - VALIDATE_PRODUCT = YES; - WATCHOS_DEPLOYMENT_TARGET = 10.0; - }; - name = Release; - }; - 66C6C8170E97A44A5472C01194F2E1A0 /* Release */ = { + 5D249F97F4CFC9569534BE2CB17D687A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -393,29 +399,6 @@ }; name = Release; }; - 69036077269CCDEE6A856173993766F6 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Wristivo; - INFOPLIST_KEY_WKApplication = YES; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(WRISTIVO_BUNDLE_PREFIX)"; - INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "$(WRISTIVO_BUNDLE_PREFIX).watchkitapp"; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; - }; - name = Debug; - }; 7CF594D2A1A8B67A92FF51BEF2E85928 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 72EF8E42887FFEC5B4C2590143572AC2 /* Base.xcconfig */; @@ -478,27 +461,33 @@ }; name = Debug; }; + FEA9ACEBD5497DD7394F3F4E0965A32D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Wristivo; + INFOPLIST_KEY_WKApplication = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(WRISTIVO_BUNDLE_PREFIX)"; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "$(WRISTIVO_BUNDLE_PREFIX).watchkitapp"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 53BE8879069346EEFCBB651C0F32164D /* Build configuration list for PBXNativeTarget "Wristivo" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 66C6C8170E97A44A5472C01194F2E1A0 /* Release */, - 51D640C39FC1A3D291D46E18FEDC030A /* Debug */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 8603BA37D79B0E833C7EF148546C35FB /* Build configuration list for PBXNativeTarget "WristivoWatch" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 553F6EB42092FB1108A50A2653994F7D /* Release */, - 69036077269CCDEE6A856173993766F6 /* Debug */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 8CC8CEDC4982E5F11D3289DD1493CE12 /* Build configuration list for PBXProject "Wristivo" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -508,34 +497,66 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 93F0F7B38CDEC6A75FDEC3AFA9C730CE /* Build configuration list for PBXNativeTarget "Wristivo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5D249F97F4CFC9569534BE2CB17D687A /* Release */, + 142429C6DF0CAB80FB8127E0D4DAAE55 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FA77B001FC3F57F319FC00E23843C2E4 /* Build configuration list for PBXNativeTarget "WristivoWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FEA9ACEBD5497DD7394F3F4E0965A32D /* Release */, + 0AE74C51A5980FF7C345A772BFB29C55 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */ = { + 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/WristivoKit; }; + C392D09E694FC793E9D75786813ADCCC /* XCLocalSwiftPackageReference "WatchBridge" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Adapters/WatchBridge; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 17F7FFE0C5B5A43B2969CB383115F529 /* RemoteCore */ = { + 4BDCC823CAE7AF85DEB80F49EA2FA408 /* GestureCore */ = { isa = XCSwiftPackageProductDependency; - package = 80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */; - productName = RemoteCore; - }; - 2D9485594FC92EDB674203BAD168EDA4 /* SamsungProtocol */ = { - isa = XCSwiftPackageProductDependency; - package = 80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */; - productName = SamsungProtocol; - }; - 340123967C59555926E737A0F34AB1D8 /* GestureCore */ = { - isa = XCSwiftPackageProductDependency; - package = 80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */; + package = 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */; productName = GestureCore; }; - 8F92019D5A61504D315E161021F43F1A /* RemoteCore */ = { + 680557163CFB560AC3E3060AB13041E5 /* WatchBridgePhone */ = { isa = XCSwiftPackageProductDependency; - package = 80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */; + package = C392D09E694FC793E9D75786813ADCCC /* XCLocalSwiftPackageReference "WatchBridge" */; + productName = WatchBridgePhone; + }; + 9E6C269953D3C4013B98995A8D81F8F4 /* RemoteCore */ = { + isa = XCSwiftPackageProductDependency; + package = 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */; + productName = RemoteCore; + }; + ABFF37EAE43C10491026D0A9DA7A4D16 /* SamsungProtocol */ = { + isa = XCSwiftPackageProductDependency; + package = 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */; + productName = SamsungProtocol; + }; + D39AAD66EB3940123D81B61C66173DE9 /* WatchBridgeWatch */ = { + isa = XCSwiftPackageProductDependency; + package = C392D09E694FC793E9D75786813ADCCC /* XCLocalSwiftPackageReference "WatchBridge" */; + productName = WatchBridgeWatch; + }; + E8753247C07C7B446324EE3A71951236 /* RemoteCore */ = { + isa = XCSwiftPackageProductDependency; + package = 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */; productName = RemoteCore; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Wristivo.xcodeproj/xcshareddata/xcschemes/Wristivo.xcscheme b/Wristivo.xcodeproj/xcshareddata/xcschemes/Wristivo.xcscheme index 57d3a37..3625de8 100644 --- a/Wristivo.xcodeproj/xcshareddata/xcschemes/Wristivo.xcscheme +++ b/Wristivo.xcodeproj/xcshareddata/xcschemes/Wristivo.xcscheme @@ -14,7 +14,7 @@ buildForAnalyzing = "YES"> @@ -30,7 +30,7 @@ @@ -53,7 +53,7 @@ runnableDebuggingMode = "0"> @@ -70,7 +70,7 @@ runnableDebuggingMode = "0"> diff --git a/Wristivo.xcodeproj/xcshareddata/xcschemes/WristivoWatch.xcscheme b/Wristivo.xcodeproj/xcshareddata/xcschemes/WristivoWatch.xcscheme index 9b9d8cd..00d7a79 100644 --- a/Wristivo.xcodeproj/xcshareddata/xcschemes/WristivoWatch.xcscheme +++ b/Wristivo.xcodeproj/xcshareddata/xcschemes/WristivoWatch.xcscheme @@ -14,7 +14,7 @@ buildForAnalyzing = "YES"> @@ -30,7 +30,7 @@ @@ -53,7 +53,7 @@ runnableDebuggingMode = "0"> @@ -70,7 +70,7 @@ runnableDebuggingMode = "0"> diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bf748f4..53cde68 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,7 +31,7 @@ Apple TN3135 restricts ordinary watch apps' low-level networking, including URLS | Packages/WristivoKit/SamsungProtocol | Key mapping and frame/URL encoding | Pure encoding, no socket | | Packages/WristivoKit/GestureCore | Normalized directional excursion state machine | Algorithm seam, no raw sensor classifier | | Adapters/TVTransport | iPhone URLSession WebSocket state machine | Planned | -| Adapters/WatchBridge | WCSession lifecycle and live delivery | Planned | +| Adapters/WatchBridge | WCSession lifecycle and live delivery | Source/test package and injection seams ready; live WCSession implementation pending | | Adapters/MotionCapture | Core Motion, coordinate calibration, normalization | Planned | | Adapters/SecureStorage | Keychain token and trust persistence | Planned | diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index f838222..9d82d1d 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -1,6 +1,6 @@ # Command contract v1 -Source of truth: `Packages/WristivoKit/Sources/RemoteCore`. The integration owner maintains these types. This document defines production adapter behavior in addition to the foundation types; the scaffold does not yet implement the relay/session handshake. +Source of truth: `Packages/WristivoKit/Sources/RemoteCore`, including `WatchSession.swift`. The integration owner maintains these types. The v1 control-plane DTOs, codec and actor protocol are now defined. WCSession delivery and the production phone actor are still to be implemented; protocol definitions are not a functioning handshake. ## Commands and envelope @@ -8,7 +8,9 @@ Source of truth: `Packages/WristivoKit/Sources/RemoteCore`. The integration owne `CommandEnvelope`: `schemaVersion` (1), `id` (UUID), `sessionID` (UUID), `televisionID` (UUID), `sequence` (UInt64), `issuedAt` (Date), `expiresAt` (Date), `command`. UUIDs identify local opaque records, not TV serial numbers or network addresses. Date encoding on the future bridge is explicitly JSON milliseconds since Unix epoch; both encoders/decoders must set the same strategy. Package round-trip tests alone are not sufficient to validate that adapter. -A session is issued by the phone for one selected paired TV, bound to the currently activated paired WCSession. The watch cannot select an arbitrary target by manufacturing an envelope. Proposed control-plane operations: `beginSession` returns v1, sessionID, televisionID and expiry policy; `endSession` invalidates that exact session; a new phone process, TV switch, re-pair, bridge deactivation or app stop invalidates old authorization. Add typed control-plane DTOs under integration ownership before transport implementation. Starting a session must never issue a TV key. +A session is issued by the phone for one selected paired TV, bound to the currently activated paired WCSession. The watch cannot select an arbitrary target by manufacturing an envelope. `BeginRemoteSessionRequest` returns `RemoteSessionGrant` with v1, sessionID, televisionID, issuedAt and maximumCommandLifetimeMilliseconds (1...1000); `EndRemoteSessionRequest` ends that exact session and returns `RemoteSessionEnded`. Errors use `RelayFailure`, which conforms to Error for Result return values. A new phone process, TV switch, re-pair, bridge deactivation or app stop invalidates old authorization. Starting a session must never issue a TV key. + +Use `RelayJSON.encode(_:)` and `RelayJSON.decode(_:from:)` for all bridge payloads. They enforce a 4096-byte payload limit and the millisecond date strategy. Control DTO invariants are checked on encode and decode; direct Codable use bypasses that validation. The phone actor must still validate control requests from local callers. Command decoding performs no semantic admission and consumes no duplicate/sequence state. A grant is not a command; its issue date does not extend command lifetime. Apply the existing one-second command deadline independently to each command. Sequence starts at 0 or 1 and strictly increases within a session; all senders in a given session must share one counter. At UInt64 exhaustion start a new session rather than wrapping. The receiver rejects wrong target/session/version, invalid/non-finite times, future timestamps beyond 250 ms, messages over one second old, expiry in the past, expiry preceding issue, or a declared lifetime longer than one second. No arbitrary clock-skew widening: report need to resync/start a new session. Test actual paired-device skew. Adapter also maintains a receiver-local monotonic deadline so wall-clock changes cannot extend pending work. @@ -18,12 +20,16 @@ Validate decoded values at every trust boundary; Codable construction alone does `CommandReceipt` correlates the envelope ID. Status is sentToTransport, rejected, expired, duplicate or unavailable. A successful send only means the adapter completed the socket write; Samsung may provide no per-key execution acknowledgement. Never display "TV confirmed" based only on that receipt. Timeout after possible delivery is uncertain; ask the user to issue a new action if needed rather than replaying. -Planned bridge container: `["schemaVersion": 1, "kind": "command", "payload": Data]` with bounded JSON payload (4 KiB maximum), delivered using sendMessage/replyHandler. Validate container types and payload size before decoding. Reply uses the same versioned container with kind `receipt`. Exactly one reply per incoming request, including errors. No passwords/tokens/raw motion in these messages. Test Codable round trips, invalid payloads, schema mismatches and restart cases. +Frozen bridge container: exactly `["schemaVersion": 1, "kind": String, "payload": Data]`, with bounded JSON payload (4 KiB maximum), delivered using sendMessage/replyHandler. `RelayMessageKind` freezes beginSession/session, endSession/ended, command/receipt, and error. Validate exact container keys/types and payload size before decoding. Boolean NSNumber is not an integer version; reject it explicitly. Unknown kinds, extra/missing container keys, inappropriate request/reply direction, unsupported container versions and malformed payloads fail closed with an error reply. DTO fields may evolve only under the compatibility policy below. Exactly one reply per incoming request, including errors. The request's WCSession reply closure supplies correlation for control requests; command receipts additionally match CommandEnvelope.id. Never execute a reply payload as a request. No passwords/tokens/raw motion in these messages. ## Adapter interfaces Existing `RemoteTransport.send(_ envelope: CommandEnvelope) async -> CommandReceipt` is the command sink. The sender has no implicit retry guarantee. Platform agents propose small lifecycle protocols for connection, session acquisition, token repository and motion source through the integrator; do not hide setup inside `send` or start pairing in response to a gesture. +`PhoneCommandCoordinating: Actor` is now the injected phone seam. A/integration implements it on the same actor that owns CommandAdmission and TV dispatch, alongside RemoteTransport if useful. B does not implement a second authoritative actor. Its methods are `watchBridgeDidActivate(peer:)`, `beginWatchSession(_:peer:)`, `endWatchSession(_:peer:)`, `submitWatchCommand(_:peer:)` and `watchBridgeDidDeactivate(peer:)`. A fake conforming actor is sufficient for B's independent implementation/tests; the live A actor is required only for end-to-end integration. + +The local `WatchPeerGeneration` is a fresh UUID per phone-side bridge activation. It is not Codable and must not cross the wire, persist, identify watch hardware, or come from the remote message. B serializes lifecycle delivery, awaits activation before forwarding requests, and passes the captured generation on every call. The phone actor rejects requests from non-current peers and ignores delayed deactivation for old peers. Repeated activation of the same peer is idempotent; activating a new peer invalidates previous grants. Repeated begin requests create fresh sessions and invalidate the previous session; B must serialize starts and ignore delayed replies after a lifecycle or local request-generation change. Stale end requests return sessionInvalidated and never end a newer session. After every suspension, the actor rechecks generation/session/target/deadline before sending. A protocol cannot enforce these semantics; A's tests must prove them. + GestureCore receives normalized inputs from the future MotionCapture adapter. Its thresholds are algorithm fixtures, not physically calibrated degrees or validated human-motion confidence. Sensor coordinate transforms and physical traces are required before claiming wrist-swipe recognition. Watch and phone UI depend on domain contracts, never Samsung frame strings. ## Compatibility change policy diff --git a/docs/agents/README.md b/docs/agents/README.md index 1841e9f..e25121a 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -21,6 +21,8 @@ The coordinating engineer is the integration owner. Agents implement focused bra Package paths in the table mean `Packages/WristivoKit/Sources/` and `Tests/Tests`. A new adapter needs tests in an adjacent Tests directory; integrator creates target membership before the implementation branch starts. QA may run acceptance planning immediately, but must not edit another agent's tests or report planned cases as passed. +**B prerequisites are now established.** Follow [the Watch relay handoff](WATCH-RELAY-HANDOFF.md): frozen session DTOs/codec/actor protocol live in RemoteCore/WatchSession.swift; the adjacent WatchBridge package provides three source/test targets and its platform products are linked to the apps. B works in a separate worktree, uses a fake actor until A is integrated, and does not need to take over the integration role. Adapter Package.swift files, dependency changes and Xcode project wiring remain integrator-owned; new Swift source/test files within existing target paths are B-owned and automatically included. + ## A — TV transport prompt > Implement an iPhone-only TV connection actor and pure bounded event parser. Use SamsungRemoteEncoder without copying a community implementation. Support explicit connect/pair/deny/timeout/revoke/disconnect, authorize before ready, serialize commands, consume admission once, recheck deadlines before sends, and cancel old-generation operations on TV/session changes. Integrate injected token/trust storage from E. No blanket TLS trust, arbitrary key API, silent plaintext fallback or replay on reconnect. Use synthetic redacted event fixtures for malformed/oversize/unknown/missing-token responses and fake socket tests for deadline, ordering and cancellation. Deliver a manual physical pairing script showing the name Wristivo, reconnect and revoke. Do not mark the TV compatible until the owner runs it. Begin with a hardware feasibility spike for the candidate endpoint and TLS policy before expanding UI dependencies. diff --git a/docs/agents/WATCH-RELAY-HANDOFF.md b/docs/agents/WATCH-RELAY-HANDOFF.md new file mode 100644 index 0000000..e1d2409 --- /dev/null +++ b/docs/agents/WATCH-RELAY-HANDOFF.md @@ -0,0 +1,28 @@ +# Watch relay — integration prerequisites ready + +Scope: shared seams and compile/test membership. This is not a completed relay or a hardware verification. + +## Instructions for B + +Use the dedicated `feat/watch-relay` worktree prepared by the integrating engineer. Do not switch the original TV-transport checkout or edit shared contracts/project files. The integration commit in this worktree supplies all prerequisites below. A branch name alone does not isolate work: separate working directories are required when multiple agents run. + +1. Read RemoteCore/WatchSession.swift and docs/CONTRACTS.md for exact types and semantics. The historical proposal's method signatures have been superseded. +2. Implement only Adapters/WatchBridge sources/tests. All three target memberships already exist and discover new Swift files automatically. The package manifest and Xcode generator remain integration-owned; ask only for an actual dependency/target change, not ordinary new source files. +3. Use PhoneCommandCoordinating through a fake actor while A implements production conformance. B owns WCSession activation, facades, live delivery, bounded containers, routing, timeout and reply-once handling. A/integration owns the sole authorized session/admission/TV-dispatch actor. +4. Use RelayJSON for all payloads; no default JSON date coders. Control DTO validation does not replace command admission. Match command receipt IDs and expected reply kinds. A timeout or an old-generation reply never retries an action or re-arms input. +5. Establish and capture a fresh local WatchPeerGeneration for each phone bridge activation. Deliver lifecycle changes in order and await activation before forwarding requests. Pass that peer on every actor call. Never accept a peer ID from wire data or put it in application context. +6. Keep OS APIs behind injectable facades and platform guards. Test fake behavior on macOS without subclassing WCSession; compile real delegates on each native SDK. Do not start WCSession or change the demo app behavior at module import. + +## Required B tests + +Exactly-once completion on success/error/timeout races; stale callback after deactivate/reactivate; ignored delayed begin grant/end acknowledgement; unreachable and unactivated session; wrong kind/version/types/extra keys; Bool-as-version rejection; 4096/4097-byte boundary; malformed/unknown payload; receipt ID mismatch; bounded pending requests; expiry while waiting; no replay after reconnect; application-context allowlist excludes tokens, raw motion and commands. Validate phone/watch counterpart replacement and reinstall recovery with fakes first. + +Run `bash Scripts/check.sh`. The integrator also runs both app builds before merge. Hardware testing of locked-phone wake, relaunch, actual reachability and detached-debugger lifecycle remains unperformed and mandatory before feature completion. Do not block fake-driven implementation on access to a TV. + +## Responsibilities for A/integration + +Implement PhoneCommandCoordinating on the single existing/new TV command actor, validate caller and peer lifecycle, require a ready selected TV for a grant, and make stale end/deactivation harmless. Test actor reentrancy and recheck all state before socket writes. Do not forward an admitted command into another consuming admission gate. The storage/TLS proposal from A is outside this relay-prerequisite patch and remains separate integration work. + +## Delivery + +Return a branch/commit, focused test evidence, explicit native compilation status, documented errors and remaining hardware gates. Do not push main. The user is handling remote pushes; this prerequisite change does not push anything.