Skip to main content

tilo_app/framework/
attached.rs

1//! An app's view of one record, attached and never drawn: how a host
2//! without a screen — an agent host, a relay, a room service — performs
3//! actions.
4//!
5//! Nothing is drawn if no one is looking, but no action is performed out of
6//! sight. An [`AttachedView`] mounts the app's real component over the
7//! record exactly as a window does, lays it out and never paints it; every
8//! operation goes through the view's bound dispatcher, the door a person's
9//! click takes, admitted for the participant as invoker and checked against
10//! its permissions. Where the action landed is where the participant's
11//! presence shows ([`AttachedView::landed`]), and the person's devices draw
12//! the same record from synced state when someone looks.
13//!
14//! Sync is the caller's: [`AttachedView::take_changes`] returns this
15//! participant's own changes since the last call (the whole document the
16//! first time, for a record it created) and [`AttachedView::merge`] takes a
17//! peer's, through the record store's replication frontier as a browser
18//! page's mounts do.
19
20use std::cell::RefCell;
21use std::rc::Rc;
22
23use tilo_actions::door::{ActionAddress, ActionDescriptor, DoorError};
24use tilo_actions::{ActionEnvelope, ActionOutput, ActionTransport};
25use ui_core::application::{
26    ApplicationDefinition, ComponentRunnerInputs, HostContext, HostContextInputs, LaunchTarget,
27    RestoreInput,
28};
29use ui_core::application_view::InstanceViewStore;
30use ui_core::collaboration::DocumentMutation;
31use ui_core::headless::FrameHarness;
32use ui_core::permissions::{PermissionContext, UserId};
33use ui_core::portable_storage::{PortableStorage, StorageKey};
34use ui_core::record::{RecordCheckout, RecordOwners, RecordStore};
35use ui_core::state::DocumentFrontier;
36
37/// The component instance a view mounts.
38const INSTANCE: u64 = 1;
39
40/// Where the view lays out. Never drawn; it only gives the view a size, as
41/// a window would.
42const VIEWPORT: (u32, u32) = (1280, 720);
43
44/// Device name the view's storage is kept under.
45const DEVICE: &str = "attached-view";
46
47/// The actions the view applied, as its transport replicates them.
48#[derive(Clone, Default)]
49struct Landed(Rc<RefCell<Vec<ActionEnvelope>>>);
50
51impl ActionTransport for Landed {
52    fn replicate(&mut self, env: &ActionEnvelope) {
53        self.0.borrow_mut().push(env.clone());
54    }
55}
56
57/// One record held in its app's view, attached and never drawn. See the
58/// [module docs](self).
59pub struct AttachedView {
60    definition: &'static ApplicationDefinition,
61    checkout: RecordCheckout,
62    /// The app's view of the record: mounted, laid out, never drawn.
63    frame: Box<FrameHarness>,
64    /// The record store the view reads and writes.
65    records: RecordStore,
66    permissions: Rc<RefCell<PermissionContext>>,
67    /// Who acts through the view: the invoker its operations are admitted for.
68    me: String,
69    /// The invoker as admitted: its kind and the person it acts for.
70    invoker: tilo_actions::ActionInvoker,
71    /// The task the invoker is carrying out, recorded with what it does.
72    context: Option<tilo_actions::ActionContext>,
73    landed: Landed,
74    /// Everything taken or merged so far: what lies past it is this
75    /// participant's own.
76    frontier: DocumentFrontier,
77    /// The whole document is still to be sent: the participant created it.
78    fresh: bool,
79    /// This participant's changes set aside, untaken, while a peer's merged.
80    unsent: Vec<Vec<u8>>,
81}
82
83/// An [`AttachedView`] about to open ([`AttachedView::opening`]): whom it
84/// is for, what they may do, and what the host lends the view.
85pub struct OpenView<'a> {
86    definition: &'static ApplicationDefinition,
87    room: &'a str,
88    checkout: RecordCheckout,
89    document: Vec<u8>,
90    invoker: tilo_actions::ActionInvoker,
91    permissions: PermissionContext,
92    created: bool,
93    content: Option<tilo_content::ContentStore>,
94}
95
96impl OpenView<'_> {
97    /// Open it for `invoker` (by default the record's owner).
98    #[must_use]
99    pub fn for_invoker(mut self, invoker: &tilo_actions::ActionInvoker) -> Self {
100        self.invoker = invoker.clone();
101        self
102    }
103
104    /// What the invoker may do through it (by default anything).
105    #[must_use]
106    pub fn with_permissions(mut self, permissions: PermissionContext) -> Self {
107        self.permissions = permissions;
108        self
109    }
110
111    /// Whether the invoker just created the record, so the first
112    /// [`AttachedView::take_changes`] sends it whole.
113    #[must_use]
114    pub const fn created(mut self, created: bool) -> Self {
115        self.created = created;
116        self
117    }
118
119    /// Lend the view the room's content store, as a host lends a window
120    /// its: the bytes a content hash names (an image another app made).
121    #[must_use]
122    pub fn lending(mut self, store: tilo_content::ContentStore) -> Self {
123        self.content = Some(store);
124        self
125    }
126
127    /// Mount the view.
128    ///
129    /// # Errors
130    /// As [`AttachedView::open`].
131    pub fn open(self) -> Result<AttachedView, String> {
132        AttachedView::open_view(self)
133    }
134}
135
136impl AttachedView {
137    /// Mount `definition`'s view over the record `document` holds in
138    /// `room`, for `me` with `permissions`. `created` when `me` just created
139    /// the record, so the first [`Self::take_changes`] sends it whole.
140    ///
141    /// # Errors
142    /// Returns why the record could not be held or the view would not open
143    /// over it.
144    pub fn open(
145        definition: &'static ApplicationDefinition,
146        room: &str,
147        checkout: RecordCheckout,
148        document: Vec<u8>,
149        me: &str,
150        permissions: PermissionContext,
151        created: bool,
152    ) -> Result<Self, String> {
153        Self::open_for(
154            definition,
155            room,
156            checkout,
157            document,
158            &tilo_actions::ActionInvoker::actor(me),
159            permissions,
160            created,
161        )
162    }
163
164    /// [`Self::open`] for `invoker` — an agent, say, which may act for a
165    /// person — rather than a person by their id alone. Its changes are
166    /// written under the record writer's actor for its handle on this host.
167    ///
168    /// # Errors
169    /// As [`Self::open`].
170    pub fn open_for(
171        definition: &'static ApplicationDefinition,
172        room: &str,
173        checkout: RecordCheckout,
174        document: Vec<u8>,
175        invoker: &tilo_actions::ActionInvoker,
176        permissions: PermissionContext,
177        created: bool,
178    ) -> Result<Self, String> {
179        Self::opening(definition, room, checkout, document)
180            .for_invoker(invoker)
181            .with_permissions(permissions)
182            .created(created)
183            .open()
184    }
185
186    /// The view of `definition` over the record `document` holds in `room`,
187    /// to open once said for whom, with what and what the host lends it
188    /// ([`OpenView`]).
189    #[must_use]
190    pub fn opening<'a>(
191        definition: &'static ApplicationDefinition,
192        room: &'a str,
193        checkout: RecordCheckout,
194        document: Vec<u8>,
195    ) -> OpenView<'a> {
196        OpenView {
197            definition,
198            room,
199            checkout,
200            document,
201            invoker: tilo_actions::ActionInvoker::actor("owner"),
202            permissions: PermissionContext::permissive(),
203            created: false,
204            content: None,
205        }
206    }
207
208    fn open_view(view: OpenView<'_>) -> Result<Self, String> {
209        let OpenView {
210            definition,
211            room,
212            checkout,
213            document,
214            invoker,
215            permissions,
216            created,
217            content,
218        } = view;
219        let invoker = &invoker;
220        let me = invoker.actor.as_str();
221        let cache = PortableStorage::new("attached-views", DEVICE)?;
222        let key = if checkout.id.is_package_primary() {
223            StorageKey::Primary {
224                package: definition.app_id.into(),
225                workspace: room.to_owned(),
226            }
227        } else {
228            StorageKey::Record {
229                package: definition.app_id.into(),
230                workspace: room.to_owned(),
231                record: checkout.id.as_str().into(),
232            }
233        };
234        cache.hydrate(key, document, Some(me.as_bytes().to_vec()))?;
235        let records = RecordOwners::portable(cache.clone())
236            .open(definition.app_id, room)
237            .map_err(|error| error.to_string())?;
238        let views =
239            Rc::new(InstanceViewStore::portable(cache, room).map_err(|error| error.to_string())?);
240        let permissions = Rc::new(RefCell::new(permissions));
241        let target = LaunchTarget::Record {
242            checkout: checkout.clone(),
243            reveal: None,
244        };
245        let context = HostContext::prepare(
246            definition,
247            HostContextInputs {
248                records: records.clone(),
249                views,
250                device_id: DEVICE.into(),
251                instance_id: INSTANCE,
252                permissions: permissions.clone(),
253                author: UserId(me.to_owned()),
254                action_context: tilo_actions::ActionContext::default()
255                    .with_invoker(invoker.clone()),
256            },
257            &target,
258        )
259        .map_err(|error| error.to_string())?;
260        let mut context = context;
261        if let Some(store) = content {
262            context.provide_content(store);
263        }
264        let component = definition
265            .instantiate(context, target, RestoreInput::None)
266            .map_err(|error| error.to_string())?;
267        let mut frame = FrameHarness::from_component(
268            component,
269            VIEWPORT,
270            ComponentRunnerInputs {
271                theme: ui_core::Theme::default(),
272                text_renderer: ui_core::TextRenderer::new(),
273            },
274        )
275        .map_err(|error| error.to_string())?;
276        let landed = Landed::default();
277        frame.set_action_transport(Box::new(landed.clone()))?;
278        let mut frontier = DocumentFrontier::default();
279        if let Some(heads) = records.with(&checkout.id, ui_core::automerge::AutoCommit::get_heads) {
280            frontier.advance(heads);
281        }
282        Ok(Self {
283            definition,
284            checkout,
285            frame: Box::new(frame),
286            records,
287            permissions,
288            me: me.to_owned(),
289            invoker: invoker.clone(),
290            context: None,
291            landed,
292            frontier,
293            fresh: created,
294            unsent: Vec::new(),
295        })
296    }
297
298    /// The app whose view this is.
299    #[must_use]
300    pub const fn definition(&self) -> &'static ApplicationDefinition {
301        self.definition
302    }
303
304    /// The record the view holds.
305    #[must_use]
306    pub const fn checkout(&self) -> &RecordCheckout {
307        &self.checkout
308    }
309
310    /// Who acts through the view.
311    #[must_use]
312    pub fn me(&self) -> &str {
313        &self.me
314    }
315
316    /// The participant's permissions changed: a role granted or taken away.
317    pub fn set_permissions(&mut self, permissions: PermissionContext) {
318        *self.permissions.borrow_mut() = permissions;
319        self.frame.relayout();
320    }
321
322    /// The tree the view publishes, as the participant may reach it.
323    #[must_use]
324    pub fn resources(&self) -> Vec<tilo_actions::Resource> {
325        let permissions = self.permissions.borrow();
326        self.frame
327            .resources()
328            .into_iter()
329            .filter_map(|resource| permissions.permitted_resource(self.definition.app_id, resource))
330            .collect()
331    }
332
333    /// Every document operation the participant may perform through the
334    /// view now, with its typed parameters.
335    #[must_use]
336    pub fn operations(&self) -> Vec<ActionDescriptor> {
337        tilo_actions::door::survey(&self.resources())
338            .into_iter()
339            .filter(|descriptor| descriptor.kind == tilo_actions::OperationKind::Document)
340            .collect()
341    }
342
343    /// Perform the operation `address` names through the view, for the
344    /// participant: permission, confirmation and strict argument binding
345    /// exactly as for a person's click (`confirmed` when the person it acts
346    /// for confirmed it). The change it made leaves with
347    /// [`Self::take_changes`].
348    ///
349    /// # Errors
350    /// The door's refusal: an address the view does not offer the
351    /// participant, a needed confirmation, bad arguments, or the action's
352    /// own failure.
353    pub fn invoke(
354        &mut self,
355        address: &ActionAddress,
356        arguments: &serde_json::Map<String, serde_json::Value>,
357        confirmed: bool,
358    ) -> Result<ActionOutput, DoorError> {
359        self.invoke_appearing(address, arguments, confirmed, None)
360    }
361
362    /// [`Self::invoke`] showing `appearance`, which the door already admitted
363    /// for this one invocation. `None` clears any appearance the view's
364    /// context carried: only a door sets one.
365    ///
366    /// # Errors
367    /// As [`Self::invoke`].
368    pub fn invoke_appearing(
369        &mut self,
370        address: &ActionAddress,
371        arguments: &serde_json::Map<String, serde_json::Value>,
372        confirmed: bool,
373        appearance: Option<tilo_actions::Appearance>,
374    ) -> Result<ActionOutput, DoorError> {
375        // The task may name the person the participant acts for now.
376        let invoker = self
377            .context
378            .as_ref()
379            .and_then(|context| context.invoker.clone())
380            .filter(|invoker| invoker.actor == self.invoker.actor)
381            .unwrap_or_else(|| self.invoker.clone())
382            .with_appearance(appearance);
383        self.invoke_as(invoker, address, arguments, confirmed)
384    }
385
386    /// [`Self::invoke`] for `invoker`, whom the host admitted for this one
387    /// invocation (an agent's, delivered to the app that owns the record):
388    /// the operation and every edit it makes are theirs. Any operation the
389    /// view offers — a document action, or an edit at the invoker's own
390    /// caret.
391    ///
392    /// # Errors
393    /// As [`Self::invoke`].
394    pub fn invoke_as(
395        &mut self,
396        invoker: tilo_actions::ActionInvoker,
397        address: &ActionAddress,
398        arguments: &serde_json::Map<String, serde_json::Value>,
399        confirmed: bool,
400    ) -> Result<ActionOutput, DoorError> {
401        if !tilo_actions::door::survey(&self.resources())
402            .iter()
403            .any(|entry| &entry.address == address)
404        {
405            return Err(DoorError::Unknown(address.clone()));
406        }
407        self.landed.0.borrow_mut().clear();
408        // History names the app the view is of, as its window does, within
409        // whatever task the participant carries out.
410        let ambient = self.context.clone().unwrap_or_default().enriched_by(
411            &tilo_actions::ActionContext::default()
412                .with_app(self.definition.app_id, self.definition.title),
413        );
414        let (_, started) = self.frame.start_bound_job_within(
415            Some(invoker),
416            Some(&ambient),
417            &ui_core::bound_dispatch::BoundActionAddress {
418                instance_id: INSTANCE,
419                address: address.clone(),
420            },
421            arguments,
422            confirmed,
423        )?;
424        self.frame.relayout();
425        Ok(started.completed().unwrap_or_default())
426    }
427
428    /// Perform `invoke`, an action of the record's resource `R` built from
429    /// its typed reference (`Note::EDIT.with(..)`, `Invoke::edit(..)`),
430    /// through the view as [`Self::invoke`] does.
431    ///
432    /// # Errors
433    /// As [`Self::invoke`], and when `invoke` does not bind or the view
434    /// publishes no `R`.
435    pub fn invoke_typed<R: tilo_resource::Resource>(
436        &mut self,
437        invoke: &tilo_resource::Invoke<R>,
438        confirmed: bool,
439    ) -> Result<ActionOutput, DoorError> {
440        if let Some(refusal) = invoke.refusal() {
441            return Err(DoorError::InvalidArguments(refusal.to_owned()));
442        }
443        let resource = self
444            .resources()
445            .into_iter()
446            .find(|resource| resource.kind == R::KIND)
447            .ok_or_else(|| DoorError::Unknown(ActionAddress::new(R::KIND, invoke.id)))?;
448        self.invoke(
449            &ActionAddress::new(resource.id.as_str(), invoke.id),
450            &invoke.arguments,
451            confirmed,
452        )
453    }
454
455    /// [`Self::invoke`] acting at `place` instead of at the participant's
456    /// caret: `place` is given as the operation's place parameter
457    /// (`#[param(place)]`), and the participant lands there.
458    ///
459    /// # Errors
460    /// As [`Self::invoke`]; refused as invalid arguments when the operation
461    /// takes no place.
462    pub fn invoke_at(
463        &mut self,
464        place: &tilo_resource::Place,
465        address: &ActionAddress,
466        arguments: &serde_json::Map<String, serde_json::Value>,
467        confirmed: bool,
468    ) -> Result<ActionOutput, DoorError> {
469        let resources = self.resources();
470        let param = tilo_actions::door::survey(&resources)
471            .into_iter()
472            .find(|entry| &entry.address == address)
473            .ok_or_else(|| DoorError::Unknown(address.clone()))?
474            .params
475            .iter()
476            .find(|param| param.is_place())
477            .map(|param| param.name.to_string())
478            .ok_or_else(|| {
479                DoorError::InvalidArguments(format!(
480                    "`{}` takes no place to act at",
481                    address.action
482                ))
483            })?;
484        let mut arguments = arguments.clone();
485        arguments.insert(
486            param,
487            serde_json::to_value(place)
488                .map_err(|error| DoorError::InvalidArguments(error.to_string()))?,
489        );
490        self.invoke(address, &arguments, confirmed)
491    }
492
493    /// The task the participant carries out from now on (`None`: none),
494    /// recorded with each thing it does.
495    pub fn set_context(&mut self, context: Option<tilo_actions::ActionContext>) {
496        self.context = context;
497    }
498
499    /// The task the participant carries out, when it has one.
500    #[must_use]
501    pub const fn context(&self) -> Option<&tilo_actions::ActionContext> {
502        self.context.as_ref()
503    }
504
505    /// Record one instruction that changed several records as one run in
506    /// this record's history: a marker, no content. Leaves with
507    /// [`Self::take_changes`]; whether anything was recorded.
508    pub fn record_run(
509        &mut self,
510        label: &str,
511        issued_by: Option<&str>,
512        decision: Option<&str>,
513        steps: &[ui_core::RunStep],
514    ) -> bool {
515        self.records
516            .with(&self.checkout.id, |doc| {
517                let by = doc.get_actor().to_hex_string();
518                let recorded = ui_core::record_run_with_details(
519                    doc,
520                    label,
521                    Some(&by),
522                    issued_by,
523                    Some(label),
524                    decision,
525                    steps,
526                );
527                if recorded.is_some() {
528                    tilo_resource::history::commit_stamped_with_message(
529                        doc,
530                        Some(tilo_actions::FrameworkChange::Run.change_message()),
531                    );
532                }
533                recorded.is_some()
534            })
535            .unwrap_or(false)
536    }
537
538    /// The last action the view applied for the participant's latest
539    /// invocation: where its presence shows.
540    #[must_use]
541    pub fn landed(&self) -> Option<ActionEnvelope> {
542        self.landed.0.borrow().last().cloned()
543    }
544
545    /// Read the record's document.
546    pub fn read<R>(&self, read: impl FnOnce(&ui_core::automerge::AutoCommit) -> R) -> Option<R> {
547        self.records.read(&self.checkout.id, read)
548    }
549
550    /// The participant's changes since the last call, or `None`. The first
551    /// call on a record the participant created returns it whole.
552    pub fn take_changes(&mut self) -> Option<DocumentMutation> {
553        if std::mem::take(&mut self.fresh) {
554            self.unsent.clear();
555            let (bytes, heads) = self.records.replication_snapshot(&self.checkout.id).ok()?;
556            self.frontier.advance(heads);
557            return Some(DocumentMutation::Snapshot(bytes));
558        }
559        self.set_aside_own();
560        let own: Vec<u8> = std::mem::take(&mut self.unsent).concat();
561        (!own.is_empty()).then_some(DocumentMutation::Incremental(own))
562    }
563
564    /// Merge a peer's change into the view's record. Incremental changes
565    /// that build on nothing this record holds belong to another record (or
566    /// arrived ahead of what they build on) and are left alone: `Ok(false)`.
567    ///
568    /// # Errors
569    /// Returns why the record store refused the peer's document.
570    pub fn merge(&mut self, mutation: &DocumentMutation) -> Result<bool, String> {
571        if let DocumentMutation::Incremental(bytes) = mutation {
572            let mut incoming = ui_core::automerge::AutoCommit::new();
573            if tilo_automerge::ApplyIncoming::load_incoming(&mut incoming, bytes).is_err() {
574                return Ok(false);
575            }
576            let missing = incoming.get_missing_deps(&[]);
577            let known = self
578                .records
579                .with(&self.checkout.id, |doc| {
580                    missing
581                        .iter()
582                        .all(|hash| doc.get_change_by_hash(hash).is_some())
583                })
584                .unwrap_or(false);
585            if !known {
586                return Ok(false);
587            }
588        }
589        // What this participant did and has not taken stays its own.
590        if !self.fresh {
591            self.set_aside_own();
592        }
593        let (changed, observed) = self
594            .records
595            .merge_replica(&self.checkout, mutation.clone())?;
596        self.frontier.observe(observed);
597        self.frame.relayout();
598        Ok(changed)
599    }
600
601    /// Move this participant's changes past the frontier into `unsent`.
602    fn set_aside_own(&mut self) {
603        if let Ok((delta, heads)) = self
604            .records
605            .replication_delta(&self.checkout.id, &mut self.frontier)
606        {
607            self.frontier.advance(heads);
608            if !delta.is_empty() {
609                self.unsent.push(delta);
610            }
611        }
612    }
613}