Skip to main content

omni_cli/transport/
relay.rs

1//! iOS/watch outbound relay transport (bd-a5452f).
2//!
3//! watchOS cannot host a TCP server (Apple TN3135), so omni endpoints (iOS
4//! extension while open, watch app, Wear service) dial OUTBOUND to a relay and
5//! omni-cli posts commands to it. Harry chose relay-in-omni-cli, so the relay is
6//! the `omni serve` subcommand. Protocol (apple-utils omni/docs/injection.md,
7//! "Relay protocol (omni listen)"): TCP, NDJSON (one JSON object per line).
8//!
9//! Roles: DEVICE (registers + receives), SENDER (`omni send --relay` / cast),
10//! RELAY (`omni serve`). The DEVICE only ever receives **bare**
11//! [`InjectionCommand`] lines, so the device drain/apply stays trivial.
12//!
13//! Frames:
14//! - device -> relay: `{"v":1,"op":"register","device":"<id>","platform":"...","token":"<secret>"}`
15//! - sender -> relay: `{"v":1,"op":"cast","device":"<id|all>","cmd":{InjectionCommand},"token":"<secret>"}`
16//! - relay -> device: a bare InjectionCommand line
17//! - local app -> daemon: `{"v":1,"op":"subscribe","token":"..."}`
18//! - daemon -> local app: mirrored bare InjectionCommand lines
19//! - keepalive: `{"v":1,"op":"ping"}` / `{"v":1,"op":"pong"}`
20//!
21//! A shared `token` (from SOPS/env, never hard-coded) gates register + cast.
22//! Device-ids are operator-assigned (e.g. `harry-watch`).
23
24use std::collections::HashMap;
25use std::io::{BufRead, BufReader, Write};
26use std::net::{Shutdown, TcpListener, TcpStream, ToSocketAddrs};
27use std::sync::mpsc::{self, Receiver, Sender};
28use std::sync::{Arc, Mutex};
29use std::thread;
30use std::time::Duration;
31
32use socket2::{SockRef, TcpKeepalive};
33
34use crate::AppError;
35use crate::events::SemanticEvent;
36use crate::wire::{InjectionCommand, WIRE_VERSION};
37
38pub use omni_sdk::transport::relay::{ControlFrame, DEVICE_ALL, TOKEN_ENV};
39
40/// Idle time on a registered device socket before the OS starts sending TCP
41/// keepalive probes (bd-86ab7a).
42const KEEPALIVE_IDLE: Duration = Duration::from_secs(20);
43
44/// Interval between TCP keepalive probes once they start (bd-86ab7a).
45const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
46
47/// The NDJSON line the relay sends to reject an unauthorized register/cast.
48/// Typed (serializes a [`ControlFrame::Error`], op-first like the other frames)
49/// so device clients match it by type rather than substring-scanning.
50fn unauthorized_line() -> String {
51    ControlFrame::Error {
52        v: WIRE_VERSION,
53        reason: "unauthorized".to_owned(),
54    }
55    .json_line()
56    .unwrap_or_else(|_| r#"{"op":"error","v":1,"reason":"unauthorized"}"#.to_owned())
57}
58
59/// The NDJSON line the relay sends back to a SENDER after routing a cast
60/// (bd-0b4e45): how many registered devices received it. `delivered == 0` tells
61/// `omni send --relay` the target device is not connected.
62fn result_line(device: &str, delivered: usize) -> String {
63    ControlFrame::Result {
64        v: WIRE_VERSION,
65        device: device.to_owned(),
66        delivered,
67    }
68    .json_line()
69    .unwrap_or_else(|_| format!(r#"{{"op":"result","v":1,"device":"","delivered":{delivered}}}"#))
70}
71
72/// A device-bound output frame serialized by the single per-device writer
73/// thread. Routing pongs through the same channel as casts guarantees exactly
74/// ONE thread ever writes to a device socket, so two NDJSON lines can never be
75/// spliced together by concurrent writes (bd-c7bcfc).
76#[derive(Debug, Clone, PartialEq, Eq)]
77enum DeviceOut {
78    /// A bare [`InjectionCommand`] line for the device to apply.
79    Cmd(InjectionCommand),
80    Event(SemanticEvent),
81    /// A keepalive pong reply to a device-initiated ping.
82    Pong,
83}
84
85/// The relay's device registry: device-id -> a sender feeding that device's
86/// single writer thread (commands + pong replies).
87#[derive(Clone, Default)]
88pub struct Registry {
89    inner: Arc<Mutex<HashMap<String, RegisteredDevice>>>,
90}
91
92struct RegisteredDevice {
93    sender: Sender<DeviceOut>,
94    events: bool,
95}
96
97impl Registry {
98    #[must_use]
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    fn register(&self, device: &str, tx: Sender<DeviceOut>) {
104        self.register_capable(device, tx, false);
105    }
106
107    fn register_capable(&self, device: &str, tx: Sender<DeviceOut>, events: bool) {
108        self.inner
109            .lock()
110            .unwrap()
111            .insert(device.to_owned(), RegisteredDevice { sender: tx, events });
112    }
113
114    /// Only explicitly capable registrations receive non-injecting events.
115    pub fn route_event(&self, device: &str, event: &SemanticEvent) -> usize {
116        if event.validate().is_err() {
117            return 0;
118        }
119        self.inner
120            .lock()
121            .unwrap()
122            .iter()
123            .filter(|(id, peer)| peer.events && (device == DEVICE_ALL || id.as_str() == device))
124            .filter(|(_, peer)| peer.sender.send(DeviceOut::Event(event.clone())).is_ok())
125            .count()
126    }
127
128    fn unregister(&self, device: &str) {
129        self.inner.lock().unwrap().remove(device);
130    }
131
132    /// Whether `device` currently has a live registration.
133    #[must_use]
134    pub fn is_registered(&self, device: &str) -> bool {
135        self.inner.lock().unwrap().contains_key(device)
136    }
137
138    /// The device ids currently registered, sorted (bd-bc8b5c). Used to answer a
139    /// sender's `list` query so operators can see which cross-device endpoints
140    /// are connected to the hub.
141    #[must_use]
142    pub fn device_ids(&self) -> Vec<String> {
143        let mut ids: Vec<String> = self.inner.lock().unwrap().keys().cloned().collect();
144        ids.sort();
145        ids
146    }
147
148    /// Number of currently-registered devices.
149    #[must_use]
150    pub fn device_count(&self) -> usize {
151        self.inner.lock().unwrap().len()
152    }
153
154    /// Route a command to one device id, or to all when `device == "all"`.
155    /// Returns the number of devices the command was delivered to (queued).
156    pub fn route(&self, device: &str, cmd: &InjectionCommand) -> usize {
157        let map = self.inner.lock().unwrap();
158        if device == DEVICE_ALL {
159            let mut n = 0;
160            for tx in map.values() {
161                if tx.sender.send(DeviceOut::Cmd(cmd.clone())).is_ok() {
162                    n += 1;
163                }
164            }
165            n
166        } else if let Some(tx) = map.get(device) {
167            usize::from(tx.sender.send(DeviceOut::Cmd(cmd.clone())).is_ok())
168        } else {
169            0
170        }
171    }
172}
173
174/// Options for [`serve`].
175#[derive(Debug, Clone)]
176pub struct ServeOptions {
177    /// Shared auth token required on register + cast frames.
178    pub token: String,
179    /// Stop accepting after this many connections (for tests); `None` = forever.
180    pub max_conns: Option<usize>,
181}
182
183/// Bind a relay listener on `addr`.
184///
185/// # Errors
186/// Returns [`AppError::internal`] if binding fails.
187pub fn bind(addr: &str) -> Result<TcpListener, AppError> {
188    TcpListener::bind(addr).map_err(|e| AppError::internal("relay_bind", format!("{addr}: {e}")))
189}
190
191/// Run the relay accept loop on `listener`. One thread per connection; the
192/// shared [`Registry`] routes sender casts to registered devices.
193///
194/// # Errors
195/// Returns [`AppError::internal`] on a fatal accept error.
196pub fn serve(listener: &TcpListener, opts: &ServeOptions) -> Result<(), AppError> {
197    serve_with_registry(listener, opts, &Registry::new())
198}
199
200/// Like [`serve`] but routes through a caller-provided [`Registry`], so the
201/// device-registration set is observable (used by tests to assert that a dead
202/// device is reaped; bd-86ab7a).
203///
204/// # Errors
205/// Returns [`AppError::internal`] on a fatal accept error.
206pub fn serve_with_registry(
207    listener: &TcpListener,
208    opts: &ServeOptions,
209    registry: &Registry,
210) -> Result<(), AppError> {
211    let mut accepted = 0usize;
212    for incoming in listener.incoming() {
213        let stream = incoming.map_err(|e| AppError::internal("relay_accept", e.to_string()))?;
214        let registry = registry.clone();
215        let token = opts.token.clone();
216        thread::spawn(move || {
217            if let Err(error) = handle_conn(stream, &registry, &token) {
218                eprintln!("omni serve: connection error: {error}");
219            }
220        });
221        accepted += 1;
222        if let Some(max) = opts.max_conns {
223            if accepted >= max {
224                break;
225            }
226        }
227    }
228    Ok(())
229}
230
231fn write_line<W: Write>(stream: &mut W, line: &str) -> std::io::Result<()> {
232    stream.write_all(line.as_bytes())?;
233    stream.write_all(b"\n")?;
234    stream.flush()
235}
236
237/// Best-effort: enable TCP keepalive on a registered device socket so the OS
238/// surfaces a half-open connection (a network drop with no FIN — common for a
239/// watch/phone over a flaky tailnet) as a socket error. That lets the relay's
240/// reader observe the death and reap the dead registration proactively, rather
241/// than waiting for the next cast to be queued into the void (bd-86ab7a).
242/// Failures are ignored: keepalive is hardening, never required for delivery.
243fn enable_device_keepalive(stream: &TcpStream) {
244    let keepalive = TcpKeepalive::new()
245        .with_time(KEEPALIVE_IDLE)
246        .with_interval(KEEPALIVE_INTERVAL);
247    let _ = SockRef::from(stream).set_tcp_keepalive(&keepalive);
248}
249
250/// Drain queued [`DeviceOut`] frames to a device `writer` — the SOLE writer to
251/// the device socket, so command and pong lines can never interleave on the
252/// wire (bd-c7bcfc). On the FIRST write failure the device socket is dead, so
253/// reap the registration immediately — otherwise later casts are silently
254/// `tx.send`-queued into a dead connection and lost (bd-86ab7a) — and run
255/// `on_dead` (used to shut the socket down so the paired reader loop unblocks).
256/// Returns when the channel closes (normal disconnect) or after evicting a dead
257/// device.
258fn run_device_writer<W: Write>(
259    rx: &Receiver<DeviceOut>,
260    writer: &mut W,
261    registry: &Registry,
262    device: &str,
263    on_dead: impl FnOnce(),
264) {
265    while let Ok(out) = rx.recv() {
266        let line = match out {
267            DeviceOut::Cmd(cmd) => match cmd.json_line() {
268                Ok(line) => line,
269                Err(_) => continue,
270            },
271            DeviceOut::Event(event) => match event.json_line() {
272                Ok(line) => line,
273                Err(_) => continue,
274            },
275            DeviceOut::Pong => r#"{"v":1,"op":"pong"}"#.to_owned(),
276        };
277        if write_line(writer, &line).is_err() {
278            registry.unregister(device);
279            on_dead();
280            return;
281        }
282    }
283}
284
285fn handle_conn(stream: TcpStream, registry: &Registry, token: &str) -> Result<(), AppError> {
286    let mut reader = BufReader::new(
287        stream
288            .try_clone()
289            .map_err(|e| AppError::internal("relay_clone", e.to_string()))?,
290    );
291    let mut first = String::new();
292    let n = reader
293        .read_line(&mut first)
294        .map_err(|e| AppError::internal("relay_read", e.to_string()))?;
295    if n == 0 {
296        return Ok(()); // connected and closed without a frame.
297    }
298    let frame: ControlFrame = serde_json::from_str(first.trim())
299        .map_err(|e| AppError::validation("relay_frame", e.to_string()))?;
300
301    match frame {
302        ControlFrame::Register {
303            device, token: t, ..
304        } => {
305            if t.trim() != token.trim() {
306                let mut s = stream;
307                let _ = write_line(&mut s, &unauthorized_line());
308                return Ok(());
309            }
310            // An additive registration capability keeps events off legacy devices.
311            let events = serde_json::from_str::<serde_json::Value>(first.trim())
312                .ok()
313                .is_some_and(|v| v["events"] == true);
314            serve_device(stream, reader, registry, &device, events)
315        }
316        ControlFrame::Cast {
317            device,
318            cmd,
319            token: t,
320            ..
321        } => {
322            if t.trim() != token.trim() {
323                let mut s = stream;
324                let _ = write_line(&mut s, &unauthorized_line());
325                return Ok(());
326            }
327            let delivered = registry.route(&device, &cmd);
328            // Ack the FIRST cast back to the sender (bd-0b4e45) so `omni send
329            // --relay` learns whether the target device is connected. Only the
330            // first cast is acked: a streaming sender that never reads results
331            // accumulates at most one unread line, so its receive buffer cannot
332            // fill and stall the relay.
333            if let Ok(mut ack) = stream.try_clone() {
334                let _ = write_line(&mut ack, &result_line(&device, delivered));
335            }
336            // A sender may stream more cast/ping frames until it closes.
337            serve_sender(stream, reader, registry, token)
338        }
339        frame @ ControlFrame::EventCast { .. } => {
340            serve_event_sender(stream, reader, registry, token, frame)
341        }
342        // Subscriptions are local `omni daemon` operations, not relay-hub roles.
343        ControlFrame::Subscribe { .. } | ControlFrame::SubscribeEvents { .. } => Ok(()),
344        ControlFrame::Ping { .. } => {
345            let mut s = stream;
346            let _ = write_line(&mut s, r#"{"v":1,"op":"pong"}"#);
347            Ok(())
348        }
349        ControlFrame::Pong { .. } => Ok(()),
350        ControlFrame::List { token: t, .. } => {
351            let mut s = stream;
352            if t.trim() != token.trim() {
353                let _ = write_line(&mut s, &unauthorized_line());
354                return Ok(());
355            }
356            let frame = ControlFrame::Devices {
357                v: WIRE_VERSION,
358                devices: registry.device_ids(),
359            };
360            let _ = write_line(&mut s, &frame.json_line().unwrap_or_default());
361            Ok(())
362        }
363        // Error/Result/Devices are relay->client only; a client never
364        // legitimately sends them (bd-0b4e45 Result, bd-bc8b5c Devices).
365        ControlFrame::Error { .. }
366        | ControlFrame::Result { .. }
367        | ControlFrame::EventResult { .. }
368        | ControlFrame::EventsSubscribed { .. }
369        | ControlFrame::Devices { .. } => Ok(()),
370    }
371}
372
373/// A registered DEVICE connection: forward queued [`InjectionCommand`]s to the
374/// socket (writer thread), and read keepalives until the device disconnects.
375///
376/// Liveness hardening (bd-86ab7a): TCP keepalive is enabled so the OS surfaces
377/// a half-open socket as a read error (reaped by the reader loop below), and
378/// the writer thread reaps the registration + shuts the socket down the moment
379/// a write fails, so a dead device is not left registered with later casts
380/// silently queued into the void.
381fn serve_device(
382    stream: TcpStream,
383    mut reader: BufReader<TcpStream>,
384    registry: &Registry,
385    device: &str,
386    events: bool,
387) -> Result<(), AppError> {
388    enable_device_keepalive(&stream);
389
390    let (tx, rx) = mpsc::channel::<DeviceOut>();
391    if events {
392        registry.register_capable(device, tx.clone(), true);
393    } else {
394        registry.register(device, tx.clone());
395    }
396    // The reader enqueues pongs to this same writer thread instead of writing
397    // to the socket itself, so the device socket has exactly ONE writer and
398    // NDJSON lines can't interleave (bd-c7bcfc).
399    let pong_tx = tx;
400
401    let mut writer_stream = stream
402        .try_clone()
403        .map_err(|e| AppError::internal("relay_clone", e.to_string()))?;
404    let shutdown_stream = stream
405        .try_clone()
406        .map_err(|e| AppError::internal("relay_clone", e.to_string()))?;
407    let writer_registry = registry.clone();
408    let writer_device = device.to_owned();
409    let writer = thread::spawn(move || {
410        run_device_writer(
411            &rx,
412            &mut writer_stream,
413            &writer_registry,
414            &writer_device,
415            || {
416                // Unblock the (possibly half-open-blocked) reader loop so the
417                // connection tears down promptly instead of leaking a thread.
418                let _ = shutdown_stream.shutdown(Shutdown::Both);
419            },
420        );
421    });
422
423    let mut line = String::new();
424    loop {
425        line.clear();
426        match reader.read_line(&mut line) {
427            Ok(0) | Err(_) => break, // device disconnected (or keepalive surfaced a dead socket)
428            Ok(_) => {
429                if let Ok(ControlFrame::Ping { .. }) =
430                    serde_json::from_str::<ControlFrame>(line.trim())
431                {
432                    // Enqueue the pong to the single writer thread (never write
433                    // to the socket here, bd-c7bcfc). A send error just means
434                    // the device/writer is already gone.
435                    let _ = pong_tx.send(DeviceOut::Pong);
436                }
437            }
438        }
439    }
440
441    drop(pong_tx); // release our writer-channel sender so the writer can finish
442    registry.unregister(device); // drops the registry's sender -> writer loop ends
443    let _ = writer.join();
444    Ok(())
445}
446
447/// Event senders require a result for every event; legacy cast acknowledgement
448/// behaviour stays unchanged. Nothing on this path becomes an InjectionCommand.
449fn serve_event_sender(
450    mut writer: TcpStream,
451    mut reader: BufReader<TcpStream>,
452    registry: &Registry,
453    token: &str,
454    first: ControlFrame,
455) -> Result<(), AppError> {
456    let mut frame = first;
457    loop {
458        let (reply, close) = match frame {
459            ControlFrame::EventCast {
460                v,
461                device,
462                event,
463                token: got,
464            } => {
465                if got.trim() != token.trim() {
466                    (unauthorized_line(), true)
467                } else if v != WIRE_VERSION || event.validate().is_err() {
468                    (
469                        ControlFrame::Error {
470                            v: WIRE_VERSION,
471                            reason: "invalid_event".into(),
472                        }
473                        .json_line()?,
474                        true,
475                    )
476                } else {
477                    let delivered = registry.route_event(&device, &event);
478                    (
479                        ControlFrame::EventResult {
480                            v: WIRE_VERSION,
481                            device,
482                            delivered,
483                        }
484                        .json_line()?,
485                        false,
486                    )
487                }
488            }
489            ControlFrame::Ping { .. } => {
490                (ControlFrame::Pong { v: WIRE_VERSION }.json_line()?, false)
491            }
492            _ => (
493                ControlFrame::Error {
494                    v: WIRE_VERSION,
495                    reason: "expected_event_frame".into(),
496                }
497                .json_line()?,
498                true,
499            ),
500        };
501        write_line(&mut writer, &reply)
502            .map_err(|e| AppError::internal("event_relay_write", e.to_string()))?;
503        if close {
504            return Ok(());
505        }
506        let mut line = String::new();
507        let n = std::io::Read::take(&mut reader, (crate::events::MAX_EVENT_BYTES + 4097) as u64)
508            .read_line(&mut line)
509            .map_err(|e| AppError::internal("event_relay_read", e.to_string()))?;
510        if n == 0 {
511            return Ok(());
512        }
513        if n > crate::events::MAX_EVENT_BYTES + 4096 {
514            return Err(AppError::validation("event_size", "event frame too large"));
515        }
516        frame = serde_json::from_str(line.trim())
517            .map_err(|e| AppError::validation("event_frame", e.to_string()))?;
518    }
519}
520
521/// A SENDER connection that has already delivered its first cast: keep reading
522/// additional cast/ping frames until it closes.
523fn serve_sender(
524    stream: TcpStream,
525    mut reader: BufReader<TcpStream>,
526    registry: &Registry,
527    token: &str,
528) -> Result<(), AppError> {
529    let mut pong_stream = stream;
530    let mut line = String::new();
531    loop {
532        line.clear();
533        match reader.read_line(&mut line) {
534            Ok(0) | Err(_) => break,
535            Ok(_) => match serde_json::from_str::<ControlFrame>(line.trim()) {
536                Ok(ControlFrame::Cast {
537                    device,
538                    cmd,
539                    token: t,
540                    ..
541                }) if t.trim() == token.trim() => {
542                    registry.route(&device, &cmd);
543                }
544                Ok(ControlFrame::Ping { .. }) => {
545                    let _ = write_line(&mut pong_stream, r#"{"v":1,"op":"pong"}"#);
546                }
547                _ => {}
548            },
549        }
550    }
551    Ok(())
552}
553
554/// How long a sender waits for the relay's delivery result before giving up (a
555/// legacy relay sends none). Short so `omni send --relay` never hangs (bd-0b4e45).
556const RESULT_READ_TIMEOUT: Duration = Duration::from_millis(800);
557
558/// Outcome of a relay cast (bd-0b4e45): whether the relay acknowledged it and
559/// how many registered devices it was routed to.
560#[derive(Debug, Clone, Copy, PartialEq, Eq)]
561pub struct RelayDelivery {
562    /// True when the relay returned a delivery result. A legacy relay (or a read
563    /// timeout) leaves this false, so a caller must not treat `delivered` as
564    /// authoritative unless `acked` is true.
565    pub acked: bool,
566    /// Number of registered devices the cast was routed to (0 = the target
567    /// device id is not connected to the relay).
568    pub delivered: usize,
569}
570
571/// Read the relay's delivery result for the first cast on `stream` (bd-0b4e45).
572/// Bounded + best-effort: a legacy relay that sends nothing, a timeout, EOF, or
573/// a malformed line all yield `acked == false`, so the sender never blocks.
574fn read_delivery(stream: &mut TcpStream) -> RelayDelivery {
575    let _ = stream.set_read_timeout(Some(RESULT_READ_TIMEOUT));
576    let mut reader = BufReader::new(stream);
577    let mut line = String::new();
578    match reader.read_line(&mut line) {
579        Ok(n) if n > 0 => match serde_json::from_str::<ControlFrame>(line.trim()) {
580            Ok(ControlFrame::Result { delivered, .. }) => RelayDelivery {
581                acked: true,
582                delivered,
583            },
584            _ => RelayDelivery {
585                acked: false,
586                delivered: 0,
587            },
588        },
589        _ => RelayDelivery {
590            acked: false,
591            delivered: 0,
592        },
593    }
594}
595
596/// SENDER client: connect to a relay and post one or more commands for `device`,
597/// then read the relay's delivery result for the first cast (bd-0b4e45) so the
598/// caller can tell whether the target device was actually connected. Casting to
599/// a plain `omni daemon` (which sends no result) yields `acked == false`.
600///
601/// # Errors
602/// Returns [`AppError::internal`] on connect / I/O failure.
603pub fn cast_via_relay<A: ToSocketAddrs>(
604    addr: A,
605    token: &str,
606    device: &str,
607    cmds: &[InjectionCommand],
608) -> Result<RelayDelivery, AppError> {
609    let mut stream = super::connect_bounded(addr, super::CONNECT_TIMEOUT)
610        .map_err(|e| AppError::internal("relay_connect", e.to_string()))?;
611    for cmd in cmds {
612        let frame = ControlFrame::Cast {
613            v: WIRE_VERSION,
614            device: device.to_owned(),
615            cmd: cmd.clone(),
616            token: token.to_owned(),
617        };
618        let line = frame.json_line()?;
619        write_line(&mut stream, &line)
620            .map_err(|e| AppError::internal("relay_send", e.to_string()))?;
621    }
622    Ok(read_delivery(&mut stream))
623}
624
625/// Query a relay for the device ids currently registered (bd-bc8b5c), so
626/// `omni devices --relay` can show operators which cross-device endpoints are
627/// connected. Bounded: a legacy relay that does not understand `list` sends no
628/// `devices` reply, so this returns an empty list (never hangs) within the read
629/// timeout. An auth rejection surfaces as a validation error.
630///
631/// # Errors
632/// [`AppError::internal`] on connect / I/O failure; [`AppError::validation`]
633/// when the relay rejects the token.
634pub fn list_relay_devices<A: ToSocketAddrs>(addr: A, token: &str) -> Result<Vec<String>, AppError> {
635    let mut stream = super::connect_bounded(addr, super::CONNECT_TIMEOUT)
636        .map_err(|e| AppError::internal("relay_connect", e.to_string()))?;
637    let frame = ControlFrame::List {
638        v: WIRE_VERSION,
639        token: token.to_owned(),
640    };
641    write_line(&mut stream, &frame.json_line()?)
642        .map_err(|e| AppError::internal("relay_send", e.to_string()))?;
643    let _ = stream.set_read_timeout(Some(RESULT_READ_TIMEOUT));
644    let mut reader = BufReader::new(stream);
645    let mut line = String::new();
646    match reader.read_line(&mut line) {
647        Ok(n) if n > 0 => match serde_json::from_str::<ControlFrame>(line.trim()) {
648            Ok(ControlFrame::Devices { devices, .. }) => Ok(devices),
649            Ok(ControlFrame::Error { reason, .. }) => {
650                Err(AppError::validation("relay_list", reason))
651            }
652            _ => Ok(Vec::new()),
653        },
654        _ => Ok(Vec::new()),
655    }
656}
657
658/// A hold-open relay SENDER for STREAMING dispatch (bd-772cb2): connects once
659/// and writes a `Cast` frame per command instead of reconnecting per command
660/// (the sender side needs no register handshake — the relay routes casts to the
661/// registered devices). Lazy-connects on the first cast; reconnects on the next
662/// cast after a write error so a transient drop does not abort the stream.
663pub struct RelaySender {
664    addr: String,
665    device: String,
666    token: String,
667    stream: Option<TcpStream>,
668}
669
670impl RelaySender {
671    /// Build a sender targeting `addr` (host:port) for `device` with `token`
672    /// (no connection is made until the first cast).
673    #[must_use]
674    pub fn new(
675        addr: impl Into<String>,
676        device: impl Into<String>,
677        token: impl Into<String>,
678    ) -> Self {
679        Self {
680            addr: addr.into(),
681            device: device.into(),
682            token: token.into(),
683            stream: None,
684        }
685    }
686
687    /// Write one `Cast` frame over the held-open connection, connecting first if
688    /// needed. On any I/O error the connection is dropped so the next call
689    /// reconnects.
690    ///
691    /// # Errors
692    /// [`AppError::internal`] on connect / write failure (the stream is reset
693    /// for a fresh reconnect next time).
694    pub fn cast_one(&mut self, cmd: &InjectionCommand) -> Result<(), AppError> {
695        if self.stream.is_none() {
696            let s = super::connect_bounded(&self.addr, super::CONNECT_TIMEOUT)
697                .map_err(|e| AppError::internal("relay_connect", format!("{}: {e}", self.addr)))?;
698            self.stream = Some(s);
699        }
700        let frame = ControlFrame::Cast {
701            v: WIRE_VERSION,
702            device: self.device.clone(),
703            cmd: cmd.clone(),
704            token: self.token.clone(),
705        };
706        let line = frame.json_line()?;
707        let stream = self.stream.as_mut().expect("stream just set");
708        if let Err(e) = write_line(stream, &line) {
709            self.stream = None; // force reconnect next time
710            return Err(AppError::internal(
711                "relay_send",
712                format!("{}: {e}", self.addr),
713            ));
714        }
715        Ok(())
716    }
717}
718
719/// Resolve the shared token from an explicit flag or the [`TOKEN_ENV`] env var.
720///
721/// # Errors
722/// Returns [`AppError::validation`] when no non-empty token is available.
723pub fn resolve_token(flag: Option<&str>) -> Result<String, AppError> {
724    resolve_token_with(flag, std::env::var(TOKEN_ENV).ok())
725}
726
727/// Pure core of [`resolve_token`] (env value injected for testability).
728///
729/// # Errors
730/// Returns [`AppError::validation`] when neither flag nor env supplies a
731/// non-empty token.
732pub fn resolve_token_with(
733    flag: Option<&str>,
734    env_value: Option<String>,
735) -> Result<String, AppError> {
736    // Trim surrounding whitespace so a token sourced from a FILE (e.g. a
737    // sops-decrypted secret with a trailing newline) matches a clean token.
738    // The relay auth gate also trims on both sides (bd-790088), so this keeps
739    // omni-native senders/hubs consistent with that normalization.
740    if let Some(t) = flag {
741        let t = t.trim();
742        if !t.is_empty() {
743            return Ok(t.to_owned());
744        }
745    }
746    match env_value {
747        Some(t) if !t.trim().is_empty() => Ok(t.trim().to_owned()),
748        _ => Err(AppError::validation(
749            "relay_token",
750            format!("a shared token is required; pass --token or set {TOKEN_ENV}"),
751        )),
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use std::time::Duration;
759
760    #[test]
761    fn frames_round_trip() {
762        let reg = ControlFrame::Register {
763            v: 1,
764            device: "harry-watch".into(),
765            platform: "watchos".into(),
766            token: "secret".into(),
767        };
768        let line = reg.json_line().unwrap();
769        assert_eq!(
770            line,
771            r#"{"op":"register","v":1,"device":"harry-watch","platform":"watchos","token":"secret"}"#
772        );
773        assert_eq!(serde_json::from_str::<ControlFrame>(&line).unwrap(), reg);
774
775        let cast = ControlFrame::Cast {
776            v: 1,
777            device: "all".into(),
778            cmd: InjectionCommand::text("hi"),
779            token: "secret".into(),
780        };
781        let line = cast.json_line().unwrap();
782        assert_eq!(serde_json::from_str::<ControlFrame>(&line).unwrap(), cast);
783
784        let err = ControlFrame::Error {
785            v: 1,
786            reason: "unauthorized".into(),
787        };
788        let line = err.json_line().unwrap();
789        assert_eq!(line, r#"{"op":"error","v":1,"reason":"unauthorized"}"#);
790        assert_eq!(serde_json::from_str::<ControlFrame>(&line).unwrap(), err);
791    }
792
793    #[test]
794    fn registry_routes_to_device_and_all() {
795        let reg = Registry::new();
796        let (tx, rx) = mpsc::channel();
797        reg.register("dev1", tx);
798        assert_eq!(reg.route("dev1", &InjectionCommand::key("enter")), 1);
799        assert_eq!(
800            rx.recv_timeout(Duration::from_secs(1)).unwrap(),
801            DeviceOut::Cmd(InjectionCommand::key("enter"))
802        );
803        // unknown device -> 0 delivered, no panic
804        assert_eq!(reg.route("nope", &InjectionCommand::text("x")), 0);
805        // all
806        assert_eq!(reg.route(DEVICE_ALL, &InjectionCommand::scroll(2)), 1);
807        assert_eq!(
808            rx.recv_timeout(Duration::from_secs(1)).unwrap(),
809            DeviceOut::Cmd(InjectionCommand::scroll(2))
810        );
811    }
812
813    #[test]
814    fn registry_is_registered_and_count_track_membership() {
815        let reg = Registry::new();
816        assert_eq!(reg.device_count(), 0);
817        assert!(!reg.is_registered("dev1"));
818        let (tx, _rx) = mpsc::channel();
819        reg.register("dev1", tx);
820        assert!(reg.is_registered("dev1"));
821        assert_eq!(reg.device_count(), 1);
822        reg.unregister("dev1");
823        assert!(!reg.is_registered("dev1"));
824        assert_eq!(reg.device_count(), 0);
825    }
826
827    /// A [`Write`] that always fails, standing in for a dead/half-open device
828    /// socket so the eviction path is deterministic without real networking.
829    struct DeadWriter;
830    impl Write for DeadWriter {
831        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
832            Err(std::io::Error::new(
833                std::io::ErrorKind::BrokenPipe,
834                "dead socket",
835            ))
836        }
837        fn flush(&mut self) -> std::io::Result<()> {
838            Ok(())
839        }
840    }
841
842    #[test]
843    fn dead_device_writer_evicts_registration_on_failed_cast() {
844        // Regression for bd-86ab7a: a cast routed to a half-open device must NOT
845        // leave the registration in place (which would silently queue every
846        // later cast into the void). The first failed write reaps it.
847        let registry = Registry::new();
848        let (tx, rx) = mpsc::channel::<DeviceOut>();
849        registry.register("harry-watch", tx.clone());
850        assert!(registry.is_registered("harry-watch"));
851
852        // Queue one cast, then drop our handle so recv() ends after that cast.
853        tx.send(DeviceOut::Cmd(InjectionCommand::text("lost")))
854            .unwrap();
855        drop(tx);
856
857        let dead_signalled = std::cell::Cell::new(false);
858        run_device_writer(&rx, &mut DeadWriter, &registry, "harry-watch", || {
859            dead_signalled.set(true);
860        });
861
862        assert!(
863            !registry.is_registered("harry-watch"),
864            "a dead device must be evicted on the failed write"
865        );
866        assert!(
867            dead_signalled.get(),
868            "on_dead must fire so the reader socket is shut down"
869        );
870        // A subsequent route now honestly reports 0 delivered, rather than
871        // silently queueing into a dead connection.
872        assert_eq!(
873            registry.route("harry-watch", &InjectionCommand::text("after")),
874            0
875        );
876    }
877
878    #[test]
879    fn healthy_device_writer_delivers_without_eviction() {
880        // The eviction path must not regress the happy path: a healthy writer
881        // delivers every queued command and the device stays registered when
882        // the channel closes on a normal disconnect.
883        let registry = Registry::new();
884        let (tx, rx) = mpsc::channel::<DeviceOut>();
885        tx.send(DeviceOut::Cmd(InjectionCommand::text("a")))
886            .unwrap();
887        tx.send(DeviceOut::Cmd(InjectionCommand::key("enter")))
888            .unwrap();
889        drop(tx); // normal disconnect: feeding channel closes, writer returns cleanly
890
891        // A live registration whose sender stays alive, so we can assert the
892        // healthy writer path left the registry untouched (no eviction).
893        let (keep_tx, _keep_rx) = mpsc::channel();
894        registry.register("dev", keep_tx);
895
896        let mut buf: Vec<u8> = Vec::new();
897        let mut dead_signalled = false;
898        run_device_writer(&rx, &mut buf, &registry, "dev", || dead_signalled = true);
899
900        assert!(
901            !dead_signalled,
902            "a healthy channel close must not signal death"
903        );
904        assert!(
905            registry.is_registered("dev"),
906            "channel-close (normal disconnect) does not evict via the writer path"
907        );
908        let text = String::from_utf8(buf).unwrap();
909        assert_eq!(text.lines().count(), 2, "both commands delivered as NDJSON");
910    }
911
912    #[test]
913    fn device_writer_serializes_pongs_and_commands_on_one_writer() {
914        // bd-c7bcfc: pongs are routed through the SAME writer as casts, so a
915        // device ping reply and a cast can never interleave on the socket.
916        // Feed both kinds and assert three whole, un-spliced NDJSON lines.
917        let registry = Registry::new();
918        let (tx, rx) = mpsc::channel::<DeviceOut>();
919        tx.send(DeviceOut::Cmd(InjectionCommand::text("hi")))
920            .unwrap();
921        tx.send(DeviceOut::Pong).unwrap();
922        tx.send(DeviceOut::Cmd(InjectionCommand::key("enter")))
923            .unwrap();
924        drop(tx); // channel closes after the three frames -> writer returns
925
926        let mut buf: Vec<u8> = Vec::new();
927        let mut dead_signalled = false;
928        run_device_writer(&rx, &mut buf, &registry, "dev", || dead_signalled = true);
929        assert!(!dead_signalled, "no write failed, so no eviction");
930
931        let lines: Vec<&str> = std::str::from_utf8(&buf).unwrap().lines().collect();
932        assert_eq!(lines.len(), 3, "three whole NDJSON lines, none spliced");
933        assert_eq!(
934            InjectionCommand::decode_line(lines[0]).unwrap(),
935            InjectionCommand::text("hi")
936        );
937        assert_eq!(lines[1], r#"{"v":1,"op":"pong"}"#);
938        assert_eq!(
939            InjectionCommand::decode_line(lines[2]).unwrap(),
940            InjectionCommand::key("enter")
941        );
942    }
943
944    #[test]
945    fn end_to_end_dead_device_is_reaped_from_registry() {
946        // With an observable registry, a device that drops its connection is
947        // reaped so it no longer shows as registered (bd-86ab7a).
948        let listener = bind("127.0.0.1:0").unwrap();
949        let addr = listener.local_addr().unwrap();
950        let opts = ServeOptions {
951            token: "tok".into(),
952            max_conns: Some(1),
953        };
954        let registry = Registry::new();
955        let serve_registry = registry.clone();
956        thread::spawn(move || {
957            let _ = serve_with_registry(&listener, &opts, &serve_registry);
958        });
959
960        let dev = TcpStream::connect(addr).unwrap();
961        let mut dev_w = dev.try_clone().unwrap();
962        let reg = ControlFrame::Register {
963            v: 1,
964            device: "harry-watch".into(),
965            platform: "watchos".into(),
966            token: "tok".into(),
967        };
968        write_line(&mut dev_w, &reg.json_line().unwrap()).unwrap();
969
970        // Wait for the relay to register the device.
971        assert!(
972            wait_until(|| registry.is_registered("harry-watch"), 2000),
973            "device should register"
974        );
975
976        // Device drops its connection; the relay reader must reap it.
977        drop(dev_w);
978        drop(dev);
979        assert!(
980            wait_until(|| !registry.is_registered("harry-watch"), 2000),
981            "a disconnected device should be reaped from the registry"
982        );
983    }
984
985    /// Poll `cond` every 10ms until it is true or `timeout_ms` elapses.
986    fn wait_until(mut cond: impl FnMut() -> bool, timeout_ms: u64) -> bool {
987        let start = std::time::Instant::now();
988        while start.elapsed() < Duration::from_millis(timeout_ms) {
989            if cond() {
990                return true;
991            }
992            thread::sleep(Duration::from_millis(10));
993        }
994        cond()
995    }
996
997    #[test]
998    fn resolve_token_prefers_flag_then_env() {
999        assert_eq!(
1000            resolve_token_with(Some("flagtok"), None).unwrap(),
1001            "flagtok"
1002        );
1003        assert_eq!(
1004            resolve_token_with(None, Some("envtok".to_owned())).unwrap(),
1005            "envtok"
1006        );
1007        // empty flag falls through to env; empty/absent env -> error
1008        assert_eq!(
1009            resolve_token_with(Some(""), None).unwrap_err().code(),
1010            "relay_token"
1011        );
1012        assert_eq!(
1013            resolve_token_with(None, Some(String::new()))
1014                .unwrap_err()
1015                .code(),
1016            "relay_token"
1017        );
1018    }
1019
1020    #[test]
1021    fn resolve_token_trims_surrounding_whitespace() {
1022        // A trailing newline (e.g. from a sops-decrypted token file) is trimmed
1023        // so it matches a clean token at the relay auth gate (bd-790088).
1024        assert_eq!(
1025            resolve_token_with(Some("flagtok\n"), None).unwrap(),
1026            "flagtok"
1027        );
1028        assert_eq!(
1029            resolve_token_with(None, Some("  envtok \n".to_owned())).unwrap(),
1030            "envtok"
1031        );
1032        // Whitespace-only is treated as empty -> error (no silent empty token).
1033        assert_eq!(
1034            resolve_token_with(Some("   \n"), None).unwrap_err().code(),
1035            "relay_token"
1036        );
1037    }
1038
1039    #[test]
1040    fn end_to_end_trailing_newline_token_authenticates() {
1041        // Server has a clean token; a device + sender whose token carries a
1042        // trailing newline (the sops-file footgun) must still authenticate and
1043        // route, not be silently rejected (bd-790088).
1044        let listener = bind("127.0.0.1:0").unwrap();
1045        let addr = listener.local_addr().unwrap();
1046        let opts = ServeOptions {
1047            token: "tok".into(),
1048            max_conns: Some(2),
1049        };
1050        thread::spawn(move || {
1051            let _ = serve(&listener, &opts);
1052        });
1053
1054        let dev = TcpStream::connect(addr).unwrap();
1055        let mut dev_w = dev.try_clone().unwrap();
1056        let mut dev_r = BufReader::new(dev);
1057        let reg = ControlFrame::Register {
1058            v: 1,
1059            device: "harry-watch".into(),
1060            platform: "watchos".into(),
1061            token: "tok\n".into(),
1062        };
1063        write_line(&mut dev_w, &reg.json_line().unwrap()).unwrap();
1064        thread::sleep(Duration::from_millis(100));
1065
1066        cast_via_relay(
1067            addr,
1068            "tok\n",
1069            "harry-watch",
1070            &[InjectionCommand::text("hi")],
1071        )
1072        .unwrap();
1073
1074        let mut got = String::new();
1075        dev_r.read_line(&mut got).unwrap();
1076        let cmd = InjectionCommand::decode_line(got.trim()).unwrap();
1077        assert_eq!(cmd, InjectionCommand::text("hi"));
1078    }
1079
1080    #[test]
1081    fn end_to_end_device_receives_cast() {
1082        let listener = bind("127.0.0.1:0").unwrap();
1083        let addr = listener.local_addr().unwrap();
1084        let opts = ServeOptions {
1085            token: "tok".into(),
1086            max_conns: Some(2),
1087        };
1088        thread::spawn(move || {
1089            let _ = serve(&listener, &opts);
1090        });
1091
1092        // DEVICE connects + registers, then reads one delivered bare command.
1093        let dev = TcpStream::connect(addr).unwrap();
1094        let mut dev_w = dev.try_clone().unwrap();
1095        let mut dev_r = BufReader::new(dev);
1096        let reg = ControlFrame::Register {
1097            v: 1,
1098            device: "harry-watch".into(),
1099            platform: "watchos".into(),
1100            token: "tok".into(),
1101        };
1102        write_line(&mut dev_w, &reg.json_line().unwrap()).unwrap();
1103
1104        // Give the relay a moment to register the device before casting.
1105        thread::sleep(Duration::from_millis(100));
1106
1107        // SENDER casts to the device.
1108        cast_via_relay(
1109            addr,
1110            "tok",
1111            "harry-watch",
1112            &[InjectionCommand::text("hello watch")],
1113        )
1114        .unwrap();
1115
1116        // DEVICE should receive the bare InjectionCommand line.
1117        let mut got = String::new();
1118        dev_r.read_line(&mut got).unwrap();
1119        let cmd = InjectionCommand::decode_line(got.trim()).unwrap();
1120        assert_eq!(cmd, InjectionCommand::text("hello watch"));
1121    }
1122
1123    #[test]
1124    fn end_to_end_unauthorized_register_is_rejected() {
1125        let listener = bind("127.0.0.1:0").unwrap();
1126        let addr = listener.local_addr().unwrap();
1127        let opts = ServeOptions {
1128            token: "right".into(),
1129            max_conns: Some(1),
1130        };
1131        thread::spawn(move || {
1132            let _ = serve(&listener, &opts);
1133        });
1134
1135        let dev = TcpStream::connect(addr).unwrap();
1136        let mut dev_w = dev.try_clone().unwrap();
1137        let mut dev_r = BufReader::new(dev);
1138        let reg = ControlFrame::Register {
1139            v: 1,
1140            device: "d".into(),
1141            platform: "ios".into(),
1142            token: "wrong".into(),
1143        };
1144        write_line(&mut dev_w, &reg.json_line().unwrap()).unwrap();
1145        let mut got = String::new();
1146        dev_r.read_line(&mut got).unwrap();
1147        assert!(
1148            got.contains("unauthorized"),
1149            "expected unauthorized, got: {got}"
1150        );
1151    }
1152
1153    #[test]
1154    fn cast_via_relay_reports_delivered_to_registered_device() {
1155        // bd-0b4e45: a cast to a CONNECTED device reports acked + delivered==1.
1156        let listener = bind("127.0.0.1:0").unwrap();
1157        let addr = listener.local_addr().unwrap();
1158        let opts = ServeOptions {
1159            token: "tok".into(),
1160            max_conns: Some(2),
1161        };
1162        let registry = Registry::new();
1163        let serve_registry = registry.clone();
1164        thread::spawn(move || {
1165            let _ = serve_with_registry(&listener, &opts, &serve_registry);
1166        });
1167
1168        let dev = TcpStream::connect(addr).unwrap();
1169        let mut dev_w = dev.try_clone().unwrap();
1170        let reg = ControlFrame::Register {
1171            v: 1,
1172            device: "harry-watch".into(),
1173            platform: "watchos".into(),
1174            token: "tok".into(),
1175        };
1176        write_line(&mut dev_w, &reg.json_line().unwrap()).unwrap();
1177        assert!(
1178            wait_until(|| registry.is_registered("harry-watch"), 2000),
1179            "device should register"
1180        );
1181
1182        let delivery =
1183            cast_via_relay(addr, "tok", "harry-watch", &[InjectionCommand::text("hi")]).unwrap();
1184        assert!(delivery.acked, "relay should ack the cast");
1185        assert_eq!(
1186            delivery.delivered, 1,
1187            "delivered to the one connected device"
1188        );
1189    }
1190
1191    #[test]
1192    fn cast_via_relay_reports_zero_for_unconnected_device() {
1193        // bd-0b4e45: a cast to a device id that is NOT registered reports
1194        // acked == true with delivered == 0 (no more silent fire-and-forget).
1195        let listener = bind("127.0.0.1:0").unwrap();
1196        let addr = listener.local_addr().unwrap();
1197        let opts = ServeOptions {
1198            token: "tok".into(),
1199            max_conns: Some(1),
1200        };
1201        thread::spawn(move || {
1202            let _ = serve(&listener, &opts);
1203        });
1204
1205        let delivery =
1206            cast_via_relay(addr, "tok", "ghost-device", &[InjectionCommand::text("hi")]).unwrap();
1207        assert!(delivery.acked, "relay should still ack (with delivered=0)");
1208        assert_eq!(delivery.delivered, 0, "no device connected -> delivered 0");
1209    }
1210
1211    #[test]
1212    fn cast_via_relay_does_not_hang_without_a_result() {
1213        // bd-0b4e45 backward-compat: a legacy relay / plain daemon that sends no
1214        // result must not hang the sender; cast_via_relay returns acked=false
1215        // after the bounded read timeout rather than blocking.
1216        let listener = bind("127.0.0.1:0").unwrap();
1217        let addr = listener.local_addr().unwrap();
1218        thread::spawn(move || {
1219            if let Ok((sock, _)) = listener.accept() {
1220                let mut r = BufReader::new(sock.try_clone().unwrap());
1221                let mut line = String::new();
1222                let _ = r.read_line(&mut line);
1223                // Hold the socket open WITHOUT replying, simulating a relay that
1224                // never sends a delivery result.
1225                thread::sleep(Duration::from_secs(2));
1226                drop(sock);
1227            }
1228        });
1229
1230        let start = std::time::Instant::now();
1231        let delivery =
1232            cast_via_relay(addr, "tok", "local", &[InjectionCommand::text("hi")]).unwrap();
1233        assert!(!delivery.acked, "no result -> acked must be false");
1234        assert!(
1235            start.elapsed() < Duration::from_secs(2),
1236            "must return on the read timeout, not block for the full hold"
1237        );
1238    }
1239
1240    #[test]
1241    fn device_ids_returns_sorted_registered() {
1242        // bd-bc8b5c: device_ids reflects the registered set, sorted.
1243        let reg = Registry::new();
1244        let (tx_b, _rx_b) = mpsc::channel();
1245        let (tx_a, _rx_a) = mpsc::channel();
1246        reg.register("phone", tx_b);
1247        reg.register("harry-watch", tx_a);
1248        assert_eq!(
1249            reg.device_ids(),
1250            vec!["harry-watch".to_owned(), "phone".to_owned()]
1251        );
1252        reg.unregister("phone");
1253        assert_eq!(reg.device_ids(), vec!["harry-watch".to_owned()]);
1254    }
1255
1256    #[test]
1257    fn end_to_end_list_devices_returns_registered() {
1258        // bd-bc8b5c: a sender can query the hub for the connected device ids.
1259        let listener = bind("127.0.0.1:0").unwrap();
1260        let addr = listener.local_addr().unwrap();
1261        let opts = ServeOptions {
1262            token: "tok".into(),
1263            max_conns: Some(3),
1264        };
1265        let registry = Registry::new();
1266        let serve_registry = registry.clone();
1267        thread::spawn(move || {
1268            let _ = serve_with_registry(&listener, &opts, &serve_registry);
1269        });
1270
1271        // Hold the device connections open for the duration of the test.
1272        let mut held = Vec::new();
1273        for id in ["harry-watch", "phone"] {
1274            let dev = TcpStream::connect(addr).unwrap();
1275            let mut dev_w = dev.try_clone().unwrap();
1276            let reg = ControlFrame::Register {
1277                v: 1,
1278                device: id.into(),
1279                platform: "watchos".into(),
1280                token: "tok".into(),
1281            };
1282            write_line(&mut dev_w, &reg.json_line().unwrap()).unwrap();
1283            held.push(dev);
1284            held.push(dev_w);
1285        }
1286        assert!(
1287            wait_until(|| registry.device_ids().len() == 2, 2000),
1288            "both devices register"
1289        );
1290
1291        let mut got = list_relay_devices(addr, "tok").unwrap();
1292        got.sort();
1293        assert_eq!(got, vec!["harry-watch".to_owned(), "phone".to_owned()]);
1294        drop(held);
1295    }
1296
1297    #[test]
1298    fn list_devices_unauthorized_rejected() {
1299        // bd-bc8b5c: a wrong token on a list query is rejected.
1300        let listener = bind("127.0.0.1:0").unwrap();
1301        let addr = listener.local_addr().unwrap();
1302        let opts = ServeOptions {
1303            token: "right".into(),
1304            max_conns: Some(1),
1305        };
1306        thread::spawn(move || {
1307            let _ = serve(&listener, &opts);
1308        });
1309        let err = list_relay_devices(addr, "wrong").unwrap_err();
1310        assert_eq!(err.code(), "relay_list");
1311    }
1312
1313    #[test]
1314    fn list_relay_devices_no_hang_without_response() {
1315        // bd-bc8b5c backward-compat: a server that never replies (legacy relay)
1316        // yields an empty list within the bounded timeout, not a hang.
1317        let listener = bind("127.0.0.1:0").unwrap();
1318        let addr = listener.local_addr().unwrap();
1319        thread::spawn(move || {
1320            if let Ok((sock, _)) = listener.accept() {
1321                let mut r = BufReader::new(sock.try_clone().unwrap());
1322                let mut line = String::new();
1323                let _ = r.read_line(&mut line);
1324                thread::sleep(Duration::from_secs(2));
1325                drop(sock);
1326            }
1327        });
1328        let start = std::time::Instant::now();
1329        let devices = list_relay_devices(addr, "tok").unwrap();
1330        assert!(devices.is_empty(), "no reply -> empty list");
1331        assert!(
1332            start.elapsed() < Duration::from_secs(2),
1333            "must return on the read timeout, not hang"
1334        );
1335    }
1336}