1use std::cell::RefCell;
24use std::rc::Rc;
25
26use tilo_actions::door::{ActionAddress, ActionDescriptor, DoorError};
27use tilo_actions::{ActionEnvelope, ActionOutput, ActionTransport};
28use ui_core::application::{
29 ApplicationDefinition, ComponentRunnerInputs, HostContext, HostContextInputs, LaunchTarget,
30 RestoreInput,
31};
32use ui_core::application_view::InstanceViewStore;
33use ui_core::collaboration::DocumentMutation;
34use ui_core::headless::FrameHarness;
35use ui_core::permissions::{PermissionContext, SharedPermissions, UserId};
36use ui_core::portable_storage::{PortableStorage, StorageKey};
37use ui_core::record::{RecordCheckout, RecordOwners, RecordStore, RecordSync};
38
39const INSTANCE: u64 = 1;
41
42const VIEWPORT: (u32, u32) = (1280, 720);
45
46const DEVICE: &str = "attached-view";
48
49#[derive(Clone, Default)]
51struct Landed(Rc<RefCell<Vec<ActionEnvelope>>>);
52
53impl ActionTransport for Landed {
54 fn replicate(&mut self, env: &ActionEnvelope) {
55 self.0.borrow_mut().push(env.clone());
56 }
57}
58
59pub struct AttachedView {
62 definition: &'static ApplicationDefinition,
63 checkout: RecordCheckout,
64 frame: Box<FrameHarness>,
66 records: RecordStore,
68 permissions: SharedPermissions,
69 me: String,
71 invoker: tilo_actions::ActionInvoker,
73 context: Option<tilo_actions::ActionContext>,
75 landed: Landed,
76 sync: RecordSync,
79 beside: std::collections::HashMap<ui_core::record::RecordId, RecordSync>,
82 cache: PortableStorage,
85 workspace: String,
86}
87
88pub struct OpenView<'a> {
91 definition: &'static ApplicationDefinition,
92 workspace: &'a str,
93 checkout: RecordCheckout,
94 document: Vec<u8>,
95 invoker: tilo_actions::ActionInvoker,
96 permissions: PermissionContext,
97 created: bool,
98 content: Option<tilo_content::ContentStore>,
99}
100
101impl OpenView<'_> {
102 #[must_use]
104 pub fn for_invoker(mut self, invoker: &tilo_actions::ActionInvoker) -> Self {
105 self.invoker = invoker.clone();
106 self
107 }
108
109 #[must_use]
111 pub fn with_permissions(mut self, permissions: PermissionContext) -> Self {
112 self.permissions = permissions;
113 self
114 }
115
116 #[must_use]
119 pub const fn created(mut self, created: bool) -> Self {
120 self.created = created;
121 self
122 }
123
124 #[must_use]
127 pub fn lending(mut self, store: tilo_content::ContentStore) -> Self {
128 self.content = Some(store);
129 self
130 }
131
132 pub fn open(self) -> Result<AttachedView, String> {
137 AttachedView::open_view(self)
138 }
139}
140
141impl AttachedView {
142 pub fn open(
146 definition: &'static ApplicationDefinition,
147 workspace: &str,
148 checkout: RecordCheckout,
149 document: Vec<u8>,
150 me: &str,
151 permissions: PermissionContext,
152 created: bool,
153 ) -> Result<Self, String> {
154 Self::open_for(
155 definition,
156 workspace,
157 checkout,
158 document,
159 &tilo_actions::ActionInvoker::actor(me),
160 permissions,
161 created,
162 )
163 }
164
165 pub fn open_for(
172 definition: &'static ApplicationDefinition,
173 workspace: &str,
174 checkout: RecordCheckout,
175 document: Vec<u8>,
176 invoker: &tilo_actions::ActionInvoker,
177 permissions: PermissionContext,
178 created: bool,
179 ) -> Result<Self, String> {
180 Self::opening(definition, workspace, checkout, document)
181 .for_invoker(invoker)
182 .with_permissions(permissions)
183 .created(created)
184 .open()
185 }
186
187 #[must_use]
191 pub fn opening<'a>(
192 definition: &'static ApplicationDefinition,
193 workspace: &'a str,
194 checkout: RecordCheckout,
195 document: Vec<u8>,
196 ) -> OpenView<'a> {
197 OpenView {
198 definition,
199 workspace,
200 checkout,
201 document,
202 invoker: tilo_actions::ActionInvoker::actor("owner"),
203 permissions: PermissionContext::permissive(),
204 created: false,
205 content: None,
206 }
207 }
208
209 fn open_view(view: OpenView<'_>) -> Result<Self, String> {
210 let OpenView {
211 definition,
212 workspace,
213 checkout,
214 document,
215 invoker,
216 permissions,
217 created,
218 content,
219 } = view;
220 let invoker = &invoker;
221 let me = invoker.actor.as_str();
222 let joined_at = (!created).then(|| heads_of(&document)).transpose()?;
223 let cache = PortableStorage::new("attached-views", DEVICE)?;
224 let key = if checkout.id.is_package_primary() {
225 StorageKey::Primary {
226 package: definition.app_id().into(),
227 workspace: workspace.to_owned(),
228 }
229 } else {
230 StorageKey::Record {
231 package: definition.app_id().into(),
232 workspace: workspace.to_owned(),
233 record: checkout.id.as_str().into(),
234 }
235 };
236 cache.hydrate(key, document, None)?;
239 let cache_kept = cache.clone();
240 let records = RecordOwners::portable(cache.clone())
241 .open(definition.app_id(), workspace)
242 .map_err(|error| error.to_string())?;
243 let views = Rc::new(
244 InstanceViewStore::portable(cache, workspace).map_err(|error| error.to_string())?,
245 );
246 let permissions = SharedPermissions::new(permissions);
247 let target = LaunchTarget::Record {
248 checkout: checkout.clone(),
249 reveal: None,
250 };
251 let context = HostContext::prepare(
252 definition,
253 HostContextInputs {
254 records: records.clone(),
255 views,
256 device_id: DEVICE.into(),
257 instance_id: INSTANCE,
258 permissions: permissions.clone(),
259 author: UserId(me.to_owned()),
260 action_context: tilo_actions::ActionContext::default()
261 .with_invoker(invoker.clone()),
262 },
263 &target,
264 )
265 .map_err(|error| error.to_string())?;
266 let mut context = context;
267 if let Some(store) = content {
268 context.provide_content(store);
269 }
270 let component = definition
271 .instantiate(context, target, RestoreInput::None)
272 .map_err(|error| error.to_string())?;
273 let mut frame = FrameHarness::from_component(
274 component,
275 VIEWPORT,
276 ComponentRunnerInputs {
277 theme: ui_core::Theme::default(),
278 text_renderer: ui_core::TextRenderer::new(),
279 },
280 )
281 .map_err(|error| error.to_string())?;
282 let landed = Landed::default();
283 frame.set_action_transport(Box::new(landed.clone()))?;
284 if let Some(joined_at) = &joined_at {
285 refuse_written_on_open(definition, &records, &checkout, joined_at)?;
286 }
287 let started = joined_at.or_else(|| {
292 records
293 .with(&checkout.id, ui_core::automerge::AutoCommit::get_heads)
294 .filter(|_| !created)
295 });
296 let sync = started.map_or_else(RecordSync::new, RecordSync::delivered);
297 Ok(Self {
298 definition,
299 checkout,
300 frame: Box::new(frame),
301 records,
302 permissions,
303 me: me.to_owned(),
304 invoker: invoker.clone(),
305 context: None,
306 landed,
307 sync,
308 beside: std::collections::HashMap::new(),
309 cache: cache_kept,
310 workspace: workspace.to_owned(),
311 })
312 }
313
314 #[must_use]
316 pub const fn definition(&self) -> &'static ApplicationDefinition {
317 self.definition
318 }
319
320 #[must_use]
321 pub const fn checkout(&self) -> &RecordCheckout {
322 &self.checkout
323 }
324
325 #[must_use]
327 pub fn me(&self) -> &str {
328 &self.me
329 }
330
331 pub fn set_permissions(&mut self, permissions: PermissionContext) {
333 self.permissions.set(permissions);
334 self.frame.relayout();
335 }
336
337 #[must_use]
339 pub fn resources(&self) -> Vec<tilo_actions::ResourceSpec> {
340 let permissions = self.permissions.borrow();
341 self.frame
342 .resources()
343 .into_iter()
344 .filter_map(|resource| {
345 permissions.permitted_resource(self.definition.app_id(), resource)
346 })
347 .collect()
348 }
349
350 #[must_use]
353 pub fn operations(&self) -> Vec<ActionDescriptor> {
354 tilo_actions::door::survey(&self.resources())
355 .into_iter()
356 .filter(|descriptor| descriptor.kind == tilo_actions::OperationKind::Document)
357 .collect()
358 }
359
360 pub fn invoke(
366 &mut self,
367 address: &ActionAddress,
368 arguments: &serde_json::Map<String, serde_json::Value>,
369 confirmed: bool,
370 ) -> Result<ActionOutput, DoorError> {
371 self.invoke_appearing(address, arguments, confirmed, None)
372 }
373
374 pub fn invoke_appearing(
381 &mut self,
382 address: &ActionAddress,
383 arguments: &serde_json::Map<String, serde_json::Value>,
384 confirmed: bool,
385 appearance: Option<tilo_actions::Appearance>,
386 ) -> Result<ActionOutput, DoorError> {
387 let invoker = self.invoker_appearing(appearance);
388 self.invoke_as(invoker, address, arguments, confirmed)
389 }
390
391 #[must_use]
395 pub fn invoker_appearing(
396 &self,
397 appearance: Option<tilo_actions::Appearance>,
398 ) -> tilo_actions::ActionInvoker {
399 self.context
400 .as_ref()
401 .and_then(|context| context.invoker.clone())
402 .filter(|invoker| invoker.actor == self.invoker.actor)
403 .unwrap_or_else(|| self.invoker.clone())
404 .with_appearance(appearance)
405 }
406
407 pub fn invoke_as(
416 &mut self,
417 invoker: tilo_actions::ActionInvoker,
418 address: &ActionAddress,
419 arguments: &serde_json::Map<String, serde_json::Value>,
420 confirmed: bool,
421 ) -> Result<ActionOutput, DoorError> {
422 let (_, started) = self.start_as(invoker, address, arguments, confirmed)?;
423 self.frame.settle_owed();
427 Ok(started.completed().unwrap_or_default())
428 }
429
430 pub fn invoke_as_awaiting(
441 &mut self,
442 invoker: tilo_actions::ActionInvoker,
443 address: &ActionAddress,
444 arguments: &serde_json::Map<String, serde_json::Value>,
445 confirmed: bool,
446 within: ui_core::time::Duration,
447 ) -> Result<ActionOutput, DoorError> {
448 let (invocation, started) = self.start_as(invoker, address, arguments, confirmed)?;
449 if let Some(output) = started.completed() {
450 self.frame.settle_owed();
451 return Ok(output);
452 }
453 let ended = |frame: &FrameHarness| {
454 frame
455 .job_updates(invocation)
456 .iter()
457 .find(|update| update.is_terminal())
458 .cloned()
459 };
460 if self
461 .frame
462 .wait_until(within, |frame| ended(frame).is_some())
463 {
464 self.frame.settle_owed();
466 }
467 match ended(&self.frame) {
468 Some(tilo_actions::InvocationUpdate::Succeeded { output, .. }) => Ok(output),
469 Some(tilo_actions::InvocationUpdate::Failed { error, .. }) => {
470 Err(DoorError::Failed(error.message))
471 }
472 Some(_) => Err(DoorError::Failed(format!(
473 "{} was cancelled",
474 address.action
475 ))),
476 None => {
477 let _ = self.frame.cancel_job(invocation);
478 Err(DoorError::Failed(format!(
479 "{} did not finish within {} seconds",
480 address.action,
481 within.as_secs()
482 )))
483 }
484 }
485 }
486
487 fn start_as(
490 &mut self,
491 invoker: tilo_actions::ActionInvoker,
492 address: &ActionAddress,
493 arguments: &serde_json::Map<String, serde_json::Value>,
494 confirmed: bool,
495 ) -> Result<
496 (
497 tilo_actions::InvocationId,
498 ui_core::host_operation::ResourceOperationStart,
499 ),
500 DoorError,
501 > {
502 if !tilo_actions::door::survey(&self.resources())
503 .iter()
504 .any(|entry| &entry.address == address)
505 {
506 return Err(DoorError::Unknown(address.clone()));
507 }
508 self.landed.0.borrow_mut().clear();
509 let ambient = self.context.clone().unwrap_or_default().enriched_by(
512 &tilo_actions::ActionContext::default()
513 .with_app(self.definition.app_id(), self.definition.title()),
514 );
515 self.frame.start_bound_job_within(
516 Some(invoker),
517 Some(&ambient),
518 &ui_core::bound_dispatch::BoundActionAddress {
519 instance_id: INSTANCE,
520 address: address.clone(),
521 },
522 arguments,
523 confirmed,
524 )
525 }
526
527 pub fn invoke_typed<R: tilo_resource::Resource>(
535 &mut self,
536 invoke: &tilo_resource::Invoke<R>,
537 confirmed: bool,
538 ) -> Result<ActionOutput, DoorError> {
539 if let Some(refusal) = invoke.refusal() {
540 return Err(DoorError::InvalidArguments(refusal.to_owned()));
541 }
542 let app = self.definition.app_id();
546 let resource = self
547 .resources()
548 .into_iter()
549 .find(|resource| resource.kind == R::KIND && resource.app().is_none_or(|of| of == app))
550 .ok_or_else(|| DoorError::Unknown(ActionAddress::new(R::KIND, invoke.id)))?;
551 self.invoke(
552 &ActionAddress::new(resource.id.as_str(), invoke.id),
553 &invoke.arguments,
554 confirmed,
555 )
556 }
557
558 pub fn invoke_at(
566 &mut self,
567 place: &tilo_resource::Place,
568 address: &ActionAddress,
569 arguments: &serde_json::Map<String, serde_json::Value>,
570 confirmed: bool,
571 ) -> Result<ActionOutput, DoorError> {
572 let resources = self.resources();
573 let param = tilo_actions::door::survey(&resources)
574 .into_iter()
575 .find(|entry| &entry.address == address)
576 .ok_or_else(|| DoorError::Unknown(address.clone()))?
577 .params
578 .iter()
579 .find(|param| param.is_place())
580 .map(|param| param.name.to_string())
581 .ok_or_else(|| {
582 DoorError::InvalidArguments(format!(
583 "`{}` takes no place to act at",
584 address.action
585 ))
586 })?;
587 let mut arguments = arguments.clone();
588 arguments.insert(
589 param,
590 serde_json::to_value(place)
591 .map_err(|error| DoorError::InvalidArguments(error.to_string()))?,
592 );
593 self.invoke(address, &arguments, confirmed)
594 }
595
596 pub fn set_context(&mut self, context: Option<tilo_actions::ActionContext>) {
599 self.context = context;
600 }
601
602 #[must_use]
604 pub const fn context(&self) -> Option<&tilo_actions::ActionContext> {
605 self.context.as_ref()
606 }
607
608 pub fn record_run(
612 &mut self,
613 label: &str,
614 issued_by: Option<&str>,
615 decision: Option<&str>,
616 steps: &[ui_core::RunStep],
617 ) -> bool {
618 self.records
619 .with(&self.checkout.id, |doc| {
620 let by = doc.get_actor().to_hex_string();
621 let recorded = ui_core::record_run_with_details(
622 doc,
623 label,
624 Some(&by),
625 issued_by,
626 Some(label),
627 decision,
628 steps,
629 );
630 if recorded.is_some() {
631 tilo_resource::history::commit_stamped_with_message(
632 doc,
633 Some(tilo_actions::FrameworkChange::Run.change_message()),
634 );
635 }
636 recorded.is_some()
637 })
638 .unwrap_or(false)
639 }
640
641 #[must_use]
644 pub fn landed(&self) -> Option<ActionEnvelope> {
645 self.landed.0.borrow().last().cloned()
646 }
647
648 pub fn read<R>(&self, read: impl FnOnce(&ui_core::automerge::AutoCommit) -> R) -> Option<R> {
650 self.records.read(&self.checkout.id, read)
651 }
652
653 pub fn take_changes(&mut self) -> Option<DocumentMutation> {
656 let next = publish(&mut self.sync, &self.records, &self.checkout.id)?;
657 Some(next)
658 }
659
660 pub fn merge(&mut self, mutation: &DocumentMutation) -> Result<bool, String> {
664 if let DocumentMutation::Incremental(bytes) = mutation
665 && !self
666 .records
667 .with(&self.checkout.id, |doc| RecordSync::builds_on(doc, bytes))
668 .unwrap_or(false)
669 {
670 return Ok(false);
671 }
672 if self.sync.snapshotted() {
674 self.sync.set_aside(&self.records, &self.checkout.id).ok();
675 }
676 let changed = self
677 .sync
678 .merge(&self.records, &self.checkout, mutation.clone())?;
679 self.frame.relayout();
680 Ok(changed)
681 }
682
683 #[must_use]
687 pub fn chains(&self) -> Vec<RecordCheckout> {
688 self.frame
690 .resources()
691 .iter()
692 .flat_map(|resource| std::iter::once(resource).chain(&resource.children))
693 .filter_map(|resource| {
694 let chain = super::chain_of_resource_id(resource.id.as_str())?;
695 Some(RecordCheckout::new(
696 chain,
697 ui_core::record::RecordKind::new(resource.kind.clone()),
698 ))
699 })
700 .collect()
701 }
702
703 #[must_use]
707 pub fn beside(&self, record: &ui_core::record::RecordId) -> Option<RecordCheckout> {
708 let (chain, _) = record.chain_part()?;
709 self.chains()
710 .into_iter()
711 .find(|held| held.id == chain)
712 .map(|held| RecordCheckout::new(record.clone(), held.kind))
713 }
714
715 #[must_use]
719 pub fn records_beside(&self) -> Vec<RecordCheckout> {
720 let chains = self.chains();
721 self.records
722 .open_records()
723 .into_iter()
724 .filter(|checkout| {
725 checkout
726 .id
727 .chain_part()
728 .is_some_and(|(chain, _)| chains.iter().any(|held| held.id == chain))
729 })
730 .collect()
731 }
732
733 pub fn read_record<R>(
734 &self,
735 record: &ui_core::record::RecordId,
736 read: impl FnOnce(&ui_core::automerge::AutoCommit) -> R,
737 ) -> Option<R> {
738 self.beside(record)?;
739 self.records.read(record, read)
740 }
741
742 pub fn take_record_changes(
746 &mut self,
747 record: &ui_core::record::RecordId,
748 ) -> Option<DocumentMutation> {
749 let sync = self.beside.entry(record.clone()).or_default();
750 publish(sync, &self.records, record)
751 }
752
753 pub fn merge_record(
759 &mut self,
760 record: &ui_core::record::RecordId,
761 mutation: &DocumentMutation,
762 ) -> Result<bool, String> {
763 let checkout = self.beside(record).ok_or_else(|| {
764 format!(
765 "{} is not a part of a chain {} keeps",
766 record.as_str(),
767 self.definition.app_id()
768 )
769 })?;
770 if !self.records.is_ready(record) && self.records.try_open_record(&checkout).is_err() {
771 let DocumentMutation::Snapshot(bytes) = mutation else {
773 return Ok(false);
774 };
775 self.cache.hydrate(
776 StorageKey::Record {
777 package: self.definition.app_id().into(),
778 workspace: self.workspace.clone(),
779 record: record.as_str().into(),
780 },
781 bytes.clone(),
782 None,
783 )?;
784 self.records
785 .try_open_record(&checkout)
786 .map_err(|error| error.to_string())?;
787 }
788 if let DocumentMutation::Incremental(bytes) = mutation
789 && !self
790 .records
791 .with(record, |doc| RecordSync::builds_on(doc, bytes))
792 .unwrap_or(false)
793 {
794 return Ok(false);
795 }
796 let heads = self
800 .records
801 .with(record, ui_core::automerge::AutoCommit::get_heads)
802 .unwrap_or_default();
803 let sync = self
804 .beside
805 .entry(record.clone())
806 .or_insert_with(move || RecordSync::delivered(heads));
807 if sync.snapshotted() {
808 sync.set_aside(&self.records, record).ok();
809 }
810 let changed = sync.merge(&self.records, &checkout, mutation.clone())?;
811 self.frame.relayout();
812 Ok(changed)
813 }
814}
815
816fn publish(
819 sync: &mut RecordSync,
820 records: &RecordStore,
821 id: &ui_core::record::RecordId,
822) -> Option<DocumentMutation> {
823 if !sync.snapshotted() {
824 let whole = sync.take_outbound(records, id).ok()??;
825 sync.advance(whole.heads);
826 return Some(whole.mutation);
827 }
828 sync.set_aside(records, id).ok()?;
829 sync.take_set_aside()
830}
831
832fn heads_of(document: &[u8]) -> Result<Vec<ui_core::automerge::ChangeHash>, String> {
836 ui_core::automerge::AutoCommit::load(document)
837 .map(|mut doc| doc.get_heads())
838 .map_err(|error| error.to_string())
839}
840
841fn refuse_written_on_open(
844 definition: &ApplicationDefinition,
845 records: &RecordStore,
846 checkout: &RecordCheckout,
847 joined_at: &[ui_core::automerge::ChangeHash],
848) -> Result<(), String> {
849 match records.with(&checkout.id, |doc| written_on_open(doc, joined_at)) {
850 Some(Some(wrote)) => Err(format!(
851 "{} wrote to a record it joined as it opened ({wrote}): only the record's \
852 creator writes its initial content, and a participant that joins adds \
853 nothing on open. What the record starts with belongs in the app's seed; \
854 what a participant adds belongs behind a control they use",
855 definition.app_id
856 )),
857 _ => Ok(()),
858 }
859}
860
861fn written_on_open(
871 doc: &mut ui_core::automerge::AutoCommit,
872 joined_at: &[ui_core::automerge::ChangeHash],
873) -> Option<String> {
874 use tilo_actions::{ActionContext, Did, FrameworkChange, HistoryOp};
875 let wrote: Vec<String> = doc
876 .get_changes(joined_at)
877 .into_iter()
878 .filter_map(|change| {
879 let message = change.message()?;
880 if message.starts_with(tilo_actions::CONTINUATION_PREFIX)
881 || FrameworkChange::from_change_message(message) == Some(FrameworkChange::Native)
882 {
883 return Some("an edit".to_owned());
884 }
885 Some(match ActionContext::from_change_message(message)?.what? {
886 Did::Verb(verb) => format!("`{}`", verb.action.action),
887 Did::Edit(edit) => format!("an edit of `{}`", edit.field),
888 Did::Group(_) => "a group of edits".to_owned(),
889 Did::History(step) if matches!(step.op, HistoryOp::Migrate { .. }) => return None,
890 Did::History(_) => "a history step".to_owned(),
891 })
892 })
893 .collect();
894 (!wrote.is_empty()).then(|| wrote.join(", "))
895}