Browse / github.com/storytold/craft-libs / patch
craft-web-host: a page's own file storage (window.craftStorage) for every Craft app
A shared crate for a web page's own file storage (window.craftStorage) ## The idea A web page can show a Craft app in the browser, for example a cloud drive that opens PhotoCraft or VectorCraft. That page often has its own file storage. It would help if File › Open and File › Save could use that storage, not only this device. Two app PRs already do this: storytold/photocraft#9346 and storytold/vectorcraft#4112. A maintainer pointed out in storytold/vectorcraft#4112 that the contract should be designed once for the whole family, as a shared crate here, so the apps can't drift apart. This issue is for that shared crate. ## How it works The page sets `window.cra
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- hayone1 (imported from pull request #360, open)
- Base
9ff719499d8f4730b93071abed993e5f12fe1be8- Size
- +599 −0 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
36c1dd5df04be135ee4e2f64
How it went
No other operator has reported using it yet.
Diff
raw · .github/workflows/web-host.yml Cargo.lock Cargo.toml README.md crates/web-host/Cargo.toml crates/web-host/README.md crates/web-host/src/lib.rs crates/web-host/src/path.rs crates/web-host/src/storage.rs crates/web-host/tests/storage.rs
new file mode 100644@@ -0,0 +1,56 @@+name: Web host++on:+ workflow_dispatch:+ push:+ branches: [main]+ paths: ['crates/web-host/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/web-host.yml']+ pull_request:+ paths: ['crates/web-host/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/web-host.yml']++permissions:+ contents: read++jobs:+ native:+ timeout-minutes: 15+ name: Web host+ runs-on: ubuntu-latest+ steps:+ - uses: actions/checkout@v7+ with:+ persist-credentials: false+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable+ with:+ components: rustfmt, clippy+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3+ with:+ key: craft-web-host+ - run: cargo fmt --check+ - run: cargo clippy -p craft-web-host --all-targets --locked -- -D warnings+ - run: cargo test -p craft-web-host --locked+ wasm:+ timeout-minutes: 20+ name: Web host WASM+ runs-on: ubuntu-latest+ env:+ # The tests run in Node: the module reads `globalThis.craftStorage`, which is `window` in a page.+ CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner+ steps:+ - uses: actions/checkout@v7+ with:+ persist-credentials: false+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable+ with:+ targets: wasm32-unknown-unknown+ components: clippy+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3+ with:+ key: craft-web-host-wasm+ - name: Install the matching WASM test runner+ shell: bash+ run: |+ runner_version=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); print(next(p["version"] for p in d["package"] if p["name"] == "wasm-bindgen"))')+ cargo install wasm-bindgen-cli --version "$runner_version" --locked+ - run: cargo clippy -p craft-web-host --all-targets --locked --target wasm32-unknown-unknown -- -D warnings+ - run: cargo test -p craft-web-host --locked --target wasm32-unknown-unknown@@ -1066,6 +1066,16 @@ dependencies = [ "zip", ] +[[package]]+name = "craft-web-host"+version = "0.1.0"+dependencies = [+ "js-sys",+ "wasm-bindgen",+ "wasm-bindgen-futures",+ "wasm-bindgen-test",+]+ [[package]] name = "craft-window" version = "0.1.0"@@ -43,6 +43,7 @@ craft-text = { path = "crates/text" } craft-ui = { path = "crates/ui" } craft-update = { path = "crates/update" } craft-window = { path = "crates/window" }+craft-web-host = { path = "crates/web-host" } egui = { version = "0.37", default-features = false } rayon = "1" serde = { version = "1", features = ["derive"] }@@ -26,6 +26,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit ## Libraries - [`craft-platform`](crates/platform/README.md): optional native dialog lifecycle, Linux/BSD desktop appearance observation and browser download ownership; application policy stays in the apps.+- [`craft-web-host`](crates/web-host/README.md): what a web page that hosts a Craft app offers it, the same for every app: the host's own file storage (`window.craftStorage`), host paths, and a queue that saves to the host one at a time. What a save means for a document stays in the app. - [`craft-ai-core`](crates/ai-core/README.md): the data model and contract for AI in the Craft Apps (connections, models, tools, requests, jobs, consent, history, route spec). Pure, no I/O, wasm-safe. Contract in [docs/ai-shared-layer.md](docs/ai-shared-layer.md). - [`craft-pdf`](crates/pdf/README.md): independently optional PDF parsing, interpretation, rasterization and guarded authoring. Apps retain layout, fonts, editing and export policy; writer-only consumers do not enable the shared reader.new file mode 100644@@ -0,0 +1,22 @@+[package]+name = "craft-web-host"+description = "What a web page that hosts a Craft app offers it: the host's own file storage (`window.craftStorage`)."+version.workspace = true+edition.workspace = true+license.workspace = true+rust-version.workspace = true+repository.workspace = true++[dependencies]++# Already in the apps' Cargo.lock through eframe's web runner.+[target.'cfg(target_arch = "wasm32")'.dependencies]+wasm-bindgen = "0.2"+wasm-bindgen-futures = "0.4"+js-sys = "0.3"++[target.'cfg(target_arch = "wasm32")'.dev-dependencies]+wasm-bindgen-test = "0.3"++[lints]+workspace = truenew file mode 100644@@ -0,0 +1,40 @@+# craft-web-host++What a web page that hosts a Craft app offers it, in one place for every app. Today that is the+host's own file storage, `window.craftStorage`: a page that serves an app (a cloud drive, a team+folder) can offer its storage, and the app's File › Open and Save offer it next to this device.++```js+window.craftStorage = {+ // File › Open: {path, bytes} for a file in the host's storage, "device" for the browser's+ // own file picker, or null when the user cancelled.+ async chooseOpen(extensions /* ["psd", …] or null */) { … },+ // A save or export: a path in the host's storage, "device" for a download, or null.+ async chooseSave(suggested /* a file name, or the document's host path */) { … },+ // Writes a file the user saved to a host path. The save counts once it resolves; a+ // rejection leaves the document unsaved.+ async write(path, bytes /* Uint8Array */) { … },+};+```++| Part | What it does |+| --- | --- |+| `is_host_path` (every target) | A host path has a scheme of its own, `cloud:home/art/photo.psd`. A one-letter scheme is a Windows drive. Documents opened from a host path keep it, so File › Save writes back there. |+| `storage::available` | Whether the page offers `craftStorage`. Without it the app works as it does on its own. |+| `storage::choose_open`, `storage::choose_save` | The host's dialogs as `OpenChoice` / `SaveChoice`. Only `"device"` means the device; a host that rejects or answers something else is an error, which the app treats as nothing chosen. |+| `storage::write` | Writes to a host path; `Ok` once the host has the file. |+| `storage::SaveQueue` | Saves one at a time, in order, so an earlier save of a file never lands after a later one. Each result comes back with the id `push` gave it. |++Rules for a host:++- Answer `"device"` right from the user's click in your own dialog: browsers open a file picker+ only during a click.+- Make every `write` settle. Saves run one at a time, so one that never settles holds the rest. A+ host whose storage can hang should reject after its own timeout (only the host can tell a slow+ upload from a stuck one).++What a save means for a document (its path, its saved state, when it may close) stays in each+app: the app builds each save's future, pushes it, and applies the results it takes back.++The `storage` module is for `wasm32` only. Its tests run in Node (`wasm-bindgen-test-runner`) with+a fake host, since the module reads `globalThis.craftStorage`, which is `window` in a page.new file mode 100644@@ -0,0 +1,42 @@+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]++//! What a web page that hosts a Craft app offers it. Today that is the host's own file storage:+//! a page that serves an app (a cloud drive, a team folder) can set `window.craftStorage`, and the+//! app's File › Open and Save offer it next to this device. The contract is the same for every+//! app, so each app's web shell calls this crate instead of its own copy.+//!+//! ```js+//! window.craftStorage = {+//! // File › Open: {path, bytes} for a file in the host's storage, "device" for the browser's+//! // own file picker, or null when the user cancelled.+//! async chooseOpen(extensions /* ["psd", …] or null */) { … },+//! // A save or export: a path in the host's storage, "device" for a download, or null.+//! async chooseSave(suggested /* a file name, or the document's host path */) { … },+//! // Writes a file the user saved to a host path. The save counts once it resolves; a+//! // rejection leaves the document unsaved.+//! async write(path, bytes /* Uint8Array */) { … },+//! };+//! ```+//!+//! Host paths have a scheme of their own, such as `cloud:home/art/photo.psd` ([`is_host_path`]):+//! a document opened from one keeps it, so File › Save writes back there without asking.+//!+//! The rules every app follows (the [`storage`] module carries them out):+//!+//! - Only the answer `"device"` opens the browser's picker or download. A host that rejects, or+//! answers anything else, means nothing was chosen.+//! - Browsers open a file picker only during a click, so a host should answer `"device"` right+//! from the user's click in its own dialog.+//! - Saves reach the host one at a time, in the order the user made them ([`storage::SaveQueue`]),+//! so an earlier save never lands after a later one. So each `write` must settle: a host whose+//! storage can hang should reject after its own timeout (only the host can tell a slow upload+//! from a stuck one).+//!+//! What a save means for a document (its path, its saved state, closing it) stays in each app.++mod path;++pub use path::is_host_path;++#[cfg(target_arch = "wasm32")]+pub mod storage;new file mode 100644@@ -0,0 +1,37 @@+//! Host paths: names in a host's own storage.++/// Whether a file name is a path in a host's own storage, `<scheme>:<folder>/<path>` such as+/// `cloud:home/art/logo.svg`, rather than a bare file name. A one-letter scheme is a Windows+/// drive, not a host.+pub fn is_host_path(name: &str) -> bool {+ name.split_once(':').is_some_and(|(scheme, rest)| {+ rest.contains('/')+ && scheme.len() > 1+ && scheme.starts_with(|c: char| c.is_ascii_alphabetic())+ && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))+ })+}++#[cfg(test)]+mod tests {+ use super::is_host_path;++ #[test]+ fn host_paths_have_a_scheme_and_a_folder() {+ assert!(is_host_path("cloud:home/art/photo.psd"));+ assert!(is_host_path("team-drive:shared/logo.svg"));+ assert!(is_host_path("s3+eu.west:bucket/a.png"));+ }++ #[test]+ fn bare_names_drives_and_odd_schemes_are_not_host_paths() {+ assert!(!is_host_path("photo.psd"));+ assert!(!is_host_path("C:/Users/me/photo.psd"));+ assert!(!is_host_path("c:\\Users\\me\\photo.psd"));+ assert!(!is_host_path("cloud:photo.psd"), "no folder");+ assert!(!is_host_path("1cloud:home/photo.psd"), "a scheme starts with a letter");+ assert!(!is_host_path("my cloud:home/photo.psd"));+ assert!(!is_host_path(":home/photo.psd"));+ assert!(!is_host_path(""));+ }+}new file mode 100644@@ -0,0 +1,229 @@+//! The host's file storage, `window.craftStorage` (see the crate docs for the contract).+//!+//! The calls answer in Rust terms: [`choose_open`] and [`choose_save`] turn the host's answer+//! into a choice, or an error for a host that failed or answered something else, which the app+//! treats as nothing chosen. [`SaveQueue`] runs saves one at a time and reports each back.++use std::cell::RefCell;+use std::collections::VecDeque;+use std::future::Future;+use std::pin::Pin;+use std::rc::Rc;++use wasm_bindgen::{JsCast, JsValue};+use wasm_bindgen_futures::JsFuture;++use crate::is_host_path;++/// The page's `craftStorage` object, when it offers one.+fn host() -> Option<JsValue> {+ let storage = js_sys::Reflect::get(&js_sys::global(), &"craftStorage".into()).ok()?;+ storage.is_object().then_some(storage)+}++/// Whether the page offers its own storage. Without it the app works as it does on its own.+pub fn available() -> bool {+ host().is_some()+}++/// What a JavaScript failure says: an `Error`'s message, a thrown string, or a plain note.+fn message(e: &JsValue) -> String {+ if let Some(e) = e.dyn_ref::<js_sys::Error>() {+ return e.message().into();+ }+ e.as_string().unwrap_or_else(|| "the host's storage failed".to_string())+}++/// Call `craftStorage.<method>(args…)` and wait for its answer.+async fn call(method: &str, args: &[JsValue]) -> Result<JsValue, String> {+ let storage = host().ok_or("the page has no craftStorage")?;+ let f = js_sys::Reflect::get(&storage, &method.into()).map_err(|e| message(&e))?;+ let f: js_sys::Function = f.dyn_into().map_err(|_| format!("craftStorage.{method} is not a function"))?;+ let answer = f.apply(&storage, &args.iter().collect::<js_sys::Array>()).map_err(|e| message(&e))?;+ // A host may answer at once instead of with a promise.+ match answer.dyn_into::<js_sys::Promise>() {+ Ok(promise) => JsFuture::from(promise).await.map_err(|e| message(&e)),+ Err(answer) => Ok(answer),+ }+}++/// What the user picked in the host's Open dialog.+#[derive(Clone, Debug, PartialEq, Eq)]+pub enum OpenChoice {+ /// A file in the host's storage: its host path and its bytes.+ File { path: String, bytes: Vec<u8> },+ /// The browser's own file picker (the host answered `"device"`).+ Device,+ /// Nothing (the host answered `null`).+ Cancelled,+}++/// What the user picked in the host's Save dialog.+#[derive(Clone, Debug, PartialEq, Eq)]+pub enum SaveChoice {+ /// A path in the host's storage, to [`write`] to.+ Host(String),+ /// A browser download (the host answered `"device"`).+ Device,+ /// Nothing (the host answered `null`).+ Cancelled,+}++fn is_device(answer: &JsValue) -> bool {+ answer.as_string().as_deref() == Some("device")+}++fn unexpected(method: &str, answer: &JsValue) -> String {+ format!("craftStorage.{method} answered something unexpected: {answer:?}")+}++/// Ask the host which file to open; `extensions` (without dots) narrows its list, `None` shows all.+/// An error means the host failed or answered something else: nothing was chosen.+pub async fn choose_open(extensions: Option<&[&str]>) -> Result<OpenChoice, String> {+ let exts = extensions.map_or(JsValue::NULL, |e| e.iter().map(|x| JsValue::from(*x)).collect::<js_sys::Array>().into());+ let answer = call("chooseOpen", &[exts]).await?;+ if answer.is_null() {+ return Ok(OpenChoice::Cancelled);+ }+ if is_device(&answer) {+ return Ok(OpenChoice::Device);+ }+ let path = js_sys::Reflect::get(&answer, &"path".into()).ok().and_then(|p| p.as_string()).filter(|p| is_host_path(p));+ let bytes = js_sys::Reflect::get(&answer, &"bytes".into())+ .ok()+ .filter(|b| b.is_instance_of::<js_sys::Uint8Array>() || b.is_instance_of::<js_sys::ArrayBuffer>())+ .map(|b| js_sys::Uint8Array::new(&b).to_vec());+ match (path, bytes) {+ (Some(path), Some(bytes)) => Ok(OpenChoice::File { path, bytes }),+ _ => Err(unexpected("chooseOpen", &answer)),+ }+}++/// Ask the host where a save of `suggested` (a file name, or the document's host path) goes.+/// An error means the host failed or answered something else: nothing was chosen.+pub async fn choose_save(suggested: &str) -> Result<SaveChoice, String> {+ let answer = call("chooseSave", &[suggested.into()]).await?;+ if answer.is_null() {+ return Ok(SaveChoice::Cancelled);+ }+ if is_device(&answer) {+ return Ok(SaveChoice::Device);+ }+ match answer.as_string().filter(|p| is_host_path(p)) {+ Some(path) => Ok(SaveChoice::Host(path)),+ None => Err(unexpected("chooseSave", &answer)),+ }+}++/// Write `bytes` to `path` in the host's storage. `Ok` once the host has the file.+pub async fn write(path: &str, bytes: &[u8]) -> Result<(), String> {+ call("write", &[path.into(), js_sys::Uint8Array::from(bytes).into()]).await.map(|_| ())+}++/// A save waiting in a [`SaveQueue`].+type Save<R> = Pin<Box<dyn Future<Output = R>>>;++struct Queue<R> {+ waiting: VecDeque<(u64, Save<R>)>,+ running: Option<u64>,+ /// The worker that works through `waiting` is alive (it may be between two saves).+ worker: bool,+ last_id: u64,+ done: Vec<(u64, R)>,+ wake: Option<Rc<dyn Fn()>>,+}++/// Saves to the host, one at a time in the order they were pushed, so an earlier save of a file+/// never lands after a later one. Each save is a future the app builds (asking the host where it+/// goes, then [`write`]); its result comes back through [`Self::take_done`] with the id+/// [`Self::push`] gave it. Cloning shares the queue.+pub struct SaveQueue<R> {+ queue: Rc<RefCell<Queue<R>>>,+}++impl<R> Clone for SaveQueue<R> {+ fn clone(&self) -> Self {+ Self { queue: self.queue.clone() }+ }+}++impl<R: 'static> Default for SaveQueue<R> {+ fn default() -> Self {+ Self::new()+ }+}++impl<R: 'static> SaveQueue<R> {+ pub fn new() -> Self {+ Self {+ queue: Rc::new(RefCell::new(Queue { waiting: VecDeque::new(), running: None, worker: false, last_id: 0, done: Vec::new(), wake: None })),+ }+ }++ /// Called after each save finishes, for example to ask the UI for a frame that reads+ /// [`Self::take_done`].+ pub fn on_done(&self, wake: impl Fn() + 'static) {+ self.queue.borrow_mut().wake = Some(Rc::new(wake));+ }++ /// Queue a save; it starts once the saves before it have finished. Returns its id.+ pub fn push(&self, save: impl Future<Output = R> + 'static) -> u64 {+ let (id, start) = {+ let mut q = self.queue.borrow_mut();+ q.last_id += 1;+ let id = q.last_id;+ q.waiting.push_back((id, Box::pin(save)));+ let start = !q.worker;+ q.worker = true;+ (id, start)+ };+ if start {+ self.run();+ }+ id+ }++ /// The id of the latest save that hasn't finished, if any.+ pub fn latest(&self) -> Option<u64> {+ let q = self.queue.borrow();+ q.waiting.back().map(|w| w.0).or(q.running)+ }++ /// Whether a save is waiting or running.+ pub fn busy(&self) -> bool {+ let q = self.queue.borrow();+ q.running.is_some() || !q.waiting.is_empty()+ }++ /// The saves that finished since the last call, in order: (id, result).+ pub fn take_done(&self) -> Vec<(u64, R)> {+ std::mem::take(&mut self.queue.borrow_mut().done)+ }++ fn run(&self) {+ let queue = self.queue.clone();+ wasm_bindgen_futures::spawn_local(async move {+ loop {+ let next = {+ let mut q = queue.borrow_mut();+ let next = q.waiting.pop_front();+ q.running = next.as_ref().map(|n| n.0);+ q.worker = next.is_some();+ next+ };+ let Some((id, save)) = next else { break };+ // No borrow is held while the save runs: it may push more saves.+ let result = save.await;+ let wake = {+ let mut q = queue.borrow_mut();+ q.running = None;+ q.done.push((id, result));+ q.wake.clone()+ };+ if let Some(wake) = wake {+ wake();+ }+ }+ });+ }+}new file mode 100644@@ -0,0 +1,161 @@+#![cfg(target_arch = "wasm32")]+#![allow(clippy::unwrap_used)]++use std::cell::Cell;+use std::rc::Rc;++use craft_web_host::storage::{self, OpenChoice, SaveChoice, SaveQueue};+use wasm_bindgen::prelude::*;+use wasm_bindgen_futures::JsFuture;+use wasm_bindgen_test::wasm_bindgen_test;++// A fake host: `open` and `save` name the answer its dialogs give ("reject" throws), `write`+// takes `delay` ms and throws when `fails`. Every call goes to a log.+#[wasm_bindgen(inline_js = r#"+const answers = {+ file: () => ({ path: "cloud:home/a.psd", bytes: new Uint8Array([1, 2, 3]) }),+ buffer: () => ({ path: "cloud:home/b.psd", bytes: new Uint8Array([4]).buffer }),+ bare: () => ({ path: "a.psd", bytes: new Uint8Array([1]) }),+ nobytes: () => ({ path: "cloud:home/a.psd" }),+ host: () => "cloud:home/out.psd",+ barename: () => "out.psd",+ device: () => "device",+ Device: () => "Device",+ null: () => null,+ number: () => 7,+};+export function host(open, save, fails, delay) {+ const log = [];+ const answer = (name) => { if (name === "reject") throw new Error("the host is offline"); return answers[name](); };+ globalThis.craftStorage = {+ async chooseOpen(exts) { log.push(["chooseOpen", exts]); return answer(open); },+ async chooseSave(name) { log.push(["chooseSave", name]); return answer(save); },+ async write(path, bytes) {+ log.push(["start", path, bytes.length]);+ await new Promise((r) => setTimeout(r, delay));+ log.push(["end", path]);+ if (fails) throw new Error("disk full");+ },+ };+ globalThis.hostLog = log;+}+export function sync_host() { globalThis.craftStorage = { chooseSave: () => "device" }; }+export function no_host() { delete globalThis.craftStorage; }+export function log() { return JSON.stringify(globalThis.hostLog); }+export function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }+"#)]+extern "C" {+ fn host(open: &str, save: &str, fails: bool, delay: u32);+ fn sync_host();+ fn no_host();+ fn log() -> String;+ fn sleep(ms: u32) -> js_sys::Promise;+}++async fn wait(ms: u32) {+ JsFuture::from(sleep(ms)).await.unwrap();+}++#[wasm_bindgen_test]+fn without_a_host_nothing_is_offered() {+ no_host();+ assert!(!storage::available());+}++#[wasm_bindgen_test]+async fn open_answers_become_choices() {+ for (answer, want) in [+ ("file", Ok(OpenChoice::File { path: "cloud:home/a.psd".into(), bytes: vec![1, 2, 3] })),+ ("buffer", Ok(OpenChoice::File { path: "cloud:home/b.psd".into(), bytes: vec![4] })),+ ("device", Ok(OpenChoice::Device)),+ ("null", Ok(OpenChoice::Cancelled)),+ ] {+ host(answer, "null", false, 0);+ assert!(storage::available());+ assert_eq!(storage::choose_open(Some(&["psd", "png"])).await, want, "{answer}");+ }+ assert!(log().contains(r#"["chooseOpen",["psd","png"]]"#), "{}", log());+}++#[wasm_bindgen_test]+async fn only_device_means_the_device_and_a_failed_host_chooses_nothing() {+ for answer in ["reject", "bare", "nobytes", "Device", "number"] {+ host(answer, "null", false, 0);+ assert!(storage::choose_open(None).await.is_err(), "open {answer}");+ }+ for answer in ["reject", "barename", "Device", "number"] {+ host("null", answer, false, 0);+ assert!(storage::choose_save("x.psd").await.is_err(), "save {answer}");+ }+ host("null", "reject", false, 0);+ assert_eq!(storage::choose_save("x.psd").await, Err("the host is offline".into()));+ no_host();+ assert!(storage::choose_save("x.psd").await.is_err());+}++#[wasm_bindgen_test]+async fn save_answers_become_choices_and_writes_report_back() {+ for (answer, want) in [("host", SaveChoice::Host("cloud:home/out.psd".into())), ("device", SaveChoice::Device), ("null", SaveChoice::Cancelled)] {+ host("null", answer, false, 0);+ assert_eq!(storage::choose_save("out.psd").await, Ok(want), "{answer}");+ }+ host("null", "null", false, 0);+ assert_eq!(storage::write("cloud:home/out.psd", &[1, 2]).await, Ok(()));+ host("null", "null", true, 0);+ assert_eq!(storage::write("cloud:home/out.psd", &[1, 2]).await, Err("disk full".into()));+ // A host may answer without a promise.+ sync_host();+ assert_eq!(storage::choose_save("out.psd").await, Ok(SaveChoice::Device));+}++#[wasm_bindgen_test]+async fn the_queue_saves_one_at_a_time_in_order() {+ host("null", "null", false, 20);+ let queue: SaveQueue<Result<(), String>> = SaveQueue::new();+ let woken = Rc::new(Cell::new(0));+ let w = woken.clone();+ queue.on_done(move || w.set(w.get() + 1));+ assert_eq!((queue.busy(), queue.latest()), (false, None));+ let ids: Vec<u64> =+ ["a", "b", "c"].into_iter().map(|n| queue.push(async move { storage::write(&format!("cloud:home/{n}.psd"), &[0]).await })).collect();+ assert!(queue.busy());+ assert_eq!(queue.latest(), Some(ids[2]));+ while queue.busy() {+ wait(10).await;+ }+ let done = queue.take_done();+ assert_eq!(done.iter().map(|d| d.0).collect::<Vec<_>>(), ids);+ assert!(done.iter().all(|d| d.1.is_ok()));+ assert_eq!(woken.get(), 3);+ assert!(queue.take_done().is_empty());+ // Each write ended before the next one started.+ let order = r#"["start","cloud:home/a.psd",1],["end","cloud:home/a.psd"],["start","cloud:home/b.psd",1],["end","cloud:home/b.psd"],["start","cloud:home/c.psd",1],["end","cloud:home/c.psd"]"#;+ assert!(log().contains(order), "{}", log());+}++#[wasm_bindgen_test]+async fn a_save_pushed_when_one_finishes_still_waits_its_turn() {+ host("null", "null", true, 10);+ let queue: SaveQueue<Result<(), String>> = SaveQueue::new();+ let q = queue.clone();+ let pushed = Rc::new(Cell::new(false));+ let p = pushed.clone();+ queue.on_done(move || {+ if !p.replace(true) {+ q.push(async { storage::write("cloud:home/again.psd", &[0]).await });+ }+ });+ queue.push(async { storage::write("cloud:home/first.psd", &[0]).await });+ queue.push(async { storage::write("cloud:home/second.psd", &[0]).await });+ while queue.busy() {+ wait(10).await;+ }+ let done = queue.take_done();+ assert_eq!(done.len(), 3);+ assert!(done.iter().all(|d| d.1 == Err("disk full".to_string())));+ // One write at a time, and the one pushed from the callback stays behind the save that was+ // already waiting.+ let log = log();+ let order = r#"["start","cloud:home/first.psd",1],["end","cloud:home/first.psd"],["start","cloud:home/second.psd",1],["end","cloud:home/second.psd"],["start","cloud:home/again.psd",1],["end","cloud:home/again.psd"]"#;+ assert!(log.contains(order), "{log}");+}