use crossbeam_channel::{Receiver, bounded, select};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub const CLIPBOARD_TIMEOUT: Duration = Duration::from_millis(1500);
pub const OPEN_URL_TIMEOUT: Duration = Duration::from_millis(3000);
pub const NOTIFY_TIMEOUT: Duration = Duration::from_millis(5000);
const CANCEL_POLL_SLICE: Duration = Duration::from_millis(50);
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
pub enum ToolBridgeError {
#[error("iOS tool bridge unavailable")]
BridgeUnavailable,
#[error("request was canceled")]
Canceled,
#[error("request timed out")]
Timeout,
#[error("platform error: {0}")]
Platform(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IosToolRequest {
pub request_id: u64,
pub name: String,
pub args_json: String,
}
pub type ToolBridgeReply = Result<serde_json::Value, ToolBridgeError>;
pub struct IosToolPending {
pub request_id: u64,
pub reply_rx: Receiver<ToolBridgeReply>,
cancel: Option<Box<dyn FnOnce() + Send>>,
}
impl IosToolPending {
pub fn new(
request_id: u64,
reply_rx: Receiver<ToolBridgeReply>,
cancel: Box<dyn FnOnce() + Send>,
) -> Self {
Self {
request_id,
reply_rx,
cancel: Some(cancel),
}
}
pub fn wait(&self, deadline: Duration, is_canceled: &dyn Fn() -> bool) -> ToolBridgeReply {
let deadline_at = Instant::now().checked_add(deadline);
loop {
if is_canceled() {
return Err(ToolBridgeError::Canceled);
}
let now = Instant::now();
if deadline_at.is_some_and(|d| now >= d) {
return Err(ToolBridgeError::Timeout);
}
let slice = deadline_at
.map(|d| CANCEL_POLL_SLICE.min(d.saturating_duration_since(now)))
.unwrap_or(CANCEL_POLL_SLICE);
select! {
recv(self.reply_rx) -> res => {
match res {
Ok(reply) => {
if is_canceled() {
return Err(ToolBridgeError::Canceled);
}
return reply;
}
Err(_) => return Err(ToolBridgeError::BridgeUnavailable),
}
}
recv(crossbeam_channel::after(slice)) -> _ => {
continue;
}
}
}
}
pub fn cancel(self) {
if let Some(cancel) = self.cancel {
cancel();
}
}
}
pub trait IosToolBridge: Send + Sync {
fn dispatch(&self, request: IosToolRequest) -> Result<IosToolPending, ToolBridgeError>;
}
#[derive(Default)]
pub struct MockBridge {
script: Mutex<VecDeque<MockResponse>>,
dispatched: Mutex<Vec<IosToolRequest>>,
cancels: Arc<Mutex<Vec<u64>>>,
next_id: AtomicU64,
}
#[derive(Debug)]
pub enum MockResponse {
Reply(ToolBridgeReply),
DropSender,
}
impl MockBridge {
pub fn script(&self, response: MockResponse) {
self.script
.lock()
.unwrap_or_else(|e| e.into_inner())
.push_back(response);
}
pub fn dispatched(&self) -> Vec<IosToolRequest> {
self.dispatched
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub fn cancels(&self) -> Vec<u64> {
self.cancels
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
}
impl IosToolBridge for MockBridge {
fn dispatch(&self, request: IosToolRequest) -> Result<IosToolPending, ToolBridgeError> {
let request_id = self.next_id.fetch_add(1, Ordering::Relaxed);
let request = IosToolRequest {
request_id,
..request
};
self.dispatched
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(request.clone());
let (tx, rx) = bounded::<ToolBridgeReply>(1);
let response = self
.script
.lock()
.unwrap_or_else(|e| e.into_inner())
.pop_front()
.unwrap_or(MockResponse::DropSender);
match response {
MockResponse::Reply(reply) => {
let _ = tx.send(reply);
}
MockResponse::DropSender => { }
}
Ok(IosToolPending::new(
request_id,
rx,
Box::new({
let cancels = Arc::clone(&self.cancels);
move || {
cancels
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(request_id);
}
}),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mock() -> MockBridge {
MockBridge::default()
}
fn request() -> IosToolRequest {
IosToolRequest {
request_id: 0, name: "clipboard_write".to_string(),
args_json: r#"{"text":"hello"}"#.to_string(),
}
}
#[test]
fn envelope_json_round_trips() {
let req = request();
let json = serde_json::to_string(&req).unwrap();
let back: IosToolRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "clipboard_write");
assert_eq!(back.args_json, r#"{"text":"hello"}"#);
}
#[test]
fn error_type_serializes_round_trip() {
for err in [
ToolBridgeError::BridgeUnavailable,
ToolBridgeError::Canceled,
ToolBridgeError::Timeout,
ToolBridgeError::Platform("springboard refused".into()),
] {
let json = serde_json::to_string(&err).unwrap();
let back: ToolBridgeError = serde_json::from_str(&json).unwrap();
assert_eq!(back.to_string(), err.to_string());
}
}
#[test]
fn dispatch_success_and_request_recording() {
let m = mock();
m.script(MockResponse::Reply(Ok(serde_json::json!({"ok": true}))));
let pending = m.dispatch(request()).unwrap();
assert_eq!(pending.request_id, 0);
let value = pending.wait(CLIPBOARD_TIMEOUT, &|| false).unwrap();
assert_eq!(value, serde_json::json!({"ok": true}));
let dispatched = m.dispatched();
assert_eq!(dispatched.len(), 1);
assert_eq!(dispatched[0].name, "clipboard_write");
assert_eq!(dispatched[0].request_id, 0);
let p2 = m.dispatch(request()).unwrap();
assert_eq!(p2.request_id, 1);
}
#[test]
fn platform_error_propagates() {
let m = mock();
m.script(MockResponse::Reply(Err(ToolBridgeError::Platform(
"https or mailto only".into(),
))));
let pending = m.dispatch(request()).unwrap();
match pending.wait(OPEN_URL_TIMEOUT, &|| false) {
Err(ToolBridgeError::Platform(msg)) => assert_eq!(msg, "https or mailto only"),
other => panic!("expected Platform error, got {other:?}"),
}
}
#[test]
fn zero_deadline_times_out_without_sleeping() {
let m = mock();
m.script(MockResponse::Reply(Ok(serde_json::json!({"ok": true}))));
let pending = m.dispatch(request()).unwrap();
assert!(matches!(
pending.wait(Duration::ZERO, &|| false),
Err(ToolBridgeError::Timeout)
));
}
#[test]
fn cancel_precedes_waiting_reply() {
let m = mock();
m.script(MockResponse::Reply(Ok(serde_json::json!({"ok": true}))));
let pending = m.dispatch(request()).unwrap();
let canceled = true;
assert!(matches!(
pending.wait(Duration::ZERO, &|| canceled),
Err(ToolBridgeError::Canceled)
));
}
#[test]
fn cancel_wins_over_arrived_reply() {
let m = mock();
m.script(MockResponse::Reply(Ok(serde_json::json!({"ok": true}))));
let pending = m.dispatch(request()).unwrap();
let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (flag_wait, calls_wait) = (Arc::clone(&flag), Arc::clone(&calls));
let is_canceled = move || {
let n = calls_wait.fetch_add(1, Ordering::Relaxed);
if n > 0 {
flag_wait.store(true, Ordering::Relaxed);
}
flag_wait.load(Ordering::Relaxed)
};
match pending.wait(CLIPBOARD_TIMEOUT, &is_canceled) {
Err(ToolBridgeError::Canceled) => {}
other => panic!("expected Canceled over the arrived reply, got {other:?}"),
}
}
#[test]
fn cancel_hook_records_request_id() {
let m = mock();
let pending = m.dispatch(request()).unwrap();
let id = pending.request_id;
pending.cancel();
assert_eq!(m.cancels(), vec![id]);
}
#[test]
fn dropped_sender_is_bridge_unavailable() {
let m = mock();
m.script(MockResponse::DropSender);
let pending = m.dispatch(request()).unwrap();
assert!(matches!(
pending.wait(NOTIFY_TIMEOUT, &|| false),
Err(ToolBridgeError::BridgeUnavailable)
));
let pending = m.dispatch(request()).unwrap();
assert!(matches!(
pending.wait(NOTIFY_TIMEOUT, &|| false),
Err(ToolBridgeError::BridgeUnavailable)
));
}
}