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
9use tilo_actions::remote::{CatalogueQuery, ViewResourceQuery};
10use tilo_reference::link::{
11    AppId, HostOrigin, Link, LinkSecrets, LinkTarget, Mode, RecordSegment, ResourceAddress, Scope,
12    WebEntry,
13};
14use tilo_resource::share::{SHARE_INVITE_TYPE, SHARE_TYPE, ShareRequest};
15use ui_core::time::{Duration, Instant};
16use ui_core::{Arrival, CatalogueCall, RemoteActions};
17
18/// How long a minting waits for the host's locator.
19const LOOKUP_TIMEOUT: Duration = Duration::from_secs(30);
20
21/// Why a host's answer cannot make a link.
22pub(crate) const NO_WEB_ENTRY: &str = "This workspace's host serves no web page";
23
24/// The record a root publishes from, as a provider names it.
25pub(crate) struct Publisher<'a> {
26    pub(crate) app: &'a str,
27    pub(crate) record: Option<&'a str>,
28    /// The root resource: a link to it names the record, not a resource.
29    pub(crate) root: &'a str,
30}
31
32/// What a link is minted for once the locator arrives.
33struct Minting {
34    provider: String,
35    app: AppId,
36    record: RecordSegment,
37    resource: Option<ResourceAddress>,
38    view: Option<String>,
39    mode: Mode,
40}
41
42struct Pending {
43    invocation: tilo_actions::InvocationId,
44    minting: Minting,
45    call: CatalogueCall,
46    started: Instant,
47    /// A person asked through a view: the URL goes to the clipboard their
48    /// click claimed.
49    copy: Option<ui_core::clipboard::ReservedCopy>,
50    /// The minted URL, once its copy is under way: the answer waits for the
51    /// clipboard's outcome, which settles without blocking the app.
52    copying: Option<(String, ui_core::clipboard::ClipboardCopy)>,
53}
54
55/// The root's minting of links: its own client for the session's action
56/// door, and the mintings waiting on it.
57pub(crate) struct ShareLinks {
58    remote: RemoteActions,
59    pending: Vec<Pending>,
60    /// Resources a view asked to copy a link to, in the order asked, each
61    /// with the clipboard its click claimed: a browser copies only inside
62    /// the click, and the host answers after it.
63    copy_requested: Vec<(String, ui_core::clipboard::ReservedCopy)>,
64}
65
66impl Default for ShareLinks {
67    fn default() -> Self {
68        Self {
69            remote: RemoteActions::current(),
70            pending: Vec::new(),
71            copy_requested: Vec::new(),
72        }
73    }
74}
75
76pub(crate) fn handles(type_id: &str) -> bool {
77    type_id == SHARE_TYPE || type_id == SHARE_INVITE_TYPE
78}
79
80/// What the minting answered and whether a person is to be told.
81pub(crate) struct Minted {
82    pub(crate) output: tilo_actions::ActionOutput,
83    pub(crate) copied: bool,
84}
85
86impl ShareLinks {
87    pub(crate) fn remote_mut(&mut self) -> &mut RemoteActions {
88        &mut self.remote
89    }
90
91    /// A view's control is about to invoke a link operation on `resource`:
92    /// its URL is copied, not only answered.
93    pub(crate) fn note_view_invocation(&mut self, invocation: &tilo_actions::ControlInvocation) {
94        if invocation.address.action == tilo_resource::share::SHARE_LINK
95            || invocation.address.action == tilo_resource::share::SHARE_INVITE_LINK
96        {
97            self.copy_requested.push((
98                invocation.address.resource.clone(),
99                ui_core::reserve_clipboard_copy(),
100            ));
101        }
102    }
103
104    /// Start minting the link `payload` asks for.
105    pub(crate) fn start(
106        &mut self,
107        invocation: tilo_actions::InvocationId,
108        payload: &[u8],
109        publisher: &Publisher<'_>,
110        may_invite: bool,
111    ) -> Result<Option<Minted>, String> {
112        let request: ShareRequest =
113            tilo_actions::decode_json(payload).map_err(|error| error.to_string())?;
114        let copy = self
115            .copy_requested
116            .iter()
117            .position(|(resource, _)| *resource == request.resource)
118            .map(|at| self.copy_requested.remove(at).1);
119        if request.invite {
120            if !may_invite {
121                return Err("You may not invite people to this workspace".into());
122            }
123            // Invitations are created through the workspace's membership door,
124            // which only the shell reaches; an app cannot create one itself.
125            return Err(
126                "Invitations are created from the workspace's members list, not from an app".into(),
127            );
128        }
129        let minting = minting(&request, publisher)?;
130        let call = self.remote.catalogue_page(CatalogueQuery {
131            provider: Some(minting.provider.clone()),
132            view_resource: Some(ViewResourceQuery {
133                id: request.resource,
134                kind: request.kind,
135            }),
136            ..CatalogueQuery::default()
137        });
138        let mut pending = Pending {
139            invocation,
140            minting,
141            call,
142            started: ui_core::time::now(),
143            copy,
144            copying: None,
145        };
146        // A host in this process answers at once.
147        if let Some(result) = settled(&pending) {
148            let url = result?;
149            let Some(reserved) = pending.copy.take() else {
150                return Ok(Some(finish(&url, None)));
151            };
152            let copy = reserved.fill(&url);
153            if let Some(copied) = copy.outcome() {
154                return Ok(Some(finish(&url, Some(copied))));
155            }
156            pending.copying = Some((url, copy));
157        }
158        self.pending.push(pending);
159        Ok(None)
160    }
161
162    /// The update of a minting this answers, once it has one.
163    pub(crate) fn poll(
164        &mut self,
165        invocation: tilo_actions::InvocationId,
166        cancellation_requested: bool,
167    ) -> Option<(tilo_actions::InvocationUpdate, bool)> {
168        let at = self
169            .pending
170            .iter()
171            .position(|pending| pending.invocation == invocation)?;
172        let pending = self.pending.get_mut(at)?;
173        if cancellation_requested {
174            self.pending.remove(at);
175            return Some((
176                tilo_actions::InvocationUpdate::Cancelled {
177                    invocation,
178                    message: None,
179                },
180                false,
181            ));
182        }
183        if pending.copying.is_none() {
184            let result = settled(pending).or_else(|| {
185                (ui_core::time::now().saturating_duration_since(pending.started) >= LOOKUP_TIMEOUT)
186                    .then(|| Err("The workspace's host did not answer".to_owned()))
187            })?;
188            match result {
189                Ok(url) => {
190                    let Some(reserved) = pending.copy.take() else {
191                        self.pending.remove(at);
192                        return Some(succeeded(invocation, finish(&url, None)));
193                    };
194                    let copy = reserved.fill(&url);
195                    pending.copying = Some((url, copy));
196                }
197                Err(message) => {
198                    self.pending.remove(at);
199                    return Some((
200                        tilo_actions::InvocationUpdate::Failed {
201                            invocation,
202                            error: tilo_actions::ActionFailure {
203                                code: "share_link".into(),
204                                message,
205                                details: None,
206                                retryable: true,
207                            },
208                        },
209                        false,
210                    ));
211                }
212            }
213        }
214        let (url, copy) = pending.copying.as_ref()?;
215        let minted = finish(url, Some(copy.outcome()?));
216        self.pending.remove(at);
217        Some(succeeded(invocation, minted))
218    }
219}
220
221/// The URL `pending`'s host answer makes, once it has answered.
222fn settled(pending: &Pending) -> Option<Result<String, String>> {
223    pending.call.with(|call| match call {
224        Arrival::Pending { .. } => None,
225        Arrival::Failed(error) => Some(Err(format!(
226            "The workspace's host would not look the resource up: {error}"
227        ))),
228        Arrival::Ready(page) => Some(render(&pending.minting, page)),
229    })
230}
231
232fn succeeded(
233    invocation: tilo_actions::InvocationId,
234    minted: Minted,
235) -> (tilo_actions::InvocationUpdate, bool) {
236    (
237        tilo_actions::InvocationUpdate::Succeeded {
238            invocation,
239            output: minted.output,
240        },
241        minted.copied,
242    )
243}
244
245/// The output for `url`, with whether it was copied when a person asked
246/// through a view.
247fn finish(url: &str, copied: Option<bool>) -> Minted {
248    Minted {
249        output: tilo_actions::ActionOutput {
250            value: Some(match copied {
251                Some(copied) => serde_json::json!({ "url": url, "copied": copied }),
252                None => serde_json::json!({ "url": url }),
253            }),
254            resources: Vec::new(),
255        },
256        copied: copied.unwrap_or(false),
257    }
258}
259
260/// What `request` links to, from the record `publisher` publishes.
261fn minting(request: &ShareRequest, publisher: &Publisher<'_>) -> Result<Minting, String> {
262    let app = AppId::new(publisher.app).map_err(|error| error.to_string())?;
263    let record =
264        RecordSegment::new(publisher.record.unwrap_or("")).map_err(|error| error.to_string())?;
265    let provider = format!("record:{}:{}", app.as_str(), record.provider_part());
266    let args = &request.args;
267    let text = |name: &str| args.get(name).and_then(serde_json::Value::as_str);
268    let view = match text("view") {
269        Some(id) => request
270            .views
271            .iter()
272            .find(|view| view.id == id)
273            .ok_or_else(|| format!("This has no view `{id}`"))?,
274        None => request
275            .views
276            .iter()
277            .find(|view| view.primary)
278            .or_else(|| request.views.first())
279            .ok_or("This has no view to link to")?,
280    };
281    let offset = |name: &str| -> Result<Option<usize>, String> {
282        args.get(name)
283            .filter(|value| !value.is_null())
284            .map(|value| {
285                value
286                    .as_u64()
287                    .and_then(|offset| usize::try_from(offset).ok())
288                    .ok_or_else(|| format!("{name} is not a position in the text"))
289            })
290            .transpose()
291    };
292    let span = match (offset("start")?, offset("end")?) {
293        (None, None) => None,
294        (Some(start), Some(end)) => {
295            if !view.range {
296                return Err(format!("The view `{}` does not open at a range", view.id));
297            }
298            let unit = match text("unit").unwrap_or("chars") {
299                "chars" => tilo_reference::SpanUnit::Chars,
300                "bytes" => tilo_reference::SpanUnit::Bytes,
301                other => return Err(format!("Unknown unit `{other}`")),
302            };
303            Some(tilo_reference::Span::new(start, end, unit))
304        }
305        _ => return Err("A range needs both start and end".into()),
306    };
307    let resource =
308        (request.resource != publisher.root || span.is_some()).then(|| ResourceAddress {
309            kind: request.kind.clone(),
310            id: request.resource.clone(),
311            span,
312        });
313    let mode = match text("mode").unwrap_or("app") {
314        "app" => Mode::App,
315        "desktop" => Mode::Desktop,
316        other => return Err(format!("Unknown mode `{other}`")),
317    };
318    Ok(Minting {
319        provider,
320        app,
321        record,
322        resource,
323        view: (!view.primary).then(|| view.id.clone()),
324        mode,
325    })
326}
327
328/// The link `minting` names under the web entry the host announced on `page`.
329fn render(minting: &Minting, page: &tilo_actions::remote::CataloguePage) -> Result<String, String> {
330    let locator = page
331        .locator
332        .as_ref()
333        .ok_or("The workspace's host did not say where the resource is")?;
334    render_at(minting, locator)
335}
336
337/// The link to the resource `request` names in `app`'s `record`, for a
338/// workspace's host that answers `share-link` itself on the records it runs
339/// headless: the link an app's root mints, under the host's own `locator`.
340pub fn host_link(
341    request: &ShareRequest,
342    app: &str,
343    record: &str,
344    locator: &tilo_actions::remote::ResourceViewLocator,
345) -> Result<String, String> {
346    if request.invite {
347        return Err(
348            "Invitations are created from the workspace's members list, not from an app".into(),
349        );
350    }
351    // The host does not know which resource is the record's root, so its
352    // links always name the resource, which opens the same place.
353    let publisher = Publisher {
354        app,
355        record: Some(record),
356        root: "",
357    };
358    render_at(&minting(request, &publisher)?, locator)
359}
360
361fn render_at(
362    minting: &Minting,
363    locator: &tilo_actions::remote::ResourceViewLocator,
364) -> Result<String, String> {
365    if locator.provider != minting.provider {
366        return Err("The workspace's host answered for another record".into());
367    }
368    let entry = locator.web_entry.as_deref().ok_or(NO_WEB_ENTRY)?;
369    let entry = WebEntry::new(entry).map_err(|error| error.to_string())?;
370    let workspace = locator
371        .workspace
372        .parse()
373        .map_err(|_| "The workspace's host named no workspace a link can carry")?;
374    let host = HostOrigin::new(&locator.origin).map_err(|error| error.to_string())?;
375    let target = LinkTarget {
376        scope: Scope::Room {
377            host,
378            id: workspace,
379            slug: None,
380        },
381        app: Some(minting.app.clone()),
382        record: Some(minting.record.clone()),
383        resource: minting.resource.clone(),
384        view: minting.view.clone(),
385        discuss: None,
386    };
387    Link {
388        mode: minting.mode,
389        target,
390        secrets: LinkSecrets::default(),
391    }
392    .to_url(&entry)
393    .map_err(|error| error.to_string())
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use tilo_actions::remote::{CataloguePage, ResourceViewLocator};
400    use tilo_resource::share::SharedView;
401
402    const WORKSPACE: &str = "6f1c2b9e-0d4a-4c1e-9b7a-2f3d1e5c8a90";
403
404    fn request(args: &serde_json::Value) -> ShareRequest {
405        ShareRequest {
406            resource: "chat.message:01JA".into(),
407            kind: "chat.message".into(),
408            views: vec![
409                SharedView {
410                    id: "message".into(),
411                    primary: true,
412                    range: true,
413                },
414                SharedView {
415                    id: "thread".into(),
416                    primary: false,
417                    range: false,
418                },
419            ],
420            invite: false,
421            args: args.as_object().cloned().unwrap_or_default(),
422        }
423    }
424
425    fn page(entry: Option<&str>) -> CataloguePage {
426        CataloguePage {
427            locator: Some(ResourceViewLocator {
428                origin: "https://ada.tilo.cloud".into(),
429                workspace: WORKSPACE.into(),
430                host_device: "ab".repeat(32),
431                provider: "record:so.tilo.chat:".into(),
432                web_entry: entry.map(str::to_owned),
433            }),
434            ..CataloguePage::default()
435        }
436    }
437
438    const CHAT: Publisher<'static> = Publisher {
439        app: "so.tilo.chat",
440        record: Some("@tilo:package-primary"),
441        root: "chat",
442    };
443
444    fn url(args: &serde_json::Value) -> Result<String, String> {
445        render(
446            &minting(&request(args), &CHAT)?,
447            &page(Some("https://tilo.so/")),
448        )
449    }
450
451    #[test]
452    fn links_render_the_grammar_under_the_announced_entry() {
453        assert_eq!(
454            url(&serde_json::json!({})).unwrap(),
455            format!(
456                "https://tilo.so/app/#/@ada.tilo.cloud/w/{WORKSPACE}/so.tilo.chat/-/chat.message:01JA"
457            )
458        );
459        assert_eq!(
460            url(&serde_json::json!({"start": 6, "end": 9, "mode": "desktop"})).unwrap(),
461            format!(
462                "https://tilo.so/#/@ada.tilo.cloud/w/{WORKSPACE}/so.tilo.chat/-/chat.message:01JA?chars=6-9"
463            )
464        );
465        assert!(
466            url(&serde_json::json!({"view": "thread"}))
467                .unwrap()
468                .ends_with("chat.message:01JA?view=thread")
469        );
470    }
471
472    #[test]
473    fn what_cannot_be_linked_is_refused_with_its_reason() {
474        assert!(url(&serde_json::json!({"view": "thread", "start": 1, "end": 2})).is_err());
475        assert!(url(&serde_json::json!({"start": 1})).is_err());
476        assert!(url(&serde_json::json!({"view": "nope"})).is_err());
477        let minting = minting(&request(&serde_json::json!({})), &CHAT).unwrap();
478        assert_eq!(render(&minting, &page(None)).unwrap_err(), NO_WEB_ENTRY);
479        let mut other = page(Some("https://tilo.so/"));
480        if let Some(locator) = other.locator.as_mut() {
481            locator.provider = "record:so.tilo.notes:".into();
482        }
483        assert!(render(&minting, &other).is_err());
484    }
485
486    #[test]
487    fn a_link_to_the_root_names_the_record() {
488        let mut root = request(&serde_json::json!({}));
489        root.resource = "chat".into();
490        let minting = minting(&root, &CHAT).unwrap();
491        assert_eq!(
492            render(&minting, &page(Some("https://tilo.so/"))).unwrap(),
493            format!("https://tilo.so/app/#/@ada.tilo.cloud/w/{WORKSPACE}/so.tilo.chat/-")
494        );
495    }
496}