diff --git a/arc/platforms.md b/arc/platforms.md index 29378ae749ac538a4344dfe8cd04b4f83406f011..ca8913ce255bb5cadd0ca3873c8dee7102e88d44 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -161,6 +161,12 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. `/Cache`. A storage worker keeps them in the origin's private file system, writing the byte ranges each burst changed through OPFS's synchronous handles, which only workers get. One tab at a time holds them. +- Open Notebook, where the browser has the File System Access API (Chromium), opens a folder + of the user's: mirrored under `/Folders`, its handle kept in IndexedDB, other apps' writes + read every few seconds. A browser takes no locks, so a commit there stands only once the + page has written the file and found nothing else wrote it since it was read; until then + it is uncertain, as one whose answer was lost, and it goes again on top of another app's + write. The sync popup says the folder isn't locked. - Menus are the kit's own, as on Linux, with the PC's chords and ⌘ for Ctrl on a Mac; the browser keeps its own window and tab chords. Dialogs are the browser's; Open and Insert ask for files to copy in; printing downloads the PDF. Servers, recording and accessibility diff --git a/crates/notebook/src/fs/web.rs b/crates/notebook/src/fs/web.rs index 2ee83f22727f963b5f4e854cf219e2b54b626f3b..d4979c80ceb5f62e01c23e3f17309570e585de9f 100644 --- a/crates/notebook/src/fs/web.rs +++ b/crates/notebook/src/fs/web.rs @@ -79,29 +79,71 @@ struct Files { nodes: BTreeMap, /// Paths written, made or removed since the host last asked. changed: BTreeSet, + /// Folders the host mirrors from elsewhere (`mount`). + mounts: BTreeSet, + /// Images committed under a mount, waiting for the host to write them (`committed`). + committed: BTreeMap>, } thread_local! { static FILES: RefCell = RefCell::new(Files { nodes: BTreeMap::from([(PathBuf::from("/"), Node::Directory)]), - changed: BTreeSet::new(), + ..Files::default() }); } +/// Mirrors the folder at `root` from somewhere only the host reaches, as a folder on the +/// user's disk. A commit to a section there goes to the host (`committed`) and stands only +/// once the host has written it and put the file back (`restore`): until then it is uncertain, +/// as a commit whose answer was lost, so an edit counts as published only once it is on disk. +pub fn mount(root: impl AsRef) { + let root = normal(root.as_ref()); + with(|files| files.mounts.insert(root)); +} + +/// The images committed under mounts since the last call, latest per file. +pub fn committed() -> Vec<(PathBuf, Vec)> { + with(|files| std::mem::take(&mut files.committed).into_iter().collect()) +} + /// What a path holds, as the host keeps it. pub enum Saved { Directory, /// The bytes, and when they last changed in milliseconds since 1970. File(Vec, f64), + /// Nothing: what was there went, with what it held. + Gone, } -/// Puts what the host kept at `path` back, as it was before the page last closed. +/// Puts what the host kept at `path` back, as it was before the page last closed or as it +/// now stands where it is kept. pub fn restore(path: impl AsRef, saved: Saved) { let path = normal(path.as_ref()); FILES.with_borrow_mut(|files| { - let node = match saved { - Saved::Directory => Node::Directory, - Saved::File(bytes, modified) => { + let node = match (saved, files.nodes.get(&path)) { + (Saved::Gone, _) => { + let gone: Vec = files + .nodes + .range(path.clone()..) + .take_while(|(each, _)| each.starts_with(&path)) + .map(|(each, _)| each.clone()) + .collect(); + for each in gone { + if let Some(Node::File(data)) = files.nodes.remove(&each) { + data.borrow_mut().path = None; + } + } + return; + } + (Saved::Directory, _) => Node::Directory, + (Saved::File(bytes, modified), Some(Node::File(data))) => { + let mut held = data.borrow_mut(); + held.bytes = bytes; + held.modified = modified; + held.unwritten = Some(Vec::new()); + return; + } + (Saved::File(bytes, modified), _) => { Node::File(Data::new(bytes, modified, path.clone(), Some(Vec::new()))) } }; @@ -122,7 +164,7 @@ pub enum Change { /// Whether any path changed since `changes` was last called. pub fn changed() -> bool { - FILES.with_borrow(|files| !files.changed.is_empty()) + FILES.with_borrow(|files| !files.changed.is_empty() || !files.committed.is_empty()) } /// The paths changed since the last call, each with how; a folder before what it holds. @@ -754,12 +796,41 @@ fn not_committed(error: io::Error) -> CommitError { } } +fn mounted(path: &Path) -> bool { + with(|files| files.mounts.iter().any(|root| path.starts_with(root))) +} + pub fn commit_file(transaction: &Transaction, path: impl AsRef) -> Result<(), CommitError> { - transaction.commit(&mut writable(path).map_err(not_committed)?) + let path = normal(path.as_ref()); + if !mounted(&path) { + return transaction.commit(&mut writable(&path).map_err(not_committed)?); + } + let mut image = File { + data: Rc::new(RefCell::new(Data { + bytes: read(&path).map_err(not_committed)?, + modified: now(), + path: None, + unwritten: None, + })), + position: 0, + append: false, + }; + transaction.commit(&mut image)?; + let image = std::mem::take(&mut image.data.borrow_mut().bytes); + with(|files| files.committed.insert(path, image)); + Err(CommitError { + state: CommitState::Unknown, + error: io::Error::new(ErrorKind::WouldBlock, "Writing the section to its folder"), + }) } +/// A mounted file is durable as the host puts it back, and keeps the version its commit wrote. pub fn confirm_file(path: impl AsRef, base: &Stamp) -> Result<(), CommitError> { - onestore::confirm(&mut writable(path).map_err(not_committed)?, base) + let mut file = writable(&path).map_err(not_committed)?; + if mounted(&normal(path.as_ref())) { + return base.check(&mut file).map_err(not_committed); + } + onestore::confirm(&mut file, base) } pub fn place_file(path: impl AsRef, ancestor: [u8; 16], name: &str) -> io::Result<()> { diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index 93b80684ca654f9a54b9b3b14dccc4b764d05058..89fb89ad48b89b1a6d0071b90272f0ad0608dbf8 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -371,6 +371,9 @@ impl Library { } }); } + #[cfg(target_arch = "wasm32")] + let mut notice = crate::platform::lock_notice(location); + #[cfg(not(target_arch = "wasm32"))] let mut notice = None; if let Some(mount) = crate::platform::smb_mount(Path::new(location)) { match crate::platform::smb_login(&mount) diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index 32b77cc537aed680af39796a6b5e8621e5cb89a3..0e2f86d2f954069ccf18db42dc0f4bc7a66f509a 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -711,13 +711,19 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { ..Spec::default() }, ); - text( - ui, - "text", - &format!("Using the system’s connection. Snowbound couldn’t sign in: {notice}"), - theme.text_dim, - ); - picked.sign_in = ui::button(ui, "sign-in", "Sign In…").clicked; + // The browser's notice is its folders' lack of locks; it reaches no server to sign in to. + #[cfg(target_arch = "wasm32")] + text(ui, "text", notice, theme.text_dim); + #[cfg(not(target_arch = "wasm32"))] + { + text( + ui, + "text", + &format!("Using the system’s connection. Snowbound couldn’t sign in: {notice}"), + theme.text_dim, + ); + picked.sign_in = ui::button(ui, "sign-in", "Sign In…").clicked; + } ui.close(); } ui.close(); diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index bb658efbc4529ab8057ca76492930b307ebd948d..d4da589bd3d2051e0447322263cbbe4bf33562ab 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -46,6 +46,14 @@ extern "C" { pub fn download(name: &str, bytes: &[u8], kind: &str); #[wasm_bindgen(js_name = pickFiles)] fn pick_files(purpose: &str, accept: &str); + /// Asks for a notebook: a folder of the user's where the browser can be given one + /// (`mounted`), else files to copy in (`files`). + #[wasm_bindgen(js_name = pickNotebook)] + fn pick_notebook_files(); + /// The folders of the user's the browser was given before and may still reach: + /// `[root, files]`, the files as `loadFiles` gives them. + #[wasm_bindgen(js_name = loadFolders, catch)] + async fn load_folders() -> Result; #[wasm_bindgen(js_name = dateText)] fn date_strings(ms: f64) -> Vec; #[wasm_bindgen(js_name = shortDate)] @@ -59,7 +67,9 @@ extern "C" { #[wasm_bindgen(js_name = openLink)] fn open_link(url: &str); /// Writes changes out: `[path]` removed, `[path, null]` a folder, and `[path, length, - /// [[offset, bytes], ...]]` a file's new length and the ranges that changed. + /// [[offset, bytes], ...]]` a file's new length and the ranges that changed; with a + /// fourth `true`, a section committed under a folder of the user's, written only where + /// nothing else wrote the file since it was read (`refreshed` answers). #[wasm_bindgen(js_name = storeFiles)] fn store_files(changes: js_sys::Array); } @@ -70,6 +80,8 @@ const CACHE: &str = "/Cache"; /// The metric-compatible faces `index.html` fetched, which only this page load holds. const FONTS: &str = "/Fonts"; const SETTINGS: &str = "/Settings"; +/// Where folders of the user's given to the browser are mirrored (`glue.js` mounts them). +const FOLDERS: &str = "/Folders"; /// Files chosen to insert, open or paste, kept where the page links them from. const CHOSEN: &str = "/Chosen"; /// How long changed files wait to be written out, so a burst of edits writes once. @@ -535,7 +547,7 @@ pub fn pick_file(_: &str, types: &[&str]) -> Option { /// Asks for notebooks, sections or packages to open; they open once read. pub fn pick_notebook(_: &str) -> Option { - pick_files("open", ".one,.onetoc2,.onepkg"); + pick_notebook_files(); None } @@ -567,6 +579,14 @@ pub fn inform(message: &str, detail: &str) { alert(message, detail); } +/// What the sync popup says of a notebook in a folder of the user's, which the browser +/// writes without the locks OneNote takes. +pub fn lock_notice(location: &str) -> Option { + location.starts_with(&format!("{FOLDERS}/")).then(|| { + "This folder isn’t locked while you edit. Edit each section in one app at a time.".into() + }) +} + /// Servers are reached through a relay in a later phase; none is mounted here. pub fn smb_mount(_: &Path) -> Option { None @@ -681,6 +701,12 @@ pub async fn start(module: JsValue, fonts: Vec) -> Result<() canvas.set_width(host(|host| host.size.width)); canvas.set_height(host(|host| host.size.height)); restore(load_files().await?); + for folder in load_folders().await?.iter() { + let folder = js_sys::Array::from(&folder); + let root = folder.get(0).as_string().unwrap_or_default(); + notebook::fs::mount(&root); + restore(folder.get(1).into()); + } let state = open(fonts) .await .map_err(|error| JsValue::from_str(&error.to_string()))?; @@ -736,6 +762,17 @@ fn store() { }; changes.push(&entry); } + for (path, image) in notebook::fs::committed() { + changes.push(&js_sys::Array::of4( + &JsValue::from_str(&path.to_string_lossy()), + &(image.len() as f64).into(), + &js_sys::Array::of1(&js_sys::Array::of2( + &0.into(), + &js_sys::Uint8Array::from(image.as_slice()), + )), + &true.into(), + )); + } if changes.length() > 0 { store_files(changes); } @@ -936,6 +973,43 @@ pub fn files(purpose: &str, files: js_sys::Array) { } } +/// A folder of the user's given to the browser, mirrored at `root` with `files` as +/// `loadFiles` gives them, opened as a notebook. +#[wasm_bindgen] +pub fn mounted(root: String, files: js_sys::Array) { + notebook::fs::mount(&root); + restore(files); + send(UserEvent::Open(vec![root.into()])); +} + +/// A path under a folder of the user's as it now stands there, after Snowbound wrote it or +/// after something else did: a file's bytes, null for a folder, undefined where it went. The +/// sections' synchronization takes it up. +#[wasm_bindgen] +pub fn refreshed(path: String, bytes: JsValue, modified: f64) { + notebook::fs::restore( + path, + if bytes.is_undefined() { + notebook::fs::Saved::Gone + } else if bytes.is_null() { + notebook::fs::Saved::Directory + } else { + notebook::fs::Saved::File(js_sys::Uint8Array::new(&bytes).to_vec(), modified) + }, + ); + send(UserEvent::Then(Box::new(|state| { + state.publish_now(Duration::ZERO); + for background in state + .notebooks + .iter() + .filter_map(|library| library.background.as_ref()) + { + background.wake(); + } + Ok(()) + }))); +} + /// Writes `[name, bytes]` pairs into `folder` under names not yet taken, returning the paths. fn keep(files: js_sys::Array, folder: &str) -> Vec { files diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index 509683f6cc8e8a320cd43db71682d95eed322101..958970ba34d7e27075ef2a70e64268af64f2ec78 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -132,10 +132,159 @@ export function loadFiles() { }); } -/** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, length, ranges]` entries. */ +/** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, length, ranges]` entries; + * those under a folder of the user's go there, a committed section only where nothing else + * wrote it since it was read. */ export function storeFiles(changes) { - const buffers = changes.flatMap(([, , ranges]) => (ranges ?? []).map(([, bytes]) => bytes.buffer)); - storage.postMessage({ kind: "store", changes }, buffers); + const kept = changes.filter(([path]) => !folderOf(path)); + for (const change of changes) if (folderOf(change[0])) serially(() => writeFolder(change)); + if (kept.length) + storage.postMessage( + { kind: "store", changes: kept }, + kept.flatMap(([, , ranges]) => (ranges ?? []).map(([, bytes]) => bytes.buffer)), + ); +} + +// Folders of the user's, through the File System Access API (Chromium): mirrored in +// `notebook::fs` under FOLDERS, their handles kept in IndexedDB to reach them again. +const FOLDERS = "/Folders"; +const folders = new Map(); +let writing = Promise.resolve(); +const serially = (work) => + (writing = writing.then(work).catch((error) => console.error("Writing to the folder", error))); +const folderOf = (path) => + [...folders.keys()].find((root) => path === root || path.startsWith(`${root}/`)); +const stamp = (file) => `${file.size}:${file.lastModified}`; + +function kept(mode, act) { + return new Promise((resolve, reject) => { + const open = indexedDB.open("snowbound-folders", 1); + open.onupgradeneeded = () => open.result.createObjectStore("folders", { keyPath: "root" }); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const request = act(open.result.transaction("folders", mode).objectStore("folders")); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }; + }); +} + +/** Every file and folder below `dir`, at `path`: `[path, File or null]`. */ +async function walk(dir, path, out = []) { + for await (const [name, entry] of dir.entries()) { + const at = `${path}/${name}`; + if (entry.kind === "directory") { + out.push([at, null]); + await walk(entry, at, out); + } else out.push([at, await entry.getFile()]); + } + return out; +} + +/** Mirrors the folder `handle` at `root`: its files as `loadFiles` gives them. */ +async function mirror(root, handle) { + const known = new Map(); + folders.set(root, { handle, known }); + const files = [ + [FOLDERS, null, 0], + [root, null, 0], + ]; + for (const [path, file] of await walk(handle, root)) { + known.set(path, file ? stamp(file) : "folder"); + files.push(file ? [path, new Uint8Array(await file.arrayBuffer()), file.lastModified] : [path, null, 0]); + } + return files; +} + +/** The folders given before that the browser may still reach: `[root, files]`. Each other + * folder offers to reconnect, which needs a click. */ +export async function loadFolders() { + const out = []; + for (const { root, handle } of await kept("readonly", (store) => store.getAll())) { + if ((await handle.queryPermission({ mode: "readwrite" })) === "granted") + out.push([root, await mirror(root, handle)]); + else { + const button = Object.assign(document.createElement("button"), { + className: "reconnect", + textContent: `Reconnect “${handle.name}”`, + }); + button.onclick = async () => { + if ((await handle.requestPermission({ mode: "readwrite" })) === "granted") location.reload(); + }; + document.body.append(button); + } + } + // Other apps' writes reach the page once they land, as a watch would report them. + setInterval(() => serially(look), 3000); + return out; +} + +/** Hands the page what changed in each folder since it was last read. */ +async function look() { + for (const [root, folder] of folders) { + const seen = new Map(); + for (const [path, file] of await walk(folder.handle, root)) { + seen.set(path, file ? stamp(file) : "folder"); + if (folder.known.get(path) !== seen.get(path)) + wasm.refreshed(path, file ? new Uint8Array(await file.arrayBuffer()) : null, file?.lastModified ?? 0); + } + for (const path of folder.known.keys()) if (!seen.has(path)) wasm.refreshed(path, undefined, 0); + folder.known = seen; + } +} + +async function locate(path) { + const root = folderOf(path); + const parts = path.slice(root.length).split("/").filter(Boolean); + let dir = folders.get(root).handle; + for (const name of parts.slice(0, -1)) dir = await dir.getDirectoryHandle(name, { create: true }); + return [dir, parts.at(-1), folders.get(root)]; +} + +async function writeFolder([path, length, ranges, committed]) { + if (folders.has(path)) return; + const [dir, name, folder] = await locate(path); + if (length === undefined) { + await dir.removeEntry(name, { recursive: true }).catch(() => {}); + for (const known of [...folder.known.keys()]) + if (known === path || known.startsWith(`${path}/`)) folder.known.delete(known); + return; + } + if (length === null) { + await dir.getDirectoryHandle(name, { create: true }); + folder.known.set(path, "folder"); + return; + } + const handle = await dir.getFileHandle(name, { create: true }); + const before = await handle.getFile(); + if (committed && folder.known.get(path) !== stamp(before)) { + // Another app wrote the section since it was read: the commit stands on what it found + // no longer, so the page takes the file as it is and its edits go again on top. + folder.known.set(path, stamp(before)); + return wasm.refreshed(path, new Uint8Array(await before.arrayBuffer()), before.lastModified); + } + const writable = await handle.createWritable({ keepExistingData: !committed }); + for (const [offset, bytes] of ranges) await writable.write({ type: "write", position: offset, data: bytes }); + await writable.truncate(length); + await writable.close(); + const after = await handle.getFile(); + folder.known.set(path, stamp(after)); + if (committed) wasm.refreshed(path, ranges[0][1], after.lastModified); +} + +/** Asks for a notebook: its folder where the browser can be given one, else files to copy in. */ +export function pickNotebook() { + if (!window.showDirectoryPicker) return pickFiles("open", ".one,.onetoc2,.onepkg"); + showDirectoryPicker({ id: "notebook", mode: "readwrite" }) + .then(async (handle) => { + for (const [root, folder] of folders) + if (await folder.handle.isSameEntry(handle)) return wasm.mounted(root, []); + let root = `${FOLDERS}/${handle.name}`; + for (let number = 2; folders.has(root); number++) root = `${FOLDERS}/${handle.name} ${number}`; + await kept("readwrite", (store) => store.put({ root, handle })); + wasm.mounted(root, await mirror(root, handle)); + }) + .catch((error) => error.name === "AbortError" || console.error("Opening the folder", error)); } export function requestFrame() { diff --git a/crates/snowbound/web/index.html b/crates/snowbound/web/index.html index a19aaf2a480635aa64ced4243fdd9a95e2952424..aeba9b93f647ac608a4a9146266c03b5d14bff3f 100644 --- a/crates/snowbound/web/index.html +++ b/crates/snowbound/web/index.html @@ -14,6 +14,10 @@ opacity: 0; resize: none; overflow: hidden; white-space: pre; font-size: 16px; caret-color: transparent; pointer-events: none; } + .reconnect { + position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); + font: 14px system-ui, sans-serif; padding: 6px 14px; + } #status { position: fixed; inset: 0; display: grid; place-items: center; font: 15px system-ui, sans-serif; color: #666; pointer-events: none;