Skip to main content

eui_core/host/
screens.rs

1//! VAPP-218 (eApps U1): a package's SCREENS, the navigation manifest. A
2//! line-by-line port of `packages/exponential-ui/src/host/screens.ts`;
3//! `fixtures/host-router.json` (`validation`, `screens`, `navigate`) locks
4//! the same behaviour on every platform. `screens: [{template, title,
5//! icon?, params?, nav?, exposed?}]` lists the templates a person can open
6//! (keyed by the template); the host draws the navigation; `app.navigate
7//! {screen, params?}` opens one, its params landing under `/params/*`.
8//! #EXP-1674: `nav: false` = a detail screen, left out of the navigation;
9//! `exposed` = the public contract other apps link to.
10
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Map, Value};
13
14use super::js_trim;
15use super::package::PackageIssue;
16use crate::generated::catalog::ICON_NAMES;
17
18/// The most NAVIGATION screens a package declares (the phone bottom bar's
19/// room); `nav: false` screens do not count.
20pub const MAX_SCREENS: usize = 5;
21/// A screen title's most characters (code points).
22pub const MAX_SCREEN_TITLE: usize = 32;
23/// An exposed screen's description's most characters (code points).
24pub const MAX_EXPOSED_DESCRIPTION: usize = 200;
25/// The host function (and action call) that opens a screen; always allowed.
26pub const NAVIGATE_FUNCTION: &str = "app.navigate";
27/// The template data key a screen's params land under (`/params/*`).
28pub const SCREEN_PARAMS_KEY: &str = "params";
29
30const SCREEN_KEYS: &[&str] = &["template", "title", "icon", "params", "nav", "exposed"];
31const EXPOSED_KEYS: &[&str] = &["description", "params"];
32const EXPOSED_PARAM_KEYS: &[&str] = &["required", "description"];
33
34/// A screen as a host opens it: its template and the merged params.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct ResolvedScreen {
37    pub screen: String,
38    pub title: String,
39    pub icon: Option<String>,
40    pub params: Map<String, Value>,
41}
42
43/// A navigate call's target.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct NavigateTarget {
46    pub screen: String,
47    pub params: Map<String, Value>,
48}
49
50fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
51    PackageIssue { path: path.into(), message: message.into() }
52}
53
54fn escape(k: &str) -> String {
55    k.replace('~', "~0").replace('/', "~1")
56}
57
58fn sorted_keys(o: &Map<String, Value>) -> Vec<&String> {
59    let mut keys: Vec<&String> = o.keys().collect();
60    keys.sort();
61    keys
62}
63
64/// Every problem with a package's `screens` (absent = none).
65pub fn validate_package_screens(pkg: &Map<String, Value>) -> Vec<PackageIssue> {
66    let Some(list) = pkg.get("screens") else { return Vec::new() };
67    let Some(list) = list.as_array().filter(|l| !l.is_empty()) else {
68        return vec![issue("/screens", "screens is a non-empty list of {template, title, icon?, params?, nav?, exposed?}")];
69    };
70    let mut issues = Vec::new();
71    let in_nav = list.iter().filter(|s| !s.is_object() || s.get("nav") != Some(&Value::Bool(false))).count();
72    if in_nav > MAX_SCREENS {
73        issues.push(issue("/screens", format!("at most {MAX_SCREENS} screens in the navigation, got {in_nav}")));
74    }
75    if in_nav == 0 {
76        issues.push(issue("/screens", "at least one screen is in the navigation (not nav: false)"));
77    }
78    let empty = Map::new();
79    let templates = pkg.get("templates").and_then(Value::as_object).unwrap_or(&empty);
80    let mut seen: Vec<String> = Vec::new();
81    for (i, s) in list.iter().enumerate() {
82        let at = format!("/screens/{i}");
83        let Some(s) = s.as_object() else {
84            issues.push(issue(&at, "a screen is {template, title, icon?, params?, nav?, exposed?}"));
85            continue;
86        };
87        for k in sorted_keys(s) {
88            if !SCREEN_KEYS.contains(&k.as_str()) {
89                issues.push(issue(format!("{at}/{}", escape(k)), format!("unknown screen key {k}")));
90            }
91        }
92        match s.get("template").and_then(Value::as_str).filter(|t| !t.is_empty()) {
93            None => issues.push(issue(format!("{at}/template"), "template is a required string")),
94            Some(t) if !templates.contains_key(t) => issues.push(issue(format!("{at}/template"), format!("template {t} names no package template"))),
95            Some(t) if seen.iter().any(|x| x == t) => issues.push(issue(format!("{at}/template"), format!("duplicate screen {t}"))),
96            Some(t) => seen.push(t.to_string()),
97        }
98        match s.get("title").and_then(Value::as_str) {
99            Some(t) if !js_trim(t).is_empty() => {
100                if t.chars().count() > MAX_SCREEN_TITLE {
101                    issues.push(issue(format!("{at}/title"), format!("title is at most {MAX_SCREEN_TITLE} characters")));
102                }
103            }
104            _ => issues.push(issue(format!("{at}/title"), "title is a required string")),
105        }
106        if let Some(icon) = s.get("icon") {
107            if !icon.as_str().is_some_and(|n| ICON_NAMES.contains(&n)) {
108                let shown = icon.as_str().map(str::to_string).unwrap_or_else(|| icon.to_string());
109                issues.push(issue(format!("{at}/icon"), format!("icon {shown} is not an Exponential UI icon")));
110            }
111        }
112        if let Some(params) = s.get("params") {
113            match params.as_object() {
114                None => issues.push(issue(format!("{at}/params"), "params is an object of strings")),
115                Some(params) => {
116                    for k in sorted_keys(params) {
117                        if !params[k].is_string() {
118                            issues.push(issue(format!("{at}/params/{}", escape(k)), format!("params.{k} is a string")));
119                        }
120                    }
121                }
122            }
123        }
124        if s.get("nav").is_some_and(|nav| *nav != Value::Bool(false)) {
125            issues.push(issue(format!("{at}/nav"), "nav is false or absent"));
126        }
127        if let Some(exposed) = s.get("exposed") {
128            issues.extend(exposed_issues(exposed, &format!("{at}/exposed")));
129        }
130    }
131    issues.extend(navigate_issues(pkg, &seen));
132    issues
133}
134
135/// #EXP-1674: what is wrong with a screen's `exposed`.
136fn exposed_issues(exposed: &Value, at: &str) -> Vec<PackageIssue> {
137    let Some(exposed) = exposed.as_object() else {
138        return vec![issue(at, "exposed is {description, params?}")];
139    };
140    let mut issues = Vec::new();
141    for k in sorted_keys(exposed) {
142        if !EXPOSED_KEYS.contains(&k.as_str()) {
143            issues.push(issue(format!("{at}/{}", escape(k)), format!("unknown exposed key {k}")));
144        }
145    }
146    match exposed.get("description").and_then(Value::as_str) {
147        Some(d) if !js_trim(d).is_empty() => {
148            if d.chars().count() > MAX_EXPOSED_DESCRIPTION {
149                issues.push(issue(format!("{at}/description"), format!("description is at most {MAX_EXPOSED_DESCRIPTION} characters")));
150            }
151        }
152        _ => issues.push(issue(format!("{at}/description"), "description is a required string")),
153    }
154    let Some(params) = exposed.get("params") else { return issues };
155    let Some(params) = params.as_object() else {
156        issues.push(issue(format!("{at}/params"), "params is an object of {required?, description?}"));
157        return issues;
158    };
159    for k in sorted_keys(params) {
160        let here = format!("{at}/params/{}", escape(k));
161        if k.is_empty() || !k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
162            issues.push(issue(&here, format!("param {k} is letters, digits and _")));
163        }
164        let Some(param) = params[k].as_object() else {
165            issues.push(issue(&here, "a param is {required?, description?}"));
166            continue;
167        };
168        for key in sorted_keys(param) {
169            if !EXPOSED_PARAM_KEYS.contains(&key.as_str()) {
170                issues.push(issue(format!("{here}/{}", escape(key)), format!("unknown param key {key}")));
171            }
172        }
173        if param.get("required").is_some_and(|r| !r.is_boolean()) {
174            issues.push(issue(format!("{here}/required"), "required is a boolean"));
175        }
176        if param.get("description").is_some_and(|d| !d.is_string()) {
177            issues.push(issue(format!("{here}/description"), "description is a string"));
178        }
179    }
180    issues
181}
182
183/// A CEL string literal (`'deals'`, `"deals"`) → its text; else None.
184fn cel_string_literal(v: &Value) -> Option<String> {
185    let s = js_trim(v.as_str()?);
186    for q in ['\'', '"'] {
187        if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) {
188            let inner = &s[1..s.len() - 1];
189            if !inner.contains(q) && !inner.contains('\\') {
190                return Some(inner.to_string());
191            }
192        }
193    }
194    None
195}
196
197/// Every literal navigate target names a screen: a press's
198/// `functionCall {call: app.navigate, args: {screen}}` (a JSON string) and
199/// an action's `{call: app.navigate, args: {screen: <CEL string literal>}}`.
200fn navigate_issues(pkg: &Map<String, Value>, screens: &[String]) -> Vec<PackageIssue> {
201    let mut issues = Vec::new();
202    let check = |screen: Option<String>, at: String, issues: &mut Vec<PackageIssue>| {
203        if let Some(screen) = screen {
204            if !screens.iter().any(|s| *s == screen) {
205                issues.push(issue(at, format!("navigate: {screen} is not a screen")));
206            }
207        }
208    };
209    if let Some(templates) = pkg.get("templates").and_then(Value::as_object) {
210        for id in sorted_keys(templates) {
211            let Some(components) = templates[id].get("components").and_then(Value::as_array) else { continue };
212            for (i, c) in components.iter().enumerate() {
213                let Some(on) = c.get("on").and_then(Value::as_object) else { continue };
214                for event in sorted_keys(on) {
215                    let Some(fc) = on[event].get("functionCall").filter(|f| f.is_object()) else { continue };
216                    if fc.get("call").and_then(Value::as_str) != Some(NAVIGATE_FUNCTION) {
217                        continue;
218                    }
219                    let at = format!("/templates/{}/components/{i}/on/{}/functionCall/args", escape(id), escape(event));
220                    match fc.get("args").and_then(Value::as_object) {
221                        None => issues.push(issue(at, "navigate takes {screen, params?}")),
222                        Some(args) => check(args.get("screen").and_then(Value::as_str).map(str::to_string), format!("{at}/screen"), &mut issues),
223                    }
224                }
225            }
226        }
227    }
228    if let Some(actions) = pkg.get("actions").and_then(Value::as_object) {
229        for name in sorted_keys(actions) {
230            let Some(steps) = actions[name].get("steps").and_then(Value::as_array) else { continue };
231            for (i, step) in steps.iter().enumerate() {
232                if !step.is_object() || step.get("call").and_then(Value::as_str) != Some(NAVIGATE_FUNCTION) {
233                    continue;
234                }
235                let at = format!("/actions/{}/steps/{i}/args", escape(name));
236                match step.get("args").and_then(Value::as_object) {
237                    None => issues.push(issue(at, "navigate takes {screen, params?}")),
238                    Some(args) => check(args.get("screen").and_then(cel_string_literal), format!("{at}/screen"), &mut issues),
239                }
240            }
241        }
242    }
243    issues
244}
245
246/// The screens of a (valid) package, in declared order.
247pub fn package_screens(pkg: &Value) -> Vec<&Map<String, Value>> {
248    pkg.get("screens").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_object).filter(|s| s.get("template").is_some_and(Value::is_string)).collect()
249}
250
251/// #EXP-1674: the screens the host's navigation paints (no `nav: false`).
252pub fn nav_screens(pkg: &Value) -> Vec<&Map<String, Value>> {
253    package_screens(pkg).into_iter().filter(|s| s.get("nav") != Some(&Value::Bool(false))).collect()
254}
255
256/// The screen a host opens for a request: the requested one when the
257/// package has it (a `nav: false` one too), else the FIRST in the navigation;
258/// its params = the screen's own, then the request's string params key by
259/// key. None = the package has no screens.
260pub fn resolve_screen(pkg: &Value, screen: Option<&str>, params: Option<&Value>) -> Option<ResolvedScreen> {
261    let screens = package_screens(pkg);
262    let first = nav_screens(pkg).first().copied();
263    let picked = screens.iter().find(|s| s.get("template").and_then(Value::as_str) == screen).copied().or(first)?;
264    let mut merged = Map::new();
265    for source in [picked.get("params"), params] {
266        let Some(o) = source.and_then(Value::as_object) else { continue };
267        for k in sorted_keys(o) {
268            if o[k].is_string() {
269                merged.insert(k.clone(), o[k].clone());
270            }
271        }
272    }
273    Some(ResolvedScreen {
274        screen: picked["template"].as_str().unwrap_or_default().to_string(),
275        title: picked.get("title").and_then(Value::as_str).unwrap_or_default().to_string(),
276        icon: picked.get("icon").and_then(Value::as_str).map(str::to_string),
277        params: merged,
278    })
279}
280
281/// A navigate call's args → the target, or why it is refused.
282pub fn navigate_target(pkg: &Value, args: &Value) -> Result<NavigateTarget, String> {
283    let Some(args) = args.as_object() else { return Err("navigate takes {screen, params?}".into()) };
284    let Some(screen) = args.get("screen").and_then(Value::as_str).filter(|s| !s.is_empty()) else { return Err("navigate: screen is a required string".into()) };
285    if !package_screens(pkg).iter().any(|s| s.get("template").and_then(Value::as_str) == Some(screen)) {
286        return Err(format!("navigate: {screen} is not a screen"));
287    }
288    let mut params = Map::new();
289    match args.get("params") {
290        None | Some(Value::Null) => {}
291        Some(Value::Object(o)) => {
292            for k in sorted_keys(o) {
293                if !o[k].is_string() {
294                    return Err(format!("navigate: params.{k} is a string"));
295                }
296                params.insert(k.clone(), o[k].clone());
297            }
298        }
299        Some(_) => return Err("navigate: params is an object of strings".into()),
300    }
301    Ok(NavigateTarget { screen: screen.to_string(), params })
302}
303
304/// `navigate_target` as the fixture's JSON: `{screen, params}` | `{error}`.
305pub fn navigate_target_value(pkg: &Value, args: &Value) -> Value {
306    match navigate_target(pkg, args) {
307        Ok(t) => json!({"screen": t.screen, "params": t.params}),
308        Err(error) => json!({"error": error}),
309    }
310}
311
312/// #EXP-1674: what a link FROM ANOTHER APP may open in this package, or why it
313/// is refused. The target (`screen`, else the first in the navigation) must be
314/// `exposed`; its params = the screen's own, then the request's string params
315/// the contract declares (anything else dropped); a `required` one must be
316/// present and non-empty.
317pub fn cross_app_target(pkg: &Value, screen: Option<&str>, params: Option<&Value>) -> Result<NavigateTarget, String> {
318    let picked = match screen.filter(|s| !s.is_empty()) {
319        Some(screen) => package_screens(pkg).into_iter().find(|s| s.get("template").and_then(Value::as_str) == Some(screen)),
320        None => nav_screens(pkg).first().copied(),
321    };
322    let Some(picked) = picked else { return Err("no such screen".into()) };
323    let template = picked.get("template").and_then(Value::as_str).unwrap_or_default();
324    let Some(exposed) = picked.get("exposed").and_then(Value::as_object) else { return Err(format!("{template} is not exposed")) };
325    let empty = Map::new();
326    let declared = exposed.get("params").and_then(Value::as_object).unwrap_or(&empty);
327    let mut merged = Map::new();
328    if let Some(own) = picked.get("params").and_then(Value::as_object) {
329        for k in sorted_keys(own) {
330            if own[k].is_string() {
331                merged.insert(k.clone(), own[k].clone());
332            }
333        }
334    }
335    if let Some(asked) = params.and_then(Value::as_object) {
336        for k in sorted_keys(asked) {
337            if declared.contains_key(k) && asked[k].is_string() {
338                merged.insert(k.clone(), asked[k].clone());
339            }
340        }
341    }
342    for k in sorted_keys(declared) {
343        let required = declared[k].get("required").and_then(Value::as_bool) == Some(true);
344        if required && merged.get(k).and_then(Value::as_str).is_none_or(str::is_empty) {
345            return Err(format!("{template} needs {k}"));
346        }
347    }
348    Ok(NavigateTarget { screen: template.to_string(), params: merged })
349}
350
351/// `cross_app_target` as the fixture's JSON: `{screen, params}` | `{error}`.
352pub fn cross_app_target_value(pkg: &Value, screen: Option<&str>, params: Option<&Value>) -> Value {
353    match cross_app_target(pkg, screen, params) {
354        Ok(t) => json!({"screen": t.screen, "params": t.params}),
355        Err(error) => json!({"error": error}),
356    }
357}
358
359/// The data a screen's template opens with: `{params}`.
360pub fn screen_data(screen: &ResolvedScreen) -> Value {
361    json!({ SCREEN_PARAMS_KEY: screen.params })
362}
363
364/// A binding source's `${params.<key>}` filled from a screen's params
365/// (percent-encoded like a query value; a missing key = empty).
366pub fn fill_screen_params(source: &str, params: Option<&Value>) -> String {
367    let Some(params) = params.and_then(Value::as_object) else { return source.to_string() };
368    if !source.contains("${params.") {
369        return source.to_string();
370    }
371    let mut out = String::with_capacity(source.len());
372    let mut rest = source;
373    while let Some(at) = rest.find("${params.") {
374        out.push_str(&rest[..at]);
375        let after = &rest[at + "${params.".len()..];
376        let key_len = after.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b'_').count();
377        if key_len > 0 && after[key_len..].starts_with('}') {
378            if let Some(v) = params.get(&after[..key_len]).and_then(Value::as_str) {
379                out.push_str(&super::sources::encode_component(v));
380            }
381            rest = &after[key_len + 1..];
382        } else {
383            out.push_str("${params.");
384            rest = after;
385        }
386    }
387    out.push_str(rest);
388    out
389}