Create Wristivo iPhone and Apple Watch foundation

This commit is contained in:
2026-09-17 17:59:08 +04:00
commit a92698e1e6
48 changed files with 2492 additions and 0 deletions
+20
View File
@@ -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"])
]
)
+53
View File
@@ -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)
}