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
@@ -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",@@ -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 }@@ -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 }@@ -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;new file mode 100644@@ -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(())+}@@ -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");@@ -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,@@ -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 `{}`