Skip to main content

shuyi_app/
page.rs

1//! Files from the page, on the web: the text files the page a web build runs
2//! in holds, read into a bundle layer (ADR-0004 ยง1) the game lays over its own
3//! files, and the game's problems with them sent back ([`report`]). It is how
4//! a page that edits a game's data beside it (the site's playground) runs the
5//! game on its edits with no install. On every other target the page holds
6//! none.
7//!
8//! The page writes each file to `sessionStorage` under
9//! `shuyi-page-file:<path>`, `<path>` being the file's path in the game's
10//! project (`files/stage.map`); [`page_layer`] takes those under one folder.
11//! The `shuyi_page_files` miniquad plugin in `crates/cli/web/page-files.js`
12//! (our own code, loaded by `crates/cli/web/index.html`) reads them.
13
14use std::collections::BTreeMap;
15
16use shuyi_assets::bundle::{Layer, LayerError};
17
18/// The plugin's version, which the JS loader compares with the `version`
19/// in `crates/cli/web/page-files.js` (a `shuyi-cli` test keeps them equal).
20/// Bump both when the functions below change.
21#[cfg(target_arch = "wasm32")]
22const PLUGIN_VERSION: u32 = 1;
23
24// Declaring the functions `crates/cli/web/page-files.js` provides. They take
25// numbers and strings as `sapp_jsutils.js`'s objects, and the JS answers ""
26// for a file that isn't there, so calling them is safe.
27#[cfg(target_arch = "wasm32")]
28#[allow(unsafe_code)]
29unsafe extern "C" {
30    /// Reads the keys of the page's files; returns how many. The other
31    /// functions index into that list until the next call.
32    safe fn shuyi_page_file_count() -> u32;
33    /// File `i`'s path, after the key's prefix.
34    safe fn shuyi_page_file_path(i: u32) -> sapp_jsutils::JsObject;
35    /// File `i`'s text.
36    safe fn shuyi_page_file_text(i: u32) -> sapp_jsutils::JsObject;
37    /// Sends the page `text`: to its parent frame, of its origin, or else
38    /// to the console.
39    safe fn shuyi_page_report(text: sapp_jsutils::JsObjectWeak);
40}
41
42/// What the JS loader calls to check the plugin's version.
43#[cfg(target_arch = "wasm32")]
44#[allow(unsafe_code)]
45#[unsafe(no_mangle)]
46pub extern "C" fn shuyi_page_files_crate_version() -> u32 {
47    PLUGIN_VERSION
48}
49
50/// How big the page's files may be ([`page_layer`]). Build it with
51/// [`PageLimits::new`] or take the [default](PageLimits::default): a struct
52/// literal doesn't compile outside this crate, so a new field never breaks a
53/// game (ADR-0039).
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[non_exhaustive]
56pub struct PageLimits {
57    /// The most one file may hold, in bytes of UTF-8.
58    pub per_file: u64,
59    /// The most the layer's files may hold together, in bytes of UTF-8.
60    pub in_all: u64,
61}
62
63impl PageLimits {
64    /// At most `per_file` bytes a file and `in_all` bytes together.
65    #[must_use]
66    pub const fn new(per_file: u64, in_all: u64) -> Self {
67        Self { per_file, in_all }
68    }
69}
70
71impl Default for PageLimits {
72    /// 256 KiB a file and 1 MiB together: a game's data files, with room.
73    fn default() -> Self {
74        Self::new(256 * 1024, 1024 * 1024)
75    }
76}
77
78/// Why the page's files could not be read into a layer ([`page_layer`]).
79#[derive(Debug)]
80#[non_exhaustive]
81pub enum PageError {
82    /// A file over the limit.
83    TooBig {
84        /// The file, as the page names it (`files/stage.map`).
85        path: String,
86        /// The limit, in bytes.
87        limit: u64,
88    },
89    /// The files together over the limit.
90    TooMuch {
91        /// The limit, in bytes.
92        limit: u64,
93    },
94    /// A path the layer refused (`files/../x`, `files/a\b`).
95    Layer(LayerError),
96}
97
98impl core::fmt::Display for PageError {
99    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
100        match self {
101            Self::TooBig { path, limit } => write!(f, "{path}: the file is over {limit} bytes"),
102            Self::TooMuch { limit } => {
103                write!(f, "the page's files are over {limit} bytes in all")
104            }
105            Self::Layer(error) => error.fmt(f),
106        }
107    }
108}
109
110impl std::error::Error for PageError {}
111
112/// The page's files under the folder `name` (`files`), as one layer named
113/// `name`, each at its path after the folder: `files/stage.map` is the
114/// layer's `stage.map`, and the bundle shows it as `files/stage.map`, as
115/// `cargo shuyi check` names the game's own. With `name` empty, every file
116/// of the page, at its path. `None` when the page holds none there, and on
117/// every target but the web. Read it once, at the start: the layer's files
118/// are kept for the life of the program, and the page runs the game again to
119/// change them.
120///
121/// # Errors
122///
123/// A file over `limits.per_file`, naming it; the files over `limits.in_all`
124/// together; a path the layer refuses.
125pub fn page_layer(name: &str, limits: PageLimits) -> Result<Option<Layer>, PageError> {
126    layer_from(name, page_files(), limits)
127}
128
129/// Every file of the page, path and text, in key order.
130#[cfg(target_arch = "wasm32")]
131fn page_files() -> Vec<(String, String)> {
132    let read = |object: sapp_jsutils::JsObject| {
133        let mut text = String::new();
134        object.to_string(&mut text);
135        text
136    };
137    (0..shuyi_page_file_count())
138        .map(|i| (read(shuyi_page_file_path(i)), read(shuyi_page_file_text(i))))
139        .collect()
140}
141
142/// Only a web page holds files.
143#[cfg(not(target_arch = "wasm32"))]
144fn page_files() -> Vec<(String, String)> {
145    Vec::new()
146}
147
148/// Sends the page `text`: the game's problems with the page's files, one a
149/// line, each with its file and line as `cargo shuyi check` prints them; the
150/// empty text when they load. The page's parent frame gets it as a
151/// `{ kind: "shuyi-report", text }` message when it has the page's origin,
152/// and the console otherwise. On every target but the web it does nothing.
153pub fn report(text: &str) {
154    #[cfg(target_arch = "wasm32")]
155    shuyi_page_report(sapp_jsutils::JsObject::string(text).weak());
156    #[cfg(not(target_arch = "wasm32"))]
157    let _ = text;
158}
159
160/// The layer [`page_layer`] makes of `entries` (each a path the page names
161/// and its text): the work, which every target compiles and tests.
162fn layer_from(
163    name: &str,
164    entries: Vec<(String, String)>,
165    limits: PageLimits,
166) -> Result<Option<Layer>, PageError> {
167    let folder = if name.is_empty() {
168        String::new()
169    } else {
170        format!("{name}/")
171    };
172    let mut files = BTreeMap::new();
173    let mut total: u64 = 0;
174    for (path, text) in entries {
175        let Some(inner) = path.strip_prefix(&folder) else {
176            continue;
177        };
178        let size = u64::try_from(text.len()).unwrap_or(u64::MAX);
179        if size > limits.per_file {
180            return Err(PageError::TooBig {
181                path,
182                limit: limits.per_file,
183            });
184        }
185        total = total.saturating_add(size);
186        if total > limits.in_all {
187            return Err(PageError::TooMuch {
188                limit: limits.in_all,
189            });
190        }
191        files.insert(inner.to_owned(), text.into_bytes());
192    }
193    if files.is_empty() {
194        return Ok(None);
195    }
196    Layer::owned(name.to_owned(), files)
197        .map(Some)
198        .map_err(PageError::Layer)
199}
200
201#[cfg(test)]
202mod tests {
203    use shuyi_assets::bundle::{BadPath, Bundle};
204
205    use super::*;
206
207    /// The game's own files, which the page's are laid over.
208    static GAME: &[(&str, &[u8])] = &[("en.ron", b"text"), ("stage.map", b"the game's map")];
209
210    /// `entries` as the page hands them.
211    fn page(entries: &[(&str, &str)]) -> Vec<(String, String)> {
212        entries
213            .iter()
214            .map(|&(path, text)| (path.to_owned(), text.to_owned()))
215            .collect()
216    }
217
218    /// `page`'s layer laid over the game's files.
219    fn over(page: Layer) -> Bundle {
220        Bundle::new(vec![page, Layer::table("files", GAME)])
221    }
222
223    #[test]
224    fn no_files_is_no_layer() {
225        let layer = layer_from("files", Vec::new(), PageLimits::default()).unwrap();
226        assert!(layer.is_none());
227    }
228
229    #[test]
230    fn a_file_of_the_page_is_read_over_the_games_own() {
231        let entries = page(&[("files/stage.map", "the page's map")]);
232        let layer = layer_from("files", entries, PageLimits::default())
233            .unwrap()
234            .unwrap();
235        let bundle = over(layer);
236        assert_eq!(bundle.file("stage.map"), Some("the page's map"));
237        assert_eq!(bundle.display_path("stage.map"), "files/stage.map");
238        assert_eq!(bundle.file("en.ron"), Some("text"));
239    }
240
241    #[test]
242    fn a_new_file_of_the_page_is_read_beside_the_games() {
243        let entries = page(&[("files/more/new.ron", "new")]);
244        let layer = layer_from("files", entries, PageLimits::default())
245            .unwrap()
246            .unwrap();
247        let bundle = over(layer);
248        assert_eq!(bundle.file("more/new.ron"), Some("new"));
249        assert_eq!(bundle.file("stage.map"), Some("the game's map"));
250    }
251
252    #[test]
253    fn files_outside_the_folder_are_not_the_layers() {
254        let entries = page(&[("other/stage.map", "not ours"), ("files", "nor this")]);
255        let layer = layer_from("files", entries, PageLimits::default()).unwrap();
256        assert!(layer.is_none());
257        // With no folder, every file at its path.
258        let entries = page(&[("other/stage.map", "all of them")]);
259        let layer = layer_from("", entries, PageLimits::default())
260            .unwrap()
261            .unwrap();
262        assert_eq!(
263            Bundle::new(vec![layer]).file("other/stage.map"),
264            Some("all of them")
265        );
266    }
267
268    #[test]
269    fn a_file_at_the_limit_is_read_and_one_over_it_is_refused() {
270        let limits = PageLimits::new(4, 100);
271        let entries = page(&[("files/a.ron", "1234"), ("files/b.ron", "12345")]);
272        let error = layer_from("files", entries, limits).unwrap_err();
273        assert!(
274            matches!(&error, PageError::TooBig { path, limit: 4 } if path == "files/b.ron"),
275            "{error:?}"
276        );
277        assert_eq!(error.to_string(), "files/b.ron: the file is over 4 bytes");
278        let entries = page(&[("files/a.ron", "1234")]);
279        assert!(layer_from("files", entries, limits).unwrap().is_some());
280    }
281
282    #[test]
283    fn files_over_the_limit_in_all_are_refused() {
284        let limits = PageLimits::new(4, 7);
285        let entries = page(&[("files/a.ron", "1234"), ("files/b.ron", "123")]);
286        assert!(layer_from("files", entries, limits).unwrap().is_some());
287        let entries = page(&[("files/a.ron", "1234"), ("files/b.ron", "1234")]);
288        let error = layer_from("files", entries, limits).unwrap_err();
289        assert!(
290            matches!(error, PageError::TooMuch { limit: 7 }),
291            "{error:?}"
292        );
293        assert!(error.to_string().contains("over 7 bytes"), "{error}");
294    }
295
296    #[test]
297    fn a_bad_path_is_refused_naming_it() {
298        let entries = page(&[("files/../secret.ron", "x")]);
299        let error = layer_from("files", entries, PageLimits::default()).unwrap_err();
300        assert!(
301            matches!(&error, PageError::Layer(e) if e.problem == BadPath::Parent && e.path == "../secret.ron"),
302            "{error:?}"
303        );
304        assert!(error.to_string().contains("../secret.ron"), "{error}");
305    }
306
307    #[test]
308    fn the_default_limits_are_256_kib_a_file_and_1_mib_in_all() {
309        assert_eq!(
310            PageLimits::default(),
311            PageLimits::new(256 * 1024, 1024 * 1024)
312        );
313    }
314
315    #[test]
316    fn off_the_web_the_page_holds_no_files_and_hears_nothing() {
317        assert!(
318            page_layer("files", PageLimits::default())
319                .unwrap()
320                .is_none()
321        );
322        assert!(page_layer("", PageLimits::default()).unwrap().is_none());
323        report("files/stage.map:3: a problem");
324    }
325}