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 | |
| 62 | use crate::fsname; |
| 63 | use crate::tools::FileRoot; |
| 64 | use crate::wasm::js_str; |
| 65 | |
| 66 | use oxedyne_fe2o3_core::prelude::*; |
| 67 | |
| 68 | use std::cell::RefCell; |
| 69 | use std::path::{Component, Path}; |
| 70 | |
| 71 | use wasm_bindgen::JsCast; |
| 72 | use wasm_bindgen::JsValue; |
| 73 | use wasm_bindgen_futures::JsFuture; |
| 74 | use web_sys::{ |
| 75 | Blob, |
| 76 | File, |
| 77 | FileSystemDirectoryHandle, |
| 78 | FileSystemFileHandle, |
| 79 | FileSystemGetDirectoryOptions, |
| 80 | FileSystemGetFileOptions, |
| 81 | FileSystemRemoveOptions, |
| 82 | FileSystemWritableFileStream, |
| 83 | }; |
| 84 | |
| 85 | |
| 86 | thread_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. |
| 110 | pub 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. |
| 117 | pub 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. |
| 123 | pub 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. |
| 132 | pub 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"`. |
| 138 | pub 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. |
| 145 | pub 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. |
| 157 | pub 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. |
| 174 | pub 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. |
| 186 | fn 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. |
| 216 | fn 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. |
| 247 | async 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. |
| 268 | async 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. |
| 339 | async 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. |
| 368 | async 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. |
| 398 | async 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`]). |
| 421 | async 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. |
| 446 | pub 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. |
| 518 | fn 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. |
| 540 | pub 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. |
| 567 | pub 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. |
| 584 | async 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. |
| 604 | async 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. |
| 613 | pub 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. |
| 652 | pub 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`. |
| 707 | pub 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. |
| 756 | pub 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. |
| 788 | fn 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`]). |
| 818 | async 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. |
| 878 | pub 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. |
| 892 | pub 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. |
| 904 | pub 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`. |
| 916 | pub 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 |