Skip to main content

tilo_app/framework/
share_link.rs

1//! Answers `share-link` and `share-invite-link` ([`tilo_resource::share`]).
2//!
3//! A link is a rendering of the address the action model already uses, under
4//! the web entry the workspace's host announces. The host is asked for a fresh
5//! locator every time, so a link never names an entry or a workspace the host no
6//! longer serves, and is refused when the host serves no web page rather than
7//! guessing one.
8//!
9//! A link that invites is the other way round: what an app shows lives in the
10//! workspace, and what is shared is the invitation into it. The desk's own action
11//! makes it ([`session_actions::ISSUE_INVITATION`]), asked through the broker as the
12//! person using the app, so the link is the invitation the invite sheet makes, under
13//! the part the person asks for and the workspace's governance allows them to hand out.
14
15use tilo_actions::InvocationUpdate;
16use tilo_actions::remote::session_actions;
17use tilo_actions::remote::{CatalogueQuery, InvokeArgument, RemoteActionTarget, ViewResourceQuery};
18use tilo_reference::link::{
19    AppId, HostOrigin, Link, LinkSecrets, LinkTarget, Mode, RecordSegment, ResourceAddress, Scope,
20    WebEntry,
21};
22use tilo_resource::share::{SHARE_INVITE_TYPE, SHARE_TYPE, ShareRequest};
23use ui_core::time::{Duration, Instant};
24use ui_core::{Arrival, CatalogueCall, InvocationCall, RemoteActions};
25
26/// How long a minting waits for the host's locator.
27const LOOKUP_TIMEOUT: Duration = Duration::from_secs(30);
28
29/// Why a host's answer cannot make a link.
30pub(crate) const NO_WEB_ENTRY: &str = "This workspace's host serves no web page";
31
32/// The record a root publishes from, as a provider names it.
33pub(crate) struct Publisher<'a> {
34    pub(crate) app: &'a str,
35    pub(crate) record: Option<&'a str>,
36    /// The root resource: a link to it names the record, not a resource.
37    pub(crate) root: &'a str,
38}
39
40/// What a link is minted for once the locator arrives.
41struct Minting {
42    provider: String,
43    app: AppId,
44    record: RecordSegment,
45    resource: Option<ResourceAddress>,
46    view: Option<String>,
47    mode: Mode,
48}
49
50/// What a minting waits for: the host's locator for the resource, whose entry the link renders
51/// under, or the desk's answer to the invitation, whose link it is.
52enum Call {
53    Locator {
54        call: CatalogueCall,
55        minting: Minting,
56    },
57    Invitation(InvocationCall),
58}
59
60struct Pending {
61    invocation: tilo_actions::InvocationId,
62    call: Call,
63    started: Instant,
64    /// A person asked through a view: the URL goes to the clipboard their
65    /// click claimed.
66    copy: Option<ui_core::clipboard::ReservedCopy>,
67    /// The minted URL, once its copy is under way: the answer waits for the
68    /// clipboard's outcome, which settles without blocking the app.
69    copying: Option<(String, ui_core::clipboard::ClipboardCopy)>,
70}
71
72/// The root's minting of links: its own client for the session's action
73/// door, and the mintings waiting on it.
74pub(crate) struct ShareLinks {
75    remote: RemoteActions,
76    pending: Vec<Pending>,
77    /// Resources a view asked to copy a link to, in the order asked, each
78    /// with the clipboard its click claimed: a browser copies only inside
79    /// the click, and the host answers after it.
80    copy_requested: Vec<(String, ui_core::clipboard::ReservedCopy)>,
81}
82
83impl Default for ShareLinks {
84    fn default() -> Self {
85        Self {
86            remote: RemoteActions::current(),
87            pending: Vec::new(),
88            copy_requested: Vec::new(),
89        }
90    }
91}
92
93pub(crate) fn handles(type_id: &str) -> bool {
94    type_id == SHARE_TYPE || type_id == SHARE_INVITE_TYPE
95}
96
97/// What the minting answered and whether a person is to be told.
98pub(crate) struct Minted {
99    pub(crate) output: tilo_actions::ActionOutput,
100    pub(crate) copied: bool,
101}
102
103impl ShareLinks {
104    pub(crate) fn remote_mut(&mut self) -> &mut RemoteActions {
105        &mut self.remote
106    }
107
108    /// A view's control is about to invoke a link operation on `resource`:
109    /// its URL is copied, not only answered.
110    pub(crate) fn note_view_invocation(&mut self, invocation: &tilo_actions::ControlInvocation) {
111        if invocation.address.action == tilo_resource::share::SHARE_LINK
112            || invocation.address.action == tilo_resource::share::SHARE_INVITE_LINK
113        {
114            self.copy_requested.push((
115                invocation.address.resource.clone(),
116                ui_core::reserve_clipboard_copy(),
117            ));
118        }
119    }
120
121    /// Start minting the link `payload` asks for.
122    pub(crate) fn start(
123        &mut self,
124        invocation: tilo_actions::InvocationId,
125        payload: &[u8],
126        publisher: &Publisher<'_>,
127        may_invite: bool,
128    ) -> Result<Option<Minted>, String> {
129        let request: ShareRequest =
130            tilo_actions::decode_json(payload).map_err(|error| error.to_string())?;
131        let copy = self
132            .copy_requested
133            .iter()
134            .position(|(resource, _)| *resource == request.resource)
135            .map(|at| self.copy_requested.remove(at).1);
136        let call = if request.invite {
137            if !may_invite {
138                return Err("You may not invite people to this workspace".into());
139            }
140            Call::Invitation(self.remote.invoke(
141                RemoteActionTarget {
142                    provider: session_actions::PROVIDER.into(),
143                    address: tilo_actions::ActionAddress::new(
144                        session_actions::DESK_RESOURCE,
145                        session_actions::ISSUE_INVITATION,
146                    ),
147                },
148                InvokeArgument::Json(invitation_arguments(&request.args)),
149                None,
150            ))
151        } else {
152            let minting = minting(&request, publisher)?;
153            let call = self.remote.catalogue_page(CatalogueQuery {
154                provider: Some(minting.provider.clone()),
155                view_resource: Some(ViewResourceQuery {
156                    id: request.resource,
157                    kind: request.kind,
158                }),
159                ..CatalogueQuery::default()
160            });
161            Call::Locator { call, minting }
162        };
163        let mut pending = Pending {
164            invocation,
165            call,
166            started: ui_core::time::now(),
167            copy,
168            copying: None,
169        };
170        // A host in this process answers at once.
171        if let Some(result) = settled(&pending) {
172            let url = result?;
173            let Some(reserved) = pending.copy.take() else {
174                return Ok(Some(finish(&url, None)));
175            };
176            let copy = reserved.fill(&url);
177            if let Some(copied) = copy.outcome() {
178                return Ok(Some(finish(&url, Some(copied))));
179            }
180            pending.copying = Some((url, copy));
181        }
182        self.pending.push(pending);
183        Ok(None)
184    }
185
186    /// The update of a minting this answers, once it has one.
187    pub(crate) fn poll(
188        &mut self,
189        invocation: tilo_actions::InvocationId,
190        cancellation_requested: bool,
191    ) -> Option<(tilo_actions::InvocationUpdate, bool)> {
192        let at = self
193            .pending
194            .iter()
195            .position(|pending| pending.invocation == invocation)?;
196        let pending = self.pending.get_mut(at)?;
197        if cancellation_requested {
198            self.pending.remove(at);
199            return Some((
200                tilo_actions::InvocationUpdate::Cancelled {
201                    invocation,
202                    message: None,
203                },
204                false,
205            ));
206        }
207        if pending.copying.is_none() {
208            let result = settled(pending).or_else(|| {
209                (ui_core::time::now().saturating_duration_since(pending.started) >= LOOKUP_TIMEOUT)
210                    .then(|| Err("The workspace's host did not answer".to_owned()))
211            })?;
212            match result {
213                Ok(url) => {
214                    let Some(reserved) = pending.copy.take() else {
215                        self.pending.remove(at);
216                        return Some(succeeded(invocation, finish(&url, None)));
217                    };
218                    let copy = reserved.fill(&url);
219                    pending.copying = Some((url, copy));
220                }
221                Err(message) => {
222                    self.pending.remove(at);
223                    return Some((
224                        tilo_actions::InvocationUpdate::Failed {
225                            invocation,
226                            error: tilo_actions::ActionFailure {
227                                code: "share_link".into(),
228                                message,
229                                details: None,
230                                retryable: true,
231                            },
232                        },
233                        false,
234                    ));
235                }
236            }
237        }
238        let (url, copy) = pending.copying.as_ref()?;
239        let minted = finish(url, Some(copy.outcome()?));
240        self.pending.remove(at);
241        Some(succeeded(invocation, minted))
242    }
243}
244
245/// The URL `pending`'s answer makes, once it has one.
246fn settled(pending: &Pending) -> Option<Result<String, String>> {
247    match &pending.call {
248        Call::Locator { call, minting } => call.with(|call| match call {
249            Arrival::Pending { .. } => None,
250            Arrival::Failed(error) => Some(Err(format!(
251                "The workspace's host would not look the resource up: {error}"
252            ))),
253            Arrival::Ready(page) => Some(render(minting, page)),
254        }),
255        Call::Invitation(call) => call.with(|call| match call {
256            Arrival::Pending { .. } => None,
257            Arrival::Failed(error) => Some(Err(format!(
258                "The workspace would not make the invitation: {error}"
259            ))),
260            // The admission is not the answer: the desk makes the link on the session's own
261            // pass, and the link comes with the update that says it did.
262            Arrival::Ready(update) if !update.is_terminal() => None,
263            Arrival::Ready(update) => Some(invited_link(update)),
264        }),
265    }
266}
267
268/// The link the desk's invitation answered with, or why it made none.
269fn invited_link(update: &InvocationUpdate) -> Result<String, String> {
270    match update {
271        InvocationUpdate::Succeeded { output, .. } => output
272            .value
273            .as_ref()
274            .and_then(|value| value.get("link"))
275            .and_then(serde_json::Value::as_str)
276            .map(str::to_owned)
277            .ok_or_else(|| "The workspace made no invitation".to_owned()),
278        InvocationUpdate::Failed { error, .. } => Err(error.message.clone()),
279        InvocationUpdate::Cancelled { message, .. } => Err(message
280            .clone()
281            .unwrap_or_else(|| "The invitation was stopped".to_owned())),
282        InvocationUpdate::Accepted { .. }
283        | InvocationUpdate::Progress { .. }
284        | InvocationUpdate::Item { .. } => Err("The invitation has not been made yet".to_owned()),
285    }
286}
287
288/// What the desk's invitation is asked for: the part the invitation brings someone in at, and
289/// for whom, exactly as the person's own sheet names them. Anything else an app passes is not
290/// the desk's to read.
291fn invitation_arguments(
292    args: &serde_json::Map<String, serde_json::Value>,
293) -> serde_json::Map<String, serde_json::Value> {
294    ["role", "invitee_name", "recipient", "expires_in_seconds"]
295        .into_iter()
296        .filter_map(|name| Some((name.to_owned(), args.get(name)?.clone())))
297        .collect()
298}
299
300fn succeeded(
301    invocation: tilo_actions::InvocationId,
302    minted: Minted,
303) -> (tilo_actions::InvocationUpdate, bool) {
304    (
305        tilo_actions::InvocationUpdate::Succeeded {
306            invocation,
307            output: minted.output,
308        },
309        minted.copied,
310    )
311}
312
313/// The output for `url`, with whether it was copied when a person asked
314/// through a view.
315fn finish(url: &str, copied: Option<bool>) -> Minted {
316    Minted {
317        output: tilo_actions::ActionOutput {
318            value: Some(match copied {
319                Some(copied) => serde_json::json!({ "url": url, "copied": copied }),
320                None => serde_json::json!({ "url": url }),
321            }),
322            resources: Vec::new(),
323        },
324        copied: copied.unwrap_or(false),
325    }
326}
327
328/// What `request` links to, from the record `publisher` publishes.
329fn minting(request: &ShareRequest, publisher: &Publisher<'_>) -> Result<Minting, String> {
330    let app = AppId::new(publisher.app).map_err(|error| error.to_string())?;
331    let record =
332        RecordSegment::new(publisher.record.unwrap_or("")).map_err(|error| error.to_string())?;
333    let provider = format!("record:{}:{}", app.as_str(), record.provider_part());
334    let args = &request.args;
335    let text = |name: &str| args.get(name).and_then(serde_json::Value::as_str);
336    let view = match text("view") {
337        Some(id) => request
338            .views
339            .iter()
340            .find(|view| view.id == id)
341            .ok_or_else(|| format!("This has no view `{id}`"))?,
342        None => request
343            .views
344            .iter()
345            .find(|view| view.primary)
346            .or_else(|| request.views.first())
347            .ok_or("This has no view to link to")?,
348    };
349    let offset = |name: &str| -> Result<Option<usize>, String> {
350        args.get(name)
351            .filter(|value| !value.is_null())
352            .map(|value| {
353                value
354                    .as_u64()
355                    .and_then(|offset| usize::try_from(offset).ok())
356                    .ok_or_else(|| format!("{name} is not a position in the text"))
357            })
358            .transpose()
359    };
360    let span = match (offset("start")?, offset("end")?) {
361        (None, None) => None,
362        (Some(start), Some(end)) => {
363            if !view.range {
364                return Err(format!("The view `{}` does not open at a range", view.id));
365            }
366            let unit = match text("unit").unwrap_or("chars") {
367                "chars" => tilo_reference::SpanUnit::Chars,
368                "bytes" => tilo_reference::SpanUnit::Bytes,
369                other => return Err(format!("Unknown unit `{other}`")),
370            };
371            Some(tilo_reference::Span::new(start, end, unit))
372        }
373        _ => return Err("A range needs both start and end".into()),
374    };
375    let resource =
376        (request.resource != publisher.root || span.is_some()).then(|| ResourceAddress {
377            kind: request.kind.clone(),
378            id: request.resource.clone(),
379            span,
380        });
381    // A view that opens on the whole desktop is linked there; any other
382    // opens its app alone full tab. The caller may ask for either.
383    let mode = match text("mode").unwrap_or(if view.desktop { "desktop" } else { "app" }) {
384        "app" => Mode::App,
385        "desktop" => Mode::Desktop,
386        other => return Err(format!("Unknown mode `{other}`")),
387    };
388    Ok(Minting {
389        provider,
390        app,
391        record,
392        resource,
393        view: (!view.primary).then(|| view.id.clone()),
394        mode,
395    })
396}
397
398/// The link `minting` names under the web entry the host announced on `page`.
399fn render(minting: &Minting, page: &tilo_actions::remote::CataloguePage) -> Result<String, String> {
400    let locator = page
401        .locator
402        .as_ref()
403        .ok_or("The workspace's host did not say where the resource is")?;
404    render_at(minting, locator)
405}
406
407/// The link to the resource `request` names in `app`'s `record`, for a
408/// workspace's host that answers `share-link` itself on the records it runs
409/// headless: the link an app's root mints, under the host's own `locator`.
410pub fn host_link(
411    request: &ShareRequest,
412    app: &str,
413    record: &str,
414    locator: &tilo_actions::remote::ResourceViewLocator,
415) -> Result<String, String> {
416    if request.invite {
417        // A host answers for its records; the invitation into the workspace is the person's own
418        // device's to make, which is where the desk that makes it runs.
419        return Err("This host makes no invitation: the person's own device makes it".into());
420    }
421    // The host does not know which resource is the record's root, so its
422    // links always name the resource, which opens the same place.
423    let publisher = Publisher {
424        app,
425        record: Some(record),
426        root: "",
427    };
428    render_at(&minting(request, &publisher)?, locator)
429}
430
431fn render_at(
432    minting: &Minting,
433    locator: &tilo_actions::remote::ResourceViewLocator,
434) -> Result<String, String> {
435    if locator.provider != minting.provider {
436        return Err("The workspace's host answered for another record".into());
437    }
438    let entry = locator.web_entry.as_deref().ok_or(NO_WEB_ENTRY)?;
439    let entry = WebEntry::new(entry).map_err(|error| error.to_string())?;
440    let workspace = locator
441        .workspace
442        .parse()
443        .map_err(|_| "The workspace's host named no workspace a link can carry")?;
444    let host = HostOrigin::new(&locator.origin).map_err(|error| error.to_string())?;
445    let target = LinkTarget {
446        scope: Scope::Room {
447            host,
448            id: workspace,
449            slug: None,
450        },
451        app: Some(minting.app.clone()),
452        record: Some(minting.record.clone()),
453        resource: minting.resource.clone(),
454        view: minting.view.clone(),
455        discuss: None,
456    };
457    Link {
458        mode: minting.mode,
459        target,
460        secrets: LinkSecrets::default(),
461    }
462    .to_url(&entry)
463    .map_err(|error| error.to_string())
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use tilo_actions::remote::{CataloguePage, ResourceViewLocator};
470    use tilo_resource::share::SharedView;
471
472    const WORKSPACE: &str = "6f1c2b9e-0d4a-4c1e-9b7a-2f3d1e5c8a90";
473
474    fn request(args: &serde_json::Value) -> ShareRequest {
475        ShareRequest {
476            resource: "chat.message:01JA".into(),
477            kind: "chat.message".into(),
478            views: vec![
479                SharedView {
480                    id: "message".into(),
481                    primary: true,
482                    range: true,
483                    desktop: false,
484                },
485                SharedView {
486                    id: "thread".into(),
487                    primary: false,
488                    range: false,
489                    desktop: false,
490                },
491            ],
492            invite: false,
493            args: args.as_object().cloned().unwrap_or_default(),
494        }
495    }
496
497    fn page(entry: Option<&str>) -> CataloguePage {
498        CataloguePage {
499            locator: Some(ResourceViewLocator {
500                origin: "https://ada.tilo.cloud".into(),
501                workspace: WORKSPACE.into(),
502                host_device: "ab".repeat(32),
503                provider: "record:so.tilo.chat:".into(),
504                web_entry: entry.map(str::to_owned),
505            }),
506            ..CataloguePage::default()
507        }
508    }
509
510    const CHAT: Publisher<'static> = Publisher {
511        app: "so.tilo.chat",
512        record: Some("@tilo:package-primary"),
513        root: "chat",
514    };
515
516    fn url(args: &serde_json::Value) -> Result<String, String> {
517        render(
518            &minting(&request(args), &CHAT)?,
519            &page(Some("https://tilo.so/")),
520        )
521    }
522
523    #[test]
524    fn links_render_the_grammar_under_the_announced_entry() {
525        assert_eq!(
526            url(&serde_json::json!({})).unwrap(),
527            format!(
528                "https://tilo.so/app/#/@ada.tilo.cloud/w/{WORKSPACE}/so.tilo.chat/-/chat.message:01JA"
529            )
530        );
531        assert_eq!(
532            url(&serde_json::json!({"start": 6, "end": 9, "mode": "desktop"})).unwrap(),
533            format!(
534                "https://tilo.so/#/@ada.tilo.cloud/w/{WORKSPACE}/so.tilo.chat/-/chat.message:01JA?chars=6-9"
535            )
536        );
537        assert!(
538            url(&serde_json::json!({"view": "thread"}))
539                .unwrap()
540                .ends_with("chat.message:01JA?view=thread")
541        );
542    }
543
544    #[test]
545    fn a_view_read_from_the_desktop_is_linked_on_the_desktop() {
546        let mut desktop = request(&serde_json::json!({}));
547        desktop.views[0].desktop = true;
548        let link = |request: &ShareRequest| {
549            render(
550                &minting(request, &CHAT).unwrap(),
551                &page(Some("https://tilo.so/")),
552            )
553            .unwrap()
554        };
555        assert_eq!(
556            link(&desktop),
557            format!(
558                "https://tilo.so/#/@ada.tilo.cloud/w/{WORKSPACE}/so.tilo.chat/-/chat.message:01JA"
559            )
560        );
561        desktop.args = serde_json::json!({"mode": "app"})
562            .as_object()
563            .cloned()
564            .unwrap();
565        assert!(link(&desktop).starts_with("https://tilo.so/app/#/"));
566    }
567
568    #[test]
569    fn what_cannot_be_linked_is_refused_with_its_reason() {
570        assert!(url(&serde_json::json!({"view": "thread", "start": 1, "end": 2})).is_err());
571        assert!(url(&serde_json::json!({"start": 1})).is_err());
572        assert!(url(&serde_json::json!({"view": "nope"})).is_err());
573        let minting = minting(&request(&serde_json::json!({})), &CHAT).unwrap();
574        assert_eq!(render(&minting, &page(None)).unwrap_err(), NO_WEB_ENTRY);
575        let mut other = page(Some("https://tilo.so/"));
576        if let Some(locator) = other.locator.as_mut() {
577            locator.provider = "record:so.tilo.notes:".into();
578        }
579        assert!(render(&minting, &other).is_err());
580    }
581
582    #[test]
583    fn a_link_to_the_root_names_the_record() {
584        let mut root = request(&serde_json::json!({}));
585        root.resource = "chat".into();
586        let minting = minting(&root, &CHAT).unwrap();
587        assert_eq!(
588            render(&minting, &page(Some("https://tilo.so/"))).unwrap(),
589            format!("https://tilo.so/app/#/@ada.tilo.cloud/w/{WORKSPACE}/so.tilo.chat/-")
590        );
591    }
592
593    /// A host answering with what the desk's `issue-invitation` answers.
594    fn desk(value: serde_json::Value) -> std::sync::Arc<tilo_actions::remote::TestHost> {
595        std::sync::Arc::new(tilo_actions::remote::TestHost::new().completing(move |_| {
596            tilo_actions::ActionOutput {
597                value: Some(value.clone()),
598                resources: Vec::new(),
599            }
600        }))
601    }
602
603    fn share_invite(args: &serde_json::Value) -> Vec<u8> {
604        let mut request = request(args);
605        request.invite = true;
606        serde_json::to_vec(&request).unwrap()
607    }
608
609    /// An app's invitation is the desk's: the same action the invite sheet drives, asked through
610    /// the broker with the part and the person it is for, and answered with the link it made.
611    #[test]
612    fn an_invitation_is_the_desks_and_answers_with_its_link() {
613        let host = desk(serde_json::json!({
614            "invite": "ab".repeat(32),
615            "link": "https://tilo.so/join#ab",
616            "role": "editor",
617            "expires_at_ns": 1,
618        }));
619        let _served = RemoteActions::enter(
620            host.clone(),
621            tilo_actions::remote::Caller::local_person("ada"),
622        );
623        let mut links = ShareLinks::default();
624        let minted = links
625            .start(
626                tilo_actions::InvocationId::new(),
627                &share_invite(&serde_json::json!({
628                    "role": "editor",
629                    "invitee_name": "Ada",
630                    "view": "message",
631                })),
632                &CHAT,
633                true,
634            )
635            .expect("the desk answers at once")
636            .expect("the invitation is made");
637        assert!(!minted.copied);
638        assert_eq!(
639            minted.output.value,
640            Some(serde_json::json!({"url": "https://tilo.so/join#ab"}))
641        );
642        let asked = host.asked();
643        assert_eq!(asked.len(), 1);
644        assert_eq!(asked[0].target.provider, session_actions::PROVIDER);
645        assert_eq!(
646            asked[0].target.address,
647            tilo_actions::ActionAddress::new(
648                session_actions::DESK_RESOURCE,
649                session_actions::ISSUE_INVITATION
650            )
651        );
652        assert_eq!(
653            asked[0].argument,
654            InvokeArgument::Json(
655                serde_json::json!({"role": "editor", "invitee_name": "Ada"})
656                    .as_object()
657                    .cloned()
658                    .expect("an object")
659            ),
660            "a view the resource links to is not the desk's to read"
661        );
662    }
663
664    /// Someone the workspace's governance lets hand out no invitation hears a reason, and the
665    /// desk is never asked.
666    #[test]
667    fn a_person_who_may_not_invite_asks_the_desk_nothing() {
668        let host = desk(serde_json::json!({"link": "https://tilo.so/join#ab"}));
669        let _served = RemoteActions::enter(
670            host.clone(),
671            tilo_actions::remote::Caller::local_person("ada"),
672        );
673        let mut links = ShareLinks::default();
674        assert_eq!(
675            links
676                .start(
677                    tilo_actions::InvocationId::new(),
678                    &share_invite(&serde_json::json!({"role": "editor"})),
679                    &CHAT,
680                    false,
681                )
682                .err(),
683            Some("You may not invite people to this workspace".to_owned())
684        );
685        assert!(host.asked().is_empty());
686    }
687}