Skip to main content

koan_core/remote/
link.rs

1//! A koan client's standing connection to the koan server it syncs from.
2//!
3//! The server can then act on the client: play a list of tracks on a phone, or
4//! pause it. Subsonic has no way for a server to reach a client, so this is a
5//! koan extension: a WebSocket at `/rest/koanLink`, authenticated like every
6//! other `/rest` call, carrying [`LinkCommand`]s as JSON text frames from the
7//! server. Track ids are the server's, which a synced client holds as each
8//! track's `remote_id`.
9
10use std::collections::HashMap;
11use std::net::TcpStream;
12use std::os::fd::RawFd;
13use std::path::Path;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::{Arc, LazyLock};
16use std::time::{Duration, Instant};
17
18use parking_lot::{Condvar, Mutex};
19use serde::{Deserialize, Serialize};
20use tungstenite::stream::MaybeTlsStream;
21
22use crate::config::{self, Config};
23use crate::helpers::{subsonic_auth, subsonic_client};
24use crate::player::state::QueueMode;
25use crate::remote::client::SubsonicAuth;
26pub use crate::remote::outputs::{LinkOutput, LinkOutputs, OutputChoice};
27use crate::remote::profile;
28use crate::remote::wire::{self, Waker};
29
30/// What a server asks a linked client to do.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "camelCase")]
33pub enum LinkCommand {
34    /// Replace the queue with these tracks and play from `start_at`,
35    /// `position_ms` into it — or load it there paused, when `paused`.
36    #[serde(rename_all = "camelCase")]
37    Play {
38        track_ids: Vec<String>,
39        #[serde(default)]
40        start_at: u32,
41        #[serde(default, skip_serializing_if = "is_zero")]
42        position_ms: u64,
43        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44        paused: bool,
45        /// Sent by a device handing its music over. One that receives it
46        /// while controlling another device takes control back: the music
47        /// is here now, so this device is what the person is listening to.
48        /// A plain play leaves control alone, so two devices can still
49        /// control each other on purpose.
50        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
51        handoff: bool,
52        /// Whether to reset the play mode, as a play from a play button
53        /// does. Absent, the modes are kept.
54        #[serde(default, skip_serializing_if = "QueueMode::is_keep")]
55        mode: QueueMode,
56    },
57    /// Append these tracks to the queue.
58    #[serde(rename_all = "camelCase")]
59    Enqueue {
60        track_ids: Vec<String>,
61    },
62    /// Insert these tracks after the current one.
63    #[serde(rename_all = "camelCase")]
64    PlayNext {
65        track_ids: Vec<String>,
66    },
67    /// Take every queue entry for these tracks out of the queue.
68    #[serde(rename_all = "camelCase")]
69    Remove {
70        track_ids: Vec<String>,
71    },
72    Clear,
73    /// Pull what the server has changed: library, favourites and playlists.
74    /// The library is walked only if the server says it moved, unless `full`,
75    /// which walks it regardless.
76    Sync {
77        #[serde(default)]
78        full: bool,
79    },
80    /// Delete the downloaded copies of these tracks, so the next play fetches
81    /// them again: for a copy that was cached while the server's was bad.
82    #[serde(rename_all = "camelCase")]
83    Evict {
84        track_ids: Vec<String>,
85    },
86    /// Play this track: from where it sits in the queue, or slotted in after
87    /// the current one when the queue does not hold it.
88    #[serde(rename_all = "camelCase")]
89    JumpTo {
90        track_id: String,
91    },
92    #[serde(rename_all = "camelCase")]
93    Seek {
94        position_ms: u64,
95    },
96    Pause,
97    Resume,
98    Next,
99    Previous,
100    /// Play this queue entry, by the id the device reported it under. Unlike
101    /// `JumpTo` it names one entry of a track queued twice, and reaches a file
102    /// only that device has.
103    PlayItem {
104        id: String,
105    },
106    RemoveItems {
107        ids: Vec<String>,
108    },
109    /// Move these queue entries before or after `target`, in the order given.
110    MoveItems {
111        ids: Vec<String>,
112        target: String,
113        after: bool,
114    },
115    /// Insert these tracks after the queue entry `after`.
116    #[serde(rename_all = "camelCase")]
117    Insert {
118        track_ids: Vec<String>,
119        after: String,
120    },
121    Undo,
122    Redo,
123    /// Turn shuffle on or off. The queue keeps its order; shuffle picks
124    /// which track yet to play this pass plays next.
125    Shuffle {
126        on: bool,
127    },
128    /// What follows a track at its end: `off`, `queue` or `one`.
129    Repeat {
130        mode: crate::player::state::Repeat,
131    },
132    /// Set the sleep timer, or with none cancel it.
133    SleepTimer {
134        timer: Option<crate::player::state::SleepTimer>,
135    },
136    /// Send this device's queue and playhead to the device `to`, as a `play`,
137    /// and pause here. The device holding the queue does it, so taking music
138    /// from another device and sending it there are one command.
139    HandOff {
140        to: String,
141    },
142    /// The account's other devices, as they are now. News rather than a
143    /// command: sent whenever one of them changes, to links that asked for it.
144    Devices {
145        devices: Vec<LinkDevice>,
146    },
147    /// The public keys the account's devices, and devices shared with it,
148    /// prove themselves with on the local network (`koanDeviceKeys`). News,
149    /// as `Devices` is: sent when a device that registered a key links, and
150    /// whenever the account's keys change. From the server alone, never from
151    /// the network: it is what the network's claims are checked against.
152    DeviceKeys {
153        keys: Vec<LinkDeviceKey>,
154    },
155    /// The accounts this device lets control it. News, as `Devices` is: sent
156    /// when it links and whenever the list changes.
157    Shares {
158        grantees: Vec<String>,
159        /// Why the last request to share or stop sharing was refused.
160        #[serde(default, skip_serializing_if = "Option::is_none")]
161        error: Option<String>,
162        /// Every account on the server, to choose from.
163        #[serde(default, skip_serializing_if = "Vec::is_empty")]
164        accounts: Vec<String>,
165    },
166    /// `command`, sent by another account this device is shared with: run as
167    /// that account's request, from the playback set (`allowed_playback`).
168    Shared {
169        command: Box<LinkCommand>,
170    },
171    /// `device` was forgotten: drop it, however it was last heard of. News,
172    /// as `Devices` is.
173    Forgotten {
174        device: String,
175    },
176    /// The account's play history on the server moved: a play recorded or
177    /// forgotten on one of its devices. The device reads what changed
178    /// (`remote::history::sync`).
179    HistoryChanged,
180    /// The account's EQ profiles kept everywhere moved on the server: one
181    /// changed or deleted on another of its devices. The device reads what
182    /// changed (`remote::dsp_sync::sync`).
183    DspProfilesChanged,
184    /// Play through this output from now on, carrying on from where the music
185    /// is, as the device's own output menu would.
186    SetOutput {
187        output: OutputChoice,
188    },
189    /// List the outputs again, and publish them if they moved: a controller
190    /// has opened its output menu. Cheap, and answered by the link state.
191    RefreshOutputs,
192    /// The volume of the renderer the device plays to, 0–100.
193    SetRendererVolume {
194        volume: u8,
195    },
196    /// Play the output `device` through the DSP profile `profile`, or
197    /// untouched with `None`. A renderer is named by its UDN.
198    SetPreset {
199        device: String,
200        profile: Option<String>,
201    },
202    /// Send this device's audio levels (`LinkReport::Levels`) while `on`: a
203    /// controller has playing bars on screen. A device on the same network may
204    /// ask; levels say no more than the now-playing it already sees.
205    WatchLevels {
206        on: bool,
207    },
208    /// A frame of the levels of `from`, a device this one watches, relayed by
209    /// the server. News, like `Devices`.
210    Levels {
211        from: String,
212        f: crate::remote::levels::Frame,
213    },
214    /// The device `from` answered the command this device sent it under
215    /// `ack`: see `remote::acks`. Relayed by the server; news, like `Levels`.
216    Acked {
217        from: String,
218        ack: u64,
219        outcome: crate::remote::acks::AckOutcome,
220    },
221}
222
223fn is_zero(n: &u64) -> bool {
224    *n == 0
225}
226
227impl LinkCommand {
228    /// What a device that is not this account's may have it do: play, pause,
229    /// skip and seek, change the queue, jump, set the volume and the sleep
230    /// timer, choose the output and the preset, and move the music here or
231    /// away. What it asks for runs as the asker's request, never with this
232    /// account's powers: nothing here changes the library, the config beyond
233    /// the output in use, or the account's favourites, playlists or history,
234    /// and a track it names that the library lacks is not synced for (see
235    /// `CommandSource`).
236    /// For a device shared with another account, and one on the local network
237    /// under Full control.
238    pub fn allowed_playback(&self) -> bool {
239        match self {
240            Self::Play { .. }
241            | Self::Enqueue { .. }
242            | Self::PlayNext { .. }
243            | Self::Remove { .. }
244            | Self::Clear
245            | Self::JumpTo { .. }
246            | Self::Seek { .. }
247            | Self::Pause
248            | Self::Resume
249            | Self::Next
250            | Self::Previous
251            | Self::PlayItem { .. }
252            | Self::RemoveItems { .. }
253            | Self::MoveItems { .. }
254            | Self::Insert { .. }
255            | Self::Undo
256            | Self::Redo
257            | Self::Shuffle { .. }
258            | Self::Repeat { .. }
259            | Self::SleepTimer { .. }
260            | Self::HandOff { .. }
261            | Self::SetOutput { .. }
262            | Self::RefreshOutputs
263            | Self::SetRendererVolume { .. }
264            | Self::SetPreset { .. }
265            | Self::WatchLevels { .. } => true,
266            // The library, and the server's own news.
267            Self::Sync { .. }
268            | Self::Evict { .. }
269            | Self::Devices { .. }
270            | Self::DeviceKeys { .. }
271            | Self::Shares { .. }
272            | Self::Shared { .. }
273            | Self::Forgotten { .. }
274            | Self::HistoryChanged
275            | Self::DspProfilesChanged
276            | Self::Levels { .. }
277            | Self::Acked { .. } => false,
278        }
279    }
280
281    /// How a command from a device on the local network runs here, if at
282    /// all: with `full` control (this device's setting) the playback set, as
283    /// `Nearby`; without, a stranger's narrower set, as `Stranger`.
284    pub fn from_the_network(&self, full: bool) -> Option<CommandSource> {
285        if full {
286            self.allowed_playback().then_some(CommandSource::Nearby)
287        } else {
288            self.allowed_nearby().then_some(CommandSource::Stranger)
289        }
290    }
291
292    /// Whether a client may have the server relay this to another device:
293    /// the commands one device gives another. The server's own news (the
294    /// device list, the device keys, shares, forgettings, history) it alone
295    /// originates; relayed, a forged copy would read as the server's. Levels
296    /// go only between live links, by their own route. Exhaustive, so a new
297    /// variant is relayable only once someone decides it is.
298    pub fn relayable(&self) -> bool {
299        match self {
300            Self::Play { .. }
301            | Self::Enqueue { .. }
302            | Self::PlayNext { .. }
303            | Self::Remove { .. }
304            | Self::Clear
305            | Self::Sync { .. }
306            | Self::Evict { .. }
307            | Self::JumpTo { .. }
308            | Self::Seek { .. }
309            | Self::Pause
310            | Self::Resume
311            | Self::Next
312            | Self::Previous
313            | Self::PlayItem { .. }
314            | Self::RemoveItems { .. }
315            | Self::MoveItems { .. }
316            | Self::Insert { .. }
317            | Self::Undo
318            | Self::Redo
319            | Self::Shuffle { .. }
320            | Self::Repeat { .. }
321            | Self::SleepTimer { .. }
322            | Self::HandOff { .. }
323            | Self::SetOutput { .. }
324            | Self::RefreshOutputs
325            | Self::SetRendererVolume { .. }
326            | Self::SetPreset { .. } => true,
327            Self::Devices { .. }
328            | Self::DeviceKeys { .. }
329            | Self::Acked { .. }
330            | Self::Shares { .. }
331            | Self::Shared { .. }
332            | Self::Forgotten { .. }
333            | Self::HistoryChanged
334            | Self::DspProfilesChanged
335            | Self::WatchLevels { .. }
336            | Self::Levels { .. } => false,
337        }
338    }
339
340    /// Only worth saying to a device that is there to hear it: sent over a
341    /// live route or not at all, never queued for an absent device and never
342    /// a push to wake one.
343    pub fn live_only(&self) -> bool {
344        match self {
345            Self::Shared { command } => command.live_only(),
346            cmd => matches!(cmd, Self::WatchLevels { .. } | Self::RefreshOutputs),
347        }
348    }
349
350    /// Whether a device on the same network, which may belong to anyone, may
351    /// send this under Playback only. Playback and the queue; nothing that touches the library or
352    /// the files on disk.
353    ///
354    /// Where the sound goes is not playback: an output switch reaches into
355    /// the room, and a preset into the config. Those are the account's own.
356    pub fn allowed_nearby(&self) -> bool {
357        !matches!(
358            self,
359            Self::Sync { .. }
360                | Self::Evict { .. }
361                | Self::Devices { .. }
362                | Self::DeviceKeys { .. }
363                | Self::Forgotten { .. }
364                | Self::HistoryChanged
365                | Self::DspProfilesChanged
366                | Self::Levels { .. }
367                | Self::Acked { .. }
368                | Self::SetOutput { .. }
369                | Self::RefreshOutputs
370                | Self::SetRendererVolume { .. }
371                | Self::SetPreset { .. }
372                | Self::Shares { .. }
373                | Self::Shared { .. }
374        )
375    }
376
377    /// The server's ids for the tracks this command names, if any.
378    pub fn track_ids(&self) -> &[String] {
379        match self {
380            Self::Play { track_ids, .. }
381            | Self::Enqueue { track_ids }
382            | Self::PlayNext { track_ids }
383            | Self::Remove { track_ids }
384            | Self::Evict { track_ids }
385            | Self::Insert { track_ids, .. } => track_ids,
386            Self::JumpTo { track_id } => std::slice::from_ref(track_id),
387            Self::Shared { command } => command.track_ids(),
388            _ => &[],
389        }
390    }
391}
392
393/// Where a command came from, which decides what it may cost.
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum CommandSource {
396    /// The signed-in server, or this person's own devices through it. The
397    /// only source that may cost a sync.
398    Account,
399    /// Another account this device is shared with, through the server: the
400    /// playback set (`allowed_playback`), as that account.
401    Shared,
402    /// A device on the local network, with this device set to Full control:
403    /// the playback set, whoever is signed in there.
404    Nearby,
405    /// A device on the local network, with this device set to Playback only:
406    /// a stranger's set (`allowed_nearby`), and a hand-off that stays on the
407    /// network.
408    Stranger,
409}
410
411/// A device's public key, as `LinkCommand::DeviceKeys` carries it.
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413pub struct LinkDeviceKey {
414    /// The device's id.
415    pub id: String,
416    /// Base64 of its 32-byte Ed25519 public key.
417    pub key: String,
418    /// The account it belongs to, for a device shared with this one's: `None`
419    /// for the account's own.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub owner: Option<String>,
422}
423
424/// Whether `key` is a public key a device may register: base64 of 32 bytes,
425/// the size of an Ed25519 public key.
426pub fn valid_device_key(key: &str) -> bool {
427    use base64::Engine as _;
428    base64::engine::general_purpose::STANDARD
429        .decode(key)
430        .is_ok_and(|bytes| bytes.len() == 32)
431}
432
433/// Another device on the same account, as the server sends it.
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(rename_all = "camelCase")]
436pub struct LinkDevice {
437    /// The device's own id: stable across its reconnects.
438    pub id: String,
439    pub name: String,
440    pub platform: String,
441    /// Linked now. A device that is not is one iOS has suspended: a command
442    /// wakes it, and music reaches it as a notification to tap.
443    pub linked: bool,
444    /// What it last reported, with the playhead placed as of sending. `None`
445    /// until it has reported at all.
446    pub state: Option<LinkState>,
447    /// Unix seconds when it last held a link, for one that does not now.
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub last_seen: Option<i64>,
450    /// Whether the server can wake it once it is not linked: it gave a push
451    /// token, and the server has a push key. `None` from a server older than
452    /// this, which listed an absent device only when it had a token.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub wakeable: Option<bool>,
455    /// The account it belongs to, for a device another account shares: `None`
456    /// for the account's own.
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub owner: Option<String>,
459    /// It answers commands sent with an id, which the server relays: see
460    /// `remote::acks`. False from a server or a device that predates that.
461    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
462    pub acks: bool,
463}
464
465impl LinkCommand {
466    /// Every track id the command carries, for translating between the
467    /// server's row ids and the uids it publishes.
468    pub fn track_ids_mut(&mut self) -> Vec<&mut String> {
469        match self {
470            Self::Play { track_ids, .. }
471            | Self::Enqueue { track_ids }
472            | Self::PlayNext { track_ids }
473            | Self::Remove { track_ids }
474            | Self::Evict { track_ids }
475            | Self::Insert { track_ids, .. } => track_ids.iter_mut().collect(),
476            Self::JumpTo { track_id } => vec![track_id],
477            Self::Shared { command } => command.track_ids_mut(),
478            Self::PlayItem { .. }
479            | Self::RemoveItems { .. }
480            | Self::MoveItems { .. }
481            | Self::Undo
482            | Self::Redo
483            | Self::Shuffle { .. }
484            | Self::Repeat { .. }
485            | Self::SleepTimer { .. }
486            | Self::HandOff { .. }
487            | Self::Devices { .. }
488            | Self::DeviceKeys { .. }
489            | Self::Shares { .. }
490            | Self::Forgotten { .. }
491            | Self::HistoryChanged
492            | Self::DspProfilesChanged
493            | Self::WatchLevels { .. }
494            | Self::Levels { .. }
495            | Self::Acked { .. }
496            | Self::SetOutput { .. }
497            | Self::RefreshOutputs
498            | Self::SetRendererVolume { .. }
499            | Self::SetPreset { .. }
500            | Self::Clear
501            | Self::Sync { .. }
502            | Self::Seek { .. }
503            | Self::Pause
504            | Self::Resume
505            | Self::Next
506            | Self::Previous => Vec::new(),
507        }
508    }
509}
510
511/// What a linked client tells the server about itself, as it changes.
512#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
513#[serde(rename_all = "camelCase")]
514pub struct LinkState {
515    pub playing: bool,
516    pub title: Option<String>,
517    pub artist: Option<String>,
518    #[serde(default)]
519    pub album: Option<String>,
520    /// Into the current track, as of when this was sent.
521    #[serde(default)]
522    pub position_ms: u64,
523    #[serde(default)]
524    pub duration_ms: u64,
525    /// The queue, or the part of it around the current track when it is long.
526    #[serde(default)]
527    pub queue: Vec<LinkQueueEntry>,
528    /// What the device can play through, for the device controlling it.
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub outputs: Option<LinkOutputs>,
531    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
532    pub shuffle: bool,
533    #[serde(default, skip_serializing_if = "crate::player::state::Repeat::is_off")]
534    pub repeat: crate::player::state::Repeat,
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub sleep: Option<crate::player::state::Sleep>,
537    /// The sleep timer is fading playback out.
538    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
539    pub sleep_fading: bool,
540}
541
542#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
543#[serde(rename_all = "camelCase")]
544pub struct LinkQueueEntry {
545    /// The queue entry's own id on that device, for `playItem` and the rest.
546    #[serde(default)]
547    pub id: Option<String>,
548    /// The server's id for the track; `None` for a file only this device has.
549    pub track_id: Option<String>,
550    pub title: String,
551    pub artist: String,
552    #[serde(default)]
553    pub album: String,
554    #[serde(default)]
555    pub duration_ms: u64,
556    pub current: bool,
557}
558
559impl LinkState {
560    /// Whether `self` says something `sent`, reported `elapsed` ago, did not.
561    /// A playhead moving at one second per second is not news; a seek, a
562    /// pause, another track or an edited queue is.
563    pub fn differs(&self, sent: &LinkState, elapsed: Duration) -> bool {
564        let strip = |s: &LinkState| LinkState {
565            position_ms: 0,
566            ..s.clone()
567        };
568        if strip(self) != strip(sent) {
569            return true;
570        }
571        let expected = if sent.playing {
572            sent.position_ms + elapsed.as_millis() as u64
573        } else {
574            sent.position_ms
575        };
576        self.position_ms.abs_diff(expected) > 3000
577    }
578}
579
580/// A message from a client, up the same socket.
581#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
582#[serde(tag = "type", rename_all = "camelCase")]
583pub enum LinkReport {
584    State(LinkState),
585    /// Where Apple's push service reaches this device, so the server can wake
586    /// it once iOS has suspended it and the socket is gone. `sandbox` for a
587    /// development build, whose tokens only the sandbox gateway accepts.
588    Push {
589        token: String,
590        sandbox: bool,
591    },
592    /// Send `command` to the device `to` on the same account. `ack` asks the
593    /// server to relay `to`'s answer: see `remote::acks`. A server that
594    /// predates it ignores it.
595    Command {
596        to: String,
597        command: LinkCommand,
598        #[serde(default, skip_serializing_if = "Option::is_none")]
599        ack: Option<u64>,
600    },
601    /// A command sent under `ack` has come off this device's link, before
602    /// it is acted on: what tells the server the link is alive, however long
603    /// the command then takes.
604    Received {
605        ack: u64,
606    },
607    /// This device's answer to a command sent to it under `ack`.
608    Ack {
609        ack: u64,
610        outcome: crate::remote::acks::AckOutcome,
611    },
612    /// Where to push updates to a Live Activity showing the device `device`;
613    /// `None` for both when the activity has ended.
614    Activity {
615        token: Option<String>,
616        device: Option<String>,
617        #[serde(default)]
618        sandbox: bool,
619    },
620    /// Who is at the other end of a connection made on the local network.
621    Hello(LinkHello),
622    /// Wake the device `to`, which is not linked: with a background push, or
623    /// with `notify` a notification to tap, for when the push has not done it.
624    Wake {
625        to: String,
626        #[serde(default)]
627        notify: bool,
628    },
629    /// Let the account `grantee` control this device, or with `allow` false
630    /// stop letting it. Only ever about the device sending it.
631    Share {
632        grantee: String,
633        allow: bool,
634    },
635    /// Forget `device`, one of this account's that is not linked: its record
636    /// and its push token. It is listed again if it links again.
637    Forget {
638        device: String,
639    },
640    /// A frame of this device's audio levels, while it is watched: see
641    /// `LinkCommand::WatchLevels`. Sent at the analyser's rate, so short.
642    Levels {
643        f: crate::remote::levels::Frame,
644    },
645}
646
647/// How a device introduces itself to one that connected to it over the local
648/// network, before anything else.
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650#[serde(rename_all = "camelCase")]
651pub struct LinkHello {
652    pub id: String,
653    pub name: String,
654    pub platform: String,
655    /// Which library it plays from, as `library_fingerprint` gives it; `None`
656    /// when it is signed in to none. Two devices with the same one share track
657    /// ids, so music can be handed between them.
658    pub library: Option<String>,
659    /// It answers commands sent with an id: see `remote::acks`. False from a
660    /// device that predates that.
661    #[serde(default)]
662    pub acks: bool,
663    /// From a listener that proves itself and asks the same of whoever
664    /// dialled it: the nonce to sign over. See `remote::proof`.
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub nonce: Option<String>,
667}
668
669/// The server this client plays from, as something two devices can compare
670/// without either saying its address to the network.
671pub fn library_fingerprint(cfg: &Config) -> Option<String> {
672    let auth = subsonic_auth(cfg)?;
673    let url = auth.base_url.trim_end_matches('/').to_ascii_lowercase();
674    Some(format!("{:x}", md5::compute(url.as_bytes())))
675}
676
677/// This device's push token, once the OS has issued one. Set by the app; sent
678/// up each link as it opens, and again if it changes.
679static PUSH_TOKEN: Mutex<Option<(String, bool)>> = Mutex::new(None);
680
681/// A Live Activity on this device showing another: its push token, the device
682/// it shows, and whether the token is the sandbox's. Sent up each link as it
683/// opens, and again when it changes; `None` once the activity has ended.
684type ActivityToken = (String, String, bool);
685static ACTIVITY: Mutex<Option<Option<ActivityToken>>> = Mutex::new(None);
686
687/// Record where the server should push updates to this device's Live
688/// Activity, or that there is none now.
689pub fn set_activity(activity: Option<ActivityToken>) {
690    *ACTIVITY.lock() = Some(activity);
691    if let Some(up) = LINK.lock().as_ref() {
692        up.waker.wake();
693    }
694}
695
696/// A command as a push notification carries it: the same JSON as over the
697/// link.
698pub fn parse_command(json: &str) -> Result<LinkCommand, String> {
699    serde_json::from_str(json).map_err(|e| e.to_string())
700}
701
702/// Record the push token the OS issued this app, and link now to send it.
703pub fn set_push_token(token: String, sandbox: bool) {
704    *PUSH_TOKEN.lock() = Some((token, sandbox));
705    nudge();
706    if let Some(up) = LINK.lock().as_ref() {
707        up.waker.wake();
708    }
709}
710
711/// How a client describes itself when it links.
712#[derive(Debug, Clone)]
713pub struct LinkIdentity {
714    /// Shown to whoever picks a client to play on: "James's iPhone".
715    pub name: String,
716    /// `ios`, `macos` or `linux`.
717    pub platform: String,
718    /// Stable across restarts, so a reconnect replaces its own entry on the
719    /// server rather than listing the device twice.
720    pub device_id: String,
721}
722
723impl LinkIdentity {
724    /// This machine, named `name` or else by its hostname.
725    pub fn this_device(name: Option<String>) -> Self {
726        let (platform, label) = if cfg!(target_os = "ios") {
727            ("ios", "iPhone")
728        } else if cfg!(target_os = "tvos") {
729            ("tvos", "Apple TV")
730        } else if cfg!(target_os = "macos") {
731            ("macos", "Mac")
732        } else {
733            ("linux", "Linux")
734        };
735        Self {
736            name: name
737                .filter(|n| !n.trim().is_empty())
738                .or_else(hostname)
739                .unwrap_or_else(|| label.to_string()),
740            platform: platform.to_string(),
741            device_id: device_id(&config::config_dir()),
742        }
743    }
744}
745
746/// What this device offers whatever controls it: who it is, what it is doing,
747/// and what to do with a command. The link to the server and the connections
748/// made on the local network all serve the same one.
749#[derive(Clone)]
750pub struct Local {
751    pub identity: LinkIdentity,
752    pub state: Arc<dyn Fn() -> LinkState + Send + Sync>,
753    /// Act on a command. With a `Pending`, the sender waits for the answer:
754    /// finish it with the outcome once the command has been acted on.
755    pub on_command:
756        Arc<dyn Fn(LinkCommand, CommandSource, Option<crate::remote::acks::Pending>) + Send + Sync>,
757}
758
759/// Keep a link open to the configured server for as long as the process runs,
760/// handing each command to `on_command` on the link's own thread, and telling
761/// the server what `state` says whenever it changes: which of a person's
762/// devices is the one playing is how the server picks where to send music.
763///
764/// Reads the config before every attempt, so signing in later links without a
765/// restart. Links only to a server whose profile says it can: Navidrome has no
766/// such endpoint.
767pub fn spawn(local: Local) {
768    std::thread::Builder::new()
769        .name("koan-link".into())
770        .spawn(move || run(local))
771        .expect("failed to spawn the link thread");
772}
773
774const RETRY_MIN: Duration = Duration::from_secs(2);
775const RETRY_MAX: Duration = Duration::from_secs(60);
776
777/// Moved at every sign-in and sign-out. A link opened under an earlier one
778/// is the last account's, and closes, to open again as whoever is signed in.
779static SIGN_IN: AtomicU64 = AtomicU64::new(0);
780
781/// The account changed: close the link that is up, which was opened for the
782/// last one, and link again now as the new one. A link carries an account's
783/// news, its device keys among them, and none of it is the next account's.
784pub fn relink() {
785    SIGN_IN.fetch_add(1, Ordering::SeqCst);
786    if let Some(up) = LINK.lock().as_ref() {
787        up.waker.wake();
788    }
789    nudge();
790}
791
792fn run(local: Local) {
793    let mut wait = RETRY_MIN;
794    loop {
795        crate::quiet::wait_until_awake();
796        // Before the config is read: a sign-in after this closes the link
797        // about to open with what it read.
798        let signed_in = SIGN_IN.load(Ordering::SeqCst);
799        let cfg = Config::load().unwrap_or_default();
800        let account = crate::remote::proof::account_of(&cfg);
801        let Some(auth) = subsonic_auth(&cfg) else {
802            rest(RETRY_MAX);
803            continue;
804        };
805        match profile::for_auth(&auth) {
806            Some(p) if p.links() => {}
807            // Not a server that links; asked again when the sign-in changes.
808            Some(_) => {
809                rest(RETRY_MAX);
810                continue;
811            }
812            None => {
813                rest(wait);
814                wait = (wait * 2).min(RETRY_MAX);
815                continue;
816            }
817        }
818
819        match connect(&auth, &local.identity) {
820            Ok((mut socket, fd)) => {
821                log::info!("link: connected to {}", auth.base_url);
822                // The server is answering: downloads waiting out an outage
823                // against it need not wait for their backoff to find out.
824                if let Some(client) = crate::helpers::subsonic_client(&cfg) {
825                    client.outage().retry_now();
826                }
827                wait = RETRY_MIN;
828                if let Err(e) = serve(&mut socket, fd, &local, signed_in, account) {
829                    log::info!("link: closed: {e}");
830                }
831                *LINK.lock() = None;
832                crate::remote::devices::set_linked(false);
833            }
834            Err(e) => {
835                log::warn!("link: {e}");
836                // Asked again before the next attempt: the server may have
837                // been replaced by one that does not link. Not after a link
838                // that simply dropped, which is every time iOS suspends the
839                // app: re-asking then would put two round trips in front of
840                // every reconnect.
841                profile::forget();
842            }
843        }
844        if rest(wait) {
845            wait = RETRY_MIN;
846            continue;
847        }
848        wait = (wait * 2).min(RETRY_MAX);
849    }
850}
851
852static NUDGE: (Mutex<bool>, Condvar) = (Mutex::new(false), Condvar::new());
853
854/// Try to link again now, rather than when the backoff runs out.
855///
856/// For an app coming back to the foreground: iOS suspends a backgrounded app,
857/// its link dies with it, and the retry it was sleeping towards can be a minute
858/// away. Does nothing to a link that is up.
859pub fn nudge() {
860    *NUDGE.0.lock() = true;
861    NUDGE.1.notify_all();
862}
863
864/// Wait `d`, or less if nudged. True when nudged.
865fn rest(d: Duration) -> bool {
866    let mut nudged = NUDGE.0.lock();
867    if !*nudged {
868        NUDGE.1.wait_for(&mut nudged, d);
869    }
870    std::mem::replace(&mut *nudged, false)
871}
872
873/// Close the link, if it is up: the app has nothing to keep it open for.
874pub fn hang_up() {
875    if let Some(up) = LINK.lock().as_ref() {
876        up.waker.wake();
877    }
878}
879
880/// The link while it is up: what waits to go up it, and how to wake it.
881struct Up {
882    waker: Arc<Waker>,
883    outbox: Vec<LinkReport>,
884}
885
886static LINK: Mutex<Option<Up>> = Mutex::new(None);
887
888/// Send `report` up the link. False when the link is down.
889pub fn report(report: LinkReport) -> bool {
890    let mut link = LINK.lock();
891    let Some(up) = link.as_mut() else {
892        return false;
893    };
894    up.outbox.push(report);
895    up.waker.wake();
896    true
897}
898
899type Socket = tungstenite::WebSocket<MaybeTlsStream<TcpStream>>;
900
901fn connect(auth: &SubsonicAuth, identity: &LinkIdentity) -> Result<(Socket, RawFd), String> {
902    let url = link_url(auth, identity)?;
903    let (socket, _) = tungstenite::connect(url).map_err(|e| e.to_string())?;
904    let fd = wire::prepare(socket.get_ref())?;
905    Ok((socket, fd))
906}
907
908/// `/rest/koanLink` with the same credentials every other call carries.
909fn link_url(auth: &SubsonicAuth, identity: &LinkIdentity) -> Result<String, String> {
910    let base = if let Some(rest) = auth.base_url.strip_prefix("https://") {
911        format!("wss://{rest}")
912    } else if let Some(rest) = auth.base_url.strip_prefix("http://") {
913        format!("ws://{rest}")
914    } else {
915        return Err(format!("not an http(s) server: {}", auth.base_url));
916    };
917    let mut query = auth.query().map_err(|e| e.to_string())?;
918    // What this device proves itself with on the local network, registered
919    // against the API key the link signs in with. A server that predates
920    // device keys ignores it.
921    let device_key = crate::remote::proof::public_key().unwrap_or_default();
922    for (k, v) in [
923        ("client", identity.name.as_str()),
924        ("platform", identity.platform.as_str()),
925        ("device", identity.device_id.as_str()),
926        // Send this link the account's other devices. A server that predates
927        // them ignores it.
928        ("devices", "1"),
929        // Answer commands sent with an id. A server that predates it ignores
930        // it, and relays none.
931        ("acks", "1"),
932        ("deviceKey", device_key.as_str()),
933    ] {
934        if v.is_empty() {
935            continue;
936        }
937        query.push('&');
938        query.push_str(k);
939        query.push('=');
940        query.push_str(&percent_encode(v));
941    }
942    Ok(format!("{base}/rest/koanLink?{query}"))
943}
944
945fn serve(
946    socket: &mut Socket,
947    fd: RawFd,
948    local: &Local,
949    signed_in: u64,
950    account: Option<String>,
951) -> Result<(), String> {
952    let waker = Waker::new().map_err(|e| e.to_string())?;
953    let watcher = waker.clone();
954    wire::wake_on_engine_change(&waker);
955    *LINK.lock() = Some(Up {
956        waker: waker.clone(),
957        outbox: Vec::new(),
958    });
959    crate::remote::devices::set_linked(true);
960    let mut session = LinkSession {
961        local,
962        sent: None,
963        sent_push: None,
964        sent_activity: None,
965        waker: watcher,
966        levels: None,
967        signed_in,
968        account,
969    };
970    wire::drive(socket, fd, &waker, &mut session)
971}
972
973struct LinkSession<'a> {
974    local: &'a Local,
975    sent: Option<(LinkState, Instant)>,
976    sent_push: Option<(String, bool)>,
977    sent_activity: Option<Option<ActivityToken>>,
978    waker: Arc<Waker>,
979    /// Set while the server says a device of the account is watching this
980    /// one's levels. It counts the watchers; this link holds one watch.
981    levels: Option<crate::remote::levels::Watch>,
982    /// `SIGN_IN` when the config this link signed in with was read.
983    signed_in: u64,
984    /// The account it signed in as, which the device keys it is sent are
985    /// for (`proof::account_of`).
986    account: Option<String>,
987}
988
989impl wire::Session for LinkSession<'_> {
990    fn outgoing(&mut self) -> Vec<String> {
991        let mut out = Vec::new();
992        let push = PUSH_TOKEN.lock().clone();
993        if let Some((token, sandbox)) = push.clone()
994            && push != self.sent_push
995        {
996            out.push(LinkReport::Push { token, sandbox });
997            self.sent_push = push;
998        }
999        let activity = ACTIVITY.lock().clone();
1000        if activity.is_some() && activity != self.sent_activity {
1001            let (token, device, sandbox) = match activity.clone().flatten() {
1002                Some((t, d, s)) => (Some(t), Some(d), s),
1003                None => (None, None, false),
1004            };
1005            out.push(LinkReport::Activity {
1006                token,
1007                device,
1008                sandbox,
1009            });
1010            self.sent_activity = activity;
1011        }
1012        if let Some(up) = LINK.lock().as_mut() {
1013            out.append(&mut up.outbox);
1014        }
1015        let now = (self.local.state)();
1016        if self
1017            .sent
1018            .as_ref()
1019            .is_none_or(|(s, at)| now.differs(s, at.elapsed()))
1020        {
1021            out.push(LinkReport::State(now.clone()));
1022            self.sent = Some((now, Instant::now()));
1023        }
1024        if let Some(f) = self.levels.as_mut().and_then(|w| w.take()) {
1025            out.push(LinkReport::Levels { f });
1026        }
1027        out.iter()
1028            .filter_map(|r| serde_json::to_string(r).ok())
1029            .collect()
1030    }
1031
1032    fn incoming(&mut self, text: &str) {
1033        let envelope = match serde_json::from_str::<crate::remote::acks::Envelope>(text) {
1034            Ok(envelope) => envelope,
1035            Err(e) => {
1036                log::warn!("link: not a command ({e}): {text}");
1037                return;
1038            }
1039        };
1040        if let Some(ack) = envelope.ack {
1041            report(LinkReport::Received { ack });
1042        }
1043        // Answered up this link, whichever thread finishes it.
1044        let Some((command, pending)) = crate::remote::acks::take(envelope, |ack, outcome| {
1045            report(LinkReport::Ack { ack, outcome });
1046        }) else {
1047            return;
1048        };
1049        match command {
1050            LinkCommand::Acked { from, ack, outcome } => {
1051                crate::remote::acks::resolve(ack, &from, outcome);
1052            }
1053            LinkCommand::Devices { devices } => {
1054                crate::remote::devices::set_account(devices);
1055            }
1056            LinkCommand::DeviceKeys { keys } => {
1057                crate::remote::proof::keep(keys, self.account.clone());
1058            }
1059            LinkCommand::Shares {
1060                grantees,
1061                error,
1062                accounts,
1063            } => {
1064                crate::remote::devices::set_shares(grantees, error, accounts);
1065            }
1066            LinkCommand::Shared { command } => {
1067                // Checked by the server, and again here: this device decides
1068                // what another account may have it do.
1069                if command.allowed_playback() {
1070                    (self.local.on_command)(*command, CommandSource::Shared, pending);
1071                } else {
1072                    log::warn!("link: refused from a shared account: {command:?}");
1073                    if let Some(pending) = pending {
1074                        pending.finish(crate::remote::acks::AckOutcome::Refused {
1075                            reason: "not allowed from another account".into(),
1076                        });
1077                    }
1078                }
1079            }
1080            LinkCommand::Forgotten { device } => {
1081                crate::remote::devices::forgotten(&device);
1082            }
1083            LinkCommand::WatchLevels { on } => {
1084                self.levels = on.then(|| crate::remote::levels::feed().watch(&self.waker));
1085            }
1086            LinkCommand::Levels { from, f } => {
1087                crate::remote::levels::remote().received(&from, f);
1088            }
1089            cmd => (self.local.on_command)(cmd, CommandSource::Account, pending),
1090        }
1091    }
1092
1093    fn done(&self) -> bool {
1094        !crate::quiet::awake() || SIGN_IN.load(Ordering::SeqCst) != self.signed_in
1095    }
1096}
1097
1098/// This library's tracks for the server's ids, in the order given, and
1099/// whether a sync ran to find them.
1100///
1101/// A koan server names a track by its uid, which this library adopted when it
1102/// synced the track; another server by the id it issued. A server can name a
1103/// track added since the last sync; if any are missing and `may_sync`, an
1104/// sync runs first, and whatever is still missing after it is left
1105/// out. An id a sync already failed to find does not start another for a
1106/// while: a command naming a track deleted on the server would otherwise sync
1107/// every time it arrived.
1108pub fn resolve_tracks(
1109    db: &crate::db::connection::Database,
1110    remote_ids: &[String],
1111    may_sync: bool,
1112) -> (Vec<i64>, bool) {
1113    let lookup = |db: &crate::db::connection::Database| {
1114        let mut stmt = db
1115            .conn
1116            .prepare_cached(
1117                "SELECT id FROM tracks WHERE uid = ?1
1118                 UNION ALL SELECT id FROM tracks WHERE remote_id = ?1 LIMIT 1",
1119            )
1120            .ok();
1121        remote_ids
1122            .iter()
1123            .map(|rid| {
1124                stmt.as_mut()
1125                    .and_then(|s| s.query_row([rid], |r| r.get::<_, i64>(0)).ok())
1126            })
1127            .collect::<Vec<_>>()
1128    };
1129    let found = lookup(db);
1130    let missing: Vec<&String> = remote_ids
1131        .iter()
1132        .zip(&found)
1133        .filter(|(_, f)| f.is_none())
1134        .map(|(id, _)| id)
1135        .collect();
1136    if missing.is_empty() || !may_sync || missing.iter().all(|id| recently_missed(id)) {
1137        return (found.into_iter().flatten().collect(), false);
1138    }
1139    sync(db, crate::helpers::Walk::IfChanged);
1140    let found = lookup(db);
1141    let mut missed = MISSED.lock();
1142    let now = Instant::now();
1143    missed.retain(|_, at| now.duration_since(*at) < MISS_TTL);
1144    for (id, _) in remote_ids.iter().zip(&found).filter(|(_, f)| f.is_none()) {
1145        missed.insert(id.clone(), now);
1146    }
1147    (found.into_iter().flatten().collect(), true)
1148}
1149
1150/// Ids a sync looked for and did not find, and when.
1151static MISSED: LazyLock<Mutex<HashMap<String, Instant>>> = LazyLock::new(Default::default);
1152const MISS_TTL: Duration = Duration::from_secs(300);
1153
1154fn recently_missed(id: &str) -> bool {
1155    MISSED
1156        .lock()
1157        .get(id)
1158        .is_some_and(|at| at.elapsed() < MISS_TTL)
1159}
1160
1161/// A sync from the configured server, as the app runs its own: the library,
1162/// then favourites and playlists.
1163pub fn sync(db: &crate::db::connection::Database, walk: crate::helpers::Walk) {
1164    let cfg = Config::load().unwrap_or_default();
1165    if let Some(client) = subsonic_client(&cfg)
1166        && let Err(e) = crate::helpers::sync_remote(
1167            db,
1168            &client,
1169            walk,
1170            &cfg.remote.url,
1171            &cfg.remote.username,
1172            &|_| {},
1173        )
1174    {
1175        log::warn!("link: sync failed: {e}");
1176    }
1177}
1178
1179/// A random id kept in the config directory, and on iOS in the Keychain as
1180/// well: deleting an app empties its container but not its Keychain items, so
1181/// a reinstalled app keeps its id rather than appearing as a second device.
1182fn device_id(dir: &Path) -> String {
1183    #[cfg(any(target_os = "ios", target_os = "tvos"))]
1184    {
1185        use security_framework::passwords::{get_generic_password, set_generic_password};
1186        const SERVICE: &str = "cc.blit.koan.link";
1187        if let Some(id) = get_generic_password(SERVICE, "device-id")
1188            .ok()
1189            .and_then(|b| String::from_utf8(b).ok())
1190            .filter(|id| !id.trim().is_empty())
1191        {
1192            return id;
1193        }
1194        let id = file_device_id(dir);
1195        let _ = set_generic_password(SERVICE, "device-id", id.as_bytes());
1196        id
1197    }
1198    #[cfg(not(any(target_os = "ios", target_os = "tvos")))]
1199    file_device_id(dir)
1200}
1201
1202fn file_device_id(dir: &Path) -> String {
1203    let path = dir.join("device-id");
1204    if let Ok(id) = std::fs::read_to_string(&path) {
1205        let id = id.trim();
1206        if !id.is_empty() {
1207            return id.to_string();
1208        }
1209    }
1210    let id = uuid::Uuid::now_v7().to_string();
1211    let _ = std::fs::create_dir_all(dir);
1212    let _ = std::fs::write(&path, &id);
1213    id
1214}
1215
1216fn hostname() -> Option<String> {
1217    let mut buf = [0u8; 256];
1218    // SAFETY: the buffer is valid for its whole length, and gethostname
1219    // writes at most that many bytes.
1220    let ok = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } == 0;
1221    if !ok {
1222        return None;
1223    }
1224    let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
1225    let name = String::from_utf8_lossy(&buf[..end]);
1226    let name = name.trim_end_matches(".local").trim();
1227    (!name.is_empty() && name != "localhost").then(|| name.to_string())
1228}
1229
1230fn percent_encode(s: &str) -> String {
1231    let mut out = String::with_capacity(s.len());
1232    for b in s.bytes() {
1233        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
1234            out.push(b as char);
1235        } else {
1236            out.push_str(&format!("%{b:02X}"));
1237        }
1238    }
1239    out
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244
1245    #[test]
1246    fn levels_cross_the_link_compactly() {
1247        use crate::remote::levels::Frame;
1248        let f = Frame(61_250, 512, 300, 40);
1249
1250        let report = serde_json::to_string(&LinkReport::Levels { f }).unwrap();
1251        assert_eq!(report, r#"{"type":"levels","f":[61250,512,300,40]}"#);
1252        assert_eq!(
1253            serde_json::from_str::<LinkReport>(&report).unwrap(),
1254            LinkReport::Levels { f }
1255        );
1256
1257        let relayed = LinkCommand::Levels {
1258            from: "dev-phone".into(),
1259            f,
1260        };
1261        let text = serde_json::to_string(&relayed).unwrap();
1262        assert!(text.len() < 80, "{} bytes: {text}", text.len());
1263        assert_eq!(serde_json::from_str::<LinkCommand>(&text).unwrap(), relayed);
1264
1265        let watch = LinkCommand::WatchLevels { on: true };
1266        let text = serde_json::to_string(&watch).unwrap();
1267        assert_eq!(serde_json::from_str::<LinkCommand>(&text).unwrap(), watch);
1268        assert!(watch.allowed_nearby(), "a stranger may watch the bars");
1269        assert!(!relayed.allowed_nearby(), "only the server relays frames");
1270    }
1271    use super::*;
1272
1273    #[test]
1274    fn a_sleep_timer_travels_as_playback_and_comes_back_in_the_state() {
1275        use crate::player::state::{Sleep, SleepTimer};
1276        let cmd: LinkCommand =
1277            serde_json::from_str(r#"{"type":"sleepTimer","timer":{"kind":"after","minutes":30}}"#)
1278                .unwrap();
1279        assert_eq!(
1280            cmd,
1281            LinkCommand::SleepTimer {
1282                timer: Some(SleepTimer::After { minutes: 30 })
1283            }
1284        );
1285        let cancel: LinkCommand =
1286            serde_json::from_str(r#"{"type":"sleepTimer","timer":null}"#).unwrap();
1287        assert_eq!(cancel, LinkCommand::SleepTimer { timer: None });
1288        for cmd in [cmd, cancel] {
1289            assert!(cmd.allowed_playback() && cmd.allowed_nearby(), "{cmd:?}");
1290        }
1291
1292        let state = LinkState {
1293            sleep: Some(Sleep::EndOfRecord),
1294            ..Default::default()
1295        };
1296        let json = serde_json::to_string(&state).unwrap();
1297        assert!(json.contains(r#""sleep":{"kind":"endOfRecord"}"#), "{json}");
1298        assert_eq!(serde_json::from_str::<LinkState>(&json).unwrap(), state);
1299        assert!(
1300            !serde_json::to_string(&LinkState::default())
1301                .unwrap()
1302                .contains("sleep"),
1303            "nothing said with none set"
1304        );
1305    }
1306
1307    /// Another account, or a device on the network under Full control, gets
1308    /// the playback set: more than a stranger (outputs, presets, volume,
1309    /// hand-off), and nothing of the library or the server's news.
1310    #[test]
1311    fn the_playback_set_is_playback_and_nothing_of_the_library() {
1312        let ids = vec!["t".to_string()];
1313        for cmd in [
1314            LinkCommand::Pause,
1315            LinkCommand::JumpTo {
1316                track_id: "t".into(),
1317            },
1318            LinkCommand::Enqueue {
1319                track_ids: ids.clone(),
1320            },
1321            LinkCommand::HandOff { to: "x".into() },
1322            LinkCommand::SetRendererVolume { volume: 1 },
1323        ] {
1324            assert!(cmd.allowed_playback(), "{cmd:?}");
1325            assert_eq!(cmd.from_the_network(true), Some(CommandSource::Nearby));
1326        }
1327        for cmd in [
1328            LinkCommand::Sync { full: false },
1329            LinkCommand::Evict {
1330                track_ids: ids.clone(),
1331            },
1332            LinkCommand::Devices { devices: vec![] },
1333            LinkCommand::Shares {
1334                grantees: vec![],
1335                error: None,
1336                accounts: vec![],
1337            },
1338            LinkCommand::Shared {
1339                command: Box::new(LinkCommand::Pause),
1340            },
1341        ] {
1342            assert!(!cmd.allowed_playback(), "{cmd:?}");
1343            assert_eq!(cmd.from_the_network(true), None, "{cmd:?}");
1344            assert_eq!(cmd.from_the_network(false), None, "{cmd:?}");
1345        }
1346        // Playback only: a stranger's set, run as a stranger.
1347        assert_eq!(
1348            LinkCommand::Pause.from_the_network(false),
1349            Some(CommandSource::Stranger)
1350        );
1351        assert_eq!(
1352            LinkCommand::SetRendererVolume { volume: 1 }.from_the_network(false),
1353            None
1354        );
1355    }
1356
1357    // Neither case may reach `sync`: a test has no business reading the
1358    // machine's config and syncing against the server it names.
1359    #[test]
1360    fn unknown_ids_sync_only_when_allowed_and_not_recently_missed() {
1361        let dir = tempfile::tempdir().unwrap();
1362        let db = crate::db::connection::Database::open(&dir.path().join("koan.db")).unwrap();
1363
1364        let (found, synced) = resolve_tracks(&db, &["from-a-stranger".into()], false);
1365        assert!(found.is_empty());
1366        assert!(!synced, "a nearby peer's unknown id must not start a sync");
1367
1368        MISSED
1369            .lock()
1370            .insert("deleted-on-server".into(), Instant::now());
1371        let (_, synced) = resolve_tracks(&db, &["deleted-on-server".into()], true);
1372        assert!(
1373            !synced,
1374            "an id a sync just failed to find does not start another"
1375        );
1376    }
1377
1378    #[test]
1379    fn commands_name_their_tracks() {
1380        let play: LinkCommand =
1381            serde_json::from_str(r#"{"type":"play","trackIds":["a","b"]}"#).unwrap();
1382        assert_eq!(play.track_ids(), ["a", "b"]);
1383        let jump: LinkCommand = serde_json::from_str(r#"{"type":"jumpTo","trackId":"c"}"#).unwrap();
1384        assert_eq!(jump.track_ids(), ["c"]);
1385        assert!(LinkCommand::Pause.track_ids().is_empty());
1386    }
1387
1388    #[test]
1389    fn a_push_token_is_a_tagged_report() {
1390        let report = LinkReport::Push {
1391            token: "ab12".into(),
1392            sandbox: true,
1393        };
1394        let text = serde_json::to_string(&report).unwrap();
1395        assert_eq!(text, r#"{"type":"push","token":"ab12","sandbox":true}"#);
1396        assert_eq!(serde_json::from_str::<LinkReport>(&text).unwrap(), report);
1397    }
1398
1399    #[test]
1400    fn commands_are_tagged_json() {
1401        let play = LinkCommand::Play {
1402            track_ids: vec!["12".into(), "34".into()],
1403            start_at: 1,
1404            position_ms: 0,
1405            paused: false,
1406            handoff: false,
1407            mode: QueueMode::Keep,
1408        };
1409        let json = serde_json::to_string(&play).unwrap();
1410        assert_eq!(
1411            json,
1412            r#"{"type":"play","trackIds":["12","34"],"startAt":1}"#
1413        );
1414        assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), play);
1415        let held = LinkCommand::Play {
1416            track_ids: vec!["12".into()],
1417            start_at: 0,
1418            position_ms: 61_250,
1419            paused: true,
1420            handoff: false,
1421            mode: QueueMode::Keep,
1422        };
1423        let json = serde_json::to_string(&held).unwrap();
1424        assert_eq!(
1425            json,
1426            r#"{"type":"play","trackIds":["12"],"startAt":0,"positionMs":61250,"paused":true}"#
1427        );
1428        assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), held);
1429        assert_eq!(
1430            serde_json::from_str::<LinkCommand>(r#"{"type":"pause"}"#).unwrap(),
1431            LinkCommand::Pause
1432        );
1433        let report = LinkReport::State(LinkState {
1434            playing: true,
1435            title: Some("Portions for Foxes".into()),
1436            ..Default::default()
1437        });
1438        let json = serde_json::to_string(&report).unwrap();
1439        assert!(json.starts_with(r#"{"type":"state","playing":true,"title":"Portions for Foxes""#));
1440        assert_eq!(serde_json::from_str::<LinkReport>(&json).unwrap(), report);
1441    }
1442
1443    #[test]
1444    fn a_playhead_moving_on_time_is_not_news() {
1445        let sent = LinkState {
1446            playing: true,
1447            position_ms: 10_000,
1448            ..Default::default()
1449        };
1450        let later = |pos| LinkState {
1451            position_ms: pos,
1452            ..sent.clone()
1453        };
1454        let five = Duration::from_secs(5);
1455        assert!(!later(15_000).differs(&sent, five));
1456        assert!(later(60_000).differs(&sent, five), "a seek");
1457        let paused = LinkState {
1458            playing: false,
1459            ..later(15_000)
1460        };
1461        assert!(paused.differs(&sent, five));
1462    }
1463
1464    #[test]
1465    fn the_url_follows_the_scheme_and_names_the_device() {
1466        let identity = LinkIdentity {
1467            name: "J's iPhone".into(),
1468            platform: "ios".into(),
1469            device_id: "abc".into(),
1470        };
1471        let url = link_url(
1472            &SubsonicAuth::new("https://music.example.com", "j", "pw"),
1473            &identity,
1474        )
1475        .unwrap();
1476        assert!(url.starts_with("wss://music.example.com/rest/koanLink?"));
1477        assert!(url.contains("client=J%27s%20iPhone"));
1478        assert!(url.contains("device=abc"));
1479        assert!(url.contains("devices=1"));
1480        assert!(
1481            link_url(&SubsonicAuth::new("http://h:4000", "j", "pw"), &identity)
1482                .unwrap()
1483                .starts_with("ws://h:4000/")
1484        );
1485    }
1486
1487    #[test]
1488    fn a_relayed_command_nests_the_command() {
1489        let report = LinkReport::Command {
1490            to: "phone".into(),
1491            command: LinkCommand::HandOff { to: "mac".into() },
1492            ack: None,
1493        };
1494        let json = serde_json::to_string(&report).unwrap();
1495        assert_eq!(
1496            json,
1497            r#"{"type":"command","to":"phone","command":{"type":"handOff","to":"mac"}}"#
1498        );
1499        assert_eq!(serde_json::from_str::<LinkReport>(&json).unwrap(), report);
1500    }
1501
1502    #[test]
1503    fn an_older_queue_entry_still_reads() {
1504        let e: LinkQueueEntry =
1505            serde_json::from_str(r#"{"trackId":"7","title":"t","artist":"a","current":true}"#)
1506                .unwrap();
1507        assert_eq!(e.id, None);
1508        assert_eq!(e.duration_ms, 0);
1509    }
1510
1511    #[test]
1512    fn strangers_cannot_touch_the_library() {
1513        assert!(LinkCommand::Pause.allowed_nearby());
1514        assert!(LinkCommand::HandOff { to: "x".into() }.allowed_nearby());
1515        assert!(!LinkCommand::Sync { full: true }.allowed_nearby());
1516        assert!(!LinkCommand::Evict { track_ids: vec![] }.allowed_nearby());
1517    }
1518
1519    #[test]
1520    fn the_device_id_is_kept() {
1521        let dir = tempfile::tempdir().unwrap();
1522        let first = device_id(dir.path());
1523        assert_eq!(device_id(dir.path()), first);
1524    }
1525}
1526
1527#[cfg(test)]
1528mod device_key_tests {
1529    use super::*;
1530
1531    /// The keys are what a network peer's claims are checked against, so no
1532    /// peer may send them: not a stranger, not under Full control, not a
1533    /// shared account.
1534    #[test]
1535    fn device_keys_come_from_the_server_alone() {
1536        let cmd = LinkCommand::DeviceKeys { keys: vec![] };
1537        assert!(!cmd.allowed_nearby());
1538        assert!(!cmd.allowed_playback());
1539        assert_eq!(cmd.from_the_network(true), None);
1540        assert_eq!(cmd.from_the_network(false), None);
1541    }
1542
1543    #[test]
1544    fn the_server_never_relays_its_own_news() {
1545        for news in [
1546            LinkCommand::DeviceKeys { keys: vec![] },
1547            LinkCommand::Devices { devices: vec![] },
1548            LinkCommand::HistoryChanged,
1549            LinkCommand::DspProfilesChanged,
1550            LinkCommand::Shared {
1551                command: Box::new(LinkCommand::Pause),
1552            },
1553        ] {
1554            assert!(!news.relayable(), "{news:?}");
1555        }
1556        assert!(LinkCommand::Pause.relayable());
1557    }
1558
1559    #[test]
1560    fn a_device_key_is_32_bytes_of_base64() {
1561        use base64::Engine as _;
1562        let b64 = base64::engine::general_purpose::STANDARD;
1563        assert!(valid_device_key(&b64.encode([7u8; 32])));
1564        assert!(!valid_device_key(&b64.encode([7u8; 31])));
1565        assert!(!valid_device_key(&b64.encode([7u8; 33])));
1566        assert!(!valid_device_key("not base64!"));
1567        assert!(!valid_device_key(""));
1568    }
1569}