1use std::net::{Shutdown, TcpStream};
15use std::time::Duration;
16
17use serde::{Deserialize, Serialize};
18use tungstenite::stream::MaybeTlsStream;
19use url::{Url, form_urlencoded};
20
21use crate::config::Config;
22use crate::helpers::SignInError;
23use crate::remote::client::SubsonicError;
24
25pub const PAIR_PAGE: &str = "https://koan.rocks/pair/";
26
27const SILENCE: Duration = Duration::from_secs(90);
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "type", rename_all = "camelCase")]
35pub enum PairMessage {
36 #[serde(rename_all = "camelCase")]
37 Pending {
38 id: String,
39 code: String,
41 expires_in: u64,
43 },
44 #[serde(rename_all = "camelCase")]
45 Approved {
46 username: String,
47 api_key: String,
48 },
49 Declined,
50 Expired,
51}
52
53#[derive(Debug, thiserror::Error)]
54pub enum PairError {
55 #[error("not an http(s) server address: {0}")]
56 BadUrl(String),
57 #[error("could not reach the server: {0}")]
58 Connect(String),
59 #[error("this server does not pair devices")]
62 Unsupported,
63 #[error("too many devices are waiting to pair from this network; try again in a few minutes")]
64 TooMany,
65 #[error("the server answered with something unexpected")]
66 BadResponse,
67 #[error("the sign-in was declined")]
68 Declined,
69 #[error("the code expired before anyone approved it")]
70 Expired,
71 #[error("the connection to the server closed")]
72 Closed,
73 #[error("no server is signed in")]
74 NotSignedIn,
75 #[error(transparent)]
76 Remote(#[from] SubsonicError),
77 #[error(transparent)]
78 SignIn(#[from] SignInError),
79}
80
81type Socket = tungstenite::WebSocket<MaybeTlsStream<TcpStream>>;
82
83pub struct Pending {
85 pub id: String,
86 pub code: String,
87 pub link: String,
90 server: String,
91 socket: Socket,
92}
93
94pub struct Cancel(TcpStream);
96
97impl Cancel {
98 pub fn cancel(&self) {
99 let _ = self.0.shutdown(Shutdown::Both);
100 }
101}
102
103pub fn start(url: &str, device: &str) -> Result<Pending, PairError> {
106 let server = url.trim().trim_end_matches('/').to_owned();
107 let socket_url = pair_url(&server, device)?;
108 let (mut socket, _) = tungstenite::connect(socket_url.as_str()).map_err(|e| match e {
109 tungstenite::Error::Http(ref response) => match response.status().as_u16() {
112 429 => PairError::TooMany,
113 502..=504 => PairError::Connect(e.to_string()),
114 _ => PairError::Unsupported,
115 },
116 e => PairError::Connect(e.to_string()),
117 })?;
118 if let Some(tcp) = tcp(&socket) {
119 let _ = tcp.set_read_timeout(Some(SILENCE));
120 }
121 match read(&mut socket)? {
122 PairMessage::Pending { id, code, .. } => Ok(Pending {
123 link: link(&server, &id),
124 id,
125 code,
126 server,
127 socket,
128 }),
129 _ => Err(PairError::BadResponse),
130 }
131}
132
133impl Pending {
134 pub fn canceller(&self) -> Option<Cancel> {
136 tcp(&self.socket)?.try_clone().ok().map(Cancel)
137 }
138
139 pub fn wait(mut self) -> Result<(), PairError> {
142 let outcome = read(&mut self.socket);
143 let _ = self.socket.close(None);
144 match outcome? {
145 PairMessage::Approved { username, api_key } => {
146 crate::helpers::adopt_api_key(&self.server, &username, &api_key)?;
147 Ok(())
148 }
149 PairMessage::Declined => Err(PairError::Declined),
150 PairMessage::Expired => Err(PairError::Expired),
151 PairMessage::Pending { .. } => Err(PairError::BadResponse),
152 }
153 }
154}
155
156fn read(socket: &mut Socket) -> Result<PairMessage, PairError> {
158 loop {
159 match socket.read() {
160 Ok(tungstenite::Message::Text(text)) => {
161 return serde_json::from_str(&text).map_err(|_| PairError::BadResponse);
162 }
163 Ok(tungstenite::Message::Close(_)) => return Err(PairError::Closed),
164 Ok(_) => {}
165 Err(_) => return Err(PairError::Closed),
166 }
167 }
168}
169
170fn tcp(socket: &Socket) -> Option<&TcpStream> {
171 match socket.get_ref() {
172 MaybeTlsStream::Plain(s) => Some(s),
173 MaybeTlsStream::Rustls(s) => Some(s.get_ref()),
174 _ => None,
175 }
176}
177
178fn pair_url(server: &str, device: &str) -> Result<Url, PairError> {
179 let bad = || PairError::BadUrl(server.to_owned());
180 let mut url = Url::parse(&format!("{server}/rest/koanPair")).map_err(|_| bad())?;
181 let scheme = match url.scheme() {
182 "https" => "wss",
183 "http" => "ws",
184 _ => return Err(bad()),
185 };
186 url.set_scheme(scheme).map_err(|()| bad())?;
187 url.query_pairs_mut().append_pair("name", device);
188 Ok(url)
189}
190
191pub fn link(server: &str, id: &str) -> String {
193 let fragment = form_urlencoded::Serializer::new(String::new())
194 .append_pair("s", server)
195 .append_pair("p", id)
196 .finish();
197 format!("{PAIR_PAGE}#{fragment}")
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct PairLink {
203 pub server: String,
204 pub id: String,
205}
206
207impl PairLink {
208 pub fn parse(link: &str) -> Option<Self> {
209 let url = Url::parse(link.trim()).ok()?;
210 let params = match (url.scheme(), url.host_str()) {
211 ("https", Some("koan.rocks")) if url.path().trim_end_matches('/') == "/pair" => {
212 url.fragment()?
213 }
214 ("koan", Some("pair")) => url.fragment().or(url.query())?,
217 _ => return None,
218 };
219 let (mut server, mut id) = (None, None);
220 for (k, v) in form_urlencoded::parse(params.as_bytes()) {
221 let v = Some(v.trim().to_owned()).filter(|v| !v.is_empty());
222 match &*k {
223 "s" => server = v,
224 "p" => id = v,
225 _ => {}
226 }
227 }
228 let server = server?.trim_end_matches('/').to_owned();
229 if !matches!(Url::parse(&server).ok()?.scheme(), "http" | "https") {
230 return None;
231 }
232 Some(Self { server, id: id? })
233 }
234}
235
236fn client() -> Result<std::sync::Arc<crate::remote::client::SubsonicClient>, PairError> {
237 crate::helpers::subsonic_client(&Config::load().unwrap_or_default())
238 .ok_or(PairError::NotSignedIn)
239}
240
241pub fn info(pair: &str) -> Result<crate::remote::client::KoanPair, PairError> {
244 Ok(client()?.koan_pair_info(pair)?)
245}
246
247pub fn approve(pair: &str) -> Result<String, PairError> {
250 Ok(client()?.koan_pair_approve(pair, false)?)
251}
252
253pub fn decline(pair: &str) -> Result<String, PairError> {
255 Ok(client()?.koan_pair_approve(pair, true)?)
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn a_link_reads_back() {
264 let made = link("https://music.example.com:8443/koan", "abc_-123");
265 assert!(made.starts_with("https://koan.rocks/pair/#s=https%3A%2F%2Fmusic"));
266 assert_eq!(
267 PairLink::parse(&made),
268 Some(PairLink {
269 server: "https://music.example.com:8443/koan".into(),
270 id: "abc_-123".into(),
271 })
272 );
273 assert!(PairLink::parse("https://koan.rocks/pair#s=https%3A%2F%2Fa.example&p=x").is_some());
274 }
275
276 #[test]
277 fn the_apps_own_scheme_carries_the_same_link() {
278 let expected = Some(PairLink {
279 server: "https://a.example".into(),
280 id: "x".into(),
281 });
282 assert_eq!(
283 PairLink::parse("koan://pair#s=https%3A%2F%2Fa.example&p=x"),
284 expected
285 );
286 assert_eq!(
287 PairLink::parse("koan://pair?s=https%3A%2F%2Fa.example&p=x"),
288 expected
289 );
290 assert_eq!(
291 PairLink::parse("koan://join?s=https%3A%2F%2Fa.example&p=x"),
292 None
293 );
294 }
295
296 #[test]
297 fn other_links_are_not_pairings() {
298 for link in [
299 "https://koan.rocks/join/#s=https%3A%2F%2Fa.example&p=x",
300 "https://evil.example/pair/#s=https%3A%2F%2Fa.example&p=x",
301 "https://koan.rocks/pair/#s=javascript%3Aalert(1)&p=x",
302 "https://koan.rocks/pair/#s=https%3A%2F%2Fa.example",
303 "https://koan.rocks/pair/#p=x",
304 ] {
305 assert_eq!(PairLink::parse(link), None, "{link}");
306 }
307 }
308
309 #[test]
310 fn the_socket_address_follows_the_scheme() {
311 let url = pair_url("https://music.example.com/koan", "Living room TV").unwrap();
312 assert_eq!(
313 url.as_str(),
314 "wss://music.example.com/koan/rest/koanPair?name=Living+room+TV"
315 );
316 let url = pair_url("http://10.0.0.2:4533", "tv").unwrap();
317 assert_eq!(url.scheme(), "ws");
318 assert!(pair_url("ftp://x", "tv").is_err());
319 }
320
321 #[test]
322 fn info_carries_where_the_request_came_from() {
323 let info: crate::remote::client::KoanPair =
324 serde_json::from_str(r#"{"device":"Den TV","from":"192.168.1.20","local":true}"#)
325 .unwrap();
326 assert_eq!((info.from.as_str(), info.local), ("192.168.1.20", true));
327 }
328
329 #[test]
330 fn messages_are_the_wire_format() {
331 let pending: PairMessage = serde_json::from_str(
332 r#"{"type":"pending","id":"i","code":"ABCD-EFGH","expiresIn":600}"#,
333 )
334 .unwrap();
335 assert_eq!(
336 pending,
337 PairMessage::Pending {
338 id: "i".into(),
339 code: "ABCD-EFGH".into(),
340 expires_in: 600
341 }
342 );
343 let approved = PairMessage::Approved {
344 username: "alice".into(),
345 api_key: "k".into(),
346 };
347 assert_eq!(
348 serde_json::to_string(&approved).unwrap(),
349 r#"{"type":"approved","username":"alice","apiKey":"k"}"#
350 );
351 assert_eq!(
352 serde_json::to_string(&PairMessage::Expired).unwrap(),
353 r#"{"type":"expired"}"#
354 );
355 }
356}