1use crate::AppError;
7use crate::config::{ResolvedEndpoint, Transport};
8use crate::transport::{android, relay, tcp};
9use crate::wire::InjectionCommand;
10
11#[derive(Debug, Clone)]
13pub struct EndpointOutcome {
14 pub name: String,
15 pub kind: &'static str,
16 pub detail: String,
17 pub result: Result<Vec<String>, String>,
20}
21
22#[derive(Debug, Clone)]
24pub struct DispatchReport {
25 pub outcomes: Vec<EndpointOutcome>,
26}
27
28impl DispatchReport {
29 #[must_use]
30 pub fn ok_count(&self) -> usize {
31 self.outcomes.iter().filter(|o| o.result.is_ok()).count()
32 }
33 #[must_use]
34 pub fn fail_count(&self) -> usize {
35 self.outcomes.iter().filter(|o| o.result.is_err()).count()
36 }
37 #[must_use]
38 pub fn all_ok(&self) -> bool {
39 self.outcomes.iter().all(|o| o.result.is_ok())
40 }
41 #[must_use]
43 pub fn is_empty(&self) -> bool {
44 self.outcomes.is_empty()
45 }
46}
47
48#[must_use]
55pub fn dispatch(
56 endpoints: &[ResolvedEndpoint],
57 cmds: &[InjectionCommand],
58 dry_run: bool,
59) -> DispatchReport {
60 let outcomes = fan_out(endpoints.iter().collect::<Vec<_>>(), |ep| {
61 outcome_for(ep, cmds, dry_run)
62 });
63 DispatchReport { outcomes }
64}
65
66fn outcome_for(ep: &ResolvedEndpoint, cmds: &[InjectionCommand], dry_run: bool) -> EndpointOutcome {
68 EndpointOutcome {
69 name: ep.name.clone(),
70 kind: ep.transport.kind(),
71 detail: ep.transport.detail(),
72 result: send_to_endpoint(ep, cmds, dry_run).map_err(|e| e.to_string()),
73 }
74}
75
76fn fan_out<T, R, F>(items: Vec<T>, f: F) -> Vec<R>
83where
84 T: Send,
85 R: Send,
86 F: Fn(T) -> R + Sync,
87{
88 if items.len() <= 1 {
89 return items.into_iter().map(f).collect();
90 }
91 std::thread::scope(|scope| {
92 let f = &f;
93 let handles: Vec<_> = items
94 .into_iter()
95 .map(|item| scope.spawn(move || f(item)))
96 .collect();
97 handles
98 .into_iter()
99 .map(|h| h.join().unwrap_or_else(|p| std::panic::resume_unwind(p)))
100 .collect()
101 })
102}
103
104fn send_to_endpoint(
105 ep: &ResolvedEndpoint,
106 cmds: &[InjectionCommand],
107 dry_run: bool,
108) -> Result<Vec<String>, AppError> {
109 match &ep.transport {
110 Transport::AdbBroadcast {
111 serial,
112 sender_package,
113 } => {
114 let sp = sender_package.as_deref();
115 let mut rendered = Vec::new();
116 for cmd in cmds {
117 let casts = android::to_am_broadcasts(cmd)?;
118 let lines = android::dispatch(&casts, serial.as_deref(), sp, !dry_run)?;
119 rendered.extend(lines);
120 }
121 Ok(rendered)
122 }
123 Transport::Termux {} => {
124 let mut rendered = Vec::new();
128 for cmd in cmds {
129 for cast in &android::to_am_broadcasts(cmd)? {
130 let device_cmd = cast.device_command(android::ACTION, None);
131 rendered.push(format!("sh -c '{device_cmd}'"));
132 if !dry_run {
133 run_local_shell(&device_cmd)?;
134 }
135 }
136 }
137 Ok(rendered)
138 }
139 Transport::Tcp { host, port } => {
140 if dry_run {
141 tcp::render_bare(host, *port, cmds)
142 } else {
143 tcp::cast_bare(host, *port, cmds)?;
144 Ok(vec![format!(
145 "tcp {host}:{port} <- {} command(s)",
146 cmds.len()
147 )])
148 }
149 }
150 Transport::Relay {
151 url,
152 device,
153 token_env,
154 } => {
155 let env = token_env.as_deref().unwrap_or(relay::TOKEN_ENV);
156 let addr = relay_addr(url);
157 if dry_run {
158 Ok(vec![format!(
159 "relay {addr} device={device} <- {} command(s)",
160 cmds.len()
161 )])
162 } else {
163 let token = std::env::var(env).map_err(|_| {
164 AppError::validation(
165 "dispatch_relay_token",
166 format!("relay token env {env} is not set for this endpoint"),
167 )
168 })?;
169 let delivery = relay::cast_via_relay(&addr, &token, device, cmds)?;
170 let mut lines = vec![format!(
171 "relay {addr} device={device} <- {} command(s)",
172 cmds.len()
173 )];
174 if let Some(warning) = relay_delivery_warning(&addr, device, &delivery) {
175 lines.push(warning);
176 }
177 Ok(lines)
178 }
179 }
180 }
181}
182
183fn relay_addr(url: &str) -> String {
186 url.strip_prefix("tcp://")
187 .or_else(|| url.strip_prefix("tcps://"))
188 .unwrap_or(url)
189 .to_owned()
190}
191
192fn relay_delivery_warning(
198 addr: &str,
199 device: &str,
200 delivery: &relay::RelayDelivery,
201) -> Option<String> {
202 if delivery.acked && delivery.delivered == 0 {
203 Some(format!(
204 "relay {addr} warning: device '{device}' is not connected (delivered to 0 devices)"
205 ))
206 } else {
207 None
208 }
209}
210
211fn run_local_shell(device_cmd: &str) -> Result<(), AppError> {
212 let status = std::process::Command::new("sh")
213 .arg("-c")
214 .arg(device_cmd)
215 .status()
216 .map_err(|e| {
217 AppError::internal(
218 "termux_spawn",
219 format!("failed to run am broadcast locally (termux is on-device only): {e}"),
220 )
221 })?;
222 if !status.success() {
223 return Err(AppError::internal(
224 "termux_broadcast",
225 format!("local am broadcast exited with status {status}"),
226 ));
227 }
228 Ok(())
229}
230
231enum LiveSender {
237 Adb {
239 serial: Option<String>,
240 sender_package: Option<String>,
241 },
242 Termux,
244 Tcp(tcp::TcpSender),
246 Relay(relay::RelaySender),
248 Broken(String),
251}
252
253impl LiveSender {
254 fn cast_one(&mut self, cmd: &InjectionCommand) -> Result<(), AppError> {
255 match self {
256 LiveSender::Adb {
257 serial,
258 sender_package,
259 } => {
260 let casts = android::to_am_broadcasts(cmd)?;
261 android::dispatch(&casts, serial.as_deref(), sender_package.as_deref(), true)?;
262 Ok(())
263 }
264 LiveSender::Termux => {
265 for cast in &android::to_am_broadcasts(cmd)? {
266 run_local_shell(&cast.device_command(android::ACTION, None))?;
267 }
268 Ok(())
269 }
270 LiveSender::Tcp(sender) => sender.cast_one(cmd),
271 LiveSender::Relay(sender) => sender.cast_one(cmd),
272 LiveSender::Broken(msg) => {
273 Err(AppError::validation("dispatch_live_sender", msg.clone()))
274 }
275 }
276 }
277}
278
279struct LiveEndpoint {
280 name: String,
281 kind: &'static str,
282 detail: String,
283 sender: LiveSender,
284}
285
286pub struct LiveDispatcher {
293 senders: Vec<LiveEndpoint>,
294}
295
296impl LiveDispatcher {
297 #[must_use]
301 pub fn build(endpoints: &[ResolvedEndpoint]) -> Self {
302 let senders = endpoints
303 .iter()
304 .map(|ep| {
305 let sender = match &ep.transport {
306 Transport::AdbBroadcast {
307 serial,
308 sender_package,
309 } => LiveSender::Adb {
310 serial: serial.clone(),
311 sender_package: sender_package.clone(),
312 },
313 Transport::Termux {} => LiveSender::Termux,
314 Transport::Tcp { host, port } => {
315 LiveSender::Tcp(tcp::TcpSender::new(host, *port))
316 }
317 Transport::Relay {
318 url,
319 device,
320 token_env,
321 } => {
322 let env = token_env.as_deref().unwrap_or(relay::TOKEN_ENV);
323 match std::env::var(env) {
324 Ok(token) => LiveSender::Relay(relay::RelaySender::new(
325 relay_addr(url),
326 device.clone(),
327 token,
328 )),
329 Err(_) => LiveSender::Broken(format!(
330 "relay token env {env} is not set for this endpoint"
331 )),
332 }
333 }
334 };
335 LiveEndpoint {
336 name: ep.name.clone(),
337 kind: ep.transport.kind(),
338 detail: ep.transport.detail(),
339 sender,
340 }
341 })
342 .collect();
343 Self { senders }
344 }
345
346 pub fn cast(&mut self, cmd: &InjectionCommand) -> Vec<EndpointOutcome> {
352 fan_out(self.senders.iter_mut().collect::<Vec<_>>(), |ep| {
353 live_outcome(ep, cmd)
354 })
355 }
356
357 #[must_use]
359 pub fn endpoint_count(&self) -> usize {
360 self.senders.len()
361 }
362}
363
364fn live_outcome(ep: &mut LiveEndpoint, cmd: &InjectionCommand) -> EndpointOutcome {
367 EndpointOutcome {
368 name: ep.name.clone(),
369 kind: ep.kind,
370 detail: ep.detail.clone(),
371 result: ep
372 .sender
373 .cast_one(cmd)
374 .map(|()| Vec::new())
375 .map_err(|e| e.to_string()),
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn fan_out_preserves_input_order() {
385 let out = fan_out((0..5).collect::<Vec<_>>(), |i| i * 2);
386 assert_eq!(out, vec![0, 2, 4, 6, 8]);
387 }
388
389 #[test]
390 fn fan_out_single_and_empty_use_inline_path() {
391 assert_eq!(fan_out(vec![21], |i| i + 1), vec![22]);
392 let empty: Vec<i32> = fan_out(Vec::new(), |i| i);
393 assert!(empty.is_empty());
394 }
395
396 #[test]
397 fn fan_out_runs_items_concurrently() {
398 use std::time::{Duration, Instant};
399 let start = Instant::now();
404 let out = fan_out((0..4).collect::<Vec<_>>(), |i: u64| {
405 std::thread::sleep(Duration::from_millis(100));
406 i
407 });
408 let elapsed = start.elapsed();
409 assert_eq!(out, vec![0, 1, 2, 3]);
410 assert!(
411 elapsed < Duration::from_millis(350),
412 "fan_out should run concurrently, took {elapsed:?}"
413 );
414 }
415
416 fn ep(name: &str, transport: Transport) -> ResolvedEndpoint {
417 ResolvedEndpoint {
418 name: name.to_owned(),
419 transport,
420 }
421 }
422
423 #[test]
424 fn relay_addr_strips_scheme() {
425 assert_eq!(relay_addr("tcp://relay.example:9000"), "relay.example:9000");
426 assert_eq!(relay_addr("relay.example:9000"), "relay.example:9000");
427 }
428
429 #[test]
430 fn dry_run_renders_each_transport_without_executing() {
431 let endpoints = vec![
432 ep(
433 "phone",
434 Transport::AdbBroadcast {
435 serial: Some("ABC123".into()),
436 sender_package: None,
437 },
438 ),
439 ep(
440 "watch",
441 Transport::Tcp {
442 host: "1.2.3.4".into(),
443 port: 8099,
444 },
445 ),
446 ep(
447 "ipad",
448 Transport::Relay {
449 url: "tcp://relay:9000".into(),
450 device: "harry-ipad".into(),
451 token_env: None,
452 },
453 ),
454 ];
455 let report = dispatch(&endpoints, &[InjectionCommand::text("hi")], true);
456 assert!(
457 report.all_ok(),
458 "dry-run should succeed for all: {report:?}"
459 );
460 assert_eq!(report.ok_count(), 3);
461 assert!(report.outcomes[0].result.as_ref().unwrap()[0].contains("adb -s ABC123"));
463 assert!(
464 report.outcomes[1].result.as_ref().unwrap()[0]
465 .contains(r#"{"v":1,"type":"text","text":"hi"}"#)
466 );
467 assert!(report.outcomes[2].result.as_ref().unwrap()[0].contains("device=harry-ipad"));
468 }
469
470 #[test]
471 fn per_endpoint_error_isolation() {
472 let endpoints = vec![
476 ep(
477 "phone",
478 Transport::AdbBroadcast {
479 serial: None,
480 sender_package: None,
481 },
482 ),
483 ep(
484 "watch",
485 Transport::Tcp {
486 host: "1.2.3.4".into(),
487 port: 8099,
488 },
489 ),
490 ];
491 let report = dispatch(&endpoints, &[InjectionCommand::key("nope")], true);
492 assert_eq!(report.ok_count(), 1);
493 assert_eq!(report.fail_count(), 1);
494 assert!(!report.all_ok());
495 let phone = report.outcomes.iter().find(|o| o.name == "phone").unwrap();
496 assert!(phone.result.as_ref().unwrap_err().contains("keycode"));
497 let watch = report.outcomes.iter().find(|o| o.name == "watch").unwrap();
498 assert!(watch.result.is_ok());
499 }
500
501 #[test]
502 fn relay_endpoint_unset_token_fails_fast() {
503 let endpoints = vec![ep(
506 "ipad",
507 Transport::Relay {
508 url: "tcp://relay:9000".into(),
509 device: "x".into(),
510 token_env: Some("OMNI_DEFINITELY_UNSET_TOKEN_ENV_XYZ".into()),
511 },
512 )];
513 let report = dispatch(&endpoints, &[InjectionCommand::text("hi")], false);
514 assert_eq!(report.fail_count(), 1);
515 assert!(
516 report.outcomes[0]
517 .result
518 .as_ref()
519 .unwrap_err()
520 .contains("token env")
521 );
522 }
523
524 #[test]
525 fn live_dispatcher_builds_and_isolates_broken_relay() {
526 let endpoints = vec![
530 ep(
531 "watch",
532 Transport::Tcp {
533 host: "127.0.0.1".into(),
534 port: 1,
535 },
536 ),
537 ep(
538 "ipad",
539 Transport::Relay {
540 url: "tcp://relay:9000".into(),
541 device: "x".into(),
542 token_env: Some("OMNI_DEFINITELY_UNSET_TOKEN_ENV_LIVE".into()),
543 },
544 ),
545 ];
546 let mut live = LiveDispatcher::build(&endpoints);
547 assert_eq!(live.endpoint_count(), 2);
548 let outcomes = live.cast(&InjectionCommand::text("hi"));
549 assert_eq!(outcomes.len(), 2);
550 let ipad = outcomes.iter().find(|o| o.name == "ipad").unwrap();
551 assert!(ipad.result.as_ref().unwrap_err().contains("token env"));
552 }
553
554 #[test]
555 fn relay_delivery_warning_only_on_confirmed_zero() {
556 use crate::transport::relay::RelayDelivery;
557 let w = relay_delivery_warning(
559 "h:9",
560 "watch",
561 &RelayDelivery {
562 acked: true,
563 delivered: 0,
564 },
565 );
566 assert!(w.unwrap().contains("not connected"));
567 assert!(
569 relay_delivery_warning(
570 "h:9",
571 "watch",
572 &RelayDelivery {
573 acked: true,
574 delivered: 1,
575 },
576 )
577 .is_none()
578 );
579 assert!(
581 relay_delivery_warning(
582 "h:9",
583 "watch",
584 &RelayDelivery {
585 acked: false,
586 delivered: 0,
587 },
588 )
589 .is_none()
590 );
591 }
592}