1use std::collections::HashMap;
11use std::net::TcpStream;
12use std::os::fd::RawFd;
13use std::path::Path;
14use std::sync::{Arc, LazyLock};
15use std::time::{Duration, Instant};
16
17use parking_lot::{Condvar, Mutex};
18use serde::{Deserialize, Serialize};
19use tungstenite::stream::MaybeTlsStream;
20
21use crate::config::{self, Config};
22use crate::helpers::{subsonic_auth, subsonic_client};
23use crate::remote::client::SubsonicAuth;
24use crate::remote::profile;
25use crate::remote::wire::{self, Waker};
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "camelCase")]
30pub enum LinkCommand {
31 #[serde(rename_all = "camelCase")]
34 Play {
35 track_ids: Vec<String>,
36 #[serde(default)]
37 start_at: u32,
38 #[serde(default, skip_serializing_if = "is_zero")]
39 position_ms: u64,
40 },
41 #[serde(rename_all = "camelCase")]
43 Enqueue {
44 track_ids: Vec<String>,
45 },
46 #[serde(rename_all = "camelCase")]
48 PlayNext {
49 track_ids: Vec<String>,
50 },
51 #[serde(rename_all = "camelCase")]
53 Remove {
54 track_ids: Vec<String>,
55 },
56 Clear,
57 Radio {
58 enabled: bool,
59 },
60 Sync {
63 #[serde(default)]
64 full: bool,
65 },
66 #[serde(rename_all = "camelCase")]
69 Evict {
70 track_ids: Vec<String>,
71 },
72 #[serde(rename_all = "camelCase")]
75 JumpTo {
76 track_id: String,
77 },
78 #[serde(rename_all = "camelCase")]
79 Seek {
80 position_ms: u64,
81 },
82 Pause,
83 Resume,
84 Next,
85 Previous,
86 PlayItem {
90 id: String,
91 },
92 RemoveItems {
93 ids: Vec<String>,
94 },
95 MoveItems {
97 ids: Vec<String>,
98 target: String,
99 after: bool,
100 },
101 #[serde(rename_all = "camelCase")]
103 Insert {
104 track_ids: Vec<String>,
105 after: String,
106 },
107 Undo,
108 Redo,
109 HandOff {
113 to: String,
114 },
115 Devices {
118 devices: Vec<LinkDevice>,
119 },
120}
121
122fn is_zero(n: &u64) -> bool {
123 *n == 0
124}
125
126impl LinkCommand {
127 pub fn allowed_nearby(&self) -> bool {
131 !matches!(
132 self,
133 Self::Sync { .. } | Self::Evict { .. } | Self::Devices { .. }
134 )
135 }
136
137 pub fn track_ids(&self) -> &[String] {
139 match self {
140 Self::Play { track_ids, .. }
141 | Self::Enqueue { track_ids }
142 | Self::PlayNext { track_ids }
143 | Self::Remove { track_ids }
144 | Self::Evict { track_ids }
145 | Self::Insert { track_ids, .. } => track_ids,
146 Self::JumpTo { track_id } => std::slice::from_ref(track_id),
147 _ => &[],
148 }
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum CommandSource {
155 Account,
157 Nearby,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct LinkDevice {
165 pub id: String,
167 pub name: String,
168 pub platform: String,
169 pub linked: bool,
172 pub state: Option<LinkState>,
175}
176
177impl LinkCommand {
178 pub fn track_ids_mut(&mut self) -> Vec<&mut String> {
181 match self {
182 Self::Play { track_ids, .. }
183 | Self::Enqueue { track_ids }
184 | Self::PlayNext { track_ids }
185 | Self::Remove { track_ids }
186 | Self::Evict { track_ids }
187 | Self::Insert { track_ids, .. } => track_ids.iter_mut().collect(),
188 Self::JumpTo { track_id } => vec![track_id],
189 Self::PlayItem { .. }
190 | Self::RemoveItems { .. }
191 | Self::MoveItems { .. }
192 | Self::Undo
193 | Self::Redo
194 | Self::HandOff { .. }
195 | Self::Devices { .. }
196 | Self::Clear
197 | Self::Radio { .. }
198 | Self::Sync { .. }
199 | Self::Seek { .. }
200 | Self::Pause
201 | Self::Resume
202 | Self::Next
203 | Self::Previous => Vec::new(),
204 }
205 }
206}
207
208#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "camelCase")]
211pub struct LinkState {
212 pub playing: bool,
213 pub title: Option<String>,
214 pub artist: Option<String>,
215 #[serde(default)]
216 pub album: Option<String>,
217 #[serde(default)]
219 pub position_ms: u64,
220 #[serde(default)]
221 pub duration_ms: u64,
222 #[serde(default)]
223 pub radio: bool,
224 #[serde(default)]
226 pub queue: Vec<LinkQueueEntry>,
227}
228
229#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct LinkQueueEntry {
232 #[serde(default)]
234 pub id: Option<String>,
235 pub track_id: Option<String>,
237 pub title: String,
238 pub artist: String,
239 #[serde(default)]
240 pub album: String,
241 #[serde(default)]
242 pub duration_ms: u64,
243 pub current: bool,
244}
245
246impl LinkState {
247 pub fn differs(&self, sent: &LinkState, elapsed: Duration) -> bool {
251 let strip = |s: &LinkState| LinkState {
252 position_ms: 0,
253 ..s.clone()
254 };
255 if strip(self) != strip(sent) {
256 return true;
257 }
258 let expected = if sent.playing {
259 sent.position_ms + elapsed.as_millis() as u64
260 } else {
261 sent.position_ms
262 };
263 self.position_ms.abs_diff(expected) > 3000
264 }
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(tag = "type", rename_all = "camelCase")]
270pub enum LinkReport {
271 State(LinkState),
272 Push {
276 token: String,
277 sandbox: bool,
278 },
279 Command {
281 to: String,
282 command: LinkCommand,
283 },
284 Activity {
287 token: Option<String>,
288 device: Option<String>,
289 #[serde(default)]
290 sandbox: bool,
291 },
292 Hello(LinkHello),
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300pub struct LinkHello {
301 pub id: String,
302 pub name: String,
303 pub platform: String,
304 pub library: Option<String>,
308}
309
310pub fn library_fingerprint(cfg: &Config) -> Option<String> {
313 let auth = subsonic_auth(cfg)?;
314 let url = auth.base_url.trim_end_matches('/').to_ascii_lowercase();
315 Some(format!("{:x}", md5::compute(url.as_bytes())))
316}
317
318static PUSH_TOKEN: Mutex<Option<(String, bool)>> = Mutex::new(None);
321
322type ActivityToken = (String, String, bool);
326static ACTIVITY: Mutex<Option<Option<ActivityToken>>> = Mutex::new(None);
327
328pub fn set_activity(activity: Option<ActivityToken>) {
331 *ACTIVITY.lock() = Some(activity);
332 if let Some(up) = LINK.lock().as_ref() {
333 up.waker.wake();
334 }
335}
336
337pub fn parse_command(json: &str) -> Result<LinkCommand, String> {
340 serde_json::from_str(json).map_err(|e| e.to_string())
341}
342
343pub fn set_push_token(token: String, sandbox: bool) {
345 *PUSH_TOKEN.lock() = Some((token, sandbox));
346 nudge();
347 if let Some(up) = LINK.lock().as_ref() {
348 up.waker.wake();
349 }
350}
351
352#[derive(Debug, Clone)]
354pub struct LinkIdentity {
355 pub name: String,
357 pub platform: String,
359 pub device_id: String,
362}
363
364impl LinkIdentity {
365 pub fn this_device(name: Option<String>) -> Self {
367 let (platform, label) = if cfg!(target_os = "ios") {
368 ("ios", "iPhone")
369 } else if cfg!(target_os = "macos") {
370 ("macos", "Mac")
371 } else {
372 ("linux", "Linux")
373 };
374 Self {
375 name: name
376 .filter(|n| !n.trim().is_empty())
377 .or_else(hostname)
378 .unwrap_or_else(|| label.to_string()),
379 platform: platform.to_string(),
380 device_id: device_id(&config::config_dir()),
381 }
382 }
383}
384
385#[derive(Clone)]
389pub struct Local {
390 pub identity: LinkIdentity,
391 pub state: Arc<dyn Fn() -> LinkState + Send + Sync>,
392 pub on_command: Arc<dyn Fn(LinkCommand, CommandSource) + Send + Sync>,
393}
394
395pub fn spawn(local: Local) {
404 std::thread::Builder::new()
405 .name("koan-link".into())
406 .spawn(move || run(local))
407 .expect("failed to spawn the link thread");
408}
409
410const RETRY_MIN: Duration = Duration::from_secs(2);
411const RETRY_MAX: Duration = Duration::from_secs(60);
412
413fn run(local: Local) {
414 let mut wait = RETRY_MIN;
415 loop {
416 let cfg = Config::load().unwrap_or_default();
417 let Some(auth) = subsonic_auth(&cfg) else {
418 rest(RETRY_MAX);
419 continue;
420 };
421 match profile::for_auth(&auth) {
422 Some(p) if p.links() => {}
423 Some(_) => {
425 rest(RETRY_MAX);
426 continue;
427 }
428 None => {
429 rest(wait);
430 wait = (wait * 2).min(RETRY_MAX);
431 continue;
432 }
433 }
434
435 match connect(&auth, &local.identity) {
436 Ok((mut socket, fd)) => {
437 log::info!("link: connected to {}", auth.base_url);
438 wait = RETRY_MIN;
439 if let Err(e) = serve(&mut socket, fd, &local) {
440 log::info!("link: closed: {e}");
441 }
442 *LINK.lock() = None;
443 crate::remote::devices::set_linked(false);
444 }
445 Err(e) => {
446 log::warn!("link: {e}");
447 profile::forget();
453 }
454 }
455 if rest(wait) {
456 wait = RETRY_MIN;
457 continue;
458 }
459 wait = (wait * 2).min(RETRY_MAX);
460 }
461}
462
463static NUDGE: (Mutex<bool>, Condvar) = (Mutex::new(false), Condvar::new());
464
465pub fn nudge() {
471 *NUDGE.0.lock() = true;
472 NUDGE.1.notify_all();
473}
474
475fn rest(d: Duration) -> bool {
477 let mut nudged = NUDGE.0.lock();
478 if !*nudged {
479 NUDGE.1.wait_for(&mut nudged, d);
480 }
481 std::mem::replace(&mut *nudged, false)
482}
483
484struct Up {
486 waker: Arc<Waker>,
487 outbox: Vec<LinkReport>,
488}
489
490static LINK: Mutex<Option<Up>> = Mutex::new(None);
491
492pub fn report(report: LinkReport) -> bool {
494 let mut link = LINK.lock();
495 let Some(up) = link.as_mut() else {
496 return false;
497 };
498 up.outbox.push(report);
499 up.waker.wake();
500 true
501}
502
503type Socket = tungstenite::WebSocket<MaybeTlsStream<TcpStream>>;
504
505fn connect(auth: &SubsonicAuth, identity: &LinkIdentity) -> Result<(Socket, RawFd), String> {
506 let url = link_url(auth, identity)?;
507 let (socket, _) = tungstenite::connect(url).map_err(|e| e.to_string())?;
508 let fd = wire::prepare(socket.get_ref())?;
509 Ok((socket, fd))
510}
511
512fn link_url(auth: &SubsonicAuth, identity: &LinkIdentity) -> Result<String, String> {
514 let base = if let Some(rest) = auth.base_url.strip_prefix("https://") {
515 format!("wss://{rest}")
516 } else if let Some(rest) = auth.base_url.strip_prefix("http://") {
517 format!("ws://{rest}")
518 } else {
519 return Err(format!("not an http(s) server: {}", auth.base_url));
520 };
521 let mut query = auth.query().map_err(|e| e.to_string())?;
522 for (k, v) in [
523 ("client", identity.name.as_str()),
524 ("platform", identity.platform.as_str()),
525 ("device", identity.device_id.as_str()),
526 ("devices", "1"),
529 ] {
530 query.push('&');
531 query.push_str(k);
532 query.push('=');
533 query.push_str(&percent_encode(v));
534 }
535 Ok(format!("{base}/rest/koanLink?{query}"))
536}
537
538fn serve(socket: &mut Socket, fd: RawFd, local: &Local) -> Result<(), String> {
539 let waker = Waker::new().map_err(|e| e.to_string())?;
540 wire::wake_on_engine_change(&waker);
541 *LINK.lock() = Some(Up {
542 waker: waker.clone(),
543 outbox: Vec::new(),
544 });
545 crate::remote::devices::set_linked(true);
546 let mut session = LinkSession {
547 local,
548 sent: None,
549 sent_push: None,
550 sent_activity: None,
551 };
552 wire::drive(socket, fd, &waker, &mut session)
553}
554
555struct LinkSession<'a> {
556 local: &'a Local,
557 sent: Option<(LinkState, Instant)>,
558 sent_push: Option<(String, bool)>,
559 sent_activity: Option<Option<ActivityToken>>,
560}
561
562impl wire::Session for LinkSession<'_> {
563 fn outgoing(&mut self) -> Vec<String> {
564 let mut out = Vec::new();
565 let push = PUSH_TOKEN.lock().clone();
566 if let Some((token, sandbox)) = push.clone()
567 && push != self.sent_push
568 {
569 out.push(LinkReport::Push { token, sandbox });
570 self.sent_push = push;
571 }
572 let activity = ACTIVITY.lock().clone();
573 if activity.is_some() && activity != self.sent_activity {
574 let (token, device, sandbox) = match activity.clone().flatten() {
575 Some((t, d, s)) => (Some(t), Some(d), s),
576 None => (None, None, false),
577 };
578 out.push(LinkReport::Activity {
579 token,
580 device,
581 sandbox,
582 });
583 self.sent_activity = activity;
584 }
585 if let Some(up) = LINK.lock().as_mut() {
586 out.append(&mut up.outbox);
587 }
588 let now = (self.local.state)();
589 if self
590 .sent
591 .as_ref()
592 .is_none_or(|(s, at)| now.differs(s, at.elapsed()))
593 {
594 out.push(LinkReport::State(now.clone()));
595 self.sent = Some((now, Instant::now()));
596 }
597 out.iter()
598 .filter_map(|r| serde_json::to_string(r).ok())
599 .collect()
600 }
601
602 fn incoming(&mut self, text: &str) {
603 match serde_json::from_str::<LinkCommand>(text) {
604 Ok(LinkCommand::Devices { devices }) => {
605 crate::remote::devices::set_account(devices);
606 }
607 Ok(cmd) => (self.local.on_command)(cmd, CommandSource::Account),
608 Err(e) => log::warn!("link: not a command ({e}): {text}"),
609 }
610 }
611}
612
613pub fn resolve_tracks(
624 db: &crate::db::connection::Database,
625 remote_ids: &[String],
626 may_sync: bool,
627) -> (Vec<i64>, bool) {
628 let lookup = |db: &crate::db::connection::Database| {
629 let mut stmt = db
630 .conn
631 .prepare_cached(
632 "SELECT id FROM tracks WHERE uid = ?1
633 UNION ALL SELECT id FROM tracks WHERE remote_id = ?1 LIMIT 1",
634 )
635 .ok();
636 remote_ids
637 .iter()
638 .map(|rid| {
639 stmt.as_mut()
640 .and_then(|s| s.query_row([rid], |r| r.get::<_, i64>(0)).ok())
641 })
642 .collect::<Vec<_>>()
643 };
644 let found = lookup(db);
645 let missing: Vec<&String> = remote_ids
646 .iter()
647 .zip(&found)
648 .filter(|(_, f)| f.is_none())
649 .map(|(id, _)| id)
650 .collect();
651 if missing.is_empty() || !may_sync || missing.iter().all(|id| recently_missed(id)) {
652 return (found.into_iter().flatten().collect(), false);
653 }
654 sync(db, false);
655 let found = lookup(db);
656 let mut missed = MISSED.lock();
657 let now = Instant::now();
658 missed.retain(|_, at| now.duration_since(*at) < MISS_TTL);
659 for (id, _) in remote_ids.iter().zip(&found).filter(|(_, f)| f.is_none()) {
660 missed.insert(id.clone(), now);
661 }
662 (found.into_iter().flatten().collect(), true)
663}
664
665static MISSED: LazyLock<Mutex<HashMap<String, Instant>>> = LazyLock::new(Default::default);
667const MISS_TTL: Duration = Duration::from_secs(300);
668
669fn recently_missed(id: &str) -> bool {
670 MISSED
671 .lock()
672 .get(id)
673 .is_some_and(|at| at.elapsed() < MISS_TTL)
674}
675
676pub fn sync(db: &crate::db::connection::Database, full: bool) {
679 let cfg = Config::load().unwrap_or_default();
680 if let Some(client) = subsonic_client(&cfg)
681 && let Err(e) = crate::helpers::sync_remote(
682 db,
683 &client,
684 full,
685 &cfg.remote.url,
686 &cfg.remote.username,
687 &|_| {},
688 )
689 {
690 log::warn!("link: sync failed: {e}");
691 }
692}
693
694fn device_id(dir: &Path) -> String {
696 let path = dir.join("device-id");
697 if let Ok(id) = std::fs::read_to_string(&path) {
698 let id = id.trim();
699 if !id.is_empty() {
700 return id.to_string();
701 }
702 }
703 let id = uuid::Uuid::now_v7().to_string();
704 let _ = std::fs::create_dir_all(dir);
705 let _ = std::fs::write(&path, &id);
706 id
707}
708
709fn hostname() -> Option<String> {
710 let mut buf = [0u8; 256];
711 let ok = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } == 0;
714 if !ok {
715 return None;
716 }
717 let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
718 let name = String::from_utf8_lossy(&buf[..end]);
719 let name = name.trim_end_matches(".local").trim();
720 (!name.is_empty() && name != "localhost").then(|| name.to_string())
721}
722
723fn percent_encode(s: &str) -> String {
724 let mut out = String::with_capacity(s.len());
725 for b in s.bytes() {
726 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
727 out.push(b as char);
728 } else {
729 out.push_str(&format!("%{b:02X}"));
730 }
731 }
732 out
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738
739 #[test]
742 fn unknown_ids_sync_only_when_allowed_and_not_recently_missed() {
743 let dir = tempfile::tempdir().unwrap();
744 let db = crate::db::connection::Database::open(&dir.path().join("koan.db")).unwrap();
745
746 let (found, synced) = resolve_tracks(&db, &["from-a-stranger".into()], false);
747 assert!(found.is_empty());
748 assert!(!synced, "a nearby peer's unknown id must not start a sync");
749
750 MISSED
751 .lock()
752 .insert("deleted-on-server".into(), Instant::now());
753 let (_, synced) = resolve_tracks(&db, &["deleted-on-server".into()], true);
754 assert!(
755 !synced,
756 "an id a sync just failed to find does not start another"
757 );
758 }
759
760 #[test]
761 fn commands_name_their_tracks() {
762 let play: LinkCommand =
763 serde_json::from_str(r#"{"type":"play","trackIds":["a","b"]}"#).unwrap();
764 assert_eq!(play.track_ids(), ["a", "b"]);
765 let jump: LinkCommand = serde_json::from_str(r#"{"type":"jumpTo","trackId":"c"}"#).unwrap();
766 assert_eq!(jump.track_ids(), ["c"]);
767 assert!(LinkCommand::Pause.track_ids().is_empty());
768 }
769
770 #[test]
771 fn a_push_token_is_a_tagged_report() {
772 let report = LinkReport::Push {
773 token: "ab12".into(),
774 sandbox: true,
775 };
776 let text = serde_json::to_string(&report).unwrap();
777 assert_eq!(text, r#"{"type":"push","token":"ab12","sandbox":true}"#);
778 assert_eq!(serde_json::from_str::<LinkReport>(&text).unwrap(), report);
779 }
780
781 #[test]
782 fn commands_are_tagged_json() {
783 let play = LinkCommand::Play {
784 track_ids: vec!["12".into(), "34".into()],
785 start_at: 1,
786 position_ms: 0,
787 };
788 let json = serde_json::to_string(&play).unwrap();
789 assert_eq!(
790 json,
791 r#"{"type":"play","trackIds":["12","34"],"startAt":1}"#
792 );
793 assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), play);
794 assert_eq!(
795 serde_json::from_str::<LinkCommand>(r#"{"type":"pause"}"#).unwrap(),
796 LinkCommand::Pause
797 );
798 let report = LinkReport::State(LinkState {
799 playing: true,
800 title: Some("Portions for Foxes".into()),
801 ..Default::default()
802 });
803 let json = serde_json::to_string(&report).unwrap();
804 assert!(json.starts_with(r#"{"type":"state","playing":true,"title":"Portions for Foxes""#));
805 assert_eq!(serde_json::from_str::<LinkReport>(&json).unwrap(), report);
806 }
807
808 #[test]
809 fn a_playhead_moving_on_time_is_not_news() {
810 let sent = LinkState {
811 playing: true,
812 position_ms: 10_000,
813 ..Default::default()
814 };
815 let later = |pos| LinkState {
816 position_ms: pos,
817 ..sent.clone()
818 };
819 let five = Duration::from_secs(5);
820 assert!(!later(15_000).differs(&sent, five));
821 assert!(later(60_000).differs(&sent, five), "a seek");
822 let paused = LinkState {
823 playing: false,
824 ..later(15_000)
825 };
826 assert!(paused.differs(&sent, five));
827 }
828
829 #[test]
830 fn the_url_follows_the_scheme_and_names_the_device() {
831 let identity = LinkIdentity {
832 name: "J's iPhone".into(),
833 platform: "ios".into(),
834 device_id: "abc".into(),
835 };
836 let url = link_url(
837 &SubsonicAuth::new("https://music.example.com", "j", "pw"),
838 &identity,
839 )
840 .unwrap();
841 assert!(url.starts_with("wss://music.example.com/rest/koanLink?"));
842 assert!(url.contains("client=J%27s%20iPhone"));
843 assert!(url.contains("device=abc"));
844 assert!(url.contains("devices=1"));
845 assert!(
846 link_url(&SubsonicAuth::new("http://h:4000", "j", "pw"), &identity)
847 .unwrap()
848 .starts_with("ws://h:4000/")
849 );
850 }
851
852 #[test]
853 fn a_relayed_command_nests_the_command() {
854 let report = LinkReport::Command {
855 to: "phone".into(),
856 command: LinkCommand::HandOff { to: "mac".into() },
857 };
858 let json = serde_json::to_string(&report).unwrap();
859 assert_eq!(
860 json,
861 r#"{"type":"command","to":"phone","command":{"type":"handOff","to":"mac"}}"#
862 );
863 assert_eq!(serde_json::from_str::<LinkReport>(&json).unwrap(), report);
864 }
865
866 #[test]
867 fn an_older_queue_entry_still_reads() {
868 let e: LinkQueueEntry =
869 serde_json::from_str(r#"{"trackId":"7","title":"t","artist":"a","current":true}"#)
870 .unwrap();
871 assert_eq!(e.id, None);
872 assert_eq!(e.duration_ms, 0);
873 }
874
875 #[test]
876 fn strangers_cannot_touch_the_library() {
877 assert!(LinkCommand::Pause.allowed_nearby());
878 assert!(LinkCommand::HandOff { to: "x".into() }.allowed_nearby());
879 assert!(!LinkCommand::Sync { full: true }.allowed_nearby());
880 assert!(!LinkCommand::Evict { track_ids: vec![] }.allowed_nearby());
881 }
882
883 #[test]
884 fn the_device_id_is_kept() {
885 let dir = tempfile::tempdir().unwrap();
886 let first = device_id(dir.path());
887 assert_eq!(device_id(dir.path()), first);
888 }
889}