1use std::io::IsTerminal;
4
5use crate::cli::GlobalFlags;
6
7pub fn discovery_hint() -> &'static str {
9 if cfg!(target_os = "macos") {
10 "hint: this could mean:\n\
11 \x20 - no Sonos speakers are on this network\n\
12 \x20 - your terminal lacks Local Network access\n\
13 \x20 (System Settings > Privacy & Security > Local Network)\n\
14 \x20 - a firewall is blocking UDP multicast on port 1900"
15 } else if cfg!(target_os = "windows") {
16 "hint: this could mean:\n\
17 \x20 - no Sonos speakers are on this network\n\
18 \x20 - Network Discovery is disabled or your firewall is\n\
19 \x20 blocking UDP traffic on port 1900 (SSDP)"
20 } else {
21 "hint: this could mean:\n\
22 \x20 - no Sonos speakers are on this network\n\
23 \x20 - a firewall is blocking UDP multicast on port 1900\n\
24 \x20 (ufw: sudo ufw allow proto udp from any to 239.255.255.250 port 1900)"
25 }
26}
27
28pub fn offer_open_settings(global: &GlobalFlags) {
33 if !can_prompt(global) {
34 return;
35 }
36
37 let (cmd, args, fallback) = if cfg!(target_os = "macos") {
38 (
39 "open",
40 "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork",
41 "System Settings > Privacy & Security > Local Network",
42 )
43 } else if cfg!(target_os = "windows") {
44 (
45 "cmd",
46 "/C start ms-settings:privacy-localnetwork",
47 "Settings > Privacy & Security > Local Network",
48 )
49 } else {
50 return;
51 };
52
53 eprintln!();
54 eprint!("Open settings? [Y/n] ");
55
56 let mut input = String::new();
57 if std::io::stdin().read_line(&mut input).is_err() {
58 return;
59 }
60
61 let answer = input.trim();
62 if answer.is_empty() || answer.eq_ignore_ascii_case("y") {
63 let result = if cfg!(target_os = "windows") {
64 std::process::Command::new(cmd)
65 .args(args.split_whitespace())
66 .spawn()
67 } else {
68 std::process::Command::new(cmd).arg(args).spawn()
69 };
70
71 if result.is_err() {
72 eprintln!("Could not open settings. Navigate to {fallback} manually.");
73 }
74 }
75}
76
77fn can_prompt(global: &GlobalFlags) -> bool {
78 std::io::stdin().is_terminal() && !global.no_input
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn discovery_hint_lists_possible_causes() {
87 let hint = discovery_hint();
88 assert!(hint.starts_with("hint: this could mean:"));
89 assert!(hint.contains("no Sonos speakers are on this network"));
90 }
91
92 #[test]
93 #[cfg(target_os = "macos")]
94 fn discovery_hint_macos_mentions_local_network() {
95 let hint = discovery_hint();
96 assert!(hint.contains("Local Network"));
97 assert!(hint.contains("System Settings"));
98 }
99
100 #[test]
101 #[cfg(target_os = "windows")]
102 fn discovery_hint_windows_mentions_firewall() {
103 let hint = discovery_hint();
104 assert!(hint.contains("Network Discovery"));
105 }
106
107 #[test]
108 #[cfg(target_os = "linux")]
109 fn discovery_hint_linux_mentions_ufw() {
110 let hint = discovery_hint();
111 assert!(hint.contains("ufw"));
112 assert!(hint.contains("239.255.255.250"));
113 }
114
115 #[test]
116 fn no_input_flag_prevents_prompt() {
117 let global = GlobalFlags {
118 speaker: None,
119 group: None,
120 quiet: false,
121 verbose: 0,
122 no_input: true,
123 };
124 assert!(!can_prompt(&global));
125 }
126
127 #[test]
128 fn discovery_failed_triggers_platform_hint() {
129 let err =
132 sonos_sdk::SdkError::DiscoveryFailed("no Sonos devices found on the network".into());
133 assert!(matches!(err, sonos_sdk::SdkError::DiscoveryFailed(_)));
134
135 let hint = discovery_hint();
137 assert!(hint.starts_with("hint:"));
138 }
139
140 #[test]
141 fn speaker_not_found_routes_to_platform_hint() {
142 use crate::errors::CliError;
145 let err = CliError::SpeakerNotFound("no speakers available".into());
146 let hint = err.recovery_hint().expect("should have a recovery hint");
147 assert_eq!(hint, discovery_hint());
148 }
149
150 #[test]
151 fn group_not_found_routes_to_platform_hint() {
152 use crate::errors::CliError;
153 let err = CliError::GroupNotFound("no groups available".into());
154 let hint = err.recovery_hint().expect("should have a recovery hint");
155 assert_eq!(hint, discovery_hint());
156 }
157
158 #[test]
159 fn sdk_discovery_failed_uses_platform_hint() {
160 use crate::errors::CliError;
161 let err = CliError::Sdk(sonos_sdk::SdkError::DiscoveryFailed("test".into()));
162 let hint = err.recovery_hint().unwrap();
163 assert_eq!(hint, discovery_hint());
164 }
165
166 #[test]
167 fn sdk_non_network_error_uses_generic_hint() {
168 use crate::errors::CliError;
171 let err = CliError::Sdk(sonos_sdk::SdkError::LockPoisoned);
172 let hint = err.recovery_hint().unwrap();
173 assert_ne!(
174 hint,
175 discovery_hint(),
176 "non-network SDK errors should use generic hint"
177 );
178 }
179}