Skip to main content

arora_bridge/
lib.rs

1//! The Arora Bridge interface.
2//!
3//! A [`Bridge`] connects an Arora runtime to a remote — in practice Semio Studio
4//! over `studio-bridge`. It is modelled on studio-bridge's `device-client`
5//! trait: push local state changes out, receive device-info updates and
6//! commands in, and learn when a client is asking for data.
7//!
8//! The trait lives here (lean: `arora-types` + async primitives) so the runtime
9//! can depend on the *interface* without depending on `studio-bridge`.
10//! studio-bridge keeps its device-client implementations and provides a
11//! connector that implements this trait.
12
13use std::pin::Pin;
14
15use async_trait::async_trait;
16use futures::channel::oneshot;
17use futures::Stream;
18
19use arora_types::call::{Call, CallResult};
20use arora_types::data::{Key, StateChange};
21
22/// Neutral device metadata the bridge syncs with the remote. The bridge-flavored
23/// wire form (studio-bridge's `PartialDeviceInfo`) is converted to/from this by
24/// the connector.
25#[derive(Debug, Clone, Default, PartialEq, Eq)]
26pub struct DeviceInfo {
27    pub name: Option<String>,
28    pub description: Option<String>,
29    pub model_family: Option<String>,
30    pub hardware_version: Option<String>,
31    pub software_version: Option<String>,
32    pub owners: Vec<String>,
33}
34
35/// An operation a remote client asks the device to perform. Mirrors
36/// studio-bridge's `AroraOp`.
37#[derive(Debug, Clone)]
38pub enum BridgeOp {
39    /// Read the given keys.
40    Get(Vec<Key>),
41    /// Apply a state change.
42    Update(StateChange),
43    /// Call a function.
44    Call(Call),
45    /// Enumerate store keys under an optional path prefix — introspection for
46    /// the live-edit surface. Replies with a [`CallResult`] whose `ret` is an
47    /// `ArrayValue` of the matching key paths as `String`s.
48    ListKeys {
49        /// Only keys whose path starts with this prefix; `None` lists all.
50        prefix: Option<String>,
51    },
52    /// Enumerate callable module methods under an optional name prefix. Replies
53    /// with a [`CallResult`] whose `ret` is an `ArrayValue` of method names as
54    /// `String`s.
55    ListMethods {
56        /// Only methods whose name starts with this prefix; `None` lists all.
57        prefix: Option<String>,
58    },
59}
60
61/// Something went wrong on the bridge.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum BridgeError {
64    /// The link to the remote dropped.
65    Disconnected(String),
66    /// The device was unregistered from the remote — the runtime should stop.
67    Unregistered,
68    /// Anything else, with a message.
69    Other(String),
70}
71
72impl std::fmt::Display for BridgeError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            BridgeError::Disconnected(m) => write!(f, "bridge disconnected: {m}"),
76            BridgeError::Unregistered => write!(f, "device unregistered from the remote"),
77            BridgeError::Other(m) => write!(f, "{m}"),
78        }
79    }
80}
81
82impl std::error::Error for BridgeError {}
83
84pub type BridgeResult<T> = Result<T, BridgeError>;
85
86/// A command received from the remote, carrying a one-shot reply channel.
87///
88/// Process [`op`](BridgeCommand::op), then call [`reply`](BridgeCommand::reply)
89/// exactly once with the result (mirrors device-client's
90/// `(AroraOp, oneshot::Sender<Result<AroraCallResult, String>>)`).
91pub struct BridgeCommand {
92    pub op: BridgeOp,
93    reply: oneshot::Sender<Result<CallResult, String>>,
94}
95
96impl BridgeCommand {
97    /// Build a command from an op and its reply channel (for `Bridge` impls).
98    pub fn new(op: BridgeOp, reply: oneshot::Sender<Result<CallResult, String>>) -> Self {
99        Self { op, reply }
100    }
101
102    /// Send the result back to the remote. Ignores a dropped receiver.
103    pub fn reply(self, result: Result<CallResult, String>) {
104        let _ = self.reply.send(result);
105    }
106}
107
108/// Stream of device-info updates. `Ok(None)` means the device was unregistered.
109pub type DeviceInfoStream = Pin<Box<dyn Stream<Item = BridgeResult<Option<DeviceInfo>>> + Send>>;
110/// Stream of the "a client is asking for data" (claim) toggle.
111pub type DataRequestedStream = Pin<Box<dyn Stream<Item = bool> + Send>>;
112/// Stream of commands from the remote.
113pub type CommandStream = Pin<Box<dyn Stream<Item = BridgeCommand> + Send>>;
114
115/// The connection between an Arora runtime and a remote (e.g. Semio Studio).
116///
117/// Modelled on studio-bridge's `device-client`. Interior-mutable (`&self`) so
118/// the runtime can share it across the tasks of its run loop.
119#[async_trait]
120pub trait Bridge: Send + Sync {
121    /// The device's current info, if registered.
122    async fn get_device_info(&self) -> BridgeResult<Option<DeviceInfo>>;
123
124    /// A stream of device-info updates from the remote (`Ok(None)` = unregistered).
125    async fn device_info_updated(&self) -> BridgeResult<DeviceInfoStream>;
126
127    /// Push updated device info to the remote; returns the merged result.
128    async fn update_device_info(
129        &self,
130        info: Option<DeviceInfo>,
131    ) -> BridgeResult<Option<DeviceInfo>>;
132
133    /// A stream that toggles as a client claims/releases interest in the data.
134    async fn data_requested(&self) -> DataRequestedStream;
135
136    /// Push a state change out to the remote.
137    async fn send_data(&self, data: StateChange) -> BridgeResult<()>;
138
139    /// A stream of commands the remote issues to the device.
140    async fn commands(&self) -> CommandStream;
141}
142
143/// A no-op [`Bridge`] for tests and offline runs: never registers, never emits
144/// updates, commands, or claims, and accepts (drops) any data sent.
145#[derive(Clone, Default)]
146pub struct FakeBridge;
147
148impl FakeBridge {
149    pub fn new() -> Self {
150        Self
151    }
152}
153
154#[async_trait]
155impl Bridge for FakeBridge {
156    async fn get_device_info(&self) -> BridgeResult<Option<DeviceInfo>> {
157        Ok(None)
158    }
159
160    async fn device_info_updated(&self) -> BridgeResult<DeviceInfoStream> {
161        Ok(Box::pin(futures::stream::empty()))
162    }
163
164    async fn update_device_info(
165        &self,
166        info: Option<DeviceInfo>,
167    ) -> BridgeResult<Option<DeviceInfo>> {
168        Ok(info)
169    }
170
171    async fn data_requested(&self) -> DataRequestedStream {
172        Box::pin(futures::stream::empty())
173    }
174
175    async fn send_data(&self, _data: StateChange) -> BridgeResult<()> {
176        Ok(())
177    }
178
179    async fn commands(&self) -> CommandStream {
180        Box::pin(futures::stream::empty())
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use futures::StreamExt;
188
189    #[tokio::test]
190    async fn fake_bridge_is_usable_as_trait_object() {
191        let bridge: Box<dyn Bridge> = Box::new(FakeBridge::new());
192        assert_eq!(bridge.get_device_info().await.unwrap(), None);
193        bridge.send_data(StateChange::new()).await.unwrap();
194        assert!(bridge
195            .device_info_updated()
196            .await
197            .unwrap()
198            .next()
199            .await
200            .is_none());
201        assert!(bridge.commands().await.next().await.is_none());
202    }
203
204    #[tokio::test]
205    async fn command_reply_round_trips() {
206        let (tx, rx) = oneshot::channel();
207        let cmd = BridgeCommand::new(BridgeOp::Get(vec![Key::from("a")]), tx);
208        match &cmd.op {
209            BridgeOp::Get(keys) => assert_eq!(keys[0], Key::from("a")),
210            _ => panic!("wrong op"),
211        }
212        cmd.reply(Err("not implemented".to_string()));
213        assert!(rx.await.unwrap().is_err());
214    }
215}