#![expect(unsafe_code)]
use std::{
ptr::NonNull,
sync::{Arc, Condvar, Mutex, Once, OnceLock, PoisonError, mpsc::sync_channel},
time::Duration,
};
use block2::{DynBlock, RcBlock};
use cranpose_services::{
FramedWearableLink, WearableError, WearablePeer, WearableTransport, set_platform_wearable_link,
};
use objc2::{AnyThread, define_class, msg_send, rc::Retained, runtime::ProtocolObject};
use objc2_foundation::{NSData, NSError, NSObject, NSObjectProtocol};
use objc2_watch_connectivity::{WCSession, WCSessionActivationState, WCSessionDelegate};
const PEER: &str = "paired";
const REPLY_WAIT: Duration = Duration::from_secs(10);
const STATE_WAIT: Duration = Duration::from_secs(3);
static LINK: OnceLock<Arc<FramedWearableLink<WatchConnectivity>>> = OnceLock::new();
static STATE_LOCK: Mutex<()> = Mutex::new(());
static STATE_CHANGED: Condvar = Condvar::new();
pub(crate) fn register() {
static REGISTERED: Once = Once::new();
REGISTERED.call_once(|| {
if !unsafe { WCSession::isSupported() } {
return;
}
let link =
LINK.get_or_init(|| Arc::new(FramedWearableLink::new(WatchConnectivity::default())));
set_platform_wearable_link(Some(Arc::clone(link) as _));
let delegate = SessionDelegate::new();
unsafe {
let session = WCSession::defaultSession();
session.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
session.activateSession();
}
std::mem::forget(delegate);
});
}
fn session_when(ready: fn(&WCSession) -> bool) -> Option<Retained<WCSession>> {
let session = unsafe { WCSession::defaultSession() };
let checked = STATE_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
let (_checked, waited) = STATE_CHANGED
.wait_timeout_while(checked, STATE_WAIT, |()| !ready(&session))
.unwrap_or_else(PoisonError::into_inner);
(!waited.timed_out()).then_some(session)
}
fn active(session: &WCSession) -> bool {
unsafe { session.activationState() == WCSessionActivationState::Activated }
}
fn in_reach(session: &WCSession) -> bool {
active(session) && unsafe { session.isReachable() }
}
fn announce_state() {
let _announcing = STATE_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
STATE_CHANGED.notify_all();
}
fn describe(error: NonNull<NSError>) -> String {
unsafe { error.as_ref() }.localizedDescription().to_string()
}
#[derive(Default)]
struct WatchConnectivity {
failure: Arc<Mutex<Option<String>>>,
}
impl WearableTransport for WatchConnectivity {
fn peers(&self) -> Result<Vec<WearablePeer>, WearableError> {
let Some(session) = session_when(active) else {
return Ok(Vec::new());
};
#[cfg(target_os = "ios")]
let (present, name, has_app) = unsafe {
(
session.isPaired(),
"Apple Watch",
session.isWatchAppInstalled(),
)
};
#[cfg(target_os = "watchos")]
let (present, name, has_app) =
(true, "iPhone", unsafe { session.isCompanionAppInstalled() });
Ok(present
.then(|| WearablePeer {
id: PEER.to_owned(),
name: name.to_owned(),
has_app,
})
.into_iter()
.collect())
}
fn send(&self, _peer: &str, frame: &[u8], confirm: bool) -> Result<(), WearableError> {
let session = session_when(in_reach).ok_or_else(|| {
WearableError::Failed("the app on the paired device is out of reach".into())
})?;
let data = NSData::with_bytes(frame);
if confirm {
let (answer, answered) = sync_channel::<Result<(), String>>(1);
let failed = answer.clone();
let reply = RcBlock::new(move |_: NonNull<NSData>| {
let _ = answer.try_send(Ok(()));
});
let error = RcBlock::new(move |error: NonNull<NSError>| {
let _ = failed.try_send(Err(describe(error)));
});
unsafe {
session.sendMessageData_replyHandler_errorHandler(
&data,
Some(&reply),
Some(&error),
);
}
return match answered.recv_timeout(REPLY_WAIT) {
Ok(result) => result.map_err(WearableError::Failed),
Err(_) => Err(WearableError::Failed(
"the app on the paired device did not answer".into(),
)),
};
}
if let Some(earlier) = self
.failure
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take()
{
return Err(WearableError::Failed(earlier));
}
let failure = Arc::clone(&self.failure);
let error = RcBlock::new(move |error: NonNull<NSError>| {
*failure.lock().unwrap_or_else(PoisonError::into_inner) = Some(describe(error));
});
unsafe {
session.sendMessageData_replyHandler_errorHandler(&data, None, Some(&error));
}
Ok(())
}
fn open_url(&self, _peer: &str, _url: &str) -> Result<(), WearableError> {
Err(WearableError::Failed(
"an iPhone and its watch cannot open links on each other".into(),
))
}
}
fn receive(data: &NSData) {
if let Some(link) = LINK.get() {
link.receive(PEER, unsafe { data.as_bytes_unchecked() });
}
}
fn lose_streams() {
if let Some(link) = LINK.get() {
link.disconnect(PEER);
}
}
define_class!(
#[unsafe(super(NSObject))]
#[name = "CranposeWatchConnectivityDelegate"]
struct SessionDelegate;
unsafe impl NSObjectProtocol for SessionDelegate {}
unsafe impl WCSessionDelegate for SessionDelegate {
#[unsafe(method(session:activationDidCompleteWithState:error:))]
fn activated(
&self,
_session: &WCSession,
_state: WCSessionActivationState,
error: Option<&NSError>,
) {
if let Some(error) = error {
log::warn!(
"wearable: the session did not start: {}",
error.localizedDescription()
);
}
announce_state();
}
#[unsafe(method(sessionReachabilityDidChange:))]
fn reach_changed(&self, _session: &WCSession) {
announce_state();
}
#[unsafe(method(sessionDidBecomeInactive:))]
fn became_inactive(&self, _session: &WCSession) {
lose_streams();
}
#[unsafe(method(sessionDidDeactivate:))]
fn deactivated(&self, session: &WCSession) {
unsafe { session.activateSession() };
}
#[cfg(target_os = "ios")]
#[unsafe(method(sessionWatchStateDidChange:))]
fn watch_changed(&self, session: &WCSession) {
if !unsafe { session.isPaired() && session.isWatchAppInstalled() } {
lose_streams();
}
announce_state();
}
#[unsafe(method(session:didReceiveMessageData:))]
fn received(&self, _session: &WCSession, data: &NSData) {
receive(data);
}
#[unsafe(method(session:didReceiveMessageData:replyHandler:))]
fn received_with_reply(
&self,
_session: &WCSession,
data: &NSData,
reply: &DynBlock<dyn Fn(NonNull<NSData>)>,
) {
receive(data);
reply.call((NonNull::from(&*NSData::new()),));
}
}
);
impl SessionDelegate {
fn new() -> Retained<Self> {
let this = Self::alloc().set_ivars(());
unsafe { msg_send![super(this), init] }
}
}