collective

Browse / github.com/storytold/lightcraft / patch

web: Import and Export through a page's own storage (window.craftStorage)

Import and Export with a web page's own storage (window.craftStorage) A web page that shows LightCraft in the browser, such as a cloud drive, often has its own file storage. Right now LightCraft can only import from this device and export as downloads. PhotoCraft (storytold/photocraft#9346) and VectorCraft (storytold/vectorcraft#4112) let a page offer its storage through `window.craftStorage`. The shared part is moving into craft-libs as `craft-web-host` (storytold/craft-libs#360), so every app follows the same contract. For LightCraft this would mean two things: - Import can pick a photo from the page's storage. - Export asks where each file goes: the page's

Status
open
Score
0.00
Adopters
0 other operators
Author
hayone1 (imported from pull request #3047, open)
Base
bb08a697a41b2561a54f1b740245119503b24758
Size
+116 −4 in 8 files
Review
pending
Shared
2026-10-28
Id
19c34607db040a5fd9c1bdca

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock Cargo.toml apps/lightcraft-web/Cargo.toml apps/lightcraft-web/src/lib.rs apps/lightcraft-web/src/storage.rs apps/lightcraft-web/src/store.rs apps/lightcraft-web/src/web.rs docs/web.md

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -1215,6 +1215,16 @@ dependencies = [  "zip 9.1.0", ] +[[package]]+name = "craft-web-host"+version = "0.1.0"+source = "git+https://github.com/storytold/craft-libs?rev=31bda8afa9b94038b0bb32208789ac4be018889d#31bda8afa9b94038b0bb32208789ac4be018889d"+dependencies = [+ "js-sys",+ "wasm-bindgen",+ "wasm-bindgen-futures",+]+ [[package]] name = "craft-window" version = "0.1.0"@@ -3758,6 +3768,7 @@ dependencies = [ name = "lightcraft-web" version = "0.14.0" dependencies = [+ "craft-web-host",  "crc32fast",  "eframe",  "egui",diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -14,6 +14,8 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844e craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] } craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] }+# window.craftStorage, shared by the Craft apps' web builds (storytold/craft-libs#360).+craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "31bda8afa9b94038b0bb32208789ac4be018889d" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false }diff --git a/apps/lightcraft-web/Cargo.toml b/apps/lightcraft-web/Cargo.toml--- a/apps/lightcraft-web/Cargo.toml+++ b/apps/lightcraft-web/Cargo.toml@@ -24,6 +24,7 @@ log = { workspace = true } crc32fast = { workspace = true }  [target.'cfg(target_arch = "wasm32")'.dependencies]+craft-web-host = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "glow"] } egui = { workspace = true } wasm-bindgen = { workspace = true }diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs--- a/apps/lightcraft-web/src/lib.rs+++ b/apps/lightcraft-web/src/lib.rs@@ -8,7 +8,9 @@ //!   ([`store`]); a new library starts with the procedural demo photos; //! - renders run in Web Workers, each a second instance of this module ([`wire`], `workers`), and //!   fall back to the main thread (one job per frame) if workers can't start;-//! - export downloads the file through the browser.+//! - export downloads the file through the browser;+//! - a page that serves the app can offer its own file storage for Import and Export+//!   (`window.craftStorage`, [`storage`]). //! //! `?bench` in the URL runs a scripted first-paint / slider-latency measurement and logs it to the //! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables@@ -28,6 +30,8 @@ mod backend; #[cfg(target_arch = "wasm32")] mod safety; #[cfg(target_arch = "wasm32")]+mod storage;+#[cfg(target_arch = "wasm32")] mod web; #[cfg(target_arch = "wasm32")] mod workers;diff --git a/apps/lightcraft-web/src/storage.rs b/apps/lightcraft-web/src/storage.rsnew file mode 100644--- /dev/null+++ b/apps/lightcraft-web/src/storage.rs@@ -0,0 +1,74 @@+//! Host storage: a page that serves LightCraft can offer its own file storage (a cloud drive, a+//! team folder) next to this device, through `window.craftStorage`. Import can then pick a photo+//! there, and Export asks where each file goes. The contract and its rules are the same for every+//! Craft app and live in `craft-web-host`; this module connects them to LightCraft's import picker+//! and export download. Without `window.craftStorage` nothing changes.+//!+//! LightCraft keeps no document files: an imported photo is copied into the library, so a photo+//! from the host is imported from its bytes, and an export is a one-off file.++use craft_web_host::storage::{self, OpenChoice, SaveChoice, SaveQueue};++use crate::backend::Backend;+use crate::safety::notice;+use crate::store::{Originals, accept_extensions, download_name};++thread_local! {+    /// Exports to the host, one at a time in the order they were made (`craft_web_host`).+    static EXPORTS: SaveQueue<()> = SaveQueue::new();+}++/// Import: the host's Open dialog when the page offers one, else (or when the user picks this+/// device there) the browser's own picker, `device`.+pub fn pick(originals: Originals, backend: Option<Backend>, ctx: egui::Context, accept: String, device: impl FnOnce() + 'static) {+    if !storage::available() {+        return device();+    }+    wasm_bindgen_futures::spawn_local(async move {+        let exts = accept_extensions(&accept);+        let exts: Vec<&str> = exts.iter().map(String::as_str).collect();+        match storage::choose_open(Some(&exts)).await {+            Ok(OpenChoice::File { path, bytes }) => {+                let name = download_name(&path).to_string();+                crate::web::store_file(originals, backend, name, bytes, ctx).await;+            }+            // The user's own choice: the browser's picker.+            Ok(OpenChoice::Device) => device(),+            Ok(OpenChoice::Cancelled) => {}+            // A host that failed or answered something else: nothing was chosen.+            Err(e) => notice(format!("Couldn't import from the page's storage: {e}")),+        }+    });+}++/// Export: ask the host where the file goes (its storage, a download to `device`, or nowhere).+/// The export counts as started at once; the host's answer, or its failure, comes later.+pub fn export(path: &str, bytes: &[u8], device: fn(&str, &[u8]) -> Result<(), String>) -> Result<(), String> {+    if !storage::available() {+        return device(path, bytes);+    }+    let (name, bytes) = (download_name(path).to_string(), bytes.to_vec());+    EXPORTS.with(|q| {+        // Each export reports its own failure as a notice: nothing waits for the results.+        q.take_done();+        q.push(async move {+            match storage::choose_save(&name).await {+                Ok(SaveChoice::Host(to)) => {+                    if let Err(e) = storage::write(&to, &bytes).await {+                        notice(format!("{name} was not exported: {e}"));+                    }+                }+                // The user's own choice: a download to the device.+                Ok(SaveChoice::Device) => {+                    if let Err(e) = device(&name, &bytes) {+                        notice(format!("{name} was not exported: {e}"));+                    }+                }+                Ok(SaveChoice::Cancelled) => {}+                // A host that failed or answered something else: not exported.+                Err(e) => notice(format!("{name} was not exported: {e}")),+            }+        })+    });+    Ok(())+}diff --git a/apps/lightcraft-web/src/store.rs b/apps/lightcraft-web/src/store.rs--- a/apps/lightcraft-web/src/store.rs+++ b/apps/lightcraft-web/src/store.rs@@ -168,6 +168,12 @@ impl Originals { }  /// File name part of an export path (the browser download name).+/// A file picker's accept list (`.jpg,.png,…`) as extensions without dots, for a host's Open+/// dialog (`craftStorage.chooseOpen`).+pub fn accept_extensions(accept: &str) -> Vec<String> {+    accept.split(',').map(|e| e.trim().trim_start_matches('.').to_string()).filter(|e| !e.is_empty()).collect()+}+ pub fn download_name(path: &str) -> &str {     path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("export.png") }@@ -237,6 +243,12 @@ mod tests {         assert_eq!(out.image.width, 20);     } +    #[test]+    fn accept_lists_become_extensions() {+        assert_eq!(accept_extensions(".jpg, .png,,.dng"), ["jpg", "png", "dng"]);+        assert!(accept_extensions("").is_empty());+    }+     #[test]     fn download_names() {         assert_eq!(download_name("dir/x-lightcraft.png"), "x-lightcraft.png");diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs--- a/apps/lightcraft-web/src/web.rs+++ b/apps/lightcraft-web/src/web.rs@@ -100,7 +100,7 @@ impl Options { }  /// Store a picked/dropped file (bytes into browser storage, then queue its import).-async fn store_file(originals: Originals, backend: Option<Backend>, name: String, bytes: Vec<u8>, ctx: egui::Context) {+pub(crate) async fn store_file(originals: Originals, backend: Option<Backend>, name: String, bytes: Vec<u8>, ctx: egui::Context) {     let hash = content_hash(&bytes);     if let Some(b) = &backend         && let Err(e) = b.write(&storage_key(&hash), &bytes).await@@ -214,7 +214,9 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen             Ok(json!({"started": true}))         })),         pick_files: Some(Box::new(move || {-            open_picker(originals.clone(), backend.clone(), ctx.clone());+            // A page that serves the app may offer its own file storage (`window.craftStorage`).+            let (o, b, c) = (originals.clone(), backend.clone(), ctx.clone());+            crate::storage::pick(originals.clone(), backend.clone(), ctx.clone(), accept_list(), move || open_picker(o, b, c));             Vec::new() // files arrive asynchronously and are imported on a later frame         })),         pick_denoise_model: None,@@ -233,7 +235,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen         save_slideshow_template: None,         pick_print_templates: None,         save_print_template: None,-        write: Some(Box::new(download)),+        // Exports go to the page's own storage when it offers one (`window.craftStorage`).+        write: Some(Box::new(|path: &str, bytes: &[u8]| crate::storage::export(path, bytes, download))),         // the browser download path has no meaningful create-new file write; the control channel         // (and therefore screenshot paths) is not available on the web         write_new: None,diff --git a/docs/web.md b/docs/web.md--- a/docs/web.md+++ b/docs/web.md@@ -156,6 +156,11 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not - **Export downloads the file.** *Export…* (<kbd>⌘⇧E</kbd>) runs the same `app.export` path as   the desktop app (`lightcraft_engine::export`: JPEG/PNG/TIFF/WebP, sizing, naming). The host's   `write` service hands each file to the browser as a download instead of writing it to disk.+- **A page's own storage.** A page that serves LightCraft can set `window.craftStorage` (the+  contract every Craft app shares, in craft-libs' `craft-web-host`). Then Import offers a photo+  from that storage, and Export asks where each file goes: a path there, this device (a+  download), or nowhere. Exports to the page's storage go one at a time; a failed one shows a+  notice. Without `window.craftStorage` nothing changes (`apps/lightcraft-web/src/storage.rs`). - **Automation.** `await lightcraft.command("library.info", "{}")` runs any engine or UI command   by id on the next frame and resolves to the JSON result (`web.stats` reports storage, workers   and the render queue). The params are a JSON object; empty text and JSON `null` mean `{}`