Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

- Add the Apple post-RVD1 RVT1 device path: generated Swift APIs can target a
shared RivetRPCClient protocol; a bounded network client correlates RPCs,
forwards cancellation and delivers events, and its lifecycle-aware run()
keeps companion handlers alive until disconnect. A scoped gateway applies
RPC/state/event grants and a backend adapter calls the existing local client.
Tests cover request correlation, cancellation, shutdown, request bounds,
state-grant isolation, event filtering and host argument/result encoding.
Product listener wiring, scope selection, event fan-out, signed apps and
physical-device validation remain outstanding.

- Add an Android Keystore-backed, app-scoped pairing credential store for
the RVD1 persistence gate. Credentials and server SPKI pins are encrypted
together with AES-256-GCM and bound to the application and device ID;
Expand Down
8 changes: 8 additions & 0 deletions docs/desktop-closeout-mobile-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,14 @@ unverified.
Add the Windows gateway against the same iOS client, then bind Android
discovery/TLS streams to the existing Kotlin runtime and generated device API.

The Apple post-RVD1 RVT1 path now has a bounded client and scoped gateway
server, a backend adapter for the existing local RPC client, cancellation
forwarding, and per-device event/state-change filtering. Generated Swift APIs
support a shared RPC interface with compatibility for the released in-process
SwiftPM runtime, so network and in-process clients share typed methods.
Product host wiring, scope selection, event fan-out, and real-device
acceptance remain open gates.

Apple companion coverage extends to iOS and iPadOS through the shared
iOS transport, and watchOS through a separate WatchConnectivity request
relay to its paired iPhone. The relay requires activated/reachable
Expand Down
17 changes: 9 additions & 8 deletions docs/device-communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,15 @@ pre-session admission and fail-closed lifecycle handling. That listener now
publishes the privacy-bounded Bonjour service while ready. The framework-only
Bonjour browser discovers that service on iOS, rejects non-canonical TXT
records, and reports endpoint and advertisement changes without connecting or
granting authority. The `RivetDeviceAppleCompanion` coordinator now composes current explicit
selection, pinned reconnect or provisional pairing, credential persistence,
and session events. `RivetDeviceAppleCompanionUI` supplies a reusable SwiftUI
discovery, pair/reconnect, and manual confirmation surface for iOS/iPadOS.
Product-specific RVT1 gateway dispatch, local-network usage strings, app
packaging/signing, and real-device acceptance remain. The macOS session host
drives `RVD1`, exposes the stream only after authenticated acceptance, and
leaves product gateway dispatch to an explicit application handler; Windows
granting authority. The `RivetDeviceAppleCompanion` coordinator composes
live explicit selection, pinned reconnect or provisional pairing, credential
persistence, and an iOS/iPadOS SwiftUI confirmation surface. The post-RVD1
transport adds a bounded typed RVT1 client, scoped gateway server, and backend
RPC adapter; they apply per-device RPC, State, and Event grants and cancellation
without treating discovery as authority. Products must wire the accepted
session to application-specific RPC/state policy, event fan-out, local-network
permissions, signing, and real-device acceptance. The macOS host only exposes
application streams after authenticated acceptance; Windows
also needs its Credential Manager and transport adapters.

This design follows the core idea in [DSLs for Safe iOS/watchOS Communication](https://defn.io/2025/02/16/type-safe-watchos-communication/): encode the request/response relationship in types and centralize dispatch instead of spreading dictionaries, route strings, and casts throughout both apps. Rivet generates that relationship from the shared Racket schema while keeping the exported device surface explicit.
Expand Down
29 changes: 13 additions & 16 deletions docs/network-device-channel.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
# Network device channel design

Status: active design for [issue #85](https://github.com/turinglambdaai/rivet/issues/85).
The transport-independent `RivetDeviceGateway` policy/session core, pairing/
authentication lifecycle, and byte-exact session preface are implemented and
tested in memory. The Apple pairing-credential Keychain adapter, bounded
Network.framework TLS stream adapter, per-install identity provider, and
disabled-by-defaulThe explicit iOS Bonjour browser accepts only the privacy-bounded
canonical advertisement and does not connect or authorize a peer. The
`RivetDeviceAppleCompanion` library now composes explicit candidate selection,
pinned reconnect or provisional RVD1 pairing, the Keychain pairing store,
credential-persistence gating, and a product-supplied RVT1 application handler.
A reusable `RivetDeviceAppleCompanionUI` SwiftUI view/model is also available
for nearby discovery, explicit pairing or stored-device reconnect, pairing-code
confirmation, saved-pairing removal, and session status. Products supply the
host pairing-window ID from their QR/manual flow and the post-RVD1 application
handler. These are reusable components, not a generated or signed mobile app:
products still own local-network permission declarations, the application-
specific RVT1 client/gateway binding, packaging, and real-device acceptance.
The transport-independent `RivetDeviceGateway` policy/session core,
pairing/authentication lifecycle, and byte-exact RVD1 preface have in-memory
coverage. The Apple Keychain pairing store, Network.framework TLS transport,
per-install identity, disabled-by-default macOS listener, bounded Bonjour
advertising, and single-connection RVD1 session host are implemented. The
iOS/iPadOS companion orchestrator provides explicit Bonjour selection, pinned
reconnect or provisional pairing, Keychain persistence, and a reusable SwiftUI
pair/reconnect confirmation surface. After authenticated RVD1 acceptance, a
bounded RVT1 client, scoped per-device gateway, and backend adapter support
typed RPC, State, cancellation and filtered Event delivery. Products still own
per-device grants, gateway wiring, local-network permissions, event fan-out,
signed application packaging, and real-device verification. These reusable
targets are not a generated standalone mobile application.

### iPhone, iPad and Apple Watch companion boundaries

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,39 @@ public enum RivetEvent: Sendable {
}
}

public protocol RivetGeneratedRPCClient: Sendable {
func call(_ name: String, arguments: [RivetValue]) async throws -> RivetValue
}

// Keep generated clients compatible with the last released SwiftPM runtime.
// Authenticated network clients can use RivetAPI(call: deviceClient.call).
public extension RivetGeneratedRPCClient {
func getState(_ name: String) async throws -> RivetValue {
try await call("$state/get", arguments: [.string(name)])
}

@discardableResult
func setState(_ name: String, value: RivetValue) async throws -> RivetValue {
try await call("$state/set", arguments: [.string(name), value])
}
}

extension RivetClient: RivetGeneratedRPCClient {}

private struct RivetGeneratedClosureClient: RivetGeneratedRPCClient {
let invoke: @Sendable (String, [RivetValue]) async throws -> RivetValue

func call(_ name: String, arguments: [RivetValue]) async throws -> RivetValue {
try await invoke(name, arguments)
}
}

public struct RivetAPI: Sendable {
public let client: RivetClient
public init(client: RivetClient) { self.client = client }
public let client: any RivetGeneratedRPCClient
public init(client: any RivetGeneratedRPCClient) { self.client = client }
public init(call: @escaping @Sendable (String, [RivetValue]) async throws -> RivetValue) {
self.client = RivetGeneratedClosureClient(invoke: call)
}

public func create_task(title: String, notes: String) async throws -> RivetTypes.BoardTask {
let rivetResult = try await client.call("create-task", arguments: [encode_String(title), encode_String(notes)])
Expand Down
9 changes: 7 additions & 2 deletions platform/macos/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ let package = Package(
),
.target(
name: "RivetDeviceAppleTransport",
dependencies: ["RivetDevice", "RivetDeviceSession"],
dependencies: ["RivetDevice", "RivetDeviceSession", "RivetRuntime"],
path: "Sources/RivetDeviceAppleTransport",
linkerSettings: [
.linkedFramework("CryptoKit"),
Expand Down Expand Up @@ -159,7 +159,12 @@ let package = Package(
),
.testTarget(
name: "RivetDeviceAppleTransportTests",
dependencies: ["RivetDeviceAppleTransport", "RivetDeviceSession"],
dependencies: [
"RivetDeviceAppleTransport",
"RivetDeviceSession",
"RivetDevice",
"RivetRuntime"
],
path: "Tests/RivetDeviceAppleTransportTests"
),
.testTarget(
Expand Down
65 changes: 60 additions & 5 deletions platform/macos/Sources/RivetDevice/DeviceGateway.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ public struct RivetDeviceGatewayHostCall: Sendable {
/// authorization and remote-to-host request ID translation; the adapter owns
/// the actual host client and its cancellation primitive.
public protocol RivetDeviceGatewayHost: Sendable {
/// Returned IDs must be nonzero and unique across simultaneously pending
/// calls. Cancellation must affect exactly the call that owns the ID.
/// Payload is an encoded RivetValue list containing the RPC arguments.
/// The adapter decodes it for the embedded backend and encodes the result
/// as a RivetValue. Returned IDs must be nonzero and unique across
/// simultaneously pending calls; cancellation must affect exactly that ID.
func startRPC(name: String, payload: Data) async throws -> RivetDeviceGatewayHostCall
func cancelRPC(id: UInt64) async
}
Expand Down Expand Up @@ -167,16 +169,67 @@ public actor RivetDeviceGatewaySession {
remoteRequestID: UInt64,
name: String,
payload: Data
) async throws -> Data {
// State routes must carry an explicit state name through the guarded
// API below; treating them as ordinary RPCs would let one granted
// state capability unlock every state of the same direction.
guard name != "$state/get", name != "$state/set" else {
throw RivetDeviceGatewayError.forbidden
}
return try await invokeRPCInternal(
remoteRequestID: remoteRequestID,
name: name,
payload: payload,
isStateRoute: false
)
}

package func invokeStateRPC(
remoteRequestID: UInt64,
name: String,
stateName: String,
payload: Data
) async throws -> Data {
switch name {
case "$state/get":
try requireStateRead(stateName)
case "$state/set":
try requireStateWrite(stateName)
default:
throw RivetDeviceGatewayError.forbidden
}
return try await invokeRPCInternal(
remoteRequestID: remoteRequestID,
name: name,
payload: payload,
isStateRoute: true
)
}

private func invokeRPCInternal(
remoteRequestID: UInt64,
name: String,
payload: Data,
isStateRoute: Bool
) async throws -> Data {
guard !closed else {
throw RivetDeviceGatewayError.sessionClosed
}
guard !Task.isCancelled else {
throw RivetDeviceGatewayError.cancelled
}
guard remoteRequestID != 0 else {
throw RivetDeviceGatewayError.invalidRequestID
}
guard grants.rpcs.contains(name) else {
if isStateRoute {
guard name == "$state/get" || name == "$state/set" else {
throw RivetDeviceGatewayError.forbidden
}
} else {
// Unknown and ungranted names intentionally have the same result.
throw RivetDeviceGatewayError.forbidden
guard grants.rpcs.contains(name) else {
throw RivetDeviceGatewayError.forbidden
}
}
try checkMessageSize(payload)
guard pending[remoteRequestID] == nil else {
Expand Down Expand Up @@ -256,7 +309,9 @@ public actor RivetDeviceGatewaySession {
guard !closed else {
throw RivetDeviceGatewayError.sessionClosed
}
guard grants.events.contains(name) else {
// State changes have a separate state-name authorization path;
// never let an ordinary event grant bypass readable-state filtering.
guard name != "$state", grants.events.contains(name) else {
return nil
}
try checkMessageSize(payload)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import Foundation
import RivetDevice
import RivetRuntime

public protocol RivetDeviceAppleApplicationByteStream: Sendable {
func sendApplicationBytes(_ data: Data) async throws
func receiveApplicationBytes(maximumLength: Int) async throws -> Data?
func close() async
}

extension RivetDeviceAppleConnection: RivetDeviceAppleApplicationByteStream {
public func close() async { cancel() }
}

public enum RivetDeviceAppleRVT1ChannelError: Error, Equatable, Sendable {
case closed
case concurrentRead
case frameTooLarge(actual: Int, maximum: Int)
}

/// Bounded RVT1 framing over a stream opened only after RVD1 authentication.
public actor RivetDeviceAppleRVT1Channel {
private static let headerSize = 18
private let stream: any RivetDeviceAppleApplicationByteStream
private let maximumPayloadSize: Int
private var readBuffer = Data()
private var reading = false
private var closed = false
private var writeTail: Task<Void, any Error>?
private var writeToken: UUID?

public init(connection: RivetDeviceAppleConnection,
maximumPayloadSize: Int = rivetDeviceDefaultMaximumMessageSize) {
precondition(maximumPayloadSize > 0 &&
maximumPayloadSize <= rivetDeviceDefaultMaximumMessageSize)
stream = connection
self.maximumPayloadSize = maximumPayloadSize
}

package init(stream: any RivetDeviceAppleApplicationByteStream,
maximumPayloadSize: Int = rivetDeviceDefaultMaximumMessageSize) {
precondition(maximumPayloadSize > 0 &&
maximumPayloadSize <= rivetDeviceDefaultMaximumMessageSize)
self.stream = stream
self.maximumPayloadSize = maximumPayloadSize
}

public func send(_ frame: RivetFrame) async throws {
guard !closed else { throw RivetDeviceAppleRVT1ChannelError.closed }
guard frame.payload.count <= maximumPayloadSize else {
throw RivetDeviceAppleRVT1ChannelError.frameTooLarge(
actual: frame.payload.count, maximum: maximumPayloadSize
)
}
let data = try encodeRivetFrame(frame)
let previous = writeTail
let token = UUID()
let stream = self.stream
let task = Task<Void, any Error> {
if let previous { try await previous.value }
try await stream.sendApplicationBytes(data)
}
writeTail = task
writeToken = token
do {
try await task.value
if writeToken == token { writeTail = nil; writeToken = nil }
} catch {
if writeToken == token { writeTail = nil; writeToken = nil }
if !closed {
closed = true
await stream.close()
}
throw error
}
}

public func receive() async throws -> RivetFrame {
guard !closed else { throw RivetDeviceAppleRVT1ChannelError.closed }
guard !reading else { throw RivetDeviceAppleRVT1ChannelError.concurrentRead }
reading = true
defer { reading = false }
do {
try await fillBuffer(to: Self.headerSize)
let length = UInt32(readBuffer[14])
| (UInt32(readBuffer[15]) << 8)
| (UInt32(readBuffer[16]) << 16)
| (UInt32(readBuffer[17]) << 24)
guard length <= UInt32(maximumPayloadSize) else {
throw RivetDeviceAppleRVT1ChannelError.frameTooLarge(
actual: Int(length), maximum: maximumPayloadSize
)
}
let total = Self.headerSize + Int(length)
try await fillBuffer(to: total)
let frameData = Data(readBuffer.prefix(total))
readBuffer = Data(readBuffer.dropFirst(total))
return try decodeRivetFrame(frameData)
} catch {
// Framing and stream errors are terminal. Keeping the channel
// open would retry the same malformed bytes on the next receive.
if !closed {
closed = true
readBuffer.removeAll(keepingCapacity: false)
await stream.close()
}
throw error
}
}

public func close() async {
guard !closed else { return }
closed = true
await stream.close()
}

private func fillBuffer(to count: Int) async throws {
while readBuffer.count < count {
guard let chunk = try await stream.receiveApplicationBytes(
maximumLength: count - readBuffer.count
), !chunk.isEmpty else {
throw RivetDeviceAppleRVT1ChannelError.closed
}
readBuffer.append(chunk)
}
}
}
Loading
Loading