collective

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

diff --git a/.github/workflows/web-host.yml b/.github/workflows/web-host.ymlnew file mode 100644--- /dev/null+++ b/.github/workflows/web-host.yml@@ -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-unknowndiff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -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"diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -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"] }diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -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.diff --git a/crates/web-host/Cargo.toml b/crates/web-host/Cargo.tomlnew file mode 100644--- /dev/null+++ b/crates/web-host/Cargo.toml@@ -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 = truediff --git a/crates/web-host/README.md b/crates/web-host/README.mdnew file mode 100644--- /dev/null+++ b/crates/web-host/README.md@@ -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.diff --git a/crates/web-host/src/lib.rs b/crates/web-host/src/lib.rsnew file mode 100644--- /dev/null+++ b/crates/web-host/src/lib.rs@@ -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;diff --git a/crates/web-host/src/path.rs b/crates/web-host/src/path.rsnew file mode 100644--- /dev/null+++ b/crates/web-host/src/path.rs@@ -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(""));+    }+}diff --git a/crates/web-host/src/storage.rs b/crates/web-host/src/storage.rsnew file mode 100644--- /dev/null+++ b/crates/web-host/src/storage.rs@@ -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();+                }+            }+        });+    }+}diff --git a/crates/web-host/tests/storage.rs b/crates/web-host/tests/storage.rsnew file mode 100644--- /dev/null+++ b/crates/web-host/tests/storage.rs@@ -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}");+}