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
@@ -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"
}
}