Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/wasm/opfs.rs

44.3 KiB, 1 run

created by r2519314175:991, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! OPFS filesystem edge — the browser's persistent storage for Daimond.
2//!
3//! The Origin Private File System (OPFS) is reached on the main thread
4//! via `navigator.storage.getDirectory()`, which yields the origin's
5//! private root directory. All access is asynchronous, so this edge is
6//! built on `wasm-bindgen-futures`.
7//!
8//! Paths are workspace-relative and jailed with the same lexical
9//! discipline as [`crate::workspace::Workspace::resolve`]: absolute
10//! paths and `..` traversal that escapes the root are rejected, so a
11//! path can only ever address a descendant of the root handle. The jail
12//! is also what makes `.` and `..` unreachable as component NAMES — the
13//! two strings the File System Standard refuses outright — because they
14//! are consumed as navigation before any name is formed.
15//!
16//! ## Names the filesystem will take
17//!
18//! A workspace name is not always a filesystem name. A Maildir message is called
19//! `<uid>.<uidvalidity>.daimond:2,<flags>`, and a colon is refused by every root except a modern
20//! browser's own sandbox — including the real local folder the user may have open, which is where
21//! `mail/…` USED to land, and where a build before this one left it. [`crate::fsname`] is the
22//! codec that closes the gap, and [`disk_name`] is the single place in this module where it is
23//! applied; [`read_entries`] is the single place the inverse is. Nothing else may spell a name
24//! for the browser.
25//!
26//! Because both ends go through that one pair, a file copied from the folder to the sandbox keeps
27//! its WORKSPACE name across the crossing — decoded out of the folder's listing, encoded again for
28//! whichever root it is written to — and so cannot arrive under a second spelling of itself.
29//! Migrating `mail/` (see [`crate::wasm::diamond::adopt_from_folder`]) rests on exactly that.
30//!
31//! ## Two roots, one interface (FSA real-folder mode)
32//!
33//! Every operation resolves against a [`FileSystemDirectoryHandle`]. Two
34//! handles are possible and share the same interface:
35//!
36//! - the **OPFS root** from `navigator.storage.getDirectory()` (the
37//! zero-setup sandbox), and
38//! - an **FSA folder** from `showDirectoryPicker()` — a real local
39//! directory the user grants read/write to.
40//!
41//! FSA mode is therefore not a new filesystem but a *swapped root handle*.
42//! A thread-local override (wasm is single-threaded, so no `Send` bound is
43//! needed) holds the FSA handle when one is open. Which root a given call
44//! uses is selected by [`crate::tools::FileRoot`] **and by the path**:
45//! [`FileRoot::Workspace`](crate::tools::FileRoot::Workspace) honours the
46//! override (the file tools / Workspace edit the real folder) for the user's
47//! work, and pins the OPFS root for a path naming Daimond's own store
48//! ([`is_store_path`](crate::tools::is_store_path)), so a Diamond is the same
49//! Diamond in either mode;
50//! [`FileRoot::Opfs`](crate::tools::FileRoot::Opfs) always pins the OPFS
51//! root so Daimond's own Diamond/crystal/`.daimond` state can never pollute the user's
52//! real repo; and [`FileRoot::Machine`](crate::tools::FileRoot::Machine) always
53//! resolves to the real folder, for the one operation that copies out of it.
54//!
55//! The synchronous `createSyncAccessHandle` path (single-writer Worker,
56//! for the append-only `.daimond` log) is deferred; this async edge covers
57//! whole-file read and write, which is what the first vertical needs.
58// TODO(wasm-opfs-sync): add a Worker-hosted `createSyncAccessHandle`
59// backend for the append-only session log, where synchronous positioned
60// writes matter.
61
62use crate::fsname;
63use crate::tools::FileRoot;
64use crate::wasm::js_str;
65
66use oxedyne_fe2o3_core::prelude::*;
67
68use std::cell::RefCell;
69use std::path::{Component, Path};
70
71use wasm_bindgen::JsCast;
72use wasm_bindgen::JsValue;
73use wasm_bindgen_futures::JsFuture;
74use web_sys::{
75 Blob,
76 File,
77 FileSystemDirectoryHandle,
78 FileSystemFileHandle,
79 FileSystemGetDirectoryOptions,
80 FileSystemGetFileOptions,
81 FileSystemRemoveOptions,
82 FileSystemWritableFileStream,
83};
84
85
86thread_local! {
87 /// The active FSA real-folder handle, or `None` for OPFS-only mode.
88 ///
89 /// Set by [`set_override`] when the user opens a folder and reused
90 /// across the session; only [`FileRoot::Workspace`] resolution
91 /// consults it, so Daimond's own state ([`FileRoot::Opfs`]) is never
92 /// affected. A thread-local suffices because wasm runs single-
93 /// threaded, which also keeps the handle off any `Send` bound.
94 static WORKSPACE_OVERRIDE: RefCell<Option<FileSystemDirectoryHandle>> =
95 const { RefCell::new(None) };
96
97 /// The current account's OPFS subdirectory, or empty for the primary account.
98 ///
99 /// Several people may share one browser, one at a time; each account's workspace and Daimond's
100 /// own `.daimond` state must be invisible to the others. When this is non-empty, EVERY OPFS
101 /// operation -- both [`FileRoot::Workspace`] and [`FileRoot::Opfs`] -- resolves inside a
102 /// per-account subdirectory of the origin root, so no account can see another's files. The
103 /// primary account leaves this empty and uses the root exactly as a single-account install
104 /// always did, so nothing has to move when accounts are introduced.
105 static ACCOUNT_NS: RefCell<String> = const { RefCell::new(String::new()) };
106}
107
108/// Point every OPFS operation at the given account's subdirectory (empty for the primary account,
109/// i.e. the origin root). Set once at boot and again on an account switch.
110pub fn set_account_ns(ns: String) {
111 ACCOUNT_NS.with(|c| *c.borrow_mut() = ns);
112}
113
114/// Install `handle` as the FSA real-folder root for the file tools /
115/// Workspace. Subsequent [`FileRoot::Workspace`] operations resolve
116/// against it until [`clear_override`] is called.
117pub fn set_override(handle: FileSystemDirectoryHandle) {
118 WORKSPACE_OVERRIDE.with(|c| *c.borrow_mut() = Some(handle));
119}
120
121/// Clear any FSA override, returning the file tools / Workspace to the
122/// OPFS sandbox root.
123pub fn clear_override() {
124 WORKSPACE_OVERRIDE.with(|c| *c.borrow_mut() = None);
125}
126
127/// Whether a real folder is open at all.
128///
129/// [`workspace_mode`] answers the same question for the page, which wants a word to render; a
130/// caller that wants to know whether [`FileRoot::Machine`] can resolve wants a boolean, and
131/// comparing strings to find out is how the two answers drift apart.
132pub fn folder_open() -> bool {
133 WORKSPACE_OVERRIDE.with(|c| c.borrow().is_some())
134}
135
136/// The current file-tool root mode: `"folder"` when an FSA real folder is
137/// open, else `"opfs"`.
138pub fn workspace_mode() -> String {
139 WORKSPACE_OVERRIDE
140 .with(|c| if c.borrow().is_some() { "folder" } else { "opfs" })
141 .to_string()
142}
143
144/// The event dispatched on `window` when the browser takes the real folder away.
145pub const FOLDER_LOST_EVENT: &str = "daimond:folder-lost";
146
147/// Tell the page that the open folder is no longer reachable, so it can drop to the sandbox and
148/// offer to reconnect.
149///
150/// A grant can be withdrawn at any time -- the browser revokes it, the user resets the
151/// permission, the folder goes away -- and the file tools then fail with `NotAllowedError` while
152/// the app carries on believing the folder is open. That is the worst of both: the agent's
153/// writes fail, and the panel still names a folder the agent cannot reach. The rule the design
154/// sets is that this must never be silent, so the edge says so out loud and the page decides what
155/// to do about it (see `handlePermissionLoss` in `daimond.js`); the override is cleared there, in
156/// one place, rather than half here and half there.
157pub fn notify_folder_lost() {
158 if let Some(win) = web_sys::window() {
159 if let Ok(ev) = web_sys::CustomEvent::new(FOLDER_LOST_EVENT) {
160 let _ = win.dispatch_event(&ev);
161 }
162 }
163}
164
165/// Whether a failed tool call failed *because the real folder was taken away*.
166///
167/// The browser reports a withdrawn grant as a `NotAllowedError`, which reaches here inside the
168/// error text the tool returns. A folder that is merely missing a file, or a path outside the
169/// jail, is an ordinary error and must not be mistaken for a lost grant -- dropping the user's
170/// folder on any failure at all would be its own bug.
171///
172/// # Arguments
173/// * `result` - The text a tool call produced, whether it succeeded or failed.
174pub fn is_folder_lost(result: &str) -> bool {
175 workspace_mode() == "folder" && result.contains("NotAllowed")
176}
177
178
179/// Split a workspace-relative path into jailed components, tolerating an
180/// empty result (which addresses the root directory itself).
181///
182/// Mirrors [`crate::workspace::Workspace::resolve`]: leading slashes are
183/// stripped (treated as relative), `.` is skipped, and any absolute
184/// component or `..` that would escape the root is rejected. Returns the
185/// ordered directory/file names; an empty vector means the root directory.
186fn split_components(rel: &str) -> Outcome<Vec<String>> {
187 let rel = rel.trim_start_matches('/');
188 let mut out: Vec<String> = Vec::new();
189 for comp in Path::new(rel).components() {
190 match comp {
191 Component::Normal(c) => out.push(c.to_string_lossy().to_string()),
192 Component::CurDir => {},
193 Component::ParentDir => {
194 // Never pop above the root.
195 if out.pop().is_none() {
196 return Err(err!(
197 "OPFS: path '{}' escapes the workspace root.", rel;
198 Invalid, Input, Path));
199 }
200 }
201 Component::RootDir | Component::Prefix(_) => {
202 return Err(err!(
203 "OPFS: absolute path '{}' is not allowed.", rel;
204 Invalid, Input, Path));
205 }
206 }
207 }
208 Ok(out)
209}
210
211/// Split a workspace-relative path into jailed components, requiring at
212/// least one component (a leaf file or directory name).
213///
214/// A wrapper over [`split_components`] for the file-addressing tools,
215/// which always name a leaf; the empty (root) case is rejected here.
216fn jail_components(rel: &str) -> Outcome<Vec<String>> {
217 let out = res!(split_components(rel));
218 if out.is_empty() {
219 return Err(err!(
220 "OPFS: path '{}' has no file component.", rel;
221 Invalid, Input, Path));
222 }
223 Ok(out)
224}
225
226/// The name a component is actually stored under inside `dir`.
227///
228/// THE ONE PLACE A WORKSPACE NAME BECOMES A FILESYSTEM NAME. Every call below that reaches
229/// `getFileHandle`, `getDirectoryHandle` or `removeEntry` takes its name from here or from
230/// [`descend`] / [`descend_dir`] / [`open_parent`], which take theirs from here — because a file
231/// written under one spelling and looked for under another is worse than the loud failure this
232/// fixes. [`read_entries`] closes the loop in the other direction, decoding what it lists.
233///
234/// A name [`fsname::encode`] leaves alone is used exactly as it is, which is every ordinary name
235/// and therefore the whole of an existing store: no walk, no rename, nothing moves.
236///
237/// A name it must escape is looked for UNESCAPED first. A store written before this codec existed
238/// holds it that way — a Maildir file on a browser whose sandbox accepted `:` is precisely that,
239/// and those users' mail synced perfectly well — and reaching straight for the escaped name would
240/// leave their messages on disk under a name nothing asks for any more, with a second copy growing
241/// beside them. Only when no such entry is there is the escaped name used, so a name that has
242/// never been stored gets the legal spelling and one that has keeps the one it has.
243///
244/// # Arguments
245/// * `dir` - The directory the component sits in.
246/// * `name` - The component as the workspace spells it.
247async fn disk_name(dir: &FileSystemDirectoryHandle, name: &str) -> String {
248 let enc = fsname::encode(name);
249 if enc == name {
250 return enc;
251 }
252 // A browser that refuses the unescaped name REJECTS the promise (the File System Standard
253 // says so, and the TypeError this whole module exists for arrived that way), so a lookup
254 // costs nothing but an answer of "no".
255 if JsFuture::from(dir.get_file_handle(name)).await.is_ok() {
256 return name.to_string();
257 }
258 if JsFuture::from(dir.get_directory_handle(name)).await.is_ok() {
259 return name.to_string();
260 }
261 enc
262}
263
264/// Acquire the OPFS root directory handle for this origin.
265///
266/// Runs on the main thread via `window.navigator.storage`; a secure
267/// context (https or localhost) is required, which the browser enforces.
268async fn opfs_root() -> Outcome<FileSystemDirectoryHandle> {
269 let win = res!(web_sys::window()
270 .ok_or_else(|| err!("OPFS: no window (main-thread OPFS requires a document)."; System, Missing)));
271 let storage = win.navigator().storage();
272 // Feature-detect OPFS before calling it. A Safari without it -- older iOS, or
273 // any iOS in Private Browsing, where the API is withdrawn -- has no
274 // `getDirectory`, and the binding then throws SYNCHRONOUSLY (not a rejectable
275 // promise the `map_err` below could catch), surfacing as an uncaught page
276 // error at boot. Checking first turns that into a clean, handled Outcome.
277 let has_opfs = js_sys::Reflect::get(storage.as_ref(), &JsValue::from_str("getDirectory"))
278 .map(|f| f.is_function())
279 .unwrap_or(false);
280 if !has_opfs {
281 return Err(err!("OPFS: this browser exposes no getDirectory (an older Safari, or \
282 Private Browsing); persistent workspace storage is unavailable here."; IO, Missing));
283 }
284 let dir_val = res!(JsFuture::from(storage.get_directory()).await
285 .map_err(|e| err!("OPFS: getDirectory failed: {}.", js_str(&e); IO, File)));
286 let dir: FileSystemDirectoryHandle = res!(dir_val.dyn_into()
287 .map_err(|_| err!("OPFS: getDirectory did not return a directory handle."; IO, File)));
288
289 // A non-primary account resolves inside its own subdirectory of the root, so its files -- and
290 // Daimond's own `.daimond` state -- are invisible to every other account at this browser. The
291 // primary account leaves the namespace empty and uses the root unchanged.
292 let ns = ACCOUNT_NS.with(|c| c.borrow().clone());
293 if ns.is_empty() {
294 return Ok(dir);
295 }
296 // NOT through `disk_name`, and deliberately. The namespace is `d~` and sixteen hex
297 // characters, so the codec is the identity on it either way -- but `cloud.js`'s `opfsRoot`
298 // and the account wipe in `daimond.js` reach this same directory by its literal name, and a
299 // spelling rule applied here and not there is how one account's files end up somewhere the
300 // other three doors cannot see.
301 let opts = FileSystemGetDirectoryOptions::new();
302 opts.set_create(true);
303 let sub_val = res!(JsFuture::from(dir.get_directory_handle_with_options(&ns, &opts)).await
304 .map_err(|e| err!("OPFS: opening account subdirectory '{}' failed: {}.", ns, js_str(&e); IO, File)));
305 let sub: FileSystemDirectoryHandle = res!(sub_val.dyn_into()
306 .map_err(|_| err!("OPFS: account subdirectory was not a directory handle."; IO, File)));
307 Ok(sub)
308}
309
310/// Resolve the root handle a call operates against, for the path it is about to address.
311///
312/// [`FileRoot::Workspace`] returns the FSA override when one is open, else the OPFS root;
313/// [`FileRoot::Opfs`] always returns the OPFS root; [`FileRoot::Machine`] always returns the
314/// override and fails when there is none.
315///
316/// The path is what makes the first of those true of Daimond's own state as well as of the user's
317/// work. A Diamond lives at `diamonds/<id>`, and every reader of that path other than the store
318/// itself -- the file tools, the Workspace panel, a dispatched worker -- asks for
319/// [`FileRoot::Workspace`]. With a folder open they were reading and writing the user's project,
320/// so a Diamond's own files vanished from the panel on a switch and a worker's output landed
321/// outside the sync parcel where nothing would ever see it again. The rule is one line and it is
322/// applied here, once: a path naming the store resolves against OPFS whatever root is active (see
323/// [`crate::tools::is_store_path`]).
324///
325/// A mailbox at `mail/<address>` is the same rule for a different reason, and it was missing:
326/// mail belongs to an ACCOUNT, so following the folder put the messages inside whichever one
327/// happened to be open, hid them when none was, and wrote them somewhere else again after a
328/// switch. A real folder would not even take the names -- a Maildir colon is refused outside the
329/// sandbox. See [`crate::tools::MAIL_ROOT`].
330///
331/// [`FileRoot::Opfs`] is NOT made redundant by that and must not be deleted as though it were. It
332/// is the belt to this test's braces: `crystal.json` is written through two doors and read through
333/// a third, and the day one of them stops agreeing with the others is the day an agent reads an
334/// empty crystal and writes over work it never saw.
335///
336/// # Arguments
337/// * `root` - Which root the caller asked for.
338/// * `path` - The workspace-relative path the call addresses; empty addresses the root itself.
339async fn resolve_root(root: FileRoot, path: &str) -> Outcome<FileSystemDirectoryHandle> {
340 match root {
341 FileRoot::Workspace => {
342 // Daimond's own store never follows the folder, whoever asked.
343 if !crate::tools::is_store_path(path) {
344 if let Some(h) = WORKSPACE_OVERRIDE.with(|c| c.borrow().clone()) {
345 return Ok(h);
346 }
347 }
348 opfs_root().await
349 }
350 FileRoot::Opfs => opfs_root().await,
351 // Never a fallback to the sandbox: the one caller is a copy OUT of the folder, and a
352 // fallback would copy the sandbox onto itself and call it a migration.
353 FileRoot::Machine => match WORKSPACE_OVERRIDE.with(|c| c.borrow().clone()) {
354 Some(h) => Ok(h),
355 None => Err(err!(
356 "OPFS: '{}' was addressed on the machine folder, and no folder is open.", path;
357 IO, File, Missing)),
358 },
359 }
360}
361
362/// Descend into (creating as needed) the directory components of a
363/// jailed path, returning the handle to the directory that will hold the
364/// leaf file plus the leaf name.
365///
366/// The leaf comes back as its ON-DISK name (see [`disk_name`]), so a caller can hand it straight
367/// to the browser without knowing that a spelling question exists.
368async fn descend(
369 root: &FileSystemDirectoryHandle,
370 components: Vec<String>,
371)
372 -> Outcome<(FileSystemDirectoryHandle, String)>
373{
374 let mut dir = root.clone();
375 let last = components.len() - 1;
376 let mut leaf = String::new();
377 for (i, want) in components.into_iter().enumerate() {
378 let name = disk_name(&dir, &want).await;
379 if i == last {
380 leaf = name;
381 break;
382 }
383 let opts = FileSystemGetDirectoryOptions::new();
384 opts.set_create(true);
385 let next_val = res!(JsFuture::from(
386 dir.get_directory_handle_with_options(&name, &opts)).await
387 .map_err(|e| err!("OPFS: open/create dir '{}' failed: {}.", name, js_str(&e); IO, File)));
388 dir = res!(next_val.dyn_into()
389 .map_err(|_| err!("OPFS: dir handle for '{}' was not a directory.", name; IO, File)));
390 }
391 Ok((dir, leaf))
392}
393
394/// Descend into an *existing* directory path (no creation) beneath `root`,
395/// returning the handle. An empty path (`""`, `"."`, `"/"`) resolves to
396/// `root` itself. Errors if any component does not exist or is not a
397/// directory.
398async fn descend_dir(
399 root: &FileSystemDirectoryHandle,
400 path: &str,
401)
402 -> Outcome<FileSystemDirectoryHandle>
403{
404 let components = res!(split_components(path));
405 let mut dir = root.clone();
406 for want in components {
407 let name = disk_name(&dir, &want).await;
408 let next_val = res!(JsFuture::from(dir.get_directory_handle(&name)).await
409 .map_err(|e| err!("OPFS: open dir '{}' failed: {}.", name, js_str(&e); IO, File, Read)));
410 dir = res!(next_val.dyn_into()
411 .map_err(|_| err!("OPFS: dir handle for '{}' was not a directory.", name; IO, File, Read)));
412 }
413 Ok(dir)
414}
415
416/// Descend into the *existing* parent directory of a jailed path (no
417/// creation) beneath `root`, returning the parent handle plus the leaf
418/// name.
419///
420/// The leaf comes back as its ON-DISK name (see [`disk_name`]).
421async fn open_parent(
422 root: &FileSystemDirectoryHandle,
423 components: Vec<String>,
424)
425 -> Outcome<(FileSystemDirectoryHandle, String)>
426{
427 let mut dir = root.clone();
428 let last = components.len() - 1;
429 let mut leaf = String::new();
430 for (i, want) in components.into_iter().enumerate() {
431 let name = disk_name(&dir, &want).await;
432 if i == last {
433 leaf = name;
434 break;
435 }
436 let next_val = res!(JsFuture::from(dir.get_directory_handle(&name)).await
437 .map_err(|e| err!("OPFS: open dir '{}' failed: {}.", name, js_str(&e); IO, File)));
438 dir = res!(next_val.dyn_into()
439 .map_err(|_| err!("OPFS: dir handle for '{}' was not a directory.", name; IO, File)));
440 }
441 Ok((dir, leaf))
442}
443
444/// Write `content` to `path` under `root`, creating parent directories
445/// and the file as needed, replacing any existing contents.
446pub async fn write_file(root: FileRoot, path: &str, content: &[u8]) -> Outcome<()> {
447 let handle = res!(resolve_root(root, path).await);
448 let components = res!(jail_components(path));
449 let (dir, leaf) = res!(descend(&handle, components).await);
450
451 let opts = FileSystemGetFileOptions::new();
452 opts.set_create(true);
453 let file_val = res!(JsFuture::from(
454 dir.get_file_handle_with_options(&leaf, &opts)).await
455 .map_err(|e| err!("OPFS: open/create file '{}' failed: {}.", leaf, js_str(&e); IO, File)));
456 let file: FileSystemFileHandle = res!(file_val.dyn_into()
457 .map_err(|_| err!("OPFS: file handle for '{}' was not a file.", leaf; IO, File)));
458
459 let writable_val = res!(JsFuture::from(file.create_writable()).await
460 .map_err(|e| err!("OPFS: create writable for '{}' failed: {}.", leaf, js_str(&e); IO, File, Write)));
461 let writable: FileSystemWritableFileStream = res!(writable_val.dyn_into()
462 .map_err(|_| err!("OPFS: writable for '{}' had the wrong type.", leaf; IO, File, Write)));
463
464 // COPIED INTO A JS-OWNED BUFFER FIRST, AND THIS IS NOT AN OPTIMISATION.
465 //
466 // `write_with_u8_array(&[u8])` hands JavaScript a `Uint8Array` VIEW over wasm
467 // linear memory — zero-copy, and pointing straight into this module's heap.
468 // The write is then AWAITED. If wasm memory grows while that await is
469 // outstanding, the engine replaces the backing `ArrayBuffer` and the view no
470 // longer describes the bytes it was made from.
471 //
472 // THIS WROTE 216 MB OF RAW WASM MEMORY INTO EVERY ONE OF A USER'S FIFTEEN
473 // `meta.json` FILES. Their phone's heap had ballooned to 235 MB, every write
474 // in that window landed the wrong bytes, and all fifteen files came out
475 // byte-identical in length — 235 MB of heap less a small offset — containing
476 // no JSON at all: no name, no tags, not one quote character in the first
477 // 64 KB. The Diamond names were not corrupted, they were overwritten with a
478 // photograph of the heap.
479 //
480 // `Uint8Array::new_with_length` allocates on the JAVASCRIPT heap, and
481 // `copy_from` copies out of wasm memory immediately, before anything can be
482 // awaited. Growing the wasm heap afterwards cannot touch it. The cost is one
483 // copy of the file being written, which is the correct price.
484 let buf = js_sys::Uint8Array::new_with_length(content.len() as u32);
485 buf.copy_from(content);
486 let write_promise = res!(writable.write_with_buffer_source(&buf)
487 .map_err(|e| err!("OPFS: queue write for '{}' failed: {}.", leaf, js_str(&e); IO, File, Write)));
488 res!(JsFuture::from(write_promise).await
489 .map_err(|e| err!("OPFS: write '{}' failed: {}.", leaf, js_str(&e); IO, File, Write)));
490
491 // `close` is inherited from `WritableStream` and flushes the file.
492 res!(JsFuture::from(writable.close()).await
493 .map_err(|e| err!("OPFS: close '{}' failed: {}.", leaf, js_str(&e); IO, File, Write)));
494 announce_write(path);
495 Ok(())
496}
497
498/// Say on the page that `path` has just been written.
499///
500/// **This is the one door for bytes**, so it is the one place that can say so:
501/// a daimon's `file_write`, the Doc panel's Save, a compiled PDF and an upload all
502/// arrive here. Anything on the page that follows a file — the Typst live view is
503/// the first — hears about a write the moment it lands, instead of finding out on
504/// its next poll.
505///
506/// A `CustomEvent` on `window` rather than a call to a named object, deliberately:
507/// this module has no business knowing which features exist, and a second follower
508/// should not have to be added to a list here. Nothing subscribes by default, and
509/// an event nobody hears costs a dispatch.
510///
511/// The other actor — an external editor writing into a real folder the user marked
512/// in — cannot be announced by anything, because the File System Access API has no
513/// change events. That one is polled. Both actors drive the same loop; only the
514/// way they are noticed differs.
515///
516/// # Arguments
517/// * `path` - The workspace-relative path just written.
518fn announce_write(path: &str) {
519 let win = match web_sys::window() {
520 Some(w) => w,
521 None => return,
522 };
523 let detail = js_sys::Object::new();
524 if js_sys::Reflect::set(&detail, &JsValue::from_str("path"), &JsValue::from_str(path)).is_err() {
525 return;
526 }
527 let init = web_sys::CustomEventInit::new();
528 init.set_detail(&detail);
529 let ev = match web_sys::CustomEvent::new_with_event_init_dict("daimond-file-written", &init) {
530 Ok(e) => e,
531 Err(_) => return,
532 };
533 let _ = win.dispatch_event(&ev);
534}
535
536/// Create the directory `path` under `root`, and any parents it needs.
537///
538/// Only an agent could make a directory before this, and only as a side effect
539/// of writing a file into one; the user had no way at all.
540pub async fn create_dir(root: FileRoot, path: &str) -> Outcome<()> {
541 let handle = res!(resolve_root(root, path).await);
542 let components = res!(jail_components(path));
543 let mut dir = handle;
544 for want in components {
545 let name = disk_name(&dir, &want).await;
546 let opts = FileSystemGetDirectoryOptions::new();
547 opts.set_create(true);
548 let next = res!(JsFuture::from(dir.get_directory_handle_with_options(&name, &opts)).await
549 .map_err(|e| err!("OPFS: create dir '{}' failed: {}.", name, js_str(&e); IO, File)));
550 dir = res!(next.dyn_into()
551 .map_err(|_| err!("OPFS: handle for '{}' was not a directory.", name; IO, File)));
552 }
553 Ok(())
554}
555
556/// Move (or rename) `from` to `to` under `root`.
557///
558/// OPFS has no rename, so this copies and then deletes. Directories are copied
559/// recursively. The destination must not already exist, so a move can never
560/// silently clobber the user's work.
561///
562/// Each sub-call resolves the root for ITS OWN path (see [`resolve_root`]), so a move between
563/// Daimond's store and the user's folder crosses roots correctly rather than half-happening: the
564/// bytes are read from wherever `from` lives and written to wherever `to` lives. What it is not is
565/// a way to move a Diamond out of the store and have it still be a Diamond -- the store is where
566/// the rail reads, so such a move REMOVES it, exactly as asked.
567pub async fn move_entry(root: FileRoot, from: &str, to: &str) -> Outcome<()> {
568 if res!(exists(root, to).await) {
569 return Err(err!("'{}' already exists.", to; Invalid, Input));
570 }
571 let is_dir = res!(is_directory(root, from).await);
572 if is_dir {
573 res!(copy_dir(root, from, to).await);
574 } else {
575 let bytes = res!(read_file(root, from).await);
576 res!(write_file(root, to, &bytes).await);
577 }
578 res!(delete_entry(root, from, is_dir).await);
579 Ok(())
580}
581
582/// Copy a directory and everything under it. Recursion is spelled out with an
583/// explicit stack: an `async fn` cannot recurse without boxing its future.
584async fn copy_dir(root: FileRoot, from: &str, to: &str) -> Outcome<()> {
585 res!(create_dir(root, to).await);
586 let mut todo = vec![(from.to_string(), to.to_string())];
587 while let Some((src, dst)) = todo.pop() {
588 for (name, is_dir, _) in res!(list_dir(root, &src).await) {
589 let s = fmt!("{}/{}", src, name);
590 let d = fmt!("{}/{}", dst, name);
591 if is_dir {
592 res!(create_dir(root, &d).await);
593 todo.push((s, d));
594 } else {
595 let bytes = res!(read_file(root, &s).await);
596 res!(write_file(root, &d, &bytes).await);
597 }
598 }
599 }
600 Ok(())
601}
602
603/// True when `path` names a directory.
604async fn is_directory(root: FileRoot, path: &str) -> Outcome<bool> {
605 let handle = res!(resolve_root(root, path).await);
606 let components = res!(jail_components(path));
607 let (dir, leaf) = res!(open_parent(&handle, components).await);
608 Ok(JsFuture::from(dir.get_directory_handle(&leaf)).await.is_ok())
609}
610
611/// Read the entire contents of `path` under `root` as bytes. Errors if
612/// any path component (directory or the file itself) does not exist.
613pub async fn read_file(root: FileRoot, path: &str) -> Outcome<Vec<u8>> {
614 let handle = res!(resolve_root(root, path).await);
615 let components = res!(jail_components(path));
616 let (dir, leaf) = res!(open_parent(&handle, components).await);
617
618 let file_val = res!(JsFuture::from(dir.get_file_handle(&leaf)).await
619 .map_err(|e| err!("OPFS: open file '{}' failed: {}.", leaf, js_str(&e); IO, File, Read)));
620 let file_handle: FileSystemFileHandle = res!(file_val.dyn_into()
621 .map_err(|_| err!("OPFS: file handle for '{}' was not a file.", leaf; IO, File, Read)));
622
623 // `get_file` yields a `File` (a `Blob`); read its bytes via
624 // `arrayBuffer`, which returns the whole contents.
625 let blob_val = res!(JsFuture::from(file_handle.get_file()).await
626 .map_err(|e| err!("OPFS: get file '{}' failed: {}.", leaf, js_str(&e); IO, File, Read)));
627 let file: File = res!(blob_val.dyn_into()
628 .map_err(|_| err!("OPFS: get_file for '{}' returned a non-file.", leaf; IO, File, Read)));
629 let buf_val = res!(JsFuture::from(file.array_buffer()).await
630 .map_err(|e| err!("OPFS: read bytes of '{}' failed: {}.", leaf, js_str(&e); IO, File, Read)));
631 let bytes = js_sys::Uint8Array::new(&buf_val).to_vec();
632 Ok(bytes)
633}
634
635/// Read `len` bytes of a file from `offset`, and say how big the whole thing is.
636///
637/// [`read_file_capped`] below reads a prefix, which is what a caller wanting to know what a file
638/// IS needs. A caller wanting to SHOW a file needs to move through it -- the next page of a hex
639/// dump, a later frame -- and reading from the start each time turns a walk through a large file
640/// into a quadratic one.
641///
642/// The bytes never all exist at once: `slice` hands back another `Blob` and copies nothing until
643/// it is read, and the size comes off the handle, which a `Blob` knows without anyone reading it.
644/// An `offset` past the end is not an error; it answers with nothing and the true size, which is
645/// what lets a caller walk to the end without knowing in advance where the end is.
646///
647/// # Arguments
648/// * `root` - Which root to resolve `path` against.
649/// * `path` - The workspace-relative path.
650/// * `offset` - Where to start, in bytes.
651/// * `len` - How many bytes to take.
652pub async fn read_file_range(
653 root: FileRoot,
654 path: &str,
655 offset: f64,
656 len: u32,
657)
658 -> Outcome<(Vec<u8>, f64)>
659{
660 let handle = res!(resolve_root(root, path).await);
661 let components = res!(jail_components(path));
662 let (dir, leaf) = res!(open_parent(&handle, components).await);
663
664 let file_val = res!(JsFuture::from(dir.get_file_handle(&leaf)).await
665 .map_err(|e| err!("OPFS: open file '{}' failed: {}.", leaf, js_str(&e); IO, File, Read)));
666 let file_handle: FileSystemFileHandle = res!(file_val.dyn_into()
667 .map_err(|_| err!("OPFS: file handle for '{}' was not a file.", leaf; IO, File, Read)));
668 let blob_val = res!(JsFuture::from(file_handle.get_file()).await
669 .map_err(|e| err!("OPFS: get file '{}' failed: {}.", leaf, js_str(&e); IO, File, Read)));
670 let file: File = res!(blob_val.dyn_into()
671 .map_err(|_| err!("OPFS: get_file for '{}' returned a non-file.", leaf; IO, File, Read)));
672
673 let total = file.size();
674 let from = if offset < 0.0 { 0.0 } else { offset };
675 if from >= total {
676 return Ok((Vec::new(), total));
677 }
678 let to = (from + len as f64).min(total);
679 let want: &Blob = file.as_ref();
680 // `slice_with_f64_and_f64` rather than the i32 pair: a file over 2 GiB is exactly the case
681 // this function exists for, and an i32 offset would wrap silently in the middle of one.
682 let part = res!(want.slice_with_f64_and_f64(from, to)
683 .map_err(|e| err!("OPFS: slice '{}' failed: {}.", leaf, js_str(&e); IO, File, Read)));
684 let buf_val = res!(JsFuture::from(part.array_buffer()).await
685 .map_err(|e| err!("OPFS: read bytes of '{}' failed: {}.", leaf, js_str(&e); IO, File, Read)));
686 Ok((js_sys::Uint8Array::new(&buf_val).to_vec(), total))
687}
688
689/// Read at most `max` bytes of a file, and say how big the whole thing is.
690///
691/// WHY THIS EXISTS. `read_file` above reads whatever is on disk into memory, and
692/// on an iPhone that killed the app. A `meta.json` — a name, a version, two
693/// stamps and a few tags — had grown to hundreds of megabytes on two of one
694/// user's Diamonds, and `read_meta` slurped it on every boot: the device's own
695/// trail recorded `HEAP GREW read_meta +1404M -> 1639M`, and iOS took the tab
696/// away a second later. Wasm linear memory never shrinks, so every boot did it
697/// again, which is the whole of the login loop that ran for five sessions.
698///
699/// The size comes off the `File` handle, which costs nothing — a `Blob` knows
700/// its length without anyone reading it — and only the prefix is materialised.
701/// Returns `(bytes, total)` so a caller can tell a whole small file from the
702/// front of a huge one and say so.
703///
704/// A file this reads a prefix of is a file something is wrong with. The caller
705/// decides what to do about that; what this guarantees is that finding out
706/// cannot cost more than `max`.
707pub async fn read_file_capped(root: FileRoot, path: &str, max: u32) -> Outcome<(Vec<u8>, f64)> {
708 let handle = res!(resolve_root(root, path).await);
709 let components = res!(jail_components(path));
710 let (dir, leaf) = res!(open_parent(&handle, components).await);
711
712 let file_val = res!(JsFuture::from(dir.get_file_handle(&leaf)).await
713 .map_err(|e| err!("OPFS: open file '{}' failed: {}.", leaf, js_str(&e); IO, File, Read)));
714 let file_handle: FileSystemFileHandle = res!(file_val.dyn_into()
715 .map_err(|_| err!("OPFS: file handle for '{}' was not a file.", leaf; IO, File, Read)));
716 let blob_val = res!(JsFuture::from(file_handle.get_file()).await
717 .map_err(|e| err!("OPFS: get file '{}' failed: {}.", leaf, js_str(&e); IO, File, Read)));
718 let file: File = res!(blob_val.dyn_into()
719 .map_err(|_| err!("OPFS: get_file for '{}' returned a non-file.", leaf; IO, File, Read)));
720
721 let total = file.size();
722 // The whole file when it fits, and only the front of it when it does not.
723 // `slice` hands back another Blob and copies nothing until it is read.
724 let want: &Blob = file.as_ref();
725 let part = if total > max as f64 {
726 res!(want.slice_with_i32_and_i32(0, max as i32)
727 .map_err(|e| err!("OPFS: slice '{}' failed: {}.", leaf, js_str(&e); IO, File, Read)))
728 } else {
729 want.clone()
730 };
731 let buf_val = res!(JsFuture::from(part.array_buffer()).await
732 .map_err(|e| err!("OPFS: read bytes of '{}' failed: {}.", leaf, js_str(&e); IO, File, Read)));
733 Ok((js_sys::Uint8Array::new(&buf_val).to_vec(), total))
734}
735
736/// When `path` was last written, and how long it is: `(last_modified_ms, size)`.
737///
738/// Nothing is read. A `File` handle knows both without a byte of it being
739/// materialised, so asking sixty-three files whether they have changed costs about
740/// thirteen milliseconds -- measured, three runs, in `dev/measure_typstbook.mjs`.
741/// That is what makes a watch loop affordable: the File System Access API has no
742/// change events for a real folder, so polling is the only mechanism there is, and
743/// this is the cheapest honest question to poll with.
744///
745/// **The same test `www/js/cloud.js` already uses**, which compares `lastModified`
746/// WITH `size` rather than either alone. Two answers to "has this file changed" is
747/// how one caller decides a file is fresh while another decides it is stale, so this
748/// is deliberately the same pair rather than a second convention.
749///
750/// `None` when there is nothing at `path` -- which for a watcher is itself a change,
751/// and for a cache is a reason to re-read rather than an error.
752///
753/// # Arguments
754/// * `root` - Which filesystem root the path is under.
755/// * `path` - The workspace-relative path.
756pub async fn stamp(root: FileRoot, path: &str) -> Outcome<Option<(f64, f64)>> {
757 let handle = res!(resolve_root(root, path).await);
758 let components = res!(jail_components(path));
759 let (dir, leaf) = match open_parent(&handle, components).await {
760 Ok(v) => v,
761 Err(_) => return Ok(None),
762 };
763 let file_val = match JsFuture::from(dir.get_file_handle(&leaf)).await {
764 Ok(v) => v,
765 Err(_) => return Ok(None),
766 };
767 let file_handle: FileSystemFileHandle = match file_val.dyn_into() {
768 Ok(h) => h,
769 Err(_) => return Ok(None),
770 };
771 let blob_val = match JsFuture::from(file_handle.get_file()).await {
772 Ok(v) => v,
773 Err(_) => return Ok(None),
774 };
775 let file: File = match blob_val.dyn_into() {
776 Ok(f) => f,
777 Err(_) => return Ok(None),
778 };
779 Ok(Some((file.last_modified(), file.size())))
780}
781
782/// When a [`File`] says it was last written, in milliseconds since the epoch, or `None` where the
783/// browser did not record one.
784///
785/// A backend that keeps no modification time answers zero, and zero is 1970 rather than a fact
786/// about the file -- so it is reported as an absence and never passed off as a time. Same guard
787/// `www/js/cloud.js` already applies before it trusts a `lastModified` for its change test.
788fn file_stamp(f: &File) -> Option<f64> {
789 let ms = f.last_modified();
790 if ms.is_finite() && ms > 0.0 {
791 Some(ms)
792 } else {
793 None
794 }
795}
796
797/// Read the entries of `dir`, returning `(name, is_dir, size, last_modified_ms)` per entry.
798///
799/// The names come back AS THE WORKSPACE SPELLS THEM, not as the browser stores them: a listing
800/// that returned `70074.3.daimond%3A2,S` would hand every caller a name that does not open, and
801/// mail matches on the Maildir flags after the `:2,`, so it would read every message as unflagged.
802/// [`fsname::decode`] is the inverse of the [`disk_name`] every lookup goes through, and it leaves
803/// a name written before that codec existed exactly as it found it.
804///
805/// OPFS directory iteration is exposed as an async iterator via
806/// `FileSystemDirectoryHandle.entries()` (web-sys returns a
807/// [`js_sys::AsyncIterator`]). Each `next()` yields a `Promise` resolving
808/// to an `{ done, value }` record whose `value` is a `[name, handle]`
809/// pair; the record fields are read with [`js_sys::Reflect`]. A file
810/// entry's size comes from its [`File`] (`getFile().size`); directory
811/// entries report a size of zero.
812///
813/// **The stamp costs nothing.** It comes off the same [`File`] the size does, and that
814/// `getFile()` round trip is already paid for every file entry -- so a walk that wanted times was
815/// never the extra call it was written up as, and the caller that believed it could not have them
816/// (`file_glob`) was going without for no reason. It is `None` per ENTRY rather than per listing,
817/// because one listing can span a real folder and the sandbox at once (see [`resolve_root`]).
818async fn read_entries(dir: &FileSystemDirectoryHandle)
819 -> Outcome<Vec<(String, bool, u64, Option<f64>)>>
820{
821 let iter = dir.entries();
822 let mut out: Vec<(String, bool, u64, Option<f64>)> = Vec::new();
823 loop {
824 let promise = res!(iter.next()
825 .map_err(|e| err!("OPFS: directory iterator next() failed: {}.", js_str(&e); IO, File, Read)));
826 let record = res!(JsFuture::from(promise).await
827 .map_err(|e| err!("OPFS: awaiting directory entry failed: {}.", js_str(&e); IO, File, Read)));
828
829 // `done` signals iterator exhaustion; treat a missing/unreadable
830 // flag as done so a malformed record cannot spin forever.
831 let done = js_sys::Reflect::get(&record, &JsValue::from_str("done"))
832 .ok()
833 .and_then(|v| v.as_bool())
834 .unwrap_or(true);
835 if done {
836 break;
837 }
838
839 let value = res!(js_sys::Reflect::get(&record, &JsValue::from_str("value"))
840 .map_err(|e| err!("OPFS: read directory entry value failed: {}.", js_str(&e); IO, File, Read)));
841 let pair = js_sys::Array::from(&value);
842 let name = pair.get(0).as_string().unwrap_or_default();
843 let handle = pair.get(1);
844
845 // The handle's `kind` distinguishes files from directories.
846 let is_dir = js_sys::Reflect::get(&handle, &JsValue::from_str("kind"))
847 .ok()
848 .and_then(|v| v.as_string())
849 .map(|k| k == "directory")
850 .unwrap_or(false);
851
852 let (size, when) = if is_dir {
853 (0u64, None)
854 } else {
855 match handle.dyn_into::<FileSystemFileHandle>() {
856 Ok(fh) => {
857 let file_val = res!(JsFuture::from(fh.get_file()).await
858 .map_err(|e| err!("OPFS: get file '{}' failed: {}.", name, js_str(&e); IO, File, Read)));
859 match file_val.dyn_into::<File>() {
860 Ok(f) => (f.size() as u64, file_stamp(&f)),
861 Err(_) => (0u64, None),
862 }
863 }
864 Err(_) => (0u64, None),
865 }
866 };
867 out.push((fsname::decode(&name), is_dir, size, when));
868 }
869 Ok(out)
870}
871
872/// List the entries of the directory at `path` under `root`, returning
873/// `(name, is_dir, size)` per entry (unsorted — the caller orders them).
874/// An empty path addresses the root directory.
875///
876/// The modification times are dropped here. A caller that wants them asks
877/// [`list_dir_stamped`], which is the same walk and the same cost.
878pub async fn list_dir(root: FileRoot, path: &str) -> Outcome<Vec<(String, bool, u64)>> {
879 Ok(res!(list_dir_stamped(root, path).await)
880 .into_iter()
881 .map(|(name, is_dir, size, _)| (name, is_dir, size))
882 .collect())
883}
884
885/// As [`list_dir`], with each entry's last-modified time in milliseconds since the epoch:
886/// `(name, is_dir, size, last_modified_ms)`, unsorted.
887///
888/// `None` for a directory, which has no modification time worth reporting, and for a file whose
889/// backend does not record one -- see [`file_stamp`]. PER ENTRY, because [`resolve_root`] carves
890/// the store out of an open folder, so one listing can hold both a real-disk file that knows when
891/// it was written and a sandboxed one that does not.
892pub async fn list_dir_stamped(root: FileRoot, path: &str)
893 -> Outcome<Vec<(String, bool, u64, Option<f64>)>>
894{
895 let handle = res!(resolve_root(root, path).await);
896 let dir = res!(descend_dir(&handle, path).await);
897 read_entries(&dir).await
898}
899
900/// Delete the entry at `path` under `root`. With `recursive` set, a
901/// directory and all its contents are removed; otherwise a non-empty
902/// directory is rejected by the browser. Errors if the entry or any
903/// parent does not exist.
904pub async fn delete_entry(root: FileRoot, path: &str, recursive: bool) -> Outcome<()> {
905 let handle = res!(resolve_root(root, path).await);
906 let components = res!(jail_components(path));
907 let (dir, leaf) = res!(open_parent(&handle, components).await);
908 let opts = FileSystemRemoveOptions::new();
909 opts.set_recursive(recursive);
910 res!(JsFuture::from(dir.remove_entry_with_options(&leaf, &opts)).await
911 .map_err(|e| err!("OPFS: remove '{}' failed: {}.", leaf, js_str(&e); IO, File)));
912 Ok(())
913}
914
915/// Whether an entry (file or directory) exists at `path` under `root`.
916pub async fn exists(root: FileRoot, path: &str) -> Outcome<bool> {
917 let handle = res!(resolve_root(root, path).await);
918 let components = res!(jail_components(path));
919 let (dir, leaf) = match open_parent(&handle, components).await {
920 Ok(v) => v,
921 Err(_) => return Ok(false),
922 };
923 if JsFuture::from(dir.get_file_handle(&leaf)).await.is_ok() {
924 return Ok(true);
925 }
926 if JsFuture::from(dir.get_directory_handle(&leaf)).await.is_ok() {
927 return Ok(true);
928 }
929 Ok(false)
930}
931