Create Wristivo iPhone and Apple Watch foundation
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "WristivoKit",
|
||||
platforms: [.iOS(.v17), .watchOS(.v10), .macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "RemoteCore", targets: ["RemoteCore"]),
|
||||
.library(name: "SamsungProtocol", targets: ["SamsungProtocol"]),
|
||||
.library(name: "GestureCore", targets: ["GestureCore"])
|
||||
],
|
||||
targets: [
|
||||
.target(name: "RemoteCore"),
|
||||
.target(name: "SamsungProtocol", dependencies: ["RemoteCore"]),
|
||||
.target(name: "GestureCore", dependencies: ["RemoteCore"]),
|
||||
.testTarget(name: "RemoteCoreTests", dependencies: ["RemoteCore"]),
|
||||
.testTarget(name: "SamsungProtocolTests", dependencies: ["SamsungProtocol", "RemoteCore"]),
|
||||
.testTarget(name: "GestureCoreTests", dependencies: ["GestureCore", "RemoteCore"])
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
# WristivoKit
|
||||
|
||||
Original Swift 6 foundation for the Wristivo prototype. No external dependencies.
|
||||
Minimum declared platforms: iOS 17, watchOS 10, macOS 14.
|
||||
|
||||
- `RemoteCore`: command vocabulary, versioned envelope, receipts, transport contract,
|
||||
and a stateful admission gate with one-second command lifetime, 250 ms maximum
|
||||
future clock skew, monotonic sequence numbers, and a bounded 128-ID dedup window.
|
||||
- `SamsungProtocol`: candidate local-TV WebSocket endpoint and click-frame encoding.
|
||||
This module opens no sockets, pairs no devices, and supplies no TLS trust exceptions.
|
||||
- `GestureCore`: deterministic gate for **already-normalized directional input**.
|
||||
This is not a motion classifier. Raw Core Motion processing, calibration, wrist
|
||||
orientation, and physical-device validation remain work for the gesture agent.
|
||||
|
||||
Run `swift test` from this directory. The tests use Swift Testing.
|
||||
|
||||
## Integration contract
|
||||
|
||||
Treat `CommandEnvelope` decoding as untrusted structure. A single transport actor
|
||||
must call `CommandAdmission.admit(_:now:)` immediately before each send, and send
|
||||
only `.accepted` commands. Do not enqueue commands for reconnection or automatically
|
||||
retry a failed send. A consumed sequence stays consumed even when sending fails.
|
||||
Use `startNewSession(televisionID:)` after reconnect, TV change, or a new control
|
||||
session and convey the returned UUID to the watch before enabling gesture input.
|
||||
The gate rejects old sessions; keep the actor alive for the entire session.
|
||||
|
||||
The caller supplies increasing sequence numbers starting at zero or one. At UInt64
|
||||
exhaustion begin a fresh session, never wrap. The fixed defaults are prototype safety
|
||||
policy, not measured latency claims. Both devices require approximately aligned wall
|
||||
clocks. Use the sender's issue time and expiration; never refresh age upon reception.
|
||||
Use the same JSON date strategy on both devices; the standard Foundation Codable
|
||||
strategy is used by this package's round-trip test.
|
||||
|
||||
`sentToTransport` means submission to the transport only. It does not mean that the
|
||||
TV accepted a command or that the on-screen state changed. The transport implementer
|
||||
must map admission rejection to an appropriate receipt and expose a separate,
|
||||
truthful pairing/connection state.
|
||||
|
||||
`DirectionalGestureGate.arm(sessionID:at:)` requires deliberate user action in the
|
||||
foreground, followed by neutral input. Inputs use monotonic local seconds and the
|
||||
normalized range [-1, 1], where positive x means right and positive y means up.
|
||||
One event is allowed per neutral-to-direction excursion. Defaults propose 0.7
|
||||
activation, 0.25 neutral, 0.3 seconds between emissions, and ten seconds armed.
|
||||
Ambiguous diagonals, invalid input, and debounce suppression consume the excursion.
|
||||
Return to neutral before another attempt. Call `disarm()` when app activity,
|
||||
connectivity, selected television, or control session changes. Stop actual sensor
|
||||
delivery in the adapter as well; this pure gate owns no sensor or background task.
|
||||
|
||||
Hostnames passed to `SamsungRemoteEncoder.endpoint(host:token:)` are bare hostnames
|
||||
or IPv4 addresses; bracket IPv6 literals. The client display name is `Wristivo`.
|
||||
The optional token is a secret and appears in the returned URL: do not log that URL.
|
||||
The wire format and individual keys, particularly `KEY_PLAYPAUSE`, require physical
|
||||
TV validation. Implement explicit supported-model capabilities before shipping.
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import RemoteCore
|
||||
|
||||
/// Proposed normalized thresholds; these are not calibrated motion measurements.
|
||||
public struct GestureConfiguration: Sendable, Equatable {
|
||||
public let activationThreshold: Double
|
||||
public let neutralThreshold: Double
|
||||
public let minimumInterval: TimeInterval
|
||||
public let maximumArmedDuration: TimeInterval
|
||||
public let axisDominanceRatio: Double
|
||||
|
||||
public init() {
|
||||
activationThreshold = 0.7
|
||||
neutralThreshold = 0.25
|
||||
minimumInterval = 0.3
|
||||
maximumArmedDuration = 10
|
||||
axisDominanceRatio = 0.6
|
||||
}
|
||||
|
||||
public enum ConfigurationError: Error { case invalidValues }
|
||||
|
||||
public init(activationThreshold: Double, neutralThreshold: Double,
|
||||
minimumInterval: TimeInterval, maximumArmedDuration: TimeInterval,
|
||||
axisDominanceRatio: Double) throws {
|
||||
guard activationThreshold.isFinite, neutralThreshold.isFinite,
|
||||
minimumInterval.isFinite, maximumArmedDuration.isFinite,
|
||||
axisDominanceRatio.isFinite,
|
||||
activationThreshold > 0, activationThreshold <= 1,
|
||||
neutralThreshold >= 0, neutralThreshold < activationThreshold,
|
||||
minimumInterval >= 0, maximumArmedDuration > 0,
|
||||
axisDominanceRatio > 0, axisDominanceRatio < 1 else {
|
||||
throw ConfigurationError.invalidValues
|
||||
}
|
||||
self.activationThreshold = activationThreshold
|
||||
self.neutralThreshold = neutralThreshold
|
||||
self.minimumInterval = minimumInterval
|
||||
self.maximumArmedDuration = maximumArmedDuration
|
||||
self.axisDominanceRatio = axisDominanceRatio
|
||||
}
|
||||
}
|
||||
|
||||
public struct GestureIntent: Sendable, Equatable {
|
||||
public let sessionID: UUID
|
||||
public let command: RemoteCommand
|
||||
/// Monotonic local seconds, never serialize this as a cross-device wall clock.
|
||||
public let timestamp: TimeInterval
|
||||
}
|
||||
|
||||
/// A deterministic gate over already-normalized directional input, NOT a classifier
|
||||
/// of accelerometer/gyroscope samples or an implementation of Apple system gestures.
|
||||
/// x positive = right; y positive = up. An adapter must establish wrist orientation.
|
||||
public struct DirectionalGestureGate: Sendable {
|
||||
public let configuration: GestureConfiguration
|
||||
public private(set) var sessionID: UUID?
|
||||
private var armedAt: TimeInterval?
|
||||
private var lastSampleAt: TimeInterval?
|
||||
private var lastEmissionAt: TimeInterval?
|
||||
private var ready = false
|
||||
|
||||
public init(configuration: GestureConfiguration = GestureConfiguration()) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
/// Deliberate foreground action only. After arming, a neutral sample is required.
|
||||
public mutating func arm(sessionID: UUID, at timestamp: TimeInterval) {
|
||||
disarm()
|
||||
guard timestamp.isFinite else { return }
|
||||
self.sessionID = sessionID
|
||||
armedAt = timestamp
|
||||
}
|
||||
|
||||
/// Call on app deactivation, reachability loss, TV/session change, or user cancel.
|
||||
public mutating func disarm() {
|
||||
sessionID = nil
|
||||
armedAt = nil
|
||||
lastSampleAt = nil
|
||||
lastEmissionAt = nil
|
||||
ready = false
|
||||
}
|
||||
|
||||
public mutating func process(x: Double, y: Double, at timestamp: TimeInterval) -> GestureIntent? {
|
||||
guard let sessionID, let armedAt else { return nil }
|
||||
guard timestamp.isFinite, timestamp >= armedAt,
|
||||
timestamp - armedAt < configuration.maximumArmedDuration else {
|
||||
disarm()
|
||||
return nil
|
||||
}
|
||||
guard lastSampleAt.map({ timestamp > $0 }) ?? true else { return nil }
|
||||
lastSampleAt = timestamp
|
||||
guard x.isFinite, y.isFinite, abs(x) <= 1, abs(y) <= 1 else {
|
||||
// Invalid input requires neutral recovery and cannot preserve readiness.
|
||||
ready = false
|
||||
return nil
|
||||
}
|
||||
let magnitude = max(abs(x), abs(y))
|
||||
if magnitude <= configuration.neutralThreshold {
|
||||
ready = true
|
||||
return nil
|
||||
}
|
||||
guard ready, magnitude >= configuration.activationThreshold else { return nil }
|
||||
// Every threshold excursion consumes readiness, including ambiguous diagonals
|
||||
// and debounce suppression. Holding an input can never trigger a late event.
|
||||
ready = false
|
||||
guard lastEmissionAt.map({ timestamp - $0 >= configuration.minimumInterval }) ?? true else {
|
||||
return nil
|
||||
}
|
||||
let command: RemoteCommand
|
||||
if abs(x) > abs(y), abs(y) <= abs(x) * configuration.axisDominanceRatio {
|
||||
command = x > 0 ? .right : .left
|
||||
} else if abs(y) > abs(x), abs(x) <= abs(y) * configuration.axisDominanceRatio {
|
||||
command = y > 0 ? .up : .down
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
lastEmissionAt = timestamp
|
||||
return GestureIntent(sessionID: sessionID, command: command, timestamp: timestamp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Foundation
|
||||
|
||||
public enum RemoteCommand: String, Codable, Sendable, CaseIterable {
|
||||
case up, down, left, right, select, back, home, playPause
|
||||
case volumeUp, volumeDown, mute
|
||||
}
|
||||
|
||||
/// Decoding is structural only. Run every decoded envelope through CommandAdmission
|
||||
/// immediately before transport dispatch. Never queue an accepted command for replay.
|
||||
public struct CommandEnvelope: Codable, Sendable, Equatable {
|
||||
public let schemaVersion: Int
|
||||
public let id: UUID
|
||||
public let sessionID: UUID
|
||||
public let televisionID: UUID
|
||||
public let sequence: UInt64
|
||||
public let issuedAt: Date
|
||||
public let expiresAt: Date
|
||||
public let command: RemoteCommand
|
||||
|
||||
public init(
|
||||
sessionID: UUID, televisionID: UUID, sequence: UInt64,
|
||||
issuedAt: Date, expiresAt: Date, command: RemoteCommand,
|
||||
id: UUID = UUID(), schemaVersion: Int = 1
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.id = id
|
||||
self.sessionID = sessionID
|
||||
self.televisionID = televisionID
|
||||
self.sequence = sequence
|
||||
self.issuedAt = issuedAt
|
||||
self.expiresAt = expiresAt
|
||||
self.command = command
|
||||
}
|
||||
}
|
||||
|
||||
public struct CommandReceipt: Codable, Sendable, Equatable {
|
||||
public enum Status: String, Codable, Sendable {
|
||||
/// Bytes were submitted to the transport; this does not verify a TV effect.
|
||||
case sentToTransport
|
||||
case rejected, expired, duplicate, unavailable
|
||||
}
|
||||
public let id: UUID
|
||||
public let status: Status
|
||||
|
||||
public init(id: UUID, status: Status) {
|
||||
self.id = id
|
||||
self.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementations own admission, readiness, credentials and bounded lifetime.
|
||||
/// They must not retry/replay expired commands or describe a send as TV confirmation.
|
||||
public protocol RemoteTransport: Sendable {
|
||||
func send(_ envelope: CommandEnvelope) async -> CommandReceipt
|
||||
}
|
||||
|
||||
public enum AdmissionRejection: String, Codable, Sendable, Equatable {
|
||||
case unsupportedSchema, wrongSession, wrongTelevision, invalidTimestamp
|
||||
case invalidLifetime, futureTimestamp, expired, stale, duplicate, outOfOrder
|
||||
}
|
||||
|
||||
public enum AdmissionDecision: Sendable, Equatable {
|
||||
case accepted
|
||||
case rejected(AdmissionRejection)
|
||||
}
|
||||
|
||||
/// Value-type admission gate: isolate mutation in the transport actor. State advances
|
||||
/// at admission, so a failed send consumes its sequence rather than replaying it.
|
||||
public struct CommandAdmission: Sendable {
|
||||
public private(set) var sessionID: UUID
|
||||
public private(set) var televisionID: UUID
|
||||
public private(set) var lastSequence: UInt64?
|
||||
public static let maximumAge: TimeInterval = 1
|
||||
public static let maximumFutureSkew: TimeInterval = 0.25
|
||||
public static let maximumLifetime: TimeInterval = 1
|
||||
public static let rememberedIDLimit = 128
|
||||
private var rememberedIDs: [UUID] = []
|
||||
private var rememberedIDSet: Set<UUID> = []
|
||||
|
||||
public init(sessionID: UUID, televisionID: UUID) {
|
||||
self.sessionID = sessionID
|
||||
self.televisionID = televisionID
|
||||
}
|
||||
|
||||
/// A reconnect must create a fresh session ID; never reset into an old session.
|
||||
/// The returned ID must be communicated to the watch before admitting input.
|
||||
@discardableResult
|
||||
public mutating func startNewSession(televisionID: UUID) -> UUID {
|
||||
sessionID = UUID()
|
||||
self.televisionID = televisionID
|
||||
lastSequence = nil
|
||||
rememberedIDs.removeAll(keepingCapacity: true)
|
||||
rememberedIDSet.removeAll(keepingCapacity: true)
|
||||
return sessionID
|
||||
}
|
||||
|
||||
public mutating func admit(_ envelope: CommandEnvelope, now: Date) -> AdmissionDecision {
|
||||
guard envelope.schemaVersion == 1 else { return .rejected(.unsupportedSchema) }
|
||||
guard envelope.sessionID == sessionID else { return .rejected(.wrongSession) }
|
||||
guard envelope.televisionID == televisionID else { return .rejected(.wrongTelevision) }
|
||||
let issued = envelope.issuedAt.timeIntervalSinceReferenceDate
|
||||
let expiry = envelope.expiresAt.timeIntervalSinceReferenceDate
|
||||
let current = now.timeIntervalSinceReferenceDate
|
||||
guard issued.isFinite, expiry.isFinite, current.isFinite else {
|
||||
return .rejected(.invalidTimestamp)
|
||||
}
|
||||
let lifetime = expiry - issued
|
||||
guard lifetime > 0, lifetime <= Self.maximumLifetime else {
|
||||
return .rejected(.invalidLifetime)
|
||||
}
|
||||
guard issued - current <= Self.maximumFutureSkew else {
|
||||
return .rejected(.futureTimestamp)
|
||||
}
|
||||
guard current - issued <= Self.maximumAge else { return .rejected(.stale) }
|
||||
guard current < expiry else { return .rejected(.expired) }
|
||||
guard !rememberedIDSet.contains(envelope.id) else { return .rejected(.duplicate) }
|
||||
if let lastSequence, envelope.sequence <= lastSequence {
|
||||
return .rejected(.outOfOrder)
|
||||
}
|
||||
lastSequence = envelope.sequence
|
||||
rememberedIDs.append(envelope.id)
|
||||
rememberedIDSet.insert(envelope.id)
|
||||
if rememberedIDs.count > Self.rememberedIDLimit {
|
||||
rememberedIDSet.remove(rememberedIDs.removeFirst())
|
||||
}
|
||||
return .accepted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import RemoteCore
|
||||
|
||||
/// Prototype wire format to verify against physical TV models. No network or TLS
|
||||
/// policy is implemented here. In particular, callers must never disable trust checks.
|
||||
public enum SamsungRemoteEncoder {
|
||||
public static let clientName = "Wristivo"
|
||||
|
||||
public static func key(for command: RemoteCommand) -> String {
|
||||
switch command {
|
||||
case .up: "KEY_UP"
|
||||
case .down: "KEY_DOWN"
|
||||
case .left: "KEY_LEFT"
|
||||
case .right: "KEY_RIGHT"
|
||||
case .select: "KEY_ENTER"
|
||||
case .back: "KEY_RETURN"
|
||||
case .home: "KEY_HOME"
|
||||
case .playPause: "KEY_PLAYPAUSE"
|
||||
case .volumeUp: "KEY_VOLUP"
|
||||
case .volumeDown: "KEY_VOLDOWN"
|
||||
case .mute: "KEY_MUTE"
|
||||
}
|
||||
}
|
||||
|
||||
public static func clickFrame(for command: RemoteCommand) throws -> Data {
|
||||
try JSONEncoder().encode(Frame(params: Parameters(DataOfCmd: key(for: command))))
|
||||
}
|
||||
|
||||
public enum EndpointError: Error, Sendable { case invalidHost }
|
||||
|
||||
/// Accepts a bare hostname or IPv4 address, or a bracketed IPv6 literal.
|
||||
/// Treat token as a secret: do not log the returned URL or persist it in telemetry.
|
||||
public static func endpoint(host: String, token: String? = nil) throws -> URL {
|
||||
guard !host.isEmpty, !host.contains(where: { $0.isWhitespace }),
|
||||
!host.contains("/"), !host.contains("@"),
|
||||
!host.contains("?"), !host.contains("#"),
|
||||
!host.contains("%"), !host.contains("\\"),
|
||||
(!host.contains(":") || (host.hasPrefix("[") && host.hasSuffix("]"))) else {
|
||||
throw EndpointError.invalidHost
|
||||
}
|
||||
var parts = URLComponents()
|
||||
parts.scheme = "wss"
|
||||
parts.host = host
|
||||
parts.port = 8002
|
||||
parts.path = "/api/v2/channels/samsung.remote.control"
|
||||
var items = [URLQueryItem(name: "name", value: Data(clientName.utf8).base64EncodedString())]
|
||||
if let token { items.append(URLQueryItem(name: "token", value: token)) }
|
||||
parts.queryItems = items
|
||||
// '+' is legal in an RFC 3986 query, but some embedded servers treat it as
|
||||
// a form-space. Encode it explicitly while retaining URLComponents escaping.
|
||||
parts.percentEncodedQuery = parts.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")
|
||||
guard let url = parts.url, url.host != nil else { throw EndpointError.invalidHost }
|
||||
return url
|
||||
}
|
||||
|
||||
private struct Frame: Encodable {
|
||||
let method = "ms.remote.control"
|
||||
let params: Parameters
|
||||
}
|
||||
private struct Parameters: Encodable {
|
||||
let Cmd = "Click"
|
||||
let DataOfCmd: String
|
||||
let Option = "false"
|
||||
let TypeOfRemote = "SendRemoteKey"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import RemoteCore
|
||||
@testable import GestureCore
|
||||
|
||||
@Test func directionsRequireArmingAndNeutralThenEmitOnce() {
|
||||
let samples: [(Double, Double, RemoteCommand)] = [(0.9, 0, .right), (-0.9, 0, .left), (0, 0.9, .up), (0, -0.9, .down)]
|
||||
for (x, y, command) in samples {
|
||||
var gate = DirectionalGestureGate()
|
||||
#expect(gate.process(x: x, y: y, at: 0) == nil)
|
||||
let session = UUID()
|
||||
gate.arm(sessionID: session, at: 0)
|
||||
#expect(gate.process(x: x, y: y, at: 0.1) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 0.2) == nil)
|
||||
let intent = gate.process(x: x, y: y, at: 0.3)
|
||||
#expect(intent?.command == command)
|
||||
#expect(intent?.sessionID == session)
|
||||
#expect(gate.process(x: x, y: y, at: 1) == nil)
|
||||
#expect(gate.process(x: -x, y: -y, at: 2) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func hysteresisAndDebounceConsumeSuppressedExcursion() {
|
||||
var gate = DirectionalGestureGate()
|
||||
gate.arm(sessionID: UUID(), at: 0)
|
||||
#expect(gate.process(x: 0, y: 0, at: 0.1) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 0.2)?.command == .right)
|
||||
#expect(gate.process(x: 0.4, y: 0, at: 0.3) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 0.35) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 0.4) == nil)
|
||||
#expect(gate.process(x: -0.9, y: 0, at: 0.45) == nil)
|
||||
#expect(gate.process(x: -0.9, y: 0, at: 1) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 1.1) == nil)
|
||||
#expect(gate.process(x: -0.9, y: 0, at: 1.2)?.command == .left)
|
||||
}
|
||||
|
||||
@Test func ambiguityInvalidInputAndOutOfOrderSamplesAreSuppressed() {
|
||||
var gate = DirectionalGestureGate()
|
||||
gate.arm(sessionID: UUID(), at: 0)
|
||||
#expect(gate.process(x: 0, y: 0, at: 0.1) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0.9, at: 0.2) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 0.3) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 0.4) == nil)
|
||||
#expect(gate.process(x: .nan, y: 0, at: 0.5) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 0.6) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 1) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 0.9) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 1.1)?.command == .right)
|
||||
}
|
||||
|
||||
@Test func timeoutAndDisarmPreventActionsAndRearmingRequiresNeutral() {
|
||||
var gate = DirectionalGestureGate()
|
||||
gate.arm(sessionID: UUID(), at: 0)
|
||||
#expect(gate.process(x: 0, y: 0, at: 1) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 10) == nil)
|
||||
#expect(gate.sessionID == nil)
|
||||
gate.arm(sessionID: UUID(), at: 20)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 20.1) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 20.2) == nil)
|
||||
gate.disarm()
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 20.3) == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsInvalidConfiguration() {
|
||||
#expect(throws: GestureConfiguration.ConfigurationError.self) {
|
||||
try GestureConfiguration(activationThreshold: 0.2, neutralThreshold: 0.3, minimumInterval: 0.3, maximumArmedDuration: 10, axisDominanceRatio: 0.6)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import RemoteCore
|
||||
|
||||
private let now = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
private func envelope(session: UUID, tv: UUID, sequence: UInt64 = 1,
|
||||
id: UUID = UUID(), issued: Date = now,
|
||||
expires: Date = now.addingTimeInterval(0.5),
|
||||
version: Int = 1) -> CommandEnvelope {
|
||||
CommandEnvelope(sessionID: session, televisionID: tv, sequence: sequence,
|
||||
issuedAt: issued, expiresAt: expires, command: .right,
|
||||
id: id, schemaVersion: version)
|
||||
}
|
||||
|
||||
@Test func admitsExactlyOnceAndRequiresIncreasingSequence() {
|
||||
let session = UUID(), tv = UUID()
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
let first = envelope(session: session, tv: tv)
|
||||
#expect(gate.admit(first, now: now) == .accepted)
|
||||
#expect(gate.admit(first, now: now) == .rejected(.duplicate))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv), now: now) == .rejected(.outOfOrder))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, sequence: 2), now: now) == .accepted)
|
||||
}
|
||||
|
||||
@Test func rejectsWrongRoutingAndUnsupportedSchemaWithoutConsumingSequence() {
|
||||
let session = UUID(), tv = UUID()
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
#expect(gate.admit(envelope(session: UUID(), tv: tv), now: now) == .rejected(.wrongSession))
|
||||
#expect(gate.admit(envelope(session: session, tv: UUID()), now: now) == .rejected(.wrongTelevision))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, version: 2), now: now) == .rejected(.unsupportedSchema))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv), now: now) == .accepted)
|
||||
}
|
||||
|
||||
@Test func rejectsStaleExpiredFutureAndInvalidLifetime() {
|
||||
let session = UUID(), tv = UUID()
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, issued: now.addingTimeInterval(-2), expires: now.addingTimeInterval(-1.5)), now: now) == .rejected(.stale))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, issued: now.addingTimeInterval(-0.5), expires: now), now: now) == .rejected(.expired))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, issued: now.addingTimeInterval(0.3), expires: now.addingTimeInterval(0.8)), now: now) == .rejected(.futureTimestamp))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, expires: now.addingTimeInterval(2)), now: now) == .rejected(.invalidLifetime))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, expires: now), now: now) == .rejected(.invalidLifetime))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv), now: Date(timeIntervalSince1970: .infinity)) == .rejected(.invalidTimestamp))
|
||||
}
|
||||
|
||||
@Test func acceptsSkewBoundaryAndMaximumLifetime() {
|
||||
let session = UUID(), tv = UUID()
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
let issued = now.addingTimeInterval(0.25)
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, issued: issued, expires: issued.addingTimeInterval(1)), now: now) == .accepted)
|
||||
}
|
||||
|
||||
@Test func resetUsesFreshSessionAndOldReplayCannotPassAfterIDEviction() {
|
||||
let session = UUID(), tv = UUID()
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
let first = envelope(session: session, tv: tv, sequence: 0)
|
||||
#expect(gate.admit(first, now: now) == .accepted)
|
||||
for value in 1...CommandAdmission.rememberedIDLimit {
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, sequence: UInt64(value)), now: now) == .accepted)
|
||||
}
|
||||
#expect(gate.admit(first, now: now) == .rejected(.outOfOrder))
|
||||
let newSession = gate.startNewSession(televisionID: tv)
|
||||
#expect(newSession != session)
|
||||
#expect(gate.admit(first, now: now) == .rejected(.wrongSession))
|
||||
#expect(gate.admit(envelope(session: newSession, tv: tv, sequence: 0), now: now) == .accepted)
|
||||
}
|
||||
|
||||
@Test func decodingDoesNotBypassAdmission() throws {
|
||||
let session = UUID(), tv = UUID()
|
||||
let invalid = envelope(session: session, tv: tv, version: 99)
|
||||
let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: JSONEncoder().encode(invalid))
|
||||
#expect(decoded == invalid)
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
#expect(gate.admit(decoded, now: now) == .rejected(.unsupportedSchema))
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import RemoteCore
|
||||
@testable import SamsungProtocol
|
||||
|
||||
@Test func commandFramesContainExpectedClickProtocol() throws {
|
||||
let expected: [RemoteCommand: String] = [
|
||||
.up: "KEY_UP", .down: "KEY_DOWN", .left: "KEY_LEFT", .right: "KEY_RIGHT",
|
||||
.select: "KEY_ENTER", .back: "KEY_RETURN", .home: "KEY_HOME",
|
||||
.playPause: "KEY_PLAYPAUSE", .volumeUp: "KEY_VOLUP", .volumeDown: "KEY_VOLDOWN", .mute: "KEY_MUTE"
|
||||
]
|
||||
for command in RemoteCommand.allCases {
|
||||
let frame = try #require(JSONSerialization.jsonObject(with: SamsungRemoteEncoder.clickFrame(for: command)) as? [String: Any])
|
||||
#expect(frame["method"] as? String == "ms.remote.control")
|
||||
let parameters = try #require(frame["params"] as? [String: String])
|
||||
#expect(parameters == ["Cmd": "Click", "DataOfCmd": expected[command]!, "Option": "false", "TypeOfRemote": "SendRemoteKey"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test func endpointEscapesTokenAndIdentifiesClient() throws {
|
||||
let token = "prototype+token /?&=#%"
|
||||
let url = try SamsungRemoteEncoder.endpoint(host: "192.0.2.10", token: token)
|
||||
let parts = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))
|
||||
#expect(parts.scheme == "wss")
|
||||
#expect(parts.port == 8002)
|
||||
#expect(parts.host == "192.0.2.10")
|
||||
#expect(parts.path == "/api/v2/channels/samsung.remote.control")
|
||||
#expect(parts.queryItems?.first(where: { $0.name == "token" })?.value == token)
|
||||
#expect(!url.absoluteString.contains("+"))
|
||||
let name = try #require(parts.queryItems?.first(where: { $0.name == "name" })?.value)
|
||||
#expect(Data(base64Encoded: name).flatMap { String(data: $0, encoding: .utf8) } == "Wristivo")
|
||||
}
|
||||
|
||||
@Test func endpointOmitsAbsentTokenAndRejectsURLsAsHosts() throws {
|
||||
let url = try SamsungRemoteEncoder.endpoint(host: "tv.local")
|
||||
#expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.count == 1)
|
||||
for host in ["", "wss://tv.local", "tv.local:8002", "tv.local/path", "tv.local?token=oops", "[email protected]", "tv local", "tv.local#x"] {
|
||||
#expect(throws: SamsungRemoteEncoder.EndpointError.self) { try SamsungRemoteEncoder.endpoint(host: host) }
|
||||
}
|
||||
#expect(try SamsungRemoteEncoder.endpoint(host: "[2001:db8::1]").port == 8002)
|
||||
}
|
||||
Reference in New Issue
Block a user