tilo_app/framework/hosted.rs
1//! An app's view side as a client of its one launcher-made connection to the
2//! session, over which it is handed readings and submits actions
3//! (`docs/architecture/apps.md` §3).
4//!
5//! An app whose records the session's document host keeps holds no document,
6//! no key and no transport of its own: the host runs its model and verbs,
7//! publishes the record's tree, answers a reading of it, and admits the
8//! actions a view submits (`docs/designs/app-language/one-app-model.md` §4).
9//! What the app's view side needs of that is two things, which this module
10//! names once:
11//!
12//! - **a reading in**: the state the session publishes for a resource, as
13//! JSON ([`tilo_resource::Resource::state_json`],
14//! [`tilo_resource::ResourceKind::to_json`]);
15//! - **an action out**: the action a control submits, with its arguments,
16//! addressed to the record the session keeps.
17//!
18//! [`Connection`] is that pair as the app's process reaches it. The launcher
19//! makes the connection and the session knows which app and which launch is
20//! on it because it made it, so an action over it is decided by the session,
21//! never claimed by the app (§4). A host that runs the app inside itself (a
22//! test, a capture's fixture) supplies its own [`Answers`], and the view side
23//! is the same.
24//!
25//! The connection never carries a document, a key or another device's address:
26//! its reading is a state as JSON, and its action is a name and JSON
27//! arguments. Nothing here can reach anything else.
28
29use std::rc::Rc;
30
31use tilo_actions::ActionAddress;
32use tilo_resource::Resource;
33
34/// An action an app submits over its connection, with whom it is.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct Ask {
37 /// The action's address: the resource it changes and its name.
38 pub address: ActionAddress,
39 /// Its arguments, by parameter name; empty for an action that takes none.
40 pub arguments: serde_json::Map<String, serde_json::Value>,
41 /// Whether the person was acting in this app's view when it was
42 /// submitted. An action without one is the app's own request, which the
43 /// session decides as the app (`docs/architecture/apps.md` §4).
44 pub in_person: bool,
45}
46
47impl Ask {
48 /// The action `action` of `resource`, with `arguments`, submitted by
49 /// `in_person`'s gesture — the person's, or the app's own request.
50 #[must_use]
51 pub fn new(
52 resource: impl Into<String>,
53 action: impl Into<String>,
54 arguments: serde_json::Map<String, serde_json::Value>,
55 in_person: bool,
56 ) -> Self {
57 Self {
58 address: ActionAddress::new(resource, action),
59 arguments,
60 in_person,
61 }
62 }
63}
64
65/// What answers a [`Connection`]: the session's door, or a host that runs the
66/// app inside itself in a test or a capture.
67pub trait Answers {
68 /// The reading of `resource` now, as the host publishes it: the state a
69 /// view reads, never a document. `None` when nothing publishes it, or the
70 /// caller may not read it (denial is absence, `§6`).
71 fn reading(&self, resource: &str) -> Option<serde_json::Value>;
72
73 /// Submit `ask`, answering the door's verdict.
74 ///
75 /// # Errors
76 /// The door's refusal, in its own words (`§4`, `§6`).
77 fn submit(&self, ask: &Ask) -> Result<(), String>;
78}
79
80/// The app's side of its connection to the session's door.
81#[derive(Clone)]
82pub struct Connection {
83 answers: Rc<dyn Answers>,
84}
85
86impl Connection {
87 /// The connection the launcher made the app it started: the session's
88 /// published readings and its action door, over the one connection the
89 /// launch holds. A process the session did not start reaches nothing
90 /// through it, as a launch it did not record is refused (`§3`).
91 #[must_use]
92 pub fn launcher() -> Self {
93 Self {
94 answers: Rc::new(Session),
95 }
96 }
97
98 /// A connection answered by `answers` in this process: a test's document
99 /// host, or a capture's readings.
100 #[must_use]
101 pub fn in_process(answers: impl Answers + 'static) -> Self {
102 Self {
103 answers: Rc::new(answers),
104 }
105 }
106
107 /// The reading of `resource` now.
108 #[must_use]
109 pub fn reading(&self, resource: &str) -> Option<serde_json::Value> {
110 self.answers.reading(resource)
111 }
112
113 /// The state of `R` that `resource` names, read from the host's reading
114 /// through `R`'s own model type: what a view reads while its app holds no
115 /// document.
116 ///
117 /// # Errors
118 /// Nothing publishes a reading the caller may read, or it is not `R`'s
119 /// state.
120 pub fn state<R: Resource>(&self, resource: &str) -> Result<R::State, String> {
121 let reading = self
122 .reading(resource)
123 .ok_or_else(|| format!("the session publishes no reading of {resource}"))?;
124 tilo_resource::state_from_reading::<R>(&reading).map_err(|error| error.to_string())
125 }
126
127 /// Submit `ask`; the door's verdict.
128 pub fn submit(&self, ask: &Ask) -> Result<(), String> {
129 self.answers.submit(ask)
130 }
131}
132
133impl std::fmt::Debug for Connection {
134 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 formatter.write_str("Connection")
136 }
137}
138
139/// The session's side, reached as any app reaches the session's published
140/// resources and its door (`ui_core::collaboration::SessionResource`,
141/// `ui_core::RemoteActions`).
142#[derive(Clone, Copy)]
143struct Session;
144
145impl Answers for Session {
146 fn reading(&self, resource: &str) -> Option<serde_json::Value> {
147 let reading = crate::framework::hosted_readings::reading(resource)?;
148 serde_json::from_str(&reading)
149 .inspect_err(|error| {
150 tracing::warn!(%error, %resource, "the session's reading of a resource is not JSON");
151 })
152 .ok()
153 }
154
155 fn submit(&self, ask: &Ask) -> Result<(), String> {
156 crate::framework::hosted_readings::submit(ask)
157 }
158}
159
160#[cfg(test)]
161mod tests;