Skip to main content

tilo_app/
linked_accounts.rs

1//! Accounts elsewhere a person links to who they are in a workspace: their
2//! Discord account in a workspace bridged to a Discord server, say.
3//!
4//! A connector keeps the workspace's record (the bridged server) and publishes
5//! actions on it that any member may ask the workspace's host to run for
6//! themselves ([`tilo_actions::MEMBER_OWN_TAG`]), which the profile card and
7//! Settings use the same way for every service:
8//!
9//! - a read, [`READ_ACTION`], answering a [`Reading`]: whether the workspace is
10//!   connected to the service at all, the reader's linked account if any,
11//!   and the actions below;
12//! - the connect action, when the host offers signing in with the service,
13//!   answering a [`LinkStart`]: the service's consent page, which sends the
14//!   person back to the workspace's host and from there to Tilo with a one-time
15//!   link token;
16//! - the offer read and the accept action, which take such a token (from
17//!   the consent page's return, or from a button the service's side
18//!   handed out): the read answers the account it offers ([`OfferReading`]),
19//!   the accept links it;
20//! - the unlink action.
21//!
22//! The workspace's host runs them: the connector's code stays there, and the
23//! page or shell only asks, through a host's [`WorkspaceRecordActions`].
24//! [`WorkspaceAccounts`] asks the workspace's connectors ([`connector_apps`]) for the
25//! read and keeps what it learns as [`LinkedAccount`]s; a host lends it to
26//! the panels that show them as [`LinkedAccounts`].
27
28use std::cell::{Cell, RefCell};
29use std::collections::{BTreeMap, BTreeSet};
30use std::rc::Rc;
31
32use serde::{Deserialize, Serialize};
33
34/// The mark a connected service's buttons carry, by its connector's app
35/// id: Discord's own, a link for any other.
36#[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/// The glyph of a service by the name a profile gives it ("Discord").
45#[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/// A linked account as the person's profile names it to everyone: the
55/// service, the handle there, and the account's picture when the service
56/// has one for it.
57#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ProfileLink {
59    /// The service's name ("Discord").
60    pub service: String,
61    /// The account's unique name there, without the `@`.
62    pub handle: String,
63    /// The account's picture as a small `data:` URL.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub picture: Option<String>,
66}
67
68/// The read action a connector's workspace record publishes, answering a
69/// [`Reading`] for the person who reads it.
70pub const READ_ACTION: &str = "my-link";
71
72/// A connector's answer to [`READ_ACTION`].
73#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "kebab-case")]
75pub struct Reading {
76    /// The service's name ("Discord").
77    pub service: String,
78    /// The workspace is connected to the service (a server is bridged).
79    pub connected: bool,
80    #[serde(default)]
81    pub account: Option<AccountName>,
82    /// The linked account's picture, small, as a `data:` URL: the reader's
83    /// Tilo picture until they upload their own.
84    #[serde(default)]
85    pub picture: Option<String>,
86    /// The action that starts signing in on the service's consent page,
87    /// answering a [`LinkStart`]; none when the host offers no sign-in.
88    #[serde(default)]
89    pub connect: Option<String>,
90    /// How to link from the service's side instead ("In the Discord server,
91    /// type /link."), when it offers that.
92    #[serde(default)]
93    pub elsewhere: Option<String>,
94    /// The action that unlinks the reader's account, when they linked one.
95    #[serde(default)]
96    pub unlink: Option<String>,
97    /// The read that answers the [`OfferReading`] of a link token
98    /// (`{token}`).
99    #[serde(default)]
100    pub offer: String,
101    /// The action that links what a link token offers (`{token}`).
102    #[serde(default)]
103    pub accept: String,
104}
105
106/// How a service names an account: its unique name, and the name it shows
107/// when that is another.
108#[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    /// The account as a line shows it: the name it shows and its handle
118    /// when they differ ("Ada L · @ada.l"), else the handle alone.
119    #[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    /// The unique name as a handle: `@ada.l`.
128    #[must_use]
129    pub fn handle(&self) -> String {
130        format!("@{}", self.user_name)
131    }
132}
133
134/// What the connect action answers: the service's consent page to go to.
135#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
136pub struct LinkStart {
137    pub url: String,
138}
139
140/// What the offer read answers: the account a link token offers.
141#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "kebab-case")]
143pub struct OfferReading {
144    /// The service's name ("Discord").
145    pub service: String,
146    pub account: AccountName,
147    /// The person started it here (signing in on the consent page), so it
148    /// needs no second yes.
149    #[serde(default)]
150    pub started_here: bool,
151}
152
153/// Where a person's account with one service stands.
154#[derive(Clone, Debug, PartialEq, Eq)]
155pub enum LinkState {
156    /// Not linked. `connect`: the host offers signing in here;
157    /// `elsewhere`: how to link from the service's side.
158    Unlinked {
159        connect: bool,
160        elsewhere: Option<String>,
161    },
162    /// On the way to the service's consent page.
163    Connecting,
164    /// A link token came with the page: asking which account it offers.
165    Looking,
166    /// The account a link token offers, to say yes or no to.
167    Offered(OfferReading),
168    Linked(AccountName),
169}
170
171/// One service the workspace is connected to, and the person's account there.
172#[derive(Clone, Debug, PartialEq, Eq)]
173pub struct LinkedAccount {
174    /// The connector's app id, which choices name it by.
175    pub app: String,
176    /// The service's name ("Discord").
177    pub service: String,
178    pub state: LinkState,
179    /// What went wrong last, in words that say what to do.
180    pub problem: Option<String>,
181}
182
183/// What shows and changes a person's linked accounts in a workspace. Asking
184/// never waits: what comes of it shows in [`Self::accounts`] once the workspace's
185/// host answered, and [`Self::revision`] moves.
186pub trait LinkedAccountsHost {
187    /// Every service the workspace is connected to, with the person's account,
188    /// as last heard.
189    fn accounts(&self) -> Vec<LinkedAccount>;
190    /// Moves whenever [`Self::accounts`] changed.
191    fn revision(&self) -> u64;
192    /// The person's linked accounts as their profile names them; `None`
193    /// until the workspace's host answered where they stand.
194    fn profile_links(&self) -> Option<Vec<ProfileLink>> {
195        None
196    }
197    /// Ask again where every account stands.
198    fn refresh(&self);
199    /// Ask again where the account with `app`'s service stands.
200    fn refresh_app(&self, app: &str) {
201        let _ = app;
202        self.refresh();
203    }
204    /// Start linking the person's account with `app`'s service: off to its
205    /// consent page.
206    fn link(&self, app: &str);
207    /// Unlink the person's account with `app`'s service.
208    fn unlink(&self, app: &str);
209    /// A link token came with the page: find the account it offers.
210    fn offer(&self, token: &str) {
211        let _ = token;
212    }
213    /// Say yes to the account `app`'s service offers.
214    fn accept(&self, app: &str) {
215        let _ = app;
216    }
217    /// Say no to it.
218    fn decline(&self, app: &str) {
219        let _ = app;
220    }
221}
222
223/// A host's [`LinkedAccountsHost`], as it lends it to a panel. The default
224/// has none: no service, and asking does nothing.
225#[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    /// Whether a host stands behind it.
235    #[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
301/// How often a panel waiting on the workspace's host asks again.
302pub const LINKING_RECHECK: std::time::Duration = std::time::Duration::from_secs(2);
303
304/// How many times a shown panel that knows no service yet asks again, at
305/// [`LINKING_RECHECK`] doubling each time, before it waits to be opened
306/// anew.
307pub const UNANSWERED_RETRIES: u32 = 4;
308
309/// How many times a link token's offer is asked for before the panel says
310/// why it found none. Coming back from a consent page, the workspace's host
311/// records the offer a moment after the page arrives.
312pub const OFFER_TRIES: u32 = 10;
313
314/// Asks again while a panel shows the accounts:
315///
316/// - while a link token waits for its offer: the workspace's host records what
317///   came back from a consent page a moment after the page is back;
318/// - while it knows no service at all: the read it asked on opening can
319///   fail or go unanswered on a busy page or host, and the panel would
320///   offer nothing to link until it was opened again. A workspace with no
321///   connector answers no reading either, so these asks are few and
322///   spaced out ([`UNANSWERED_RETRIES`]).
323#[derive(Debug, Default)]
324pub struct LinkingRecheck {
325    due: Option<ui_core::time::Instant>,
326    unanswered: Option<Unanswered>,
327}
328
329/// The asks again of a shown panel that knows no service yet.
330#[derive(Debug)]
331struct Unanswered {
332    due: ui_core::time::Instant,
333    asked: u32,
334}
335
336impl LinkingRecheck {
337    /// At `now`, with `accounts` shown (or not, `shown` false): asks again
338    /// once [`LINKING_RECHECK`] passed since it last did while a token waits
339    /// for its offer, and with backoff while no service is known. When to
340    /// call again, `None` while there is nothing to ask after.
341    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            // The panel asked on opening; the first ask again comes after.
392            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    /// When to call [`Self::follow`] again.
416    #[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
430/// The answer to one action the workspace's host ran.
431pub type ActionAnswer = Result<serde_json::Value, String>;
432
433/// Of `workspace_apps`, the apps whose workspace record the workspace holds, those that
434/// may publish the read ([`READ_ACTION`]): connectors, which bridge a
435/// service elsewhere and so run on the workspace's host (its secret store, its
436/// worker), never in the person's desktop. An app the desktop runs itself
437/// (`runs_here`) publishes no reading, and asking its record would only
438/// have the host open it to answer that it has no such action.
439pub 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
449/// How a host asks the workspace's host to run an action of a workspace record for
450/// the person, on the web and natively alike.
451pub trait WorkspaceRecordActions {
452    /// The apps whose workspace record this workspace holds and may publish the read
453    /// ([`READ_ACTION`]): the workspace's connectors ([`connector_apps`]).
454    fn apps(&self) -> Vec<String>;
455    /// Ask the workspace's host to run `action` on `app`'s workspace record (its own
456    /// resource) with `arguments`, as the person; `done` gets the answer,
457    /// later, on this thread.
458    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    /// Go to `url` (a service's consent page), leaving this page; it comes
466    /// back with a link token.
467    fn open(&self, url: &str) {
468        let _ = url;
469    }
470}
471
472/// The invocation that asks a host to run `action` of `app`'s workspace
473/// record with `arguments`: the record's own resource, as the app's door
474/// offers it. The receipt is held until the action settles, so a reading
475/// answers at once and one that went elsewhere answers once it is back.
476#[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        // Nothing else is the caller's to say: the host decides it as the
497        // person whose session asked.
498        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
507/// What a host's answer to a record action says: the action's value, or why
508/// it did not run.
509pub 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/// What [`WorkspaceAccounts`] learned so far.
546#[derive(Default)]
547struct Known {
548    /// Each connector's last reading, by app id.
549    readings: BTreeMap<String, Reading>,
550    /// The link token the page came with, until a connector's offer read
551    /// answered it or it was asked for too often.
552    token: Option<String>,
553    /// How often the token's offer was asked for.
554    tries: u32,
555    /// The offer each connector answered for the token.
556    offers: BTreeMap<String, OfferReading>,
557    /// The connectors whose consent page the page is on its way to.
558    connecting: BTreeSet<String>,
559    /// What went wrong last, per connector.
560    problems: BTreeMap<String, String>,
561    /// The connectors asked for the token's offer, or to take it, and not
562    /// answered yet: readings that cross ask only once, so a token is
563    /// never spent twice.
564    asking: BTreeSet<String>,
565    accepting: BTreeSet<String>,
566}
567
568/// Linked accounts read from a workspace's records by its host, through a
569/// [`WorkspaceRecordActions`]. A link token the page came with is kept here, on
570/// this device, until it is used up or declined.
571pub struct WorkspaceAccounts {
572    actions: Rc<dyn WorkspaceRecordActions>,
573    known: Rc<RefCell<Known>>,
574    revision: Rc<Cell<u64>>,
575    /// Called when what is known changed, to draw it again.
576    changed: Rc<dyn Fn()>,
577}
578
579impl WorkspaceAccounts {
580    /// Accounts asked through `actions`; `changed` is called whenever an
581    /// answer changed them.
582    #[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    /// Read `app`'s account again, and the offer of a waiting token.
593    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                    // No reading: the record is no connector, or the host
611                    // could not answer now; the next refresh asks again.
612                    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    /// Ask `app` which account the waiting token offers.
633    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                // Signing in on the consent page was the yes.
682                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            // Asked once the readings are in.
860            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    /// A workspace whose host answers at once, from a connector's readings.
920    #[derive(Default)]
921    struct Host {
922        reading: RefCell<Reading>,
923        /// What the token offers, or why nothing.
924        offer: RefCell<Option<Result<OfferReading, String>>>,
925        /// The apps asked, in order.
926        asked: RefCell<Vec<String>>,
927        /// Where the page went.
928        opened: RefCell<Vec<String>>,
929        /// The actions asked, in order.
930        actions: RefCell<Vec<String>>,
931        /// Whether answers wait in `waiting` until let go.
932        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        // The page came back with a token, and the card opening asked again.
1121        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    /// A record action is the record's own invocation, as the app's door
1198    /// offers it: the app's record, its own resource, and the arguments the
1199    /// action takes, held until it settles.
1200    #[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    /// What the host answered is what the accounts read: the action's value,
1234    /// or why it did not run.
1235    #[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}