Skip to main content

nightshade_api/web/
files.rs

1//! Browser file plumbing shared by engine pages: saving bytes as a
2//! download, opening a file picker, reading drag-and-drop payloads (with
3//! `.zip` expansion and multi-file glTF grouping), and pointer lock. All of
4//! it is protocol-free; the page decides which worker messages the bytes
5//! become.
6
7use wasm_bindgen::JsCast;
8use wasm_bindgen_futures::{JsFuture, spawn_local};
9use web_sys::{Blob, DataTransfer};
10
11/// Saves `bytes` as a browser download named `name`.
12pub fn download_file(name: &str, bytes: &[u8]) {
13    let Some(document) = web_sys::window().and_then(|window| window.document()) else {
14        return;
15    };
16    let array = js_sys::Uint8Array::from(bytes);
17    let parts = js_sys::Array::new();
18    parts.push(&array);
19    let options = web_sys::BlobPropertyBag::new();
20    options.set_type("application/octet-stream");
21    let Ok(blob) = Blob::new_with_u8_array_sequence_and_options(&parts, &options) else {
22        return;
23    };
24    let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
25        return;
26    };
27    if let Ok(element) = document.create_element("a")
28        && let Ok(anchor) = element.dyn_into::<web_sys::HtmlAnchorElement>()
29    {
30        anchor.set_href(&url);
31        anchor.set_download(name);
32        anchor.click();
33    }
34    let _ = web_sys::Url::revoke_object_url(&url);
35}
36
37/// Requests or exits pointer lock on the element with id `canvas`, the
38/// canvas `Viewport` renders.
39pub fn apply_pointer_lock(locked: bool) {
40    let Some(document) = web_sys::window().and_then(|window| window.document()) else {
41        return;
42    };
43    if locked {
44        if let Some(canvas) = document.get_element_by_id("canvas") {
45            canvas.request_pointer_lock();
46        }
47    } else {
48        document.exit_pointer_lock();
49    }
50}
51
52/// Opens a browser file picker filtered by `accept` and reads the chosen
53/// file's bytes into `on_file`.
54pub fn pick_file(accept: &str, on_file: impl FnOnce(String, Vec<u8>) + 'static) {
55    let Some(document) = web_sys::window().and_then(|window| window.document()) else {
56        return;
57    };
58    let Ok(element) = document.create_element("input") else {
59        return;
60    };
61    let Ok(input) = element.dyn_into::<web_sys::HtmlInputElement>() else {
62        return;
63    };
64    input.set_type("file");
65    input.set_accept(accept);
66    let input_for_closure = input.clone();
67    let closure = wasm_bindgen::closure::Closure::once(Box::new(move |_event: web_sys::Event| {
68        let Some(files) = input_for_closure.files() else {
69            return;
70        };
71        let Some(file) = files.get(0) else {
72            return;
73        };
74        read_file(file, on_file);
75    }) as Box<dyn FnOnce(_)>);
76    input.set_onchange(Some(closure.as_ref().unchecked_ref()));
77    closure.forget();
78    input.click();
79}
80
81/// Reads one browser `File` into `on_file` as its name and bytes.
82pub fn read_file(file: web_sys::File, on_file: impl FnOnce(String, Vec<u8>) + 'static) {
83    spawn_local(async move {
84        let name = file.name();
85        let blob: &Blob = file.as_ref();
86        if let Ok(buffer) = JsFuture::from(blob.array_buffer()).await {
87            on_file(name, js_sys::Uint8Array::new(&buffer).to_vec());
88        }
89    });
90}
91
92/// Reads everything in a drop into `on_files` as named byte payloads: every
93/// file the transfer carried, with a single dropped `.zip` expanded to its
94/// entries. Route the result with [`route_dropped`].
95pub fn read_dropped_files(
96    transfer: DataTransfer,
97    on_files: impl FnOnce(Vec<(String, Vec<u8>)>) + 'static,
98) {
99    let Some(files) = transfer.files() else {
100        return;
101    };
102    if files.length() == 0 {
103        return;
104    }
105    spawn_local(async move {
106        let mut collected: Vec<(String, Vec<u8>)> = Vec::new();
107        for index in 0..files.length() {
108            if let Some(file) = files.item(index) {
109                let name = file.name();
110                let blob: &Blob = file.as_ref();
111                if let Ok(buffer) = JsFuture::from(blob.array_buffer()).await {
112                    collected.push((name, js_sys::Uint8Array::new(&buffer).to_vec()));
113                }
114            }
115        }
116        if collected.len() == 1
117            && collected[0].0.to_lowercase().ends_with(".zip")
118            && let Some(unzipped) = unzip(&collected[0].1)
119        {
120            collected = unzipped;
121        }
122        on_files(collected);
123    });
124}
125
126/// A routed drop: either a multi-file glTF with its sibling resources keyed
127/// relative to the `.gltf`, or plain files to load one by one.
128pub enum DroppedFiles {
129    GltfBundle {
130        name: String,
131        gltf: Vec<u8>,
132        resources: Vec<(String, Vec<u8>)>,
133    },
134    Files(Vec<(String, Vec<u8>)>),
135}
136
137/// Groups dropped files: when the set contains a `.gltf` alongside other
138/// files, the rest become its resources with the glTF's directory prefix
139/// stripped; otherwise the files pass through unchanged.
140pub fn route_dropped(mut files: Vec<(String, Vec<u8>)>) -> DroppedFiles {
141    let gltf_index = files
142        .iter()
143        .position(|(name, _)| name.to_lowercase().ends_with(".gltf"));
144    if let Some(index) = gltf_index
145        && files.len() > 1
146    {
147        let (name, gltf) = files.remove(index);
148        let directory = name
149            .rsplit_once('/')
150            .map(|(directory, _)| format!("{directory}/"))
151            .unwrap_or_default();
152        let resources: Vec<(String, Vec<u8>)> = files
153            .into_iter()
154            .map(|(path, bytes)| {
155                (
156                    path.strip_prefix(&directory).unwrap_or(&path).to_string(),
157                    bytes,
158                )
159            })
160            .collect();
161        return DroppedFiles::GltfBundle {
162            name,
163            gltf,
164            resources,
165        };
166    }
167    DroppedFiles::Files(files)
168}
169
170fn unzip(bytes: &[u8]) -> Option<Vec<(String, Vec<u8>)>> {
171    use std::io::Read;
172    let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?;
173    let mut out = Vec::new();
174    for index in 0..archive.len() {
175        let Ok(mut entry) = archive.by_index(index) else {
176            continue;
177        };
178        if entry.is_dir() {
179            continue;
180        }
181        let name = entry.name().to_string();
182        let mut data = Vec::new();
183        if entry.read_to_end(&mut data).is_ok() {
184            out.push((name, data));
185        }
186    }
187    Some(out)
188}