1use 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
26const LOOKUP_TIMEOUT: Duration = Duration::from_secs(30);
28
29pub(crate) const NO_WEB_ENTRY: &str = "This workspace's host serves no web page";
31
32pub(crate) struct Publisher<'a> {
34 pub(crate) app: &'a str,
35 pub(crate) record: Option<&'a str>,
36 pub(crate) root: &'a str,
38}
39
40struct Minting {
42 provider: String,
43 app: AppId,
44 record: RecordSegment,
45 resource: Option<ResourceAddress>,
46 view: Option<String>,
47 mode: Mode,
48}
49
50enum 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 copy: Option<ui_core::clipboard::ReservedCopy>,
67 copying: Option<(String, ui_core::clipboard::ClipboardCopy)>,
70}
71
72pub(crate) struct ShareLinks {
75 remote: RemoteActions,
76 pending: Vec<Pending>,
77 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
97pub(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 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 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 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 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
245fn 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 Arrival::Ready(update) if !update.is_terminal() => None,
263 Arrival::Ready(update) => Some(invited_link(update)),
264 }),
265 }
266}
267
268fn 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
288fn 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
313fn 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
328fn 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 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
398fn 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
407pub 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 return Err("This host makes no invitation: the person's own device makes it".into());
420 }
421 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 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 #[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 #[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}