1use std::cell::{Cell, RefCell};
29use std::collections::{BTreeMap, BTreeSet};
30use std::rc::Rc;
31
32use serde::{Deserialize, Serialize};
33
34#[must_use]
37pub fn service_icon(app: &str) -> crate::icons::Icon {
38 match app {
39 "so.tilo.discord" => crate::icons::Icon::Discord,
40 _ => crate::icons::Icon::Link,
41 }
42}
43
44#[must_use]
46pub fn service_name_icon(service: &str) -> crate::icons::Icon {
47 if service.eq_ignore_ascii_case("discord") {
48 crate::icons::Icon::Discord
49 } else {
50 crate::icons::Icon::Link
51 }
52}
53
54#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ProfileLink {
59 pub service: String,
61 pub handle: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub picture: Option<String>,
66}
67
68pub const READ_ACTION: &str = "my-link";
71
72#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "kebab-case")]
75pub struct Reading {
76 pub service: String,
78 pub connected: bool,
80 #[serde(default)]
81 pub account: Option<AccountName>,
82 #[serde(default)]
85 pub picture: Option<String>,
86 #[serde(default)]
89 pub connect: Option<String>,
90 #[serde(default)]
93 pub elsewhere: Option<String>,
94 #[serde(default)]
96 pub unlink: Option<String>,
97 #[serde(default)]
100 pub offer: String,
101 #[serde(default)]
103 pub accept: String,
104}
105
106#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "kebab-case")]
110pub struct AccountName {
111 pub user_name: String,
112 #[serde(default)]
113 pub display_name: Option<String>,
114}
115
116impl AccountName {
117 #[must_use]
120 pub fn label(&self) -> String {
121 match &self.display_name {
122 Some(shown) if *shown != self.user_name => format!("{shown} · @{}", self.user_name),
123 _ => self.handle(),
124 }
125 }
126
127 #[must_use]
129 pub fn handle(&self) -> String {
130 format!("@{}", self.user_name)
131 }
132}
133
134#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
136pub struct LinkStart {
137 pub url: String,
138}
139
140#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "kebab-case")]
143pub struct OfferReading {
144 pub service: String,
146 pub account: AccountName,
147 #[serde(default)]
150 pub started_here: bool,
151}
152
153#[derive(Clone, Debug, PartialEq, Eq)]
155pub enum LinkState {
156 Unlinked {
159 connect: bool,
160 elsewhere: Option<String>,
161 },
162 Connecting,
164 Looking,
166 Offered(OfferReading),
168 Linked(AccountName),
169}
170
171#[derive(Clone, Debug, PartialEq, Eq)]
173pub struct LinkedAccount {
174 pub app: String,
176 pub service: String,
178 pub state: LinkState,
179 pub problem: Option<String>,
181}
182
183pub trait LinkedAccountsHost {
187 fn accounts(&self) -> Vec<LinkedAccount>;
190 fn revision(&self) -> u64;
192 fn profile_links(&self) -> Option<Vec<ProfileLink>> {
195 None
196 }
197 fn refresh(&self);
199 fn refresh_app(&self, app: &str) {
201 let _ = app;
202 self.refresh();
203 }
204 fn link(&self, app: &str);
207 fn unlink(&self, app: &str);
209 fn offer(&self, token: &str) {
211 let _ = token;
212 }
213 fn accept(&self, app: &str) {
215 let _ = app;
216 }
217 fn decline(&self, app: &str) {
219 let _ = app;
220 }
221}
222
223#[derive(Clone, Default)]
226pub struct LinkedAccounts(Option<Rc<dyn LinkedAccountsHost>>);
227
228impl LinkedAccounts {
229 #[must_use]
230 pub fn new(host: Rc<dyn LinkedAccountsHost>) -> Self {
231 Self(Some(host))
232 }
233
234 #[must_use]
236 pub fn is_available(&self) -> bool {
237 self.0.is_some()
238 }
239
240 #[must_use]
241 pub fn accounts(&self) -> Vec<LinkedAccount> {
242 self.0
243 .as_ref()
244 .map(|host| host.accounts())
245 .unwrap_or_default()
246 }
247
248 #[must_use]
249 pub fn revision(&self) -> u64 {
250 self.0.as_ref().map_or(0, |host| host.revision())
251 }
252
253 #[must_use]
254 pub fn profile_links(&self) -> Option<Vec<ProfileLink>> {
255 self.0.as_ref().and_then(|host| host.profile_links())
256 }
257
258 pub fn refresh(&self) {
259 if let Some(host) = &self.0 {
260 host.refresh();
261 }
262 }
263
264 pub fn refresh_app(&self, app: &str) {
265 if let Some(host) = &self.0 {
266 host.refresh_app(app);
267 }
268 }
269
270 pub fn link(&self, app: &str) {
271 if let Some(host) = &self.0 {
272 host.link(app);
273 }
274 }
275
276 pub fn unlink(&self, app: &str) {
277 if let Some(host) = &self.0 {
278 host.unlink(app);
279 }
280 }
281
282 pub fn offer(&self, token: &str) {
283 if let Some(host) = &self.0 {
284 host.offer(token);
285 }
286 }
287
288 pub fn accept(&self, app: &str) {
289 if let Some(host) = &self.0 {
290 host.accept(app);
291 }
292 }
293
294 pub fn decline(&self, app: &str) {
295 if let Some(host) = &self.0 {
296 host.decline(app);
297 }
298 }
299}
300
301pub const LINKING_RECHECK: std::time::Duration = std::time::Duration::from_secs(2);
303
304pub const UNANSWERED_RETRIES: u32 = 4;
308
309pub const OFFER_TRIES: u32 = 10;
313
314#[derive(Debug, Default)]
324pub struct LinkingRecheck {
325 due: Option<ui_core::time::Instant>,
326 unanswered: Option<Unanswered>,
327}
328
329#[derive(Debug)]
331struct Unanswered {
332 due: ui_core::time::Instant,
333 asked: u32,
334}
335
336impl LinkingRecheck {
337 pub fn follow(
342 &mut self,
343 accounts: &LinkedAccounts,
344 shown: bool,
345 now: ui_core::time::Instant,
346 ) -> Option<ui_core::time::Instant> {
347 let known = accounts.accounts();
348 self.follow_offers(accounts, &known, now);
349 self.follow_unanswered(accounts, shown && known.is_empty(), now);
350 self.due()
351 }
352
353 fn follow_offers(
354 &mut self,
355 accounts: &LinkedAccounts,
356 known: &[LinkedAccount],
357 now: ui_core::time::Instant,
358 ) {
359 let waiting: Vec<&str> = known
360 .iter()
361 .filter(|account| account.state == LinkState::Looking)
362 .map(|account| account.app.as_str())
363 .collect();
364 if waiting.is_empty() {
365 self.due = None;
366 return;
367 }
368 match self.due {
369 Some(due) if now < due => {}
370 Some(_) => {
371 for app in waiting {
372 accounts.refresh_app(app);
373 }
374 self.due = now.checked_add(LINKING_RECHECK);
375 }
376 None => self.due = now.checked_add(LINKING_RECHECK),
377 }
378 }
379
380 fn follow_unanswered(
381 &mut self,
382 accounts: &LinkedAccounts,
383 nothing_known: bool,
384 now: ui_core::time::Instant,
385 ) {
386 if !nothing_known {
387 self.unanswered = None;
388 return;
389 }
390 let Some(unanswered) = &mut self.unanswered else {
391 self.unanswered = now
393 .checked_add(LINKING_RECHECK)
394 .map(|due| Unanswered { due, asked: 0 });
395 return;
396 };
397 if now < unanswered.due || unanswered.asked >= UNANSWERED_RETRIES {
398 return;
399 }
400 accounts.refresh();
401 if !accounts.accounts().is_empty() {
402 self.unanswered = None;
403 return;
404 }
405 unanswered.asked = unanswered.asked.saturating_add(1);
406 match LINKING_RECHECK
407 .checked_mul(1 << unanswered.asked)
408 .and_then(|wait| now.checked_add(wait))
409 {
410 Some(due) => unanswered.due = due,
411 None => unanswered.asked = UNANSWERED_RETRIES,
412 }
413 }
414
415 #[must_use]
417 pub fn due(&self) -> Option<ui_core::time::Instant> {
418 let unanswered = self
419 .unanswered
420 .as_ref()
421 .filter(|unanswered| unanswered.asked < UNANSWERED_RETRIES)
422 .map(|unanswered| unanswered.due);
423 match (self.due, unanswered) {
424 (Some(a), Some(b)) => Some(a.min(b)),
425 (a, b) => a.or(b),
426 }
427 }
428}
429
430pub type ActionAnswer = Result<serde_json::Value, String>;
432
433pub fn connector_apps(
440 workspace_apps: impl IntoIterator<Item = String>,
441 runs_here: impl Fn(&str) -> bool,
442) -> Vec<String> {
443 workspace_apps
444 .into_iter()
445 .filter(|app| !runs_here(app))
446 .collect()
447}
448
449pub trait WorkspaceRecordActions {
452 fn apps(&self) -> Vec<String>;
455 fn invoke(
459 &self,
460 app: &str,
461 action: &str,
462 arguments: serde_json::Map<String, serde_json::Value>,
463 done: Box<dyn FnOnce(ActionAnswer)>,
464 );
465 fn open(&self, url: &str) {
468 let _ = url;
469 }
470}
471
472#[must_use]
477pub fn record_invocation(
478 invocation: tilo_actions::InvocationId,
479 app: &str,
480 action: &str,
481 arguments: serde_json::Map<String, serde_json::Value>,
482) -> tilo_actions::remote::InvokeRequest {
483 use tilo_actions::remote::{InvokeArgument, MAX_REMOTE_WAIT, RemoteActionTarget};
484
485 tilo_actions::remote::InvokeRequest {
486 invocation,
487 target: RemoteActionTarget {
488 provider: format!("record:{app}:"),
489 address: tilo_actions::ActionAddress::new("", action),
490 },
491 argument: if arguments.is_empty() {
492 InvokeArgument::None
493 } else {
494 InvokeArgument::Json(arguments)
495 },
496 context: None,
499 confirmation: None,
500 revision: None,
501 on_behalf_of: None,
502 wait_ms: Some(u32::try_from(MAX_REMOTE_WAIT.as_millis()).unwrap_or(u32::MAX)),
503 appearance: None,
504 }
505}
506
507pub fn record_answer(
510 answer: Option<
511 Result<tilo_actions::remote::InvocationReceipt, tilo_actions::remote::ActionHostError>,
512 >,
513) -> ActionAnswer {
514 use tilo_actions::remote::{ActionHostError, InvocationReceipt, ReceiptStatus};
515
516 let unanswered = || "the workspace's host did not answer".to_owned();
517 match answer {
518 Some(Ok(InvocationReceipt {
519 status: ReceiptStatus::Completed { output },
520 ..
521 })) => Ok(output.value.unwrap_or(serde_json::Value::Null)),
522 Some(Ok(InvocationReceipt {
523 status: ReceiptStatus::Failed { error },
524 ..
525 })) => Err(error.message),
526 Some(
527 Ok(InvocationReceipt {
528 status: ReceiptStatus::Rejected { error },
529 ..
530 })
531 | Err(error),
532 ) => Err(ActionHostError::to_string(&error)),
533 Some(Ok(InvocationReceipt {
534 status: ReceiptStatus::Cancelled { message },
535 ..
536 })) => Err(message.unwrap_or_else(unanswered)),
537 Some(Ok(InvocationReceipt {
538 status: ReceiptStatus::Accepted,
539 ..
540 }))
541 | None => Err(unanswered()),
542 }
543}
544
545#[derive(Default)]
547struct Known {
548 readings: BTreeMap<String, Reading>,
550 token: Option<String>,
553 tries: u32,
555 offers: BTreeMap<String, OfferReading>,
557 connecting: BTreeSet<String>,
559 problems: BTreeMap<String, String>,
561 asking: BTreeSet<String>,
565 accepting: BTreeSet<String>,
566}
567
568pub struct WorkspaceAccounts {
572 actions: Rc<dyn WorkspaceRecordActions>,
573 known: Rc<RefCell<Known>>,
574 revision: Rc<Cell<u64>>,
575 changed: Rc<dyn Fn()>,
577}
578
579impl WorkspaceAccounts {
580 #[must_use]
583 pub fn new(actions: Rc<dyn WorkspaceRecordActions>, changed: Rc<dyn Fn()>) -> Self {
584 Self {
585 actions,
586 known: Rc::default(),
587 revision: Rc::default(),
588 changed,
589 }
590 }
591
592 fn read(&self, app: &str) {
594 let this = self.clone_handle();
595 let owned = app.to_owned();
596 self.actions.invoke(
597 app,
598 READ_ACTION,
599 serde_json::Map::new(),
600 Box::new(move |answer| {
601 let reading = answer.and_then(|value| {
602 serde_json::from_value::<Reading>(value).map_err(|e| e.to_string())
603 });
604 let mut known = this.known.borrow_mut();
605 let before = known.readings.get(&owned).cloned();
606 match reading {
607 Ok(reading) => {
608 known.readings.insert(owned.clone(), reading);
609 }
610 Err(error) => {
613 tracing::warn!(app = %owned, %error, "a linked account's reading could not be read");
614 known.readings.remove(&owned);
615 }
616 }
617 let after = known.readings.get(&owned).cloned();
618 let ask = known.token.is_some()
619 && after.as_ref().is_some_and(|reading| reading.connected)
620 && !known.offers.contains_key(&owned);
621 drop(known);
622 if before != after {
623 this.bump();
624 }
625 if ask {
626 this.ask_offer(&owned);
627 }
628 }),
629 );
630 }
631
632 fn ask_offer(&self, app: &str) {
634 let (action, token) = {
635 let mut known = self.known.borrow_mut();
636 let Some(reading) = known.readings.get(app) else {
637 return;
638 };
639 let Some(token) = known.token.clone() else {
640 return;
641 };
642 let action = reading.offer.clone();
643 if action.is_empty() || !known.asking.insert(app.to_owned()) {
644 return;
645 }
646 (action, token)
647 };
648 let this = self.clone_handle();
649 let owned = app.to_owned();
650 self.actions.invoke(
651 app,
652 &action,
653 token_argument(&token),
654 Box::new(move |answer| {
655 let offer = answer.and_then(|value| {
656 serde_json::from_value::<OfferReading>(value).map_err(|e| e.to_string())
657 });
658 let mut known = this.known.borrow_mut();
659 known.asking.remove(&owned);
660 if known.token.as_deref() != Some(token.as_str()) {
661 return;
662 }
663 let started_here = match offer {
664 Ok(offer) => {
665 let started_here = offer.started_here;
666 known.offers.insert(owned.clone(), offer);
667 known.problems.remove(&owned);
668 started_here
669 }
670 Err(problem) => {
671 known.tries = known.tries.saturating_add(1);
672 if known.tries >= OFFER_TRIES {
673 known.token = None;
674 known.problems.insert(owned.clone(), problem);
675 }
676 false
677 }
678 };
679 drop(known);
680 this.bump();
681 if started_here {
683 this.accept(&owned);
684 }
685 }),
686 );
687 }
688
689 fn clone_handle(&self) -> Self {
690 Self {
691 actions: Rc::clone(&self.actions),
692 known: Rc::clone(&self.known),
693 revision: Rc::clone(&self.revision),
694 changed: Rc::clone(&self.changed),
695 }
696 }
697
698 fn bump(&self) {
699 self.revision.set(self.revision.get().wrapping_add(1));
700 (self.changed)();
701 }
702}
703
704fn token_argument(token: &str) -> serde_json::Map<String, serde_json::Value> {
705 let mut arguments = serde_json::Map::new();
706 arguments.insert("token".into(), token.into());
707 arguments
708}
709
710impl LinkedAccountsHost for WorkspaceAccounts {
711 fn accounts(&self) -> Vec<LinkedAccount> {
712 let known = self.known.borrow();
713 known
714 .readings
715 .iter()
716 .filter(|(_, reading)| reading.connected)
717 .map(|(app, reading)| LinkedAccount {
718 app: app.clone(),
719 service: reading.service.clone(),
720 state: if let Some(offer) = known.offers.get(app) {
721 LinkState::Offered(offer.clone())
722 } else if known.token.is_some() {
723 LinkState::Looking
724 } else if let Some(account) = &reading.account {
725 LinkState::Linked(account.clone())
726 } else if known.connecting.contains(app) {
727 LinkState::Connecting
728 } else {
729 LinkState::Unlinked {
730 connect: reading.connect.is_some(),
731 elsewhere: reading.elsewhere.clone(),
732 }
733 },
734 problem: known.problems.get(app).cloned(),
735 })
736 .collect()
737 }
738
739 fn revision(&self) -> u64 {
740 self.revision.get()
741 }
742
743 fn profile_links(&self) -> Option<Vec<ProfileLink>> {
744 let known = self.known.borrow();
745 if known.readings.is_empty() {
746 return None;
747 }
748 Some(
749 known
750 .readings
751 .values()
752 .filter_map(|reading| {
753 let account = reading.account.as_ref()?;
754 Some(ProfileLink {
755 service: reading.service.clone(),
756 handle: account.user_name.clone(),
757 picture: reading.picture.clone(),
758 })
759 })
760 .collect(),
761 )
762 }
763
764 fn refresh(&self) {
765 for app in self.actions.apps() {
766 self.read(&app);
767 }
768 }
769
770 fn refresh_app(&self, app: &str) {
771 self.read(app);
772 }
773
774 fn link(&self, app: &str) {
775 let Some(action) = self
776 .known
777 .borrow()
778 .readings
779 .get(app)
780 .and_then(|reading| reading.connect.clone())
781 else {
782 return;
783 };
784 {
785 let mut known = self.known.borrow_mut();
786 known.problems.remove(app);
787 known.connecting.insert(app.to_owned());
788 }
789 self.bump();
790 let this = self.clone_handle();
791 let owned = app.to_owned();
792 self.actions.invoke(
793 app,
794 &action,
795 serde_json::Map::new(),
796 Box::new(move |answer| {
797 match answer.and_then(|value| {
798 serde_json::from_value::<LinkStart>(value).map_err(|e| e.to_string())
799 }) {
800 Ok(start) => this.actions.open(&start.url),
801 Err(problem) => {
802 let mut known = this.known.borrow_mut();
803 known.connecting.remove(&owned);
804 known.problems.insert(owned.clone(), problem);
805 drop(known);
806 this.bump();
807 }
808 }
809 }),
810 );
811 }
812
813 fn unlink(&self, app: &str) {
814 let Some(action) = self
815 .known
816 .borrow()
817 .readings
818 .get(app)
819 .and_then(|reading| reading.unlink.clone())
820 else {
821 return;
822 };
823 self.known.borrow_mut().problems.remove(app);
824 let this = self.clone_handle();
825 let owned = app.to_owned();
826 self.actions.invoke(
827 app,
828 &action,
829 serde_json::Map::new(),
830 Box::new(move |answer| {
831 if let Err(problem) = answer {
832 this.known
833 .borrow_mut()
834 .problems
835 .insert(owned.clone(), problem);
836 }
837 this.read(&owned);
838 }),
839 );
840 }
841
842 fn offer(&self, token: &str) {
843 {
844 let mut known = self.known.borrow_mut();
845 known.token = Some(token.to_owned());
846 known.tries = 0;
847 known.offers.clear();
848 }
849 self.bump();
850 let ready: Vec<String> = self
851 .known
852 .borrow()
853 .readings
854 .iter()
855 .filter(|(_, reading)| reading.connected)
856 .map(|(app, _)| app.clone())
857 .collect();
858 if ready.is_empty() {
859 self.refresh();
861 }
862 for app in ready {
863 self.ask_offer(&app);
864 }
865 }
866
867 fn accept(&self, app: &str) {
868 let (action, token) = {
869 let mut known = self.known.borrow_mut();
870 let Some(reading) = known.readings.get(app) else {
871 return;
872 };
873 let Some(token) = known.token.clone() else {
874 return;
875 };
876 let action = reading.accept.clone();
877 if !known.accepting.insert(app.to_owned()) {
878 return;
879 }
880 (action, token)
881 };
882 let this = self.clone_handle();
883 let owned = app.to_owned();
884 self.actions.invoke(
885 app,
886 &action,
887 token_argument(&token),
888 Box::new(move |answer| {
889 {
890 let mut known = this.known.borrow_mut();
891 known.accepting.remove(&owned);
892 known.token = None;
893 known.offers.remove(&owned);
894 match answer {
895 Ok(_) => known.problems.remove(&owned),
896 Err(problem) => known.problems.insert(owned.clone(), problem),
897 };
898 }
899 this.bump();
900 this.read(&owned);
901 }),
902 );
903 }
904
905 fn decline(&self, app: &str) {
906 {
907 let mut known = self.known.borrow_mut();
908 known.token = None;
909 known.offers.remove(app);
910 }
911 self.bump();
912 }
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918
919 #[derive(Default)]
921 struct Host {
922 reading: RefCell<Reading>,
923 offer: RefCell<Option<Result<OfferReading, String>>>,
925 asked: RefCell<Vec<String>>,
927 opened: RefCell<Vec<String>>,
929 actions: RefCell<Vec<String>>,
931 holding: Cell<bool>,
933 waiting: RefCell<Vec<Box<dyn FnOnce()>>>,
934 }
935
936 impl WorkspaceRecordActions for Rc<Host> {
937 fn apps(&self) -> Vec<String> {
938 vec!["so.tilo.chat".into(), "so.tilo.discord".into()]
939 }
940
941 fn invoke(
942 &self,
943 app: &str,
944 action: &str,
945 arguments: serde_json::Map<String, serde_json::Value>,
946 done: Box<dyn FnOnce(ActionAnswer)>,
947 ) {
948 self.asked.borrow_mut().push(app.to_owned());
949 self.actions.borrow_mut().push(action.to_owned());
950 if app != "so.tilo.discord" {
951 return done(Err("unknown_action".into()));
952 }
953 let answer = match action {
954 READ_ACTION => {
955 Ok(serde_json::to_value(self.reading.borrow().clone()).unwrap_or_default())
956 }
957 "connect" => Ok(serde_json::json!({"url": "https://discord.example/authorize"})),
958 "link-offer" => {
959 assert_eq!(arguments["token"], "T0KEN");
960 self.offered()
961 .map(|offer| serde_json::to_value(offer).unwrap_or_default())
962 }
963 "accept-link" => {
964 let offer = self.offered();
965 offer.map(|offer| {
966 self.reading.borrow_mut().account = Some(offer.account);
967 serde_json::Value::Null
968 })
969 }
970 _ => {
971 self.reading.borrow_mut().account = None;
972 Ok(serde_json::Value::Null)
973 }
974 };
975 if self.holding.get() {
976 self.waiting
977 .borrow_mut()
978 .push(Box::new(move || done(answer)));
979 } else {
980 done(answer);
981 }
982 }
983
984 fn open(&self, url: &str) {
985 self.opened.borrow_mut().push(url.to_owned());
986 }
987 }
988
989 fn discord() -> Reading {
990 Reading {
991 service: "Discord".into(),
992 connected: true,
993 connect: Some("connect".into()),
994 elsewhere: Some("In the Discord server, type /link.".into()),
995 offer: "link-offer".into(),
996 accept: "accept-link".into(),
997 ..Reading::default()
998 }
999 }
1000
1001 fn kim() -> OfferReading {
1002 OfferReading {
1003 service: "Discord".into(),
1004 account: AccountName {
1005 user_name: "kim".into(),
1006 display_name: None,
1007 },
1008 started_here: false,
1009 }
1010 }
1011
1012 impl Host {
1013 fn offered(&self) -> Result<OfferReading, String> {
1014 self.offer
1015 .borrow()
1016 .clone()
1017 .unwrap_or_else(|| Err("no offer".into()))
1018 }
1019 }
1020
1021 fn host(offer: Result<OfferReading, String>) -> Rc<Host> {
1022 Rc::new(Host {
1023 reading: RefCell::new(discord()),
1024 offer: RefCell::new(Some(offer)),
1025 ..Host::default()
1026 })
1027 }
1028
1029 #[test]
1030 fn only_connectors_are_asked_for_a_reading() {
1031 let workspace = ["so.tilo.chat", "so.tilo.contacts", "so.tilo.discord"].map(String::from);
1032 let runs_here = |app: &str| matches!(app, "so.tilo.chat" | "so.tilo.contacts");
1033 assert_eq!(connector_apps(workspace, runs_here), ["so.tilo.discord"]);
1034 }
1035
1036 #[test]
1037 fn connecting_goes_to_the_consent_page() {
1038 let host = host(Err("gone".into()));
1039 let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
1040 assert!(
1041 accounts.accounts().is_empty(),
1042 "nothing is known before asking"
1043 );
1044 accounts.refresh();
1045 assert_eq!(
1046 accounts.accounts()[0].state,
1047 LinkState::Unlinked {
1048 connect: true,
1049 elsewhere: Some("In the Discord server, type /link.".into())
1050 }
1051 );
1052 accounts.link("so.tilo.discord");
1053 assert_eq!(*host.opened.borrow(), ["https://discord.example/authorize"]);
1054 assert_eq!(accounts.accounts()[0].state, LinkState::Connecting);
1055 }
1056
1057 #[test]
1058 fn a_linked_account_is_named_with_its_picture_for_the_profile() {
1059 let host = host(Ok(kim()));
1060 let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
1061 assert_eq!(accounts.profile_links(), None, "nothing answered yet");
1062 accounts.refresh();
1063 assert_eq!(accounts.profile_links(), Some(Vec::new()), "not linked");
1064 {
1065 let mut reading = host.reading.borrow_mut();
1066 reading.account = Some(kim().account);
1067 reading.picture = Some("data:image/png;base64,AAAA".into());
1068 reading.unlink = Some("unlink-mine".into());
1069 }
1070 accounts.refresh();
1071 assert_eq!(
1072 accounts.profile_links(),
1073 Some(vec![ProfileLink {
1074 service: "Discord".into(),
1075 handle: "kim".into(),
1076 picture: Some("data:image/png;base64,AAAA".into()),
1077 }])
1078 );
1079 accounts.unlink("so.tilo.discord");
1080 accounts.refresh();
1081 assert_eq!(accounts.profile_links(), Some(Vec::new()));
1082 }
1083
1084 #[test]
1085 fn a_token_shows_its_offer_and_a_yes_links_it() {
1086 let host = host(Ok(kim()));
1087 let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
1088 accounts.offer("T0KEN");
1089 assert_eq!(accounts.accounts()[0].state, LinkState::Offered(kim()));
1090 accounts.accept("so.tilo.discord");
1091 assert_eq!(
1092 accounts.accounts()[0].state,
1093 LinkState::Linked(kim().account)
1094 );
1095 }
1096
1097 #[test]
1098 fn an_offer_started_here_links_without_a_second_yes() {
1099 let host = host(Ok(OfferReading {
1100 started_here: true,
1101 ..kim()
1102 }));
1103 let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
1104 accounts.refresh();
1105 accounts.offer("T0KEN");
1106 assert_eq!(
1107 accounts.accounts()[0].state,
1108 LinkState::Linked(kim().account)
1109 );
1110 }
1111
1112 #[test]
1113 fn readings_that_cross_spend_the_token_once() {
1114 let host = host(Ok(OfferReading {
1115 started_here: true,
1116 ..kim()
1117 }));
1118 host.holding.set(true);
1119 let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
1120 accounts.offer("T0KEN");
1122 accounts.refresh();
1123 loop {
1124 let waiting = std::mem::take(&mut *host.waiting.borrow_mut());
1125 if waiting.is_empty() {
1126 break;
1127 }
1128 for answer in waiting {
1129 answer();
1130 }
1131 }
1132 let asked = host.actions.borrow();
1133 let count = |action: &str| asked.iter().filter(|a| *a == action).count();
1134 assert_eq!(
1135 (count("link-offer"), count("accept-link")),
1136 (1, 1),
1137 "{asked:?}"
1138 );
1139 let account = &accounts.accounts()[0];
1140 assert_eq!(account.state, LinkState::Linked(kim().account));
1141 assert_eq!(account.problem, None);
1142 }
1143
1144 #[test]
1145 fn a_declined_offer_is_dropped() {
1146 let host = host(Ok(kim()));
1147 let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
1148 accounts.offer("T0KEN");
1149 accounts.decline("so.tilo.discord");
1150 assert!(matches!(
1151 accounts.accounts()[0].state,
1152 LinkState::Unlinked { .. }
1153 ));
1154 }
1155
1156 #[test]
1157 fn a_token_with_no_offer_is_asked_after_then_says_why() {
1158 let host = host(Err("This link ran out.".into()));
1159 let accounts = LinkedAccounts::new(Rc::new(WorkspaceAccounts::new(
1160 Rc::new(Rc::clone(&host)),
1161 Rc::new(|| {}),
1162 )));
1163 accounts.offer("T0KEN");
1164 assert_eq!(accounts.accounts()[0].state, LinkState::Looking);
1165 let mut recheck = LinkingRecheck::default();
1166 let mut now = ui_core::time::Instant::now();
1167 let mut rounds = 0;
1168 while let Some(due) = recheck.follow(&accounts, true, now) {
1169 now = due;
1170 rounds += 1;
1171 assert!(rounds <= 2 * OFFER_TRIES, "the asks end");
1172 }
1173 let account = &accounts.accounts()[0];
1174 assert!(matches!(account.state, LinkState::Unlinked { .. }));
1175 assert_eq!(account.problem.as_deref(), Some("This link ran out."));
1176 }
1177
1178 #[test]
1179 fn a_workspace_with_no_connector_is_asked_again_a_few_times_then_left() {
1180 let host = host(Err("gone".into()));
1181 host.reading.borrow_mut().connected = false;
1182 let accounts = LinkedAccounts::new(Rc::new(WorkspaceAccounts::new(
1183 Rc::new(Rc::clone(&host)),
1184 Rc::new(|| {}),
1185 )));
1186 let mut recheck = LinkingRecheck::default();
1187 let mut now = ui_core::time::Instant::now();
1188 let mut waits = Vec::new();
1189 while let Some(due) = recheck.follow(&accounts, true, now) {
1190 waits.push(due.duration_since(now));
1191 now = due;
1192 assert!(waits.len() <= 10, "the asks again end");
1193 }
1194 assert_eq!(host.asked.borrow().len() / 2, UNANSWERED_RETRIES as usize);
1195 }
1196
1197 #[test]
1201 fn a_record_action_asks_the_host_at_the_records_own_door() {
1202 let invocation = tilo_actions::InvocationId::new();
1203 let read = record_invocation(
1204 invocation,
1205 "so.tilo.discord",
1206 READ_ACTION,
1207 serde_json::Map::new(),
1208 );
1209 assert_eq!(read.target.provider, "record:so.tilo.discord:");
1210 assert_eq!(read.target.address.resource, "");
1211 assert_eq!(read.target.address.action, READ_ACTION);
1212 assert_eq!(read.argument, tilo_actions::remote::InvokeArgument::None);
1213 assert_eq!(read.invocation, invocation, "the answer names it");
1214 assert!(
1215 read.wait_ms.is_some(),
1216 "a reading that goes elsewhere answers once it is back"
1217 );
1218 let asked = record_invocation(
1219 invocation,
1220 "so.tilo.discord",
1221 "link-offer",
1222 serde_json::Map::from_iter([("token".to_owned(), serde_json::json!("T0KEN"))]),
1223 );
1224 assert_eq!(
1225 asked.argument,
1226 tilo_actions::remote::InvokeArgument::Json(serde_json::Map::from_iter([(
1227 "token".to_owned(),
1228 serde_json::json!("T0KEN")
1229 )]))
1230 );
1231 }
1232
1233 #[test]
1236 fn the_hosts_answer_reads_as_the_action_s_value_or_its_reason() {
1237 use tilo_actions::remote::{ActionHostError, InvocationReceipt, ReceiptStatus};
1238 use tilo_actions::{ActionFailure, ActionOutput};
1239 let receipt = |status| {
1240 Some(Ok(InvocationReceipt {
1241 invocation: tilo_actions::InvocationId::new(),
1242 status,
1243 }))
1244 };
1245 let value = serde_json::json!({"code": "ABCDEFGHJKLM"});
1246 assert_eq!(
1247 record_answer(receipt(ReceiptStatus::Completed {
1248 output: ActionOutput {
1249 value: Some(value.clone()),
1250 ..ActionOutput::default()
1251 },
1252 })),
1253 Ok(value)
1254 );
1255 assert_eq!(
1256 record_answer(receipt(ReceiptStatus::Completed {
1257 output: ActionOutput::default(),
1258 })),
1259 Ok(serde_json::Value::Null),
1260 "an action that says nothing answers nothing"
1261 );
1262 assert_eq!(
1263 record_answer(receipt(ReceiptStatus::Failed {
1264 error: ActionFailure {
1265 code: "unknown_action".into(),
1266 message: "no such action".into(),
1267 details: None,
1268 retryable: false,
1269 },
1270 })),
1271 Err("no such action".to_owned())
1272 );
1273 assert_eq!(
1274 record_answer(receipt(ReceiptStatus::Rejected {
1275 error: ActionHostError::UnknownAction,
1276 })),
1277 Err("no such action is offered".to_owned())
1278 );
1279 assert_eq!(
1280 record_answer(Some(Err(ActionHostError::Unavailable {
1281 reason: "the session is closed".into(),
1282 }))),
1283 Err("remote actions are unavailable: the session is closed".to_owned())
1284 );
1285 assert_eq!(
1286 record_answer(None),
1287 Err("the workspace's host did not answer".to_owned())
1288 );
1289 }
1290}