diff --git a/Cargo.lock b/Cargo.lock index 63681513ff912cd15b8fbfaebd7c5ccf46e21c71..0d33777aff3a96a41613618c304b701e22d727c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -664,6 +664,7 @@ dependencies = [ "serde_json", "sha2", "skrifa", + "web-time", "wgpu", ] @@ -1010,6 +1011,7 @@ dependencies = [ "roxmltree", "subsetter", "swash", + "web-time", "wgpu", "windows", ] @@ -1316,9 +1318,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2079,9 +2083,11 @@ dependencies = [ "cab", "getrandom 0.4.3", "hmac", + "js-sys", "libc", "nix", "onestore", + "rsqlite-vfs", "rusqlite", "serde", "serde_json", @@ -2090,6 +2096,9 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", "windows-sys 0.61.2", "zeroize", ] @@ -2471,6 +2480,7 @@ dependencies = [ "serde_json", "sha1", "subtle", + "web-time", "zeroize", ] @@ -3500,7 +3510,9 @@ dependencies = [ "canvas", "draw", "fontique", + "getrandom 0.4.3", "image", + "js-sys", "libc", "notebook", "objc2 0.5.2", @@ -3519,9 +3531,13 @@ dependencies = [ "ui", "unicode_names2", "ureq", + "wasm-bindgen", + "wasm-bindgen-futures", "wayland-backend", "wayland-client", "wayland-protocols", + "web-sys", + "web-time", "wgpu", "windows-sys 0.61.2", "winit", @@ -3923,6 +3939,7 @@ dependencies = [ "parley", "png", "pollster", + "web-time", "wgpu", "winit", ] @@ -4377,6 +4394,7 @@ dependencies = [ "thiserror 2.0.20", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -4401,6 +4419,15 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "30.0.1" diff --git a/arc/platforms.md b/arc/platforms.md index 97c307868b48b21cc6a22bba9c5e4b911b47b26e..2753dcfc15e846a2ce29a3296833c281fdaecb5b 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -146,6 +146,24 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. Windows has no menu bar: the toolbar and the command palette run the command table, with OneNote 2010's Ctrl chords. +### The browser + +- The same `snowbound`, built for `wasm32-unknown-unknown`: `web.rs` is its platform + module, and `web/index.html` and `web/glue.js` are the page around it. `glue.js` brings the + canvas's pointer, wheel and touch, and a hidden text area's keys, composition and paste, as + the `ui::Event`s winit would; `web.rs` stands in for winit's window and event loop and runs + `State` a turn per animation frame. `tools/release_web.py` builds and publishes it. +- The page has one thread. The notebook's section thread, sync worker and background run as + tasks on its event loop (`notebook::task`), and work the desktop gives a thread runs once + the frame is done (`spawn`). +- Files are `notebook::fs`'s: std's elsewhere, here held in memory and kept in IndexedDB, + with SQLite's VFS over the same files, so replicas and notebooks live side by side under + `/Notebooks` and `/Cache`. One tab at a time holds them. +- 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 + are still to come. + ## iOS: native around the canvas On iOS the split moves. Touch text editing depends on affordances that users diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index 50e4d9b00a870c70e2cfdd293b53375236accf0d..a73d0b670249dd67faba67f2095dbd1bd5134b49 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -19,6 +19,8 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" icu_normalizer = "2.3" skrifa = "0.44.0" +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" accesskit = { version = "0.25.0", optional = true } color_quant = { version = "1.1", optional = true } diff --git a/crates/canvas/assets/fonts/Caladea-Bold.ttf b/crates/canvas/assets/fonts/Caladea-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a4a9703fbce8958e5dc141a5b498255d9e7a3d97 Binary files /dev/null and b/crates/canvas/assets/fonts/Caladea-Bold.ttf differ diff --git a/crates/canvas/assets/fonts/Caladea-BoldItalic.ttf b/crates/canvas/assets/fonts/Caladea-BoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a9bb487022313de900d5c665baf9ebb166d38c84 Binary files /dev/null and b/crates/canvas/assets/fonts/Caladea-BoldItalic.ttf differ diff --git a/crates/canvas/assets/fonts/Caladea-Italic.ttf b/crates/canvas/assets/fonts/Caladea-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2c910c61f92637fd3ec1332340f14f80a0f82573 Binary files /dev/null and b/crates/canvas/assets/fonts/Caladea-Italic.ttf differ diff --git a/crates/canvas/assets/fonts/Caladea-OFL.txt b/crates/canvas/assets/fonts/Caladea-OFL.txt new file mode 100644 index 0000000000000000000000000000000000000000..b9d685b2fb49216f452fc0cf9728ce6673d910cb --- /dev/null +++ b/crates/canvas/assets/fonts/Caladea-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2012 The Caladea Project Authors (https://github.com/huertatipografica/Caladea) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/canvas/assets/fonts/Caladea-Regular.ttf b/crates/canvas/assets/fonts/Caladea-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a490e8f0ee259187614fb36ff3e6fae98dc5b355 Binary files /dev/null and b/crates/canvas/assets/fonts/Caladea-Regular.ttf differ diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index 507d67a4ca6198effee9ba5d182af137ae604b05..ba1f7ae4e23ee86ca1d8a9fd7aac275dcd0c9a9a 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -565,8 +565,8 @@ fn followed(format: &Format, old: &Format, new: &Format) -> Format { /// Seconds since 1980, as note tags date themselves. fn time32() -> Option { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let now = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .ok()? .as_secs(); u32::try_from(now.checked_sub(315_532_800)?).ok() diff --git a/crates/canvas/src/editor/recording.rs b/crates/canvas/src/editor/recording.rs index 4149634ba7bdc414d6d70af2184e857b9ca2559d..1c967e00d9df445a25a158e053aa38b520192bab 100644 --- a/crates/canvas/src/editor/recording.rs +++ b/crates/canvas/src/editor/recording.rs @@ -5,7 +5,7 @@ use super::*; use onestore::page::{Attachment, MediaIndex}; -use std::time::Instant; +use web_time::Instant; /// A recording in progress. pub(super) struct Live { diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 9142cd115e8945e2db9350aea161d90f7909b76f..7eb353153b15af151f8e4d1e11657995d6c5e813 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -16,10 +16,11 @@ use onestore::page::Page; use picture::Picture; use std::{ fmt, - sync::{Arc, Mutex, OnceLock, mpsc}, + sync::{Arc, Mutex}, task::Waker, - time::{Duration, Instant}, + time::Duration, }; +use web_time::Instant; /// Retained drawing data in the source page's coordinate system. pub struct PageScene { @@ -40,11 +41,13 @@ type Slot = Arc>>; /// Runs `work` on the scenes' one raster thread once `start` has passed, unless the slot /// returned has been dropped by then; the result lands in the slot and wakes `waker`. +#[cfg(not(target_arch = "wasm32"))] fn queue( start: Instant, waker: &Waker, work: impl FnOnce() -> T + Send + 'static, ) -> Slot { + use std::sync::{OnceLock, mpsc}; static WORKER: OnceLock>> = OnceLock::new(); let slot = Slot::default(); let requester = Arc::downgrade(&slot); @@ -69,6 +72,17 @@ fn queue( slot } +/// `queue` without threads, as in the browser: `work` runs now. +#[cfg(target_arch = "wasm32")] +fn queue( + _: Instant, + waker: &Waker, + work: impl FnOnce() -> T + Send + 'static, +) -> Slot { + waker.wake_by_ref(); + Arc::new(Mutex::new(Some(work()))) +} + /// Device pixels per point a raster is made at for `scale`: the next half power of two, /// so zooming makes rasters again in steps. fn density(scale: f32) -> f32 { @@ -793,6 +807,7 @@ impl PageScene { /// Where the page read without an editor lies, in page coordinates: the rectangle all /// its content covers, and the top and bottom of each line of text and each picture, which /// a sheet of paper does not cut through where it can help it. + #[cfg(feature = "pdf")] pub(crate) fn printed_extent(&self) -> Result<([f32; 4], Vec<[f32; 2]>), SceneError> { let mut bounds = [ f32::INFINITY, diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs index adb2ffe47df38eb4303d2c9a70969c370ef6195b..2b8b3e316088041c309bc8d52913437f04f7d4ba 100644 --- a/crates/canvas/src/gpu/page/background.rs +++ b/crates/canvas/src/gpu/page/background.rs @@ -6,10 +6,8 @@ use crate::gpu::Paper; use draw::RasterImage; use resvg::{tiny_skia, usvg}; use sha2::{Digest, Sha256}; -use std::{ - task::Waker, - time::{Duration, Instant}, -}; +use std::{task::Waker, time::Duration}; +use web_time::Instant; /// One of OneNote 2010's page templates: its art recreated for white paper in `#rrggbb` /// colours over a view box the size in pixels of the picture OneNote stores, that picture's diff --git a/crates/canvas/src/gpu/page/picture.rs b/crates/canvas/src/gpu/page/picture.rs index 8d79bece61f304c13de7274b539e2eec15ee891b..98607ac27c73e6802930668f7c97f8af6b4f7391 100644 --- a/crates/canvas/src/gpu/page/picture.rs +++ b/crates/canvas/src/gpu/page/picture.rs @@ -4,12 +4,8 @@ use super::{SETTLE, Slot, density, queue}; use draw::RasterImage; use onestore::ExGuid; -use std::{ - collections::BTreeMap, - sync::Arc, - task::Waker, - time::{Duration, Instant}, -}; +use std::{collections::BTreeMap, sync::Arc, task::Waker, time::Duration}; +use web_time::Instant; /// Decoded bytes a scene's pictures keep at once. Pictures in view shrink to fit it, and /// the rest of the renderer's frame budget stays for template art and the interface. diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 028a72c20b0015d5fd2d612f4e2da612fd1133a4..21d76cc70adc1f2440695a09bc6406dea58da358 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -26,10 +26,8 @@ use draw::{ Primitive, Stroke, edit::{self, Clicks, Command, Key, Modifiers, Movement, NamedKey, SelectionUnit}, }; -use std::{ - error::Error, - time::{Duration, Instant}, -}; +use std::{error::Error, time::Duration}; +use web_time::Instant; const HANDLE_HEIGHT: f32 = 6.75; /// Accessible names of the page date's fields. diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index b2d39360abf5066e85d97447ebbe1df64a927420..c8112f6d5c3ff2f8d4a1535320f063bd04e99e2a 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -96,7 +96,10 @@ impl fmt::Display for LayoutError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnsupportedSubstituteFont => { - write!(f, "Choose an Arimo, Carlito, Tinos or Cousine font file.") + write!( + f, + "Choose an Arimo, Carlito, Caladea, Tinos or Cousine font file." + ) } _ => write!(f, "Text layout failed: {self:?}"), } @@ -110,7 +113,9 @@ pub(crate) const DEFAULT_FONT: &str = "Arial"; pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0; /// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font -/// Licence files beside them, by the family each stands in for. +/// Licence files beside them, by the family each stands in for. The browser fetches them +/// beside the module instead (`register_substitute`), keeping them out of it. +#[cfg(not(target_arch = "wasm32"))] const BUNDLED: [(&str, &[&[u8]]); 4] = [ ( "Calibri", @@ -151,6 +156,7 @@ const BUNDLED: [(&str, &[&[u8]]); 4] = [ impl Default for TextEngine { /// An engine that lays out each bundled family in its substitute where it is missing. fn default() -> Self { + #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] let mut engine = Self { // Clones share loaded font files, so glyphs one lays out draw from the same cache. fonts: FontContext { @@ -161,6 +167,7 @@ impl Default for TextEngine { arial_substitutes: BTreeSet::new(), substitutes: BTreeMap::new(), }; + #[cfg(not(target_arch = "wasm32"))] for (family, faces) in BUNDLED { if engine.fonts.collection.family_id(family).is_none() { for face in faces { @@ -187,8 +194,9 @@ impl Clone for TextEngine { } impl TextEngine { - /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as - /// Courier New before creating layouts, in place of the bundled substitute. + /// Register Arimo as Arial, Carlito as Calibri, Caladea as Cambria, Tinos as Times New + /// Roman or Cousine as Courier New before creating layouts, in place of the bundled + /// substitute. pub fn register_substitute(&mut self, data: Blob) -> Result<&'static str, LayoutError> { self.register(data, false) } @@ -208,6 +216,7 @@ impl TextEngine { let (substitute, target) = match family.as_str() { "Arimo" => ("Arimo", "Arial"), "Carlito" => ("Carlito", "Calibri"), + "Caladea" => ("Caladea", "Cambria"), "Tinos" => ("Tinos", "Times New Roman"), "Cousine" => ("Cousine", "Courier New"), _ => return Err(LayoutError::UnsupportedSubstituteFont), diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index be32f891644047e9eb95e4b2a1d0fddeae43e4e2..3814408b20b83257f9ab06b9a828dba57d46d7aa 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -16,6 +16,8 @@ pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"] [dependencies] parley.workspace = true +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" base64 = { version = "0.22.1", optional = true } bytemuck = { version = "1.25", features = ["derive"], optional = true } diff --git a/crates/draw/src/edit.rs b/crates/draw/src/edit.rs index f01ccbd8356c5c38915f646dd5c134aa715af2ac..a85270749f17334f02052aa982c644bd724f87e8 100644 --- a/crates/draw/src/edit.rs +++ b/crates/draw/src/edit.rs @@ -5,10 +5,8 @@ use parley::{ Affinity, Brush, Layout, editing::{Cursor, Selection}, }; -use std::{ - collections::BTreeSet, - time::{Duration, Instant}, -}; +use std::{collections::BTreeSet, time::Duration}; +use web_time::Instant; #[derive(Clone, Debug, PartialEq)] pub enum Key { diff --git a/crates/notebook/Cargo.toml b/crates/notebook/Cargo.toml index 14589453f38a6197506a1921b47763638c8841cd..64d8d9a330acfabf4465ec6cb20fab2ae49dfec1 100644 --- a/crates/notebook/Cargo.toml +++ b/crates/notebook/Cargo.toml @@ -25,6 +25,17 @@ getrandom = "0.4.3" hmac = "0.13.0" # OneNote packages (.onepkg) are cabinet files. cab = "0.6" +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" + +# The browser: its randomness, clock, timers and event loop, and SQLite's files kept with +# the notebooks' (`fs`). +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.4.3", features = ["wasm_js"] } +js-sys = "0.3" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +rsqlite-vfs = "0.1.1" [target.'cfg(target_vendor = "apple")'.dependencies] nix = { version = "0.31", default-features = false, features = ["fs"] } diff --git a/crates/notebook/src/background.rs b/crates/notebook/src/background.rs index e90889ca0f485562e1e5413d8d100c373e3db2ee..7f9909aaafd8b81d41022f04ecc6a38976d12fbd 100644 --- a/crates/notebook/src/background.rs +++ b/crates/notebook/src/background.rs @@ -2,6 +2,7 @@ //! keeps every section of an open notebook: queued edits publish and other clients' changes //! are noticed without the section being open. +use crate::fs; use crate::{ EditStatus, Error, Remote, Replica, Result, discover::{Entry, Listed}, @@ -17,9 +18,9 @@ use std::{ Arc, Mutex, Weak, atomic::{AtomicBool, Ordering}, }, - thread, - time::{Duration, Instant}, + time::Duration, }; +use web_time::Instant; /// How long a section that could not be reached waits to be tried again; OneNote 2010 /// retries a failing section about every 31 seconds. @@ -137,168 +138,163 @@ impl Background { }); let weak = Arc::downgrade(&shared); let owner = Arc::clone(&shared); - thread::Builder::new() - .name("onestore-background".into()) - .spawn(move || { - let signal = &owner.signal; - let mut bound: Option<(B, L)> = None; - let mut news = false; - while !signal.stopped.load(Ordering::Acquire) { - if signal.offline.load(Ordering::Acquire) - && !signal.requested.load(Ordering::Acquire) - { - // Working online again lists every folder again. - if let Ok(mut watched) = owner.watched.lock() { - watched.watching(false); - watched.lost = true; - } - bound = None; - let _ = receiver.recv(); - continue; - } - let now = Instant::now(); - let next = { - let Ok(mut watched) = owner.watched.lock() else { - return; - }; - if std::mem::take(&mut watched.lost) { - bound = None; - watched.watching(false); - for watch in &mut watched.sections { - watch.due = now; - } - } - watched.next(now, bound.is_some()) - }; - let (path, replica, seen, current, image) = match next { - Next::Wait(wait) => { - signal.requested.store(false, Ordering::Release); - if std::mem::take(&mut news) { - notify(); - } - let _ = match wait { - Some(wait) => receiver.recv_timeout(wait).ok(), - None => receiver.recv().ok(), - }; - continue; - } - Next::Connect(connection) => { - let reports = Reports { - shared: weak.clone(), - connection: connection + 1, - }; - let connected = connect(reports); - let Ok(mut watched) = owner.watched.lock() else { - return; - }; - // Whatever ended before now ended with the last connection. - watched.connection += 1; - watched.lost = false; - match connected { - Ok((mut files, reported)) => { - watched.watching(reported); - let folders = watched.rescanning(); - drop(watched); - let listings = list(&mut files.1, folders); - let Ok(mut watched) = owner.watched.lock() else { - return; - }; - watched.listed(&listings, Some(copies), Instant::now()); - bound = Some(files); - } - Err(error) => { - let error = Error::RemoteIo(error); - let retry = now + RETRY; - for watch in &mut watched.sections { - news |= watch.fail(&error, None); - watch.due = watch.due.max(retry); - } - watched.relist = watched.relist.map(|due| due.max(retry)); - } - } - continue; - } - Next::List(folders) => { - let Some((_, list_folder)) = &mut bound else { - continue; - }; - let listings = list(list_folder, folders); - let Ok(mut watched) = owner.watched.lock() else { - return; - }; - watched.listed(&listings, None, Instant::now()); - continue; - } - Next::Check { - path, - replica, - seen, - current, - image, - } => (path, replica, seen, current, image), - }; - let Some((bind, _)) = &mut bound else { - continue; - }; - let (queued, outcome) = step( - &mut bind(&path), - replica.as_deref(), - seen.as_deref(), - current, - image, - copies, - ); - if outcome.as_ref().is_err_and(disconnected) { - bound = None; + crate::task::spawn("onestore-background", move || async move { + let signal = &owner.signal; + let mut bound: Option<(B, L)> = None; + let mut news = false; + while !signal.stopped.load(Ordering::Acquire) { + if signal.offline.load(Ordering::Acquire) + && !signal.requested.load(Ordering::Acquire) + { + // Working online again lists every folder again. + if let Ok(mut watched) = owner.watched.lock() { + watched.watching(false); + watched.lost = true; } + bound = None; + crate::task::wait(&receiver, None).await; + continue; + } + let now = Instant::now(); + let next = { let Ok(mut watched) = owner.watched.lock() else { return; }; - let interval = watched.interval(); - let Watched { - sections, changed, .. - } = &mut *watched; - let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else { + if std::mem::take(&mut watched.lost) { + bound = None; + watched.watching(false); + for watch in &mut watched.sections { + watch.due = now; + } + } + watched.next(now, bound.is_some()) + }; + let (path, replica, seen, current, image) = match next { + Next::Wait(wait) => { + signal.requested.store(false, Ordering::Release); + if std::mem::take(&mut news) { + notify(); + } + crate::task::wait(&receiver, wait).await; continue; - }; - let now = Instant::now(); - watch.current = false; - news |= match outcome { - Ok((stamp, moved)) => { - watch.stamp = Some(stamp); - watch.due = now + interval; - if moved { - changed.push(path); - } - let before = summary(&watch.status); - if let Some(queued) = queued { - watch.status = SyncStatus { - synced: Some(crate::now()), - error: None, - queued, + } + Next::Connect(connection) => { + let reports = Reports { + shared: weak.clone(), + connection: connection + 1, + }; + let connected = connect(reports); + let Ok(mut watched) = owner.watched.lock() else { + return; + }; + // Whatever ended before now ended with the last connection. + watched.connection += 1; + watched.lost = false; + match connected { + Ok((mut files, reported)) => { + watched.watching(reported); + let folders = watched.rescanning(); + drop(watched); + let listings = list(&mut files.1, folders); + let Ok(mut watched) = owner.watched.lock() else { + return; }; + watched.listed(&listings, Some(copies), Instant::now()); + bound = Some(files); + } + Err(error) => { + let error = Error::RemoteIo(error); + let retry = now + RETRY; + for watch in &mut watched.sections { + news |= watch.fail(&error, None); + watch.due = watch.due.max(retry); + } + watched.relist = watched.relist.map(|due| due.max(retry)); } - moved || before != summary(&watch.status) - } - Err(error) => { - watch.due = now + RETRY; - watch.fail(&error, queued) } - }; + continue; + } + Next::List(folders) => { + let Some((_, list_folder)) = &mut bound else { + continue; + }; + let listings = list(list_folder, folders); + let Ok(mut watched) = owner.watched.lock() else { + return; + }; + watched.listed(&listings, None, Instant::now()); + continue; + } + Next::Check { + path, + replica, + seen, + current, + image, + } => (path, replica, seen, current, image), + }; + let Some((bind, _)) = &mut bound else { + continue; + }; + let (queued, outcome) = step( + &mut bind(&path), + replica.as_deref(), + seen.as_deref(), + current, + image, + copies, + ); + if outcome.as_ref().is_err_and(disconnected) { + bound = None; } - if let Ok(mut watched) = owner.watched.lock() { - watched.watching(false); - if owner.discard.load(Ordering::Acquire) { - for replica in watched - .sections - .iter() - .filter_map(|watch| watch.replica.as_ref()) - { - discard(replica); + let Ok(mut watched) = owner.watched.lock() else { + return; + }; + let interval = watched.interval(); + let Watched { + sections, changed, .. + } = &mut *watched; + let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else { + continue; + }; + let now = Instant::now(); + watch.current = false; + news |= match outcome { + Ok((stamp, moved)) => { + watch.stamp = Some(stamp); + watch.due = now + interval; + if moved { + changed.push(path); } + let before = summary(&watch.status); + if let Some(queued) = queued { + watch.status = SyncStatus { + synced: Some(crate::now()), + error: None, + queued, + }; + } + moved || before != summary(&watch.status) + } + Err(error) => { + watch.due = now + RETRY; + watch.fail(&error, queued) + } + }; + } + if let Ok(mut watched) = owner.watched.lock() { + watched.watching(false); + if owner.discard.load(Ordering::Acquire) { + for replica in watched + .sections + .iter() + .filter_map(|watch| watch.replica.as_ref()) + { + discard(replica); } } - })?; + } + })?; Ok(Self(shared)) } @@ -668,7 +664,7 @@ impl Watched { && watch .replica .as_ref() - .is_some_and(|replica| !replica.exists()); + .is_some_and(|replica| fs::metadata(replica).is_err()); watch.due = if read { staggered += 1; now + STAGGER * (staggered - 1) @@ -750,7 +746,7 @@ fn discard(replica: &Path) { for suffix in ["-wal", "-shm", ""] { let mut file = replica.as_os_str().to_owned(); file.push(suffix); - let _ = std::fs::remove_file(file); + let _ = fs::remove_file(file); } } } @@ -833,14 +829,14 @@ fn step( let Some(replica) = replica else { return (Some(0), Ok((stamp, moved))); }; - if !replica.exists() { + if fs::metadata(replica).is_err() { if !copies { return (Some(0), Ok((stamp, moved))); } let copied = (|| { let image = remote.read().map_err(Error::RemoteIo)?; if let Some(folder) = replica.parent() { - std::fs::create_dir_all(folder)?; + fs::create_dir_all(folder)?; } Replica::seed(replica, &image, None)?; Ok(Stamp::of(&image)?) diff --git a/crates/notebook/src/bin/onestore-diagnostic.rs b/crates/notebook/src/bin/onestore-diagnostic.rs index f5bc0fa7ff1552d0572fb5b56ede701b2c139ffd..23bb70ca1889732c6fe646a18877f34396fb705b 100644 --- a/crates/notebook/src/bin/onestore-diagnostic.rs +++ b/crates/notebook/src/bin/onestore-diagnostic.rs @@ -1,3 +1,4 @@ +use notebook::fs; use onestore::{ Arena, ExGuid, Section, TextAttribute, document::{Format, Layout}, @@ -9,7 +10,7 @@ use onestore::{ use serde::Deserialize; use serde_json::{Value, json}; use std::{ - env, fs, + env, io::{self, Write}, }; @@ -96,7 +97,7 @@ fn run(args: &[std::ffi::OsString]) -> Result> return Err("Usage: onestore-diagnostic catalog ROOT | snapshot FILE NEW_SNAPSHOT | check SNAPSHOT - | commit FILE SNAPSHOT; edits arrive as JSON on stdin".into()); } if args[0] == "snapshot" { - let bytes = onestore::read_file(&args[1])?; + let bytes = fs::read_file(&args[1])?; let mut file = fs::OpenOptions::new() .write(true) .create_new(true) @@ -199,7 +200,7 @@ fn run(args: &[std::ffi::OsString]) -> Result> let Some(transaction) = transaction else { return Ok(json!({"ok": true, "state": "Unchanged"})); }; - Ok(match transaction.commit_file(&args[1]) { + Ok(match fs::commit_file(&transaction, &args[1]) { Ok(()) => json!({"ok": true, "state": "Committed"}), Err(error) => json!({"ok": false, "state": format!("{:?}", error.state), "kind": format!("{:?}", error.error.kind()), "error": error.error.to_string()}), diff --git a/crates/notebook/src/discover/source.rs b/crates/notebook/src/discover/source.rs index 37c912c0c39807f8a6742e5734df84c93024749b..4d37789b1ce57aebac9f3ad2e03822db7ea61542 100644 --- a/crates/notebook/src/discover/source.rs +++ b/crates/notebook/src/discover/source.rs @@ -1,4 +1,5 @@ use super::{Entry, EntryKind, Listed, Source, component}; +use crate::fs; use std::{ collections::BTreeSet, io, @@ -14,7 +15,7 @@ fn evicted(name: &str) -> Option<&str> { /// Whether the system keeps the file's contents elsewhere until it is read, as macOS 14 and /// later list a file iCloud Drive evicted (`SF_DATALESS`). -fn dataless(metadata: &std::fs::Metadata) -> bool { +fn dataless(metadata: &fs::Metadata) -> bool { const SF_DATALESS: u32 = 0x4000_0000; #[cfg(target_os = "macos")] let flags = std::os::macos::fs::MetadataExt::st_flags(metadata); @@ -41,8 +42,8 @@ pub struct Local { impl Local { pub fn open(root: impl AsRef) -> io::Result { - let root = root.as_ref().canonicalize()?; - if !root.is_dir() { + let root = fs::canonicalize(root)?; + if !fs::metadata(&root)?.is_dir() { return Err(io::ErrorKind::NotADirectory.into()); } Ok(Self { root }) @@ -52,7 +53,7 @@ impl Local { if !relative.is_empty() && !relative.split('/').all(component) { return Err(io::ErrorKind::InvalidInput.into()); } - let path = self.root.join(relative).canonicalize()?; + let path = fs::canonicalize(self.root.join(relative))?; if !path.starts_with(&self.root) { return Err(io::ErrorKind::PermissionDenied.into()); } @@ -63,7 +64,7 @@ impl Local { impl Source for Local { fn entries(&mut self, path: &str, limit: usize) -> io::Result> { let mut entries = Vec::new(); - for entry in std::fs::read_dir(self.path(path)?)? { + for entry in fs::read_dir(self.path(path)?)? { let entry = entry?; if entries.len() == limit { return Err(io::ErrorKind::FileTooLarge.into()); @@ -108,7 +109,7 @@ impl Source for Local { } fn read(&mut self, path: &str, limit: usize) -> io::Result> { - onestore::read_file_limited(self.path(path)?, limit) + fs::read_file_limited(self.path(path)?, limit) } } @@ -190,7 +191,7 @@ impl Source for Smb<'_> { /// Reads the file's stamp, then its replica's base if that has the same. fn copy(&mut self, path: &str, known: [u8; 16]) -> Option> { let replica = crate::session::replica_file(self.copies.as_ref()?, &known); - if !replica.exists() { + if fs::metadata(&replica).is_err() { return None; } let stamp = self.client.stamp(&self.path(path).ok()?).ok()?; diff --git a/crates/notebook/src/fs.rs b/crates/notebook/src/fs.rs new file mode 100644 index 0000000000000000000000000000000000000000..d991f487d3ae80fc3ef593929d5917868f83ccb8 --- /dev/null +++ b/crates/notebook/src/fs.rs @@ -0,0 +1,21 @@ +//! The file system notebooks and their replicas live in: std's natively. In the browser, +//! which gives a module none, it is `web`'s, which keeps SQLite's files beside the notebooks'. + +#[cfg(not(target_arch = "wasm32"))] +pub use onestore::{confirm_file, place_file, read_file, read_file_limited, supersede_file}; +#[cfg(not(target_arch = "wasm32"))] +pub use std::{fs::*, path::absolute, process::id as process_id}; + +/// `Transaction::commit_file` through this file system. +#[cfg(not(target_arch = "wasm32"))] +pub fn commit_file( + transaction: &onestore::Transaction, + path: impl AsRef, +) -> Result<(), onestore::CommitError> { + transaction.commit_file(path) +} + +#[cfg(target_arch = "wasm32")] +mod web; +#[cfg(target_arch = "wasm32")] +pub use web::*; diff --git a/crates/notebook/src/fs/web.rs b/crates/notebook/src/fs/web.rs new file mode 100644 index 0000000000000000000000000000000000000000..c130e7e830c1f01ef810ca2a2280e6cbc4e7876c --- /dev/null +++ b/crates/notebook/src/fs/web.rs @@ -0,0 +1,722 @@ +//! The browser's file system: std's calls over files held in memory, which the host loads +//! before anything opens (`restore`) and writes out as they change (`changes`). SQLite reaches +//! the same files through `sqlite`, its default VFS here, so a replica is a file like any other. +//! One thread: no file is ever locked. + +use onestore::{CommitError, CommitIo, CommitState, Stamp, Transaction}; +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet}, + ffi::OsString, + io::{self, ErrorKind, Read, Seek, SeekFrom, Write}, + path::{Component, Path, PathBuf}, + rc::Rc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +mod sqlite; +pub(crate) use sqlite::install as install_sqlite; + +/// A file's bytes and when they last changed, in milliseconds since 1970. +struct Data { + bytes: Vec, + modified: f64, + /// Where the file is, which a rename moves; none once it is removed. + path: Option, +} + +type Shared = Rc>; + +enum Node { + Directory, + File(Shared), +} + +#[derive(Default)] +struct Files { + nodes: BTreeMap, + /// Paths written, made or removed since the host last asked. + changed: BTreeSet, +} + +thread_local! { + static FILES: RefCell = RefCell::new(Files { + nodes: BTreeMap::from([(PathBuf::from("/"), Node::Directory)]), + changed: BTreeSet::new(), + }); +} + +/// 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), +} + +/// Puts what the host kept at `path` back, as it was before the page last closed. +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) => Node::File(Rc::new(RefCell::new(Data { + bytes, + modified, + path: Some(path.clone()), + }))), + }; + files.nodes.insert(path, node); + }); +} + +/// Whether any path changed since `changes` was last called. +pub fn changed() -> bool { + FILES.with_borrow(|files| !files.changed.is_empty()) +} + +/// The paths changed since the last call, each with what it holds now, if anything. +pub fn changes() -> Vec<(PathBuf, Option)> { + FILES.with_borrow_mut(|files| { + std::mem::take(&mut files.changed) + .into_iter() + .map(|path| { + let saved = files.nodes.get(&path).map(|node| match node { + Node::Directory => Saved::Directory, + Node::File(data) => { + let data = data.borrow(); + Saved::File(data.bytes.clone(), data.modified) + } + }); + (path, saved) + }) + .collect() + }) +} + +fn now() -> f64 { + js_sys::Date::now() +} + +/// `path` absolute, without `.` or `..`. +fn normal(path: &Path) -> PathBuf { + let mut normal = PathBuf::from("/"); + for component in path.components() { + match component { + Component::Normal(name) => normal.push(name), + Component::ParentDir => { + normal.pop(); + } + Component::RootDir | Component::CurDir | Component::Prefix(_) => {} + } + } + normal +} + +fn not_found(path: &Path) -> io::Error { + io::Error::new(ErrorKind::NotFound, format!("{} not found", path.display())) +} + +impl Files { + fn file(&self, path: &Path) -> io::Result { + match self.nodes.get(path) { + Some(Node::File(data)) => Ok(Rc::clone(data)), + Some(Node::Directory) => Err(ErrorKind::IsADirectory.into()), + None => Err(not_found(path)), + } + } + + fn parent_exists(&self, path: &Path) -> io::Result<()> { + match path.parent().map(|parent| self.nodes.get(parent)) { + None | Some(Some(Node::Directory)) => Ok(()), + Some(Some(Node::File(_))) => Err(ErrorKind::NotADirectory.into()), + Some(None) => Err(not_found(path.parent().unwrap_or(path))), + } + } + + /// A new empty file at `path`, whose folder exists. + fn create(&mut self, path: PathBuf) -> io::Result { + self.parent_exists(&path)?; + if self.nodes.contains_key(&path) { + return Err(ErrorKind::AlreadyExists.into()); + } + let data = Rc::new(RefCell::new(Data { + bytes: Vec::new(), + modified: now(), + path: Some(path.clone()), + })); + self.nodes + .insert(path.clone(), Node::File(Rc::clone(&data))); + self.changed.insert(path); + Ok(data) + } + + fn children(&self, folder: &Path) -> impl Iterator { + self.nodes + .range(folder.to_path_buf()..) + .skip(1) + .take_while(move |(path, _)| path.starts_with(folder)) + .filter(move |(path, _)| path.parent() == Some(folder)) + } +} + +fn with(act: impl FnOnce(&mut Files) -> T) -> T { + FILES.with_borrow_mut(act) +} + +/// Marks `data` changed now, where it still has a path. +fn touched(data: &mut Data) { + data.modified = now(); + if let Some(path) = data.path.clone() { + with(|files| files.changed.insert(path)); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FileType(bool); + +impl FileType { + pub fn is_dir(&self) -> bool { + self.0 + } + + pub fn is_file(&self) -> bool { + !self.0 + } + + pub fn is_symlink(&self) -> bool { + false + } +} + +#[derive(Clone, Debug)] +pub struct Metadata { + directory: bool, + length: u64, + modified: f64, +} + +impl Metadata { + pub fn is_dir(&self) -> bool { + self.directory + } + + pub fn is_file(&self) -> bool { + !self.directory + } + + pub fn file_type(&self) -> FileType { + FileType(self.directory) + } + + #[allow(clippy::len_without_is_empty, reason = "std's Metadata has none")] + pub fn len(&self) -> u64 { + self.length + } + + pub fn modified(&self) -> io::Result { + Ok(UNIX_EPOCH + Duration::from_secs_f64(self.modified.max(0.0) / 1e3)) + } +} + +fn metadata_of(node: &Node) -> Metadata { + match node { + Node::Directory => Metadata { + directory: true, + length: 0, + modified: 0.0, + }, + Node::File(data) => { + let data = data.borrow(); + Metadata { + directory: false, + length: data.bytes.len() as u64, + modified: data.modified, + } + } + } +} + +pub fn metadata(path: impl AsRef) -> io::Result { + let path = normal(path.as_ref()); + with(|files| { + files + .nodes + .get(&path) + .map(metadata_of) + .ok_or_else(|| not_found(&path)) + }) +} + +pub fn symlink_metadata(path: impl AsRef) -> io::Result { + metadata(path) +} + +pub fn exists(path: impl AsRef) -> io::Result { + let path = normal(path.as_ref()); + Ok(with(|files| files.nodes.contains_key(&path))) +} + +/// This page's number among the processes that may share its files, as other tabs do. +pub fn process_id() -> u32 { + use std::sync::OnceLock; + static ID: OnceLock = OnceLock::new(); + *ID.get_or_init(|| { + let mut bytes = [0; 4]; + let _ = getrandom::fill(&mut bytes); + u32::from_ne_bytes(bytes) + }) +} + +/// `path` from the root, as every path here is. +pub fn absolute(path: impl AsRef) -> io::Result { + Ok(normal(path.as_ref())) +} + +pub fn canonicalize(path: impl AsRef) -> io::Result { + let path = normal(path.as_ref()); + match with(|files| files.nodes.contains_key(&path)) { + true => Ok(path), + false => Err(not_found(&path)), + } +} + +pub fn read(path: impl AsRef) -> io::Result> { + let path = normal(path.as_ref()); + let data = with(|files| files.file(&path))?; + Ok(data.borrow().bytes.clone()) +} + +pub fn read_to_string(path: impl AsRef) -> io::Result { + String::from_utf8(read(path)?).map_err(|error| io::Error::new(ErrorKind::InvalidData, error)) +} + +pub fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> io::Result<()> { + let path = normal(path.as_ref()); + let data = with(|files| files.file(&path).or_else(|_| files.create(path.clone())))?; + let mut data = data.borrow_mut(); + data.bytes = contents.as_ref().to_vec(); + touched(&mut data); + Ok(()) +} + +pub fn copy(from: impl AsRef, to: impl AsRef) -> io::Result { + let bytes = read(from)?; + write(to, &bytes)?; + Ok(bytes.len() as u64) +} + +/// A copy, as nothing here writes a file under two names at once: a link is made to put a +/// finished file in place, then the first name removed. +pub fn hard_link(original: impl AsRef, link: impl AsRef) -> io::Result<()> { + let bytes = read(original)?; + let link = normal(link.as_ref()); + let data = with(|files| files.create(link))?; + data.borrow_mut().bytes = bytes; + Ok(()) +} + +pub fn create_dir(path: impl AsRef) -> io::Result<()> { + let path = normal(path.as_ref()); + with(|files| { + files.parent_exists(&path)?; + if files.nodes.contains_key(&path) { + return Err(ErrorKind::AlreadyExists.into()); + } + files.nodes.insert(path.clone(), Node::Directory); + files.changed.insert(path); + Ok(()) + }) +} + +pub fn create_dir_all(path: impl AsRef) -> io::Result<()> { + let path = normal(path.as_ref()); + for folder in path.ancestors().collect::>().into_iter().rev() { + match with(|files| { + files + .nodes + .get(folder) + .map(|node| matches!(node, Node::Directory)) + }) { + Some(true) => {} + Some(false) => return Err(ErrorKind::NotADirectory.into()), + None => create_dir(folder)?, + } + } + Ok(()) +} + +pub fn remove_file(path: impl AsRef) -> io::Result<()> { + let path = normal(path.as_ref()); + with(|files| { + let data = files.file(&path)?; + data.borrow_mut().path = None; + files.nodes.remove(&path); + files.changed.insert(path); + Ok(()) + }) +} + +pub fn remove_dir(path: impl AsRef) -> io::Result<()> { + let path = normal(path.as_ref()); + with(|files| { + match files.nodes.get(&path) { + Some(Node::Directory) => {} + Some(Node::File(_)) => return Err(ErrorKind::NotADirectory.into()), + None => return Err(not_found(&path)), + } + if files.children(&path).next().is_some() { + return Err(ErrorKind::DirectoryNotEmpty.into()); + } + files.nodes.remove(&path); + files.changed.insert(path); + Ok(()) + }) +} + +pub fn remove_dir_all(path: impl AsRef) -> io::Result<()> { + let path = normal(path.as_ref()); + with(|files| { + let gone: Vec = files + .nodes + .range(path.clone()..) + .take_while(|(each, _)| each.starts_with(&path)) + .map(|(each, _)| each.clone()) + .collect(); + if gone.is_empty() { + return Err(not_found(&path)); + } + for each in gone { + if let Some(Node::File(data)) = files.nodes.remove(&each) { + data.borrow_mut().path = None; + } + files.changed.insert(each); + } + Ok(()) + }) +} + +/// Moves a file or folder, replacing a file at `to`, as a POSIX rename does. +pub fn rename(from: impl AsRef, to: impl AsRef) -> io::Result<()> { + let (from, to) = (normal(from.as_ref()), normal(to.as_ref())); + with(|files| { + if !files.nodes.contains_key(&from) { + return Err(not_found(&from)); + } + files.parent_exists(&to)?; + if from == to { + return Ok(()); + } + match files.nodes.get(&to) { + Some(Node::Directory) if files.children(&to).next().is_some() => { + return Err(ErrorKind::DirectoryNotEmpty.into()); + } + Some(Node::File(data)) => data.borrow_mut().path = None, + _ => {} + } + let moved: Vec = files + .nodes + .range(from.clone()..) + .take_while(|(each, _)| each.starts_with(&from)) + .map(|(each, _)| each.clone()) + .collect(); + for each in moved { + let Some(node) = files.nodes.remove(&each) else { + continue; + }; + let target = to.join(each.strip_prefix(&from).unwrap_or(Path::new(""))); + if let Node::File(data) = &node { + data.borrow_mut().path = Some(target.clone()); + } + files.changed.insert(each); + files.changed.insert(target.clone()); + files.nodes.insert(target, node); + } + Ok(()) + }) +} + +pub struct DirEntry { + path: PathBuf, + metadata: Metadata, +} + +impl DirEntry { + pub fn path(&self) -> PathBuf { + self.path.clone() + } + + pub fn file_name(&self) -> OsString { + self.path.file_name().unwrap_or_default().to_owned() + } + + pub fn file_type(&self) -> io::Result { + Ok(self.metadata.file_type()) + } + + pub fn metadata(&self) -> io::Result { + Ok(self.metadata.clone()) + } +} + +pub struct ReadDir(std::vec::IntoIter); + +impl Iterator for ReadDir { + type Item = io::Result; + + fn next(&mut self) -> Option { + self.0.next().map(Ok) + } +} + +pub fn read_dir(path: impl AsRef) -> io::Result { + let path = normal(path.as_ref()); + with(|files| { + match files.nodes.get(&path) { + Some(Node::Directory) => {} + Some(Node::File(_)) => return Err(ErrorKind::NotADirectory.into()), + None => return Err(not_found(&path)), + } + let entries: Vec = files + .children(&path) + .map(|(child, node)| DirEntry { + path: child.clone(), + metadata: metadata_of(node), + }) + .collect(); + Ok(ReadDir(entries.into_iter())) + }) +} + +#[derive(Clone, Debug, Default)] +pub struct OpenOptions { + read: bool, + write: bool, + append: bool, + truncate: bool, + create: bool, + create_new: bool, +} + +impl OpenOptions { + pub fn new() -> Self { + Self::default() + } + + pub fn read(&mut self, read: bool) -> &mut Self { + self.read = read; + self + } + + pub fn write(&mut self, write: bool) -> &mut Self { + self.write = write; + self + } + + pub fn append(&mut self, append: bool) -> &mut Self { + self.append = append; + self + } + + pub fn truncate(&mut self, truncate: bool) -> &mut Self { + self.truncate = truncate; + self + } + + pub fn create(&mut self, create: bool) -> &mut Self { + self.create = create; + self + } + + pub fn create_new(&mut self, create_new: bool) -> &mut Self { + self.create_new = create_new; + self + } + + pub fn open(&self, path: impl AsRef) -> io::Result { + let path = normal(path.as_ref()); + let data = with(|files| match files.file(&path) { + Ok(_) if self.create_new => Err(ErrorKind::AlreadyExists.into()), + Err(error) + if error.kind() == ErrorKind::NotFound && (self.create || self.create_new) => + { + files.create(path.clone()) + } + found => found, + })?; + if self.truncate && self.write { + let mut data = data.borrow_mut(); + data.bytes.clear(); + touched(&mut data); + } + Ok(File { + data, + position: 0, + append: self.append, + }) + } +} + +/// An open file, which reads and writes the file under whatever name it has since. +pub struct File { + data: Shared, + position: u64, + append: bool, +} + +impl File { + pub fn open(path: impl AsRef) -> io::Result { + OpenOptions::new().read(true).open(path) + } + + pub fn create(path: impl AsRef) -> io::Result { + OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + } + + pub fn create_new(path: impl AsRef) -> io::Result { + OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(path) + } + + pub fn metadata(&self) -> io::Result { + Ok(metadata_of(&Node::File(Rc::clone(&self.data)))) + } + + pub fn sync_all(&self) -> io::Result<()> { + Ok(()) + } + + pub fn sync_data(&self) -> io::Result<()> { + Ok(()) + } + + pub fn set_len(&self, size: u64) -> io::Result<()> { + let mut data = self.data.borrow_mut(); + data.bytes.resize(size as usize, 0); + touched(&mut data); + Ok(()) + } + + fn read_from(&self, offset: u64, output: &mut [u8]) -> usize { + let data = self.data.borrow(); + let rest = data.bytes.get(offset as usize..).unwrap_or_default(); + let count = rest.len().min(output.len()); + output[..count].copy_from_slice(&rest[..count]); + count + } + + fn write_to(&self, offset: u64, input: &[u8]) { + let mut data = self.data.borrow_mut(); + let end = offset as usize + input.len(); + if data.bytes.len() < end { + data.bytes.resize(end, 0); + } + data.bytes[offset as usize..end].copy_from_slice(input); + touched(&mut data); + } +} + +impl Read for File { + fn read(&mut self, output: &mut [u8]) -> io::Result { + let count = self.read_from(self.position, output); + self.position += count as u64; + Ok(count) + } +} + +impl Write for File { + fn write(&mut self, input: &[u8]) -> io::Result { + if self.append { + self.position = self.data.borrow().bytes.len() as u64; + } + self.write_to(self.position, input); + self.position += input.len() as u64; + Ok(input.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Seek for File { + fn seek(&mut self, position: SeekFrom) -> io::Result { + let length = self.data.borrow().bytes.len() as i128; + let target = match position { + SeekFrom::Start(offset) => i128::from(offset), + SeekFrom::End(offset) => length + i128::from(offset), + SeekFrom::Current(offset) => i128::from(self.position) + i128::from(offset), + }; + self.position = u64::try_from(target).map_err(|_| ErrorKind::InvalidInput)?; + Ok(self.position) + } +} + +impl CommitIo for File { + fn read_at(&mut self, offset: u64, bytes: &mut [u8]) -> io::Result { + Ok(self.read_from(offset, bytes)) + } + + fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result { + self.write_to(offset, bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// `onestore::read_file`: a read here is whole, as nothing commits while it runs. +pub fn read_file(path: impl AsRef) -> io::Result> { + read(path) +} + +pub fn read_file_limited(path: impl AsRef, limit: usize) -> io::Result> { + let bytes = read(path)?; + if bytes.len() > limit { + return Err(ErrorKind::FileTooLarge.into()); + } + Ok(bytes) +} + +fn writable(path: impl AsRef) -> io::Result { + OpenOptions::new().read(true).write(true).open(path) +} + +fn not_committed(error: io::Error) -> CommitError { + CommitError { + state: CommitState::NotCommitted, + error, + } +} + +pub fn commit_file(transaction: &Transaction, path: impl AsRef) -> Result<(), CommitError> { + transaction.commit(&mut writable(path).map_err(not_committed)?) +} + +pub fn confirm_file(path: impl AsRef, base: &Stamp) -> Result<(), CommitError> { + onestore::confirm(&mut writable(path).map_err(not_committed)?, base) +} + +pub fn place_file(path: impl AsRef, ancestor: [u8; 16], name: &str) -> io::Result<()> { + onestore::place(&mut writable(path)?, ancestor, name) +} + +/// Puts the file at `with` in the place of the file at `path`, provided `path` still has +/// `base`'s stamp. +pub fn supersede_file( + path: impl AsRef, + base: &Stamp, + with: impl AsRef, +) -> Result<(), CommitError> { + base.check(&mut writable(&path).map_err(not_committed)?) + .map_err(not_committed)?; + rename(with, path).map_err(not_committed) +} diff --git a/crates/notebook/src/fs/web/sqlite.rs b/crates/notebook/src/fs/web/sqlite.rs new file mode 100644 index 0000000000000000000000000000000000000000..c8dae1c369229f2ccffc175e45d55f155fdee056 --- /dev/null +++ b/crates/notebook/src/fs/web/sqlite.rs @@ -0,0 +1,143 @@ +//! SQLite's default VFS in the browser: the files of `fs`, so that a replica, its write-ahead +//! log and the notebook it caches are kept and written out alike. +#![allow(unsafe_code)] + +use super::{FILES, Node, Shared, normal, touched, with}; +use rsqlite_vfs::{ + OsCallback, SQLiteIoMethods, SQLiteVfs, SQLiteVfsFile, VfsError, VfsFile, VfsResult, VfsStore, + ffi::{SQLITE_CANTOPEN, SQLITE_IOERR, SQLITE_IOERR_DELETE, sqlite3_vfs}, +}; +use std::{path::Path, rc::Rc, sync::Once, time::Duration}; + +/// Registers the VFS as SQLite's default, once. +pub(crate) fn install() { + static INSTALLED: Once = Once::new(); + INSTALLED.call_once(|| { + rsqlite_vfs::register_vfs::("snowbound", (), true) + .expect("SQLite takes the browser's VFS"); + }); +} + +struct File(Shared); + +impl VfsFile for File { + fn read(&self, output: &mut [u8], offset: usize) -> VfsResult { + let data = self.0.borrow(); + let rest = data.bytes.get(offset..).unwrap_or_default(); + let count = rest.len().min(output.len()); + output[..count].copy_from_slice(&rest[..count]); + output[count..].fill(0); + Ok(count == output.len()) + } + + fn write(&mut self, input: &[u8], offset: usize) -> VfsResult<()> { + let mut data = self.0.borrow_mut(); + let end = offset + input.len(); + if data.bytes.len() < end { + data.bytes.resize(end, 0); + } + data.bytes[offset..end].copy_from_slice(input); + touched(&mut data); + Ok(()) + } + + fn truncate(&mut self, size: usize) -> VfsResult<()> { + let mut data = self.0.borrow_mut(); + data.bytes.truncate(size); + touched(&mut data); + Ok(()) + } + + fn flush(&mut self) -> VfsResult<()> { + Ok(()) + } + + fn size(&self) -> VfsResult { + Ok(self.0.borrow().bytes.len()) + } +} + +fn error(code: i32, error: std::io::Error) -> VfsError { + VfsError::new(code, error.to_string()) +} + +struct Store; + +impl Store { + fn data(file: &SQLiteVfsFile) -> VfsResult { + // SAFETY: SQLite hands back the file `xOpen` named, whose name lives until `xClose`. + let name = unsafe { file.name() }; + with(|files| files.file(&normal(Path::new(name)))).map_err(|io| error(SQLITE_IOERR, io)) + } +} + +impl VfsStore for Store { + fn add_file(_: *mut sqlite3_vfs, name: &str, _: i32) -> VfsResult<()> { + // SQLite's own files beside a replica, such as its write-ahead log, need no folder made. + let path = normal(Path::new(name)); + with(|files| files.create(path)) + .map(drop) + .map_err(|io| error(SQLITE_CANTOPEN, io)) + } + + fn contains_file(_: *mut sqlite3_vfs, name: &str) -> VfsResult { + let path = normal(Path::new(name)); + Ok(FILES.with_borrow(|files| matches!(files.nodes.get(&path), Some(Node::File(_))))) + } + + fn delete_file(_: *mut sqlite3_vfs, name: &str) -> VfsResult<()> { + super::remove_file(name).map_err(|io| error(SQLITE_IOERR_DELETE, io)) + } + + fn with_file VfsResult>(file: &SQLiteVfsFile, act: F) -> VfsResult { + act(&File(Self::data(file)?)) + } + + fn with_file_mut VfsResult>( + file: &SQLiteVfsFile, + act: F, + ) -> VfsResult { + act(&mut File(Rc::clone(&Self::data(file)?))) + } +} + +struct Methods; + +impl SQLiteIoMethods for Methods { + type File = File; + type AppData = (); + type Store = Store; + + const VERSION: i32 = 1; +} + +struct Vfs; + +impl OsCallback for Vfs { + /// Nothing else holds a lock to wait for. + fn sleep(_: Duration) {} + + fn random(bytes: &mut [u8]) { + let _ = getrandom::fill(bytes); + } + + fn epoch_timestamp_in_ms() -> i64 { + js_sys::Date::now() as i64 + } +} + +impl SQLiteVfs for Vfs { + const VERSION: i32 = 1; + + fn sleep(duration: Duration) { + ::sleep(duration) + } + + fn random(bytes: &mut [u8]) { + ::random(bytes) + } + + fn epoch_timestamp_in_ms() -> i64 { + ::epoch_timestamp_in_ms() + } +} diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index 5545185fe49a4c7a2b3f711e91e43ed80f6aa827..49ddb44369cede88a5564f65a1d04c1abfd27794 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -3,9 +3,11 @@ #![doc = include_str!("../README.md")] pub mod discover; +pub mod fs; #[cfg(feature = "smb")] pub mod smb; +use fs::OpenOptions; use onestore::{ ExGuid, op::{Edit, OpError}, @@ -14,7 +16,6 @@ use onestore::{ }; use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; use std::{ - fs::OpenOptions, io, path::{Path, PathBuf}, sync::{Arc, Mutex, MutexGuard, mpsc}, @@ -38,6 +39,7 @@ pub use recovery::{Recovery, RecoverySummary}; pub mod sidecar; mod sync; pub use sync::{EditStatus, Remote, Synced}; +mod task; mod worker; mod working; #[cfg(feature = "smb")] @@ -137,7 +139,7 @@ impl Replica { source: impl FnOnce() -> Result>, ) -> Result { let path = path.as_ref(); - if !path.exists() { + if fs::metadata(path).is_err() { match Self::seed(path, &source()?, key) { Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {} seeded => seeded?, @@ -154,14 +156,14 @@ impl Replica { static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let build = BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mut building = path.as_os_str().to_owned(); - building.push(format!(".creating-{}-{build}", std::process::id())); + building.push(format!(".creating-{}-{build}", fs::process_id())); let building = PathBuf::from(building); let built = Self::build(&building, source); - let linked = built.and_then(|()| Ok(std::fs::hard_link(&building, path)?)); + let linked = built.and_then(|()| Ok(fs::hard_link(&building, path)?)); for suffix in ["", "-wal", "-shm"] { let mut file = building.as_os_str().to_owned(); file.push(suffix); - let _ = std::fs::remove_file(file); + let _ = fs::remove_file(file); } linked } @@ -463,6 +465,8 @@ fn signed(value: u64) -> Result { /// Opens a cache without writing to it: exclusive locking, a full sync of every commit /// (`F_FULLFSYNC` on macOS, the WAL's syncs included) and foreign keys, each queried back. fn cache_connection(path: &Path) -> Result { + #[cfg(target_arch = "wasm32")] + fs::install_sqlite(); let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; connection.busy_timeout(Duration::ZERO)?; connection.execute_batch( @@ -512,8 +516,8 @@ fn write_ahead(connection: &Connection) -> Result<()> { pub(crate) fn now() -> u64 { use std::sync::atomic::{AtomicU64, Ordering}; static LAST: AtomicU64 = AtomicU64::new(0); - let unix = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unix = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .unwrap_or_default(); let clock = (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100); diff --git a/crates/notebook/src/location.rs b/crates/notebook/src/location.rs index 7e3cc33e0113b0a7ba86f88f7ea1bdbf9731c788..b8d1278eaab9910d4b45d3e1441f5e143e1ff49d 100644 --- a/crates/notebook/src/location.rs +++ b/crates/notebook/src/location.rs @@ -6,6 +6,7 @@ //! A location is a string naming where a notebook or lone section is reached: the canonical //! path of a local folder or file (`local`), or `smb` for one on a share. +use crate::fs; use onestore::{Header, Stamp}; use std::{ io, @@ -26,11 +27,10 @@ pub fn folder(cache: &Path, location: &str) -> PathBuf { /// its canonical path, or its folder's with its name. pub fn local(path: &Path) -> io::Result { let canonical = - path.canonicalize() - .or_else(|error| match (path.parent(), path.file_name()) { - (Some(parent), Some(name)) => Ok(parent.canonicalize()?.join(name)), - _ => Err(error), - })?; + fs::canonicalize(path).or_else(|error| match (path.parent(), path.file_name()) { + (Some(parent), Some(name)) => Ok(fs::canonicalize(parent)?.join(name)), + _ => Err(error), + })?; Ok(canonical.to_string_lossy().into_owned()) } @@ -46,13 +46,15 @@ pub fn smb(address: &str, share: &str, root: &str) -> String { /// must be closed. pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> { let old = folder(cache, from); - if !old.exists() { + if fs::metadata(&old).is_err() { return Ok(()); } let new = claimed(cache, to)?; - for entry in std::fs::read_dir(&old)? { + for entry in fs::read_dir(&old)? { let name = entry?.file_name(); - if Path::new(&name).extension() == Some("sqlite".as_ref()) && !new.join(&name).exists() { + if Path::new(&name).extension() == Some("sqlite".as_ref()) + && fs::metadata(new.join(&name)).is_err() + { adopt(&old.join(&name), &new.join(&name))?; } } @@ -64,9 +66,9 @@ pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> { fn claimed(cache: &Path, location: &str) -> io::Result { let folder = folder(cache, location); let label = folder.join(LOCATION); - if !label.exists() { - std::fs::create_dir_all(&folder)?; - std::fs::write(&label, location)?; + if fs::metadata(&label).is_err() { + fs::create_dir_all(&folder)?; + fs::write(&label, location)?; } Ok(folder) } @@ -93,7 +95,7 @@ pub(crate) fn claim( let folder = claimed(cache, location)?; let missing: Vec<&[u8; 16]> = sections .iter() - .filter(|identity| !crate::session::replica_file(&folder, identity).exists()) + .filter(|identity| fs::metadata(crate::session::replica_file(&folder, identity)).is_err()) .collect(); if missing.is_empty() { return Ok(folder); @@ -107,7 +109,7 @@ pub(crate) fn claim( .iter() .map(|orphan| (crate::session::replica_file(orphan, identity), false)); for (replica, legacy) in legacy.chain(left) { - if !replica.exists() { + if fs::metadata(&replica).is_err() { continue; } let adoptable = match crate::closed(&replica).and_then(|held| crate::peek(&held)) { @@ -132,15 +134,15 @@ pub(crate) fn claim( /// The replica folders of local locations that no longer exist. fn orphans(cache: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(cache.join("replicas")) else { + let Ok(entries) = fs::read_dir(cache.join("replicas")) else { return Vec::new(); }; entries .filter_map(|entry| { let folder = entry.ok()?.path(); - let location = std::fs::read_to_string(folder.join(LOCATION)).ok()?; + let location = fs::read_to_string(folder.join(LOCATION)).ok()?; let path = Path::new(&location); - (path.is_absolute() && !path.exists()).then_some(folder) + (path.is_absolute() && fs::metadata(path).is_err()).then_some(folder) }) .collect() } @@ -160,7 +162,7 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> { let (mut source, mut target) = (from.as_os_str().to_owned(), to.as_os_str().to_owned()); source.push(suffix); target.push(suffix); - match std::fs::rename(&source, &target) { + match fs::rename(&source, &target) { Err(error) if !suffix.is_empty() && error.kind() == io::ErrorKind::NotFound => {} moved => moved?, } @@ -170,12 +172,12 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> { /// Removes a replica folder that holds no replica. fn tidy(folder: &Path) { - let empty = std::fs::read_dir(folder).is_ok_and(|mut entries| { + let empty = fs::read_dir(folder).is_ok_and(|mut entries| { entries.all(|entry| entry.is_ok_and(|entry| entry.file_name() == LOCATION)) }); if empty { - let _ = std::fs::remove_file(folder.join(LOCATION)); - let _ = std::fs::remove_dir(folder); + let _ = fs::remove_file(folder.join(LOCATION)); + let _ = fs::remove_dir(folder); } } diff --git a/crates/notebook/src/package.rs b/crates/notebook/src/package.rs index 62c3f3b9c332e3d785e6b6ee3be644ddac871aac..7244b200e182fb80aeae3164c941bcec4cb637e8 100644 --- a/crates/notebook/src/package.rs +++ b/crates/notebook/src/package.rs @@ -7,7 +7,7 @@ //! MSZIP, which every cabinet reader takes, and reads either. use crate::{ - Error, Result, + Error, Result, fs, session::{Notebook, lists, moved}, }; use onestore::{TocEdit, op::Edit, page::Page}; @@ -196,13 +196,13 @@ pub fn unpack(mut files: Files, root: &Path) -> Result<()> { transaction.apply(image)?; } } - std::fs::create_dir(root)?; + fs::create_dir(root)?; for (path, bytes) in &files { let file = root.join(path); if let Some(parent) = file.parent() { - std::fs::create_dir_all(parent)?; + fs::create_dir_all(parent)?; } - std::fs::File::create_new(file)?.write_all(bytes)?; + fs::File::create_new(file)?.write_all(bytes)?; } Ok(()) } diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 4cbc83aeb2754d5185aeae814144aab200182590..90e4cda27b0385cd206331fad3d25ea8d799d67c 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -3,7 +3,7 @@ pub use crate::background::{Background, Known}; use crate::{ - EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, + EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs, }; use onestore::{ CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction, @@ -132,17 +132,17 @@ impl Storage for Directory { } fn exists(&self, path: &str) -> bool { - self.path(path).exists() + fs::metadata(self.path(path)).is_ok() } fn read(&self, path: &str) -> Result> { - Ok(onestore::read_file(self.path(path))?) + Ok(fs::read_file(self.path(path))?) } fn read_file(&self, path: &str, limit: usize) -> Result> { use std::io::Read; let mut bytes = Vec::new(); - std::fs::File::open(self.path(path))? + fs::File::open(self.path(path))? .take(limit as u64 + 1) .read_to_end(&mut bytes)?; if bytes.len() > limit { @@ -153,15 +153,16 @@ impl Storage for Directory { fn create(&self, path: &str, bytes: &[u8]) -> Result<()> { let path = self.path(path); - if discover::placeholder(&path).is_some_and(|placeholder| placeholder.exists()) { + if discover::placeholder(&path).is_some_and(|placeholder| fs::metadata(placeholder).is_ok()) + { return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()); } - std::fs::File::create_new(path)?.write_all(bytes)?; + fs::File::create_new(path)?.write_all(bytes)?; Ok(()) } fn create_directory(&self, path: &str) -> Result<()> { - Ok(std::fs::create_dir(self.path(path))?) + Ok(fs::create_dir(self.path(path))?) } /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted; @@ -207,37 +208,33 @@ impl Storage for Directory { } fn rename(&self, from: &str, to: &str) -> Result<()> { - Ok(std::fs::rename(self.path(from), self.path(to))?) + Ok(fs::rename(self.path(from), self.path(to))?) } fn replace(&self, from: &str, to: &str) -> Result<()> { - Ok(std::fs::rename(self.path(from), self.path(to))?) + Ok(fs::rename(self.path(from), self.path(to))?) } fn delete(&self, path: &str) -> Result<()> { let path = self.path(path); - if path.is_dir() { - std::fs::remove_dir(path)?; + if fs::metadata(&path).is_ok_and(|metadata| metadata.is_dir()) { + fs::remove_dir(path)?; } else { - std::fs::remove_file(path)?; + fs::remove_file(path)?; } Ok(()) } fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> { - Ok(onestore::place_file(self.path(path), ancestor, name)?) + Ok(fs::place_file(self.path(path), ancestor, name)?) } fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { - Ok(transaction.commit_file(self.path(path))?) + Ok(fs::commit_file(transaction, self.path(path))?) } fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> { - Ok(onestore::supersede_file( - self.path(path), - base, - self.path(with), - )?) + Ok(fs::supersede_file(self.path(path), base, self.path(with))?) } } @@ -363,7 +360,7 @@ impl Notebook { pub const NEW_COLOR: u32 = 0x00aeba91; pub fn open(root: impl AsRef, cache: impl AsRef) -> Result { - let root = root.as_ref().canonicalize()?; + let root = fs::canonicalize(root)?; Self::with(Box::new(Directory(root.clone())), Some(root), cache) } @@ -376,7 +373,7 @@ impl Notebook { color: u32, page: &PageCreation, ) -> Result { - std::fs::create_dir(root.as_ref())?; + fs::create_dir(root.as_ref())?; let mut notebook = Self::open(root, cache)?; notebook.edit_toc("", &[onestore::TocEdit::Color(color)])?; notebook.refresh()?; @@ -412,13 +409,13 @@ impl Notebook { ) -> Result { let cache = cache.as_ref().to_path_buf(); let listings = cache.join("listings"); - std::fs::create_dir_all(&listings)?; + fs::create_dir_all(&listings)?; let name: String = ::digest(storage.location())[..16] .iter() .map(|byte| format!("{byte:02x}")) .collect(); let listing = listings.join(format!("{name}.json")); - let mut read = std::fs::read(&listing) + let mut read = fs::read(&listing) .ok() .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default(); @@ -1271,7 +1268,7 @@ impl Notebook { let folders: BTreeSet<&PathBuf> = placed.iter().map(|(.., folder)| folder).collect(); let mut superseded = Vec::new(); for folder in folders { - let Ok(entries) = std::fs::read_dir(folder) else { + let Ok(entries) = fs::read_dir(folder) else { continue; }; for path in entries.filter_map(|entry| Some(entry.ok()?.path())) { @@ -1337,8 +1334,8 @@ impl Notebook { let section = self.section_path(path)?; let replica = self.replica_path(path)?; // Held until the file is replaced, so that no session queues edits to the old file. - let held = replica - .exists() + let held = fs::metadata(&replica) + .is_ok() .then(|| crate::closed(&replica)) .transpose()?; if let Some(held) = &held @@ -1525,7 +1522,7 @@ impl Notebook { .into()); }; // A section replaced by a link out of the notebook is not the catalog's section. - let file = root.join(path).canonicalize()?; + let file = fs::canonicalize(root.join(path))?; if !file.starts_with(root) { return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); } @@ -1533,14 +1530,14 @@ impl Notebook { // worker checks next, as a share's does. if let discover::SectionState::Readable { document, .. } = §ion.state { let cache = replica_file(&replicas, document); - if cache.exists() { + if fs::metadata(&cache).is_ok() { let (replica, remote, mut connect) = (Replica::open(&cache)?, file.clone(), connect); return Section::start(file, replica, move || connect(&remote), notify); } } let replica = |identity: &[u8; 16], _: &[u8]| { - std::fs::create_dir_all(&replicas)?; + fs::create_dir_all(&replicas)?; Ok(replica_file(&replicas, identity)) }; Section::open_in(file, key, replica, connect, notify) @@ -1712,7 +1709,7 @@ fn remove_replica(replica: &Path) -> io::Result<()> { for suffix in ["", "-wal", "-shm"] { let mut file = replica.as_os_str().to_owned(); file.push(suffix); - match std::fs::remove_file(file) { + match fs::remove_file(file) { Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error), _ => {} } @@ -1839,7 +1836,7 @@ impl Section { connect: impl FnMut(&Path) -> io::Result + Send + 'static, notify: impl Fn() + Send + 'static, ) -> Result { - let file = file.as_ref().canonicalize()?; + let file = fs::canonicalize(file)?; let location = file.to_string_lossy().into_owned(); let replicas = |identity: &[u8; 16], source: &[u8]| { let folder = crate::location::claim(cache.as_ref(), &location, &[*identity], |_| { @@ -1875,7 +1872,7 @@ impl Section { replica: Replica, notify: impl Fn() + Send + 'static, ) -> Result { - let file = std::path::absolute(file)?; + let file = fs::absolute(file)?; let remote = file.clone(); Self::start( file, @@ -2200,14 +2197,14 @@ struct FileRemote(PathBuf); impl Remote for FileRemote { fn read(&mut self) -> io::Result> { - onestore::read_file(&self.0) + fs::read_file(&self.0) } /// Read without the whole-file lock, which would block OneNote's readers: a change /// detector, never a snapshot to edit. fn stamp(&mut self) -> io::Result { use std::io::Read; - let mut file = std::fs::File::open(&self.0)?; + let mut file = fs::File::open(&self.0)?; let mut header = [0; 1024]; file.read_exact(&mut header)?; let length = file.metadata()?.len(); @@ -2215,11 +2212,11 @@ impl Remote for FileRemote { } fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> { - transaction.commit_file(&self.0) + fs::commit_file(transaction, &self.0) } fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> { - onestore::confirm_file(&self.0, base) + fs::confirm_file(&self.0, base) } } diff --git a/crates/notebook/src/sidecar.rs b/crates/notebook/src/sidecar.rs index 22aa7df13fe6b4147bde092b5bb34c2d0e2afa69..c9a43c719d12b55373eeaf33e31daaefa2c49890 100644 --- a/crates/notebook/src/sidecar.rs +++ b/crates/notebook/src/sidecar.rs @@ -158,7 +158,7 @@ pub(crate) fn map( /// A name beside `path` no other writer picks. fn temporary(path: &str) -> String { use std::hash::{BuildHasher, RandomState}; - let unique = RandomState::new().hash_one((std::process::id(), crate::now())); + let unique = RandomState::new().hash_one((crate::fs::process_id(), crate::now())); format!("{path}.{unique:016x}.tmp") } diff --git a/crates/notebook/src/task.rs b/crates/notebook/src/task.rs new file mode 100644 index 0000000000000000000000000000000000000000..2ff7bc01631e04d97d0569ad8b5c451524004d97 --- /dev/null +++ b/crates/notebook/src/task.rs @@ -0,0 +1,159 @@ +//! The sync worker's and background's loops: threads natively, and in the browser, which +//! gives a module one thread, tasks on its event loop. A loop is an `async` block whose only +//! waits are `wait`; natively each wait blocks its thread, so `complete` runs the block to the +//! end in one poll. + +use std::{io, time::Duration}; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use std::{ + sync::mpsc::{Receiver, SyncSender as Sender}, + thread::JoinHandle, +}; + +/// A wake that waits, at most one, as `mpsc::sync_channel(1)` keeps one. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn channel() -> (Sender<()>, Receiver<()>) { + std::sync::mpsc::sync_channel(1) +} + +/// Waits for a wake or `timeout`, without one for ever. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn wait(receiver: &Receiver<()>, timeout: Option) { + let _ = match timeout { + Some(timeout) => receiver.recv_timeout(timeout).ok(), + None => receiver.recv().ok(), + }; +} + +/// Runs the loop `start` makes, whose waits block, on a thread of its own named `name`. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn spawn>( + name: &str, + start: impl FnOnce() -> F + Send + 'static, +) -> io::Result> { + std::thread::Builder::new() + .name(name.into()) + .spawn(move || complete(start())) +} + +/// `work`'s output, where nothing it awaits is pending. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn complete(work: impl Future) -> T { + let mut work = std::pin::pin!(work); + match work + .as_mut() + .poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) + { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => unreachable!("Native waits block rather than pend"), + } +} + +#[cfg(target_arch = "wasm32")] +pub(crate) use web::*; + +#[cfg(target_arch = "wasm32")] +mod web { + use super::*; + use std::{ + sync::{Arc, Mutex}, + task::{Poll, Waker}, + }; + use wasm_bindgen::{JsCast, prelude::*}; + + #[derive(Default)] + struct Bell { + rung: bool, + waiting: Option, + } + + pub(crate) struct Sender(Arc>, std::marker::PhantomData); + pub(crate) struct Receiver(Arc>, std::marker::PhantomData); + + pub(crate) fn channel() -> (Sender<()>, Receiver<()>) { + let bell = Arc::new(Mutex::new(Bell::default())); + ( + Sender(Arc::clone(&bell), Default::default()), + Receiver(bell, Default::default()), + ) + } + + impl Sender<()> { + pub(crate) fn try_send(&self, (): ()) -> Result<(), ()> { + let waiting = { + let mut bell = self.0.lock().map_err(|_| ())?; + bell.rung = true; + bell.waiting.take() + }; + if let Some(waiting) = waiting { + waiting.wake(); + } + Ok(()) + } + } + + /// Wakes `waker` after `delay` through the global scope's `setTimeout`. + fn after(delay: Duration, waker: Waker) { + let global = js_sys::global(); + let Ok(set_timeout) = js_sys::Reflect::get(&global, &"setTimeout".into()) + .and_then(|function| function.dyn_into::()) + else { + return waker.wake(); + }; + let wake = Closure::once_into_js(move || waker.wake()); + let _ = set_timeout.call2(&global, &wake, &(delay.as_secs_f64() * 1e3).into()); + } + + pub(crate) async fn wait(receiver: &Receiver<()>, timeout: Option) { + let deadline = timeout.map(|timeout| web_time::Instant::now() + timeout); + let mut armed = false; + std::future::poll_fn(|context| { + let Ok(mut bell) = receiver.0.lock() else { + return Poll::Ready(()); + }; + if std::mem::take(&mut bell.rung) + || deadline.is_some_and(|deadline| deadline <= web_time::Instant::now()) + { + return Poll::Ready(()); + } + bell.waiting = Some(context.waker().clone()); + if let Some(deadline) = deadline + && !std::mem::replace(&mut armed, true) + { + after( + deadline.saturating_duration_since(web_time::Instant::now()), + context.waker().clone(), + ); + } + Poll::Pending + }) + .await + } + + /// A task the browser's event loop runs. It cannot be waited for, and ends on its own. + pub(crate) struct JoinHandle(Arc>>); + + impl JoinHandle { + /// What the task ended with, if it has ended. + pub(crate) fn finished(self) -> Option { + self.0.lock().ok()?.take() + } + } + + pub(crate) fn spawn + 'static>( + _: &str, + start: impl FnOnce() -> F, + ) -> io::Result> { + let output = Arc::new(Mutex::new(None)); + let ended = Arc::clone(&output); + let work = start(); + wasm_bindgen_futures::spawn_local(async move { + let result = work.await; + if let Ok(mut ended) = ended.lock() { + *ended = Some(result); + } + }); + Ok(JoinHandle(output)) + } +} diff --git a/crates/notebook/src/worker.rs b/crates/notebook/src/worker.rs index be6537a1ab685878d74084c4888379d483db9363..e558ade6c555118ea434bba59d8e13435b0922a2 100644 --- a/crates/notebook/src/worker.rs +++ b/crates/notebook/src/worker.rs @@ -1,15 +1,14 @@ use super::*; +use crate::task::{self, JoinHandle}; use std::{ collections::hash_map::RandomState, hash::BuildHasher, sync::{ Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}, - mpsc::{self, SyncSender}, }, - thread::{self, JoinHandle}, - time::Instant, }; +use web_time::Instant; pub(super) struct Signal { pub(crate) stopped: AtomicBool, @@ -22,7 +21,7 @@ pub(super) struct Signal { /// waits for that instead of checking the file on its own (`Background::hold`). pub(crate) watched: AtomicBool, pause: Mutex, - sender: SyncSender<()>, + sender: task::Sender<()>, } /// How local edits wait before they publish (`SyncWorker::set_pause`). @@ -37,8 +36,8 @@ struct Pause { const LONGEST: Duration = Duration::from_secs(30); impl Signal { - pub(crate) fn new() -> (Arc, mpsc::Receiver<()>) { - let (sender, receiver) = mpsc::sync_channel(1); + pub(crate) fn new() -> (Arc, task::Receiver<()>) { + let (sender, receiver) = task::channel(); let signal = Self { stopped: AtomicBool::new(false), offline: AtomicBool::new(false), @@ -123,9 +122,12 @@ impl SyncWorker { pub fn stop(mut self) -> Result<()> { self.signal.stopped.store(true, Ordering::Release); self.signal.wake(); - self.thread - .take() - .expect("Worker owns its thread") + let thread = self.thread.take().expect("Worker owns its thread"); + // In the browser no step is in flight outside the worker's own callback. + #[cfg(target_arch = "wasm32")] + return thread.finished().unwrap_or(Ok(())); + #[cfg(not(target_arch = "wasm32"))] + thread .join() .map_err(|_| io::Error::other("Synchronization worker panicked"))? } @@ -176,118 +178,115 @@ impl Replica { let (signal, receiver) = Signal::new(); let replica = Arc::clone(self); let worker_signal = Arc::clone(&signal); - let thread = thread::Builder::new() - .name("onestore-sync".into()) - .spawn(move || { - let mut remote: Option = None; - let jitter = RandomState::new(); - let mut contention = 0_u32; - // The first step, and the first after a failure, always runs, so `observe` - // hears that the remote is reachable and what state the queue is in. - let mut reported = false; - // With nothing to do: a watched worker waits for the watch, any other looks - // again after `interval`. - let rest = || { - if worker_signal.watched.load(Ordering::Acquire) { - let _ = receiver.recv(); - } else { - let _ = receiver.recv_timeout(interval); + let thread = task::spawn("onestore-sync", move || async move { + let mut remote: Option = None; + let jitter = RandomState::new(); + let mut contention = 0_u32; + // The first step, and the first after a failure, always runs, so `observe` + // hears that the remote is reachable and what state the queue is in. + let mut reported = false; + // With nothing to do: a watched worker waits for the watch, any other looks + // again after `interval`. + let rest = || { + task::wait( + &receiver, + (!worker_signal.watched.load(Ordering::Acquire)).then_some(interval), + ) + }; + while !worker_signal.stopped.load(Ordering::Acquire) { + if worker_signal.offline.load(Ordering::Acquire) + && !worker_signal.requested.load(Ordering::Acquire) + { + remote = None; + reported = false; + task::wait(&receiver, None).await; + continue; + } + if !worker_signal.requested.load(Ordering::Acquire) + && let Some(wait) = worker_signal.paused() + { + task::wait(&receiver, Some(wait)).await; + continue; + } + if let Ok(mut pause) = worker_signal.pause.lock() { + // The step publishes what waits; edits from now on wait anew. + pause.waiting = None; + } + let result = match remote.as_mut() { + Some(remote) => { + if reported && replica.settled(remote).unwrap_or(false) { + worker_signal.requested.store(false, Ordering::Release); + worker_signal.synced.store(crate::now(), Ordering::Release); + rest().await; + continue; + } + let result = replica.sync_once(remote); + reported = result.is_ok(); + result } + None => match connect() { + Ok(connected) => { + remote = Some(connected); + continue; + } + Err(error) => Err(Error::RemoteIo(error)), + }, }; - while !worker_signal.stopped.load(Ordering::Acquire) { - if worker_signal.offline.load(Ordering::Acquire) - && !worker_signal.requested.load(Ordering::Acquire) - { - remote = None; - reported = false; - let _ = receiver.recv(); + if result.is_ok() { + worker_signal.synced.store(crate::now(), Ordering::Release); + } + observe(&result); + let idle = matches!(result, Ok(Synced { edit: None, .. })); + match result { + Ok(Synced { + edit: Some((_, EditStatus::Published { .. })), + .. + }) => { + contention = 0; continue; } - if !worker_signal.requested.load(Ordering::Acquire) - && let Some(wait) = worker_signal.paused() + Ok(Synced { edit: None, .. }) => contention = 0, + Ok(_) => {} + Err(Error::Remote(onestore::CommitError { + state: onestore::CommitState::NotCommitted, + ref error, + })) if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy + ) => { - let _ = receiver.recv_timeout(wait); - continue; - } - if let Ok(mut pause) = worker_signal.pause.lock() { - // The step publishes what waits; edits from now on wait anew. - pause.waiting = None; - } - let result = match remote.as_mut() { - Some(remote) => { - if reported && replica.settled(remote).unwrap_or(false) { - worker_signal.requested.store(false, Ordering::Release); - worker_signal.synced.store(crate::now(), Ordering::Release); - rest(); - continue; - } - let result = replica.sync_once(remote); - reported = result.is_ok(); - result + contention = contention.saturating_add(1); + let ceiling = (50_u64 << contention.min(5)).min(1000); + let until = Instant::now() + + Duration::from_millis(jitter.hash_one(contention) % ceiling); + // Local wakes must not keep competing writers in the same retry phase. + while !worker_signal.stopped.load(Ordering::Acquire) { + let Some(remaining) = until.checked_duration_since(Instant::now()) + else { + break; + }; + task::wait(&receiver, Some(remaining)).await; } - None => match connect() { - Ok(connected) => { - remote = Some(connected); - continue; - } - Err(error) => Err(Error::RemoteIo(error)), - }, - }; - if result.is_ok() { - worker_signal.synced.store(crate::now(), Ordering::Release); + continue; } - observe(&result); - let idle = matches!(result, Ok(Synced { edit: None, .. })); - match result { - Ok(Synced { - edit: Some((_, EditStatus::Published { .. })), - .. - }) => { - contention = 0; - continue; - } - Ok(Synced { edit: None, .. }) => contention = 0, - Ok(_) => {} - Err(Error::Remote(onestore::CommitError { - state: onestore::CommitState::NotCommitted, - ref error, - })) if matches!( + Err(Error::RemoteIo(ref error)) + if matches!( error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy - ) => - { - contention = contention.saturating_add(1); - let ceiling = (50_u64 << contention.min(5)).min(1000); - let until = Instant::now() - + Duration::from_millis(jitter.hash_one(contention) % ceiling); - // Local wakes must not keep competing writers in the same retry phase. - while !worker_signal.stopped.load(Ordering::Acquire) { - let Some(remaining) = until.checked_duration_since(Instant::now()) - else { - break; - }; - let _ = receiver.recv_timeout(remaining); - } - continue; - } - Err(Error::RemoteIo(ref error)) - if matches!( - error.kind(), - io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy - ) => {} - Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None, - Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {} - Err(error) => return Err(error), - } - worker_signal.requested.store(false, Ordering::Release); - if idle { - rest(); - } else if !worker_signal.stopped.load(Ordering::Acquire) { - let _ = receiver.recv_timeout(interval); - } + ) => {} + Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None, + Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => return Err(error), } - Ok(()) - })?; + worker_signal.requested.store(false, Ordering::Release); + if idle { + rest().await; + } else if !worker_signal.stopped.load(Ordering::Acquire) { + task::wait(&receiver, Some(interval)).await; + } + } + Ok(()) + })?; *owner = Arc::downgrade(&signal); Ok(SyncWorker { signal, diff --git a/crates/notebook/src/working.rs b/crates/notebook/src/working.rs index 0ccb3fbeea69fc76e86c6df393900e63e9f3259c..521a9c44c388a32036dc715472de2836d26ba29d 100644 --- a/crates/notebook/src/working.rs +++ b/crates/notebook/src/working.rs @@ -1,7 +1,8 @@ //! The section thread: the only owner of the parsed section, which is the cached base //! image with each sealed batch replayed and the open batch's edits applied. Edits apply //! here and are written in one SQLite transaction per burst; the sync thread asks it to seal -//! and, when the remote changed, to rebase the queue. +//! and, when the remote changed, to rebase the queue. In the browser, which has one thread, +//! the section is served as each request is sent, and a rebuild runs as it is asked for. use crate::{Result, base, lock, merge, queue, worker::Signal}; use onestore::{ @@ -15,7 +16,6 @@ use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, io, sync::{Arc, Mutex, Weak, mpsc}, - thread, }; pub(crate) type Reply = Box) + Send>; @@ -64,8 +64,10 @@ pub(crate) enum Request { reply: Reply<()>, }, /// From the thread that rebuilt the section: hand it the requests. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] Handover(mpsc::SyncSender), /// From the thread that rebuilt nothing: carry on with the section as it is. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] Resume, } @@ -107,7 +109,8 @@ pub(crate) struct Thread { pub(crate) worker: Mutex>, /// Taken when the replica drops, which ends every section thread. sender: Mutex>>, - threads: Mutex>>, + #[cfg(not(target_arch = "wasm32"))] + threads: Mutex>>, /// A password-protected section's key, under which its queue is sealed too. pub(crate) key: Option, } @@ -118,7 +121,10 @@ impl Thread { .lock() .ok() .and_then(|sender| sender.as_ref()?.send(request).ok()) - .ok_or_else(|| io::Error::other("The section thread stopped").into()) + .ok_or_else(|| io::Error::other("The section thread stopped"))?; + #[cfg(target_arch = "wasm32")] + serving::serve(self); + Ok(()) } /// Ends the section threads and waits for them, so the cache is released. @@ -126,6 +132,9 @@ impl Thread { if let Ok(mut sender) = self.sender.lock() { sender.take(); } + #[cfg(target_arch = "wasm32")] + serving::serve(self); + #[cfg(not(target_arch = "wasm32"))] while let Some(thread) = self .threads .lock() @@ -136,17 +145,81 @@ impl Thread { } } - fn start(self: &Arc, run: impl FnOnce(Arc) + Send + 'static) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] + fn start>( + self: &Arc, + run: impl FnOnce(Arc) -> F + Send + 'static, + ) -> Result<()> { let shared = Arc::clone(self); - let thread = thread::Builder::new() + let thread = std::thread::Builder::new() .name("onestore-section".into()) - .spawn(move || run(shared))?; + .spawn(move || crate::task::complete(run(shared)))?; self.threads .lock() .map_err(|_| io::Error::other("The section thread panicked"))? .push(thread); Ok(()) } + + #[cfg(target_arch = "wasm32")] + fn start + 'static>( + self: &Arc, + run: impl FnOnce(Arc) -> F, + ) -> Result<()> { + serving::start(self, Box::pin(run(Arc::clone(self)))); + Ok(()) + } +} + +/// The browser's section threads: each replica's `run`, polled whenever a request is sent +/// until it waits for the next. +#[cfg(target_arch = "wasm32")] +mod serving { + use super::Thread; + use std::{cell::RefCell, collections::BTreeMap, future::Future, pin::Pin, task}; + + type Serving = Pin>>; + + thread_local! { + /// By the address of each replica's `Thread`, which its `run` keeps alive. + static SERVING: RefCell> = const { RefCell::new(BTreeMap::new()) }; + } + + pub(super) fn start(thread: &Thread, run: Serving) { + SERVING.with_borrow_mut(|serving| serving.insert(std::ptr::from_ref(thread).addr(), run)); + serve(thread); + } + + /// Serves what `thread` was sent. A request sent while it is being served, as from a + /// reply, waits for the serving already under way, which takes it next. + pub(super) fn serve(thread: &Thread) { + let id = std::ptr::from_ref(thread).addr(); + let Some(mut run) = SERVING.with_borrow_mut(|serving| serving.remove(&id)) else { + return; + }; + let mut context = task::Context::from_waker(task::Waker::noop()); + if run.as_mut().poll(&mut context).is_pending() { + SERVING.with_borrow_mut(|serving| serving.insert(id, run)); + } + } +} + +/// The next request, or none once the replica dropped. +#[cfg(not(target_arch = "wasm32"))] +async fn receive(requests: &mpsc::Receiver) -> Option { + requests.recv().ok() +} + +/// The next request, waiting for `serving::serve` where none is queued, or none once the +/// replica dropped. +#[cfg(target_arch = "wasm32")] +async fn receive(requests: &mpsc::Receiver) -> Option { + std::future::poll_fn(|_| match requests.try_recv() { + Ok(request) => std::task::Poll::Ready(Some(request)), + Err(mpsc::TryRecvError::Disconnected) => std::task::Poll::Ready(None), + Err(mpsc::TryRecvError::Empty) => std::task::Poll::Pending, + }) + .await } /// Starts the section thread once the queue opens. @@ -156,6 +229,7 @@ pub(crate) fn spawn(connection: Connection, key: Option) -> Result<(Arc, VecDeque), } -fn run( +async fn run( shared: Arc, requests: mpsc::Receiver, mut backlog: VecDeque, @@ -202,7 +276,7 @@ fn run( if let Some(ready) = ready.take() { let _ = ready.send(Ok(working.section.root())); } - match working.serve(&shared, &requests, &mut backlog) { + match working.serve(&shared, &requests, &mut backlog).await { Next::Stop => return, Next::Reopen => {} Next::Handover(to, held) => { @@ -222,6 +296,7 @@ fn fail(error: crate::Error, backlog: VecDeque, requests: mpsc::Receive } /// What a rebuilding thread does before it rereads the section. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] enum Job { Rebase { image: Option>, @@ -234,6 +309,7 @@ enum Job { /// Runs `job`, rereads the section and takes the requests over from the thread that /// started it; a failed rebase changed nothing that thread serves. +#[cfg(not(target_arch = "wasm32"))] fn build(shared: Arc, job: Job, signal: mpsc::Sender) { let answer: Box = match job { Job::Reopen { reply } => Box::new(move || reply(Ok(()))), @@ -263,15 +339,34 @@ fn build(shared: Arc, job: Job, signal: mpsc::Sender) { Ok(working) => working, Err(error) => return fail(error, backlog, requests), }; - match working.serve(&shared, &requests, &mut backlog) { + match crate::task::complete(working.serve(&shared, &requests, &mut backlog)) { Next::Stop => {} - Next::Reopen => run(shared, requests, backlog, None), + Next::Reopen => crate::task::complete(run(shared, requests, backlog, None)), Next::Handover(to, held) => { let _ = to.send((requests, held)); } } } +/// `build` where there is one thread: runs `job` before the section is reread, which the +/// serving thread does once true is returned; a failed rebase changed nothing it serves. +#[cfg(target_arch = "wasm32")] +fn inline(shared: &Thread, job: Job) -> bool { + match job { + Job::Reopen { reply } => reply(Ok(())), + Job::Rebase { image, reply } => { + match rebase(&shared.connection, image, shared.key.as_ref()) { + Ok(changed) => reply(Ok(changed)), + Err(error) => { + reply(Err(error)); + return false; + } + } + } + } + true +} + /// An edit applied to the section and not yet written, with who waits for it. struct Accepted { author: String, @@ -297,7 +392,7 @@ impl<'a> Working<'a> { }) } - fn serve( + async fn serve( mut self, shared: &Arc, requests: &mpsc::Receiver, @@ -310,9 +405,9 @@ impl<'a> Working<'a> { loop { let first = match backlog.pop_front() { Some(request) => request, - None => match requests.recv() { - Ok(request) => request, - Err(_) => return Next::Stop, + None => match receive(requests).await { + Some(request) => request, + None => return Next::Stop, }, }; let mut burst: VecDeque = VecDeque::from([first]); @@ -415,23 +510,35 @@ impl<'a> Working<'a> { } Request::Rebase { image, reply } => { if self.flush(shared, &mut accepted) { + #[cfg(target_arch = "wasm32")] + let reopen = inline(shared, Job::Rebase { image, reply }); + #[cfg(not(target_arch = "wasm32"))] + let reopen = { + held = self + .rebuild(shared, Job::Rebase { image, reply }) + .then(VecDeque::new); + false + }; + reopen + } else { + reply(Err( + io::Error::other("The queue could not be written").into() + )); + true + } + } + Request::Reopen { reply } => { + self.flush(shared, &mut accepted); + #[cfg(target_arch = "wasm32")] + let reopen = inline(shared, Job::Reopen { reply }); + #[cfg(not(target_arch = "wasm32"))] + let reopen = { held = self - .rebuild(shared, Job::Rebase { image, reply }) + .rebuild(shared, Job::Reopen { reply }) .then(VecDeque::new); false - } else { - reply(Err( - io::Error::other("The queue could not be written").into() - )); - true - } - } - Request::Reopen { reply } => { - self.flush(shared, &mut accepted); - held = self - .rebuild(shared, Job::Reopen { reply }) - .then(VecDeque::new); - false + }; + reopen } Request::Handover(_) | Request::Resume => false, }; @@ -448,6 +555,7 @@ impl<'a> Working<'a> { /// Starts a thread that runs `job` and rereads the section; false when the replica is /// stopping, which answers the job's request. + #[cfg(not(target_arch = "wasm32"))] fn rebuild(&self, shared: &Arc, job: Job) -> bool { let signal = shared .sender @@ -466,7 +574,7 @@ impl<'a> Working<'a> { return false; }; shared - .start(move |shared| build(shared, job, signal)) + .start(move |shared| async move { build(shared, job, signal) }) .is_ok() } diff --git a/crates/onestore/Cargo.toml b/crates/onestore/Cargo.toml index e0134c9a34ebc2834e8c1f6605f101f4c4131e45..57382db507a815990a9a092b240f1fc446490907 100644 --- a/crates/onestore/Cargo.toml +++ b/crates/onestore/Cargo.toml @@ -17,6 +17,8 @@ roxmltree = "0.21.1" sha1 = { version = "0.11.0", features = ["zeroize"] } subtle = "2.6.1" zeroize = "1.9.0" +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" [target.'cfg(target_os = "macos")'.dependencies] nix = { version = "0.31.3", default-features = false, features = ["fs"] } diff --git a/crates/onestore/src/create.rs b/crates/onestore/src/create.rs index cb60c5d436039370866608c35da7b4ece4a9b992..bd7201ec69fcea5936704ddf5c8bd7ff0163691b 100644 --- a/crates/onestore/src/create.rs +++ b/crates/onestore/src/create.rs @@ -5,7 +5,7 @@ use crate::{ write::{append, append_list, fresh_guid, node}, }; use std::collections::BTreeMap; -use std::time::{SystemTime, UNIX_EPOCH}; +use web_time::{SystemTime, UNIX_EPOCH}; type Result = std::result::Result; diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index 3c63bd1870901eb7d0376719601b8991d0bf202c..2375669bee25edbca798c2e373b7c022c8cbf200 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -8,6 +8,7 @@ mod create; pub mod document; mod edit; mod files; +#[cfg(any(unix, windows))] mod flush; mod formatting; mod identity; diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index c0e0913e189c7756b0afd5de8a56b2b29a96ed75..be6a38917856789c564d2a362d58359b27a94884 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -17,24 +17,39 @@ png = "0.18" # Encodes a video recording's pictures as JPEG, which OneNote 2010 plays. image = { version = "0.25", default-features = false, features = ["jpeg"] } onestore = { path = "../onestore" } -notebook = { path = "../notebook", features = ["smb"] } +notebook = { path = "../notebook" } serde = { version = "1", features = ["derive"] } serde_json = "1" pollster = "0.4" wgpu = { workspace = true, optional = true } parley.workspace = true winit = "0.30.13" -arboard = { version = "3.6.1", default-features = false } accesskit = "0.25.0" accesskit_consumer = "0.39.0" -accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } -# Updates: fetched over HTTPS, verified with ed25519 and SHA-256. -ureq = { version = "3.4", default-features = false, features = ["rustls"] } -ring = "0.17" # Clears passwords typed into the password dialogs. zeroize = "1.9.0" # Names characters in More Symbols, Insert Symbol's dialog. unicode_names2 = "4.0.0" +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +notebook = { path = "../notebook", features = ["smb"] } +arboard = { version = "3.6.1", default-features = false } +accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } +# Updates: fetched over HTTPS, verified with ed25519 and SHA-256. The browser updates by +# reloading. +ureq = { version = "3.4", default-features = false, features = ["rustls"] } +ring = "0.17" + +# The browser: see arc/platforms.md. +[target.'cfg(target_arch = "wasm32")'.dependencies] +wgpu = { workspace = true, features = ["webgl"] } +getrandom = { version = "0.4", features = ["wasm_js"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "MediaQueryList", "Navigator", "Window"] } [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" @@ -43,7 +58,7 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray", "NSRange", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileVersion", "NSFileManager", "NSNotification", "NSOperation", "block2"] } block2 = "0.5.1" -[target.'cfg(not(target_os = "macos"))'.dependencies] +[target.'cfg(not(any(target_os = "macos", target_arch = "wasm32")))'.dependencies] # Clipboard bitmaps; macOS reads the pasteboard's PNG and TIFF itself. arboard = { version = "3.6.1", default-features = false, features = ["image-data"] } diff --git a/crates/snowbound/src/attachment.rs b/crates/snowbound/src/attachment.rs index a6faeb23d99707d241c27c3606f026bb62ee6058..2f76105c5854f8567e25e0afed31e4e6c7a17cb9 100644 --- a/crates/snowbound/src/attachment.rs +++ b/crates/snowbound/src/attachment.rs @@ -21,7 +21,7 @@ impl State { } let (Some(name), Ok(bytes)) = ( path.file_name().and_then(|name| name.to_str()), - std::fs::read(path), + notebook::fs::read(path), ) else { platform::alert("Couldn't attach the file", "Choose a file you can open."); return Ok(()); @@ -68,14 +68,14 @@ impl State { return Ok(None); }; let root = std::env::temp_dir().join("Snowbound Attachments"); - std::fs::create_dir_all(&root)?; + notebook::fs::create_dir_all(&root)?; // A copy opened earlier may still be open, edited, in its application. let folder = (0..1000) .map(|index| root.join(index.to_string())) - .find(|folder| std::fs::create_dir(folder).is_ok()) + .find(|folder| notebook::fs::create_dir(folder).is_ok()) .ok_or("No folder is free for a copy of the file")?; let path = folder.join(&file.filename); - std::fs::write(&path, bytes)?; + notebook::fs::write(&path, bytes)?; Ok(Some(path)) } @@ -86,7 +86,7 @@ impl State { return Ok(()); }; if let Some(path) = platform::pick_new("Save As", &file.filename, "Save", None) { - std::fs::write(path, bytes)?; + notebook::fs::write(path, bytes)?; } Ok(()) } diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 866b4f1bbed1e3ea12338fce32777fb8d60e6a6b..a9a5f8ba18e7591ff318c4b08a670547c707b041 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -1562,7 +1562,7 @@ impl State { else { return Ok(()); }; - return self.insert_picture(std::fs::read(path)?, None); + return self.insert_picture(notebook::fs::read(path)?, None); } Id::Attachment => { let Some(path) = platform::pick_file("Attach File", &[]) else { diff --git a/crates/snowbound/src/guide.rs b/crates/snowbound/src/guide.rs index 9733a8c958b19f2a3a4d2692c43ae194689a0548..1e420fd5166851a9d7612b3e35102709fc9f4b72 100644 --- a/crates/snowbound/src/guide.rs +++ b/crates/snowbound/src/guide.rs @@ -35,8 +35,8 @@ impl State { /// Opens the guide in the user's documents, copying it there first unless it is there. pub(crate) fn open_guide(&mut self) -> Result<(), Box> { let documents = platform::documents_dir().ok_or("There is no Documents folder")?; - let folder = std::path::absolute(documents.join(NAME))?; - if !folder.exists() { + let folder = notebook::fs::absolute(documents.join(NAME))?; + if notebook::fs::metadata(&folder).is_err() { copy(&folder, &self.cache)?; } self.open_notebook(folder.to_string_lossy().into_owned(), None); @@ -48,12 +48,12 @@ impl State { /// short never passes for the guide. fn copy(folder: &Path, cache: &Path) -> Result<(), Box> { let partial = folder.with_file_name(format!("{NAME}.partial")); - if partial.exists() { - std::fs::remove_dir_all(&partial)?; + if notebook::fs::metadata(&partial).is_ok() { + notebook::fs::remove_dir_all(&partial)?; } - std::fs::create_dir_all(&partial)?; + notebook::fs::create_dir_all(&partial)?; for (name, bytes) in FILES { - std::fs::write(partial.join(name), bytes)?; + notebook::fs::write(partial.join(name), bytes)?; } // The snowflake tag's art, kept in the notebook as Customize Tags keeps it. Notebook::open(&partial, cache)?.map_tag_art( @@ -62,7 +62,7 @@ fn copy(folder: &Path, cache: &Path) -> Result<(), Box> { include_bytes!("../assets/tags/snowflake.svg"), "svg", )?; - std::fs::rename(&partial, folder)?; + notebook::fs::rename(&partial, folder)?; Ok(()) } @@ -75,7 +75,7 @@ mod tests { #[test] fn the_guide_copies_whole() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/guide-notebook"); - let mut committed: Vec = std::fs::read_dir(root.join(NAME)) + let mut committed: Vec = notebook::fs::read_dir(root.join(NAME)) .unwrap() .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) .filter(|name| !name.starts_with('.')) @@ -87,7 +87,7 @@ mod tests { let temporary = std::env::temp_dir().join(format!("snowbound-guide-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); let folder = temporary.join(NAME); copy(&folder, &temporary.join("cache")).unwrap(); let notebook = Notebook::open(&folder, temporary.join("cache")).unwrap(); @@ -99,8 +99,8 @@ mod tests { .collect::>(), [("Snowflake", 34)] ); - let committed = std::fs::read_to_string(root.join(NAME).join(".snowbound/tags.json")); + let committed = notebook::fs::read_to_string(root.join(NAME).join(".snowbound/tags.json")); assert!(committed.unwrap().contains(&art[0].art)); - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } } diff --git a/crates/snowbound/src/history.rs b/crates/snowbound/src/history.rs index b18c231bc475a2bae92327f84083191e0e8cad7c..3c28e016a2403d97f90ace7cf1b59e2deb0b5db9 100644 --- a/crates/snowbound/src/history.rs +++ b/crates/snowbound/src/history.rs @@ -226,7 +226,7 @@ impl State { }; let library = Arc::clone(&session.library); let proxy = self.proxy.clone(); - std::thread::spawn(move || { + crate::spawn(move || { for path in sections(&library, &folder) { if path == open { continue; diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index 95c278c47562f9b6463fcc404c78a404fb205120..93b80684ca654f9a54b9b3b14dccc4b764d05058 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -4,6 +4,7 @@ use canvas::{editor::NoteTag, gpu::TagArt}; use notebook::discover::{Folder, SectionState}; use notebook::session::{Background, Notebook, Section}; use notebook::sidecar::themes::Themes; +#[cfg(not(target_arch = "wasm32"))] use notebook::smb::{Client, Credentials}; use onestore::protected::Key; use std::{ @@ -14,8 +15,9 @@ use std::{ Arc, Mutex, OnceLock, atomic::{AtomicBool, Ordering}, }, - time::{Duration, Instant}, + time::Duration, }; +use web_time::Instant; /// OneNote's Work Offline, which like OneNote's holds for every notebook. static OFFLINE: AtomicBool = AtomicBool::new(false); @@ -63,7 +65,7 @@ pub struct Mount { /// the system reports mount points with symlinks (`/tmp`, `/var`) resolved. #[cfg(unix)] pub fn within_mount(path: &std::path::Path, point: &str) -> Option { - let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned()); + let path = notebook::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned()); Some( path.strip_prefix(point) .ok()? @@ -271,6 +273,7 @@ struct Server { login: Login, } +#[cfg(not(target_arch = "wasm32"))] impl Server { fn connect(&self) -> io::Result { Client::connect( @@ -398,8 +401,15 @@ impl Library { } } + /// No server is in reach of the browser until a relay carries SMB to it. + #[cfg(target_arch = "wasm32")] + pub fn on_share(_: &str, _: Mount, _: Login, _: &Path) -> Result { + Err(crate::platform::smb::Refusal::Unreachable.to_string()) + } + /// The notebook at `location`, which `mount` names on its server, opened through the /// embedded client signed in as `login`. + #[cfg(not(target_arch = "wasm32"))] pub fn on_share( location: &str, mount: Mount, @@ -443,8 +453,8 @@ impl Library { let mut names = read_display_names(&file); names.insert(self.location.clone(), name.to_owned()); let partial = file.with_extension("partial"); - std::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?; - std::fs::rename(partial, file) + notebook::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?; + notebook::fs::rename(partial, file) } /// The art the notebook's tags draw with. @@ -478,7 +488,7 @@ impl Library { } let extension = art.rsplit('.').next().unwrap_or_default().to_owned(); let (library, tag) = (Arc::clone(self), tag.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let kept = library.reopen().and_then(|notebook| { Ok(notebook.map_tag_art(&tag.label, tag.shape, &bytes, &extension)?) }); @@ -529,7 +539,7 @@ impl Library { .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((Instant::now(), Arc::new(themes))); let library = Arc::clone(self); - std::thread::spawn(move || { + crate::spawn(move || { let kept = library .reopen() .and_then(|notebook| Ok(notebook.save_themes(change)?)); @@ -546,9 +556,9 @@ impl Library { return; } let library = Arc::clone(self); - std::thread::spawn(move || { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + crate::spawn(move || { + let now = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .map_or(0, |unix| unix.as_secs()) // Time32 counts from 1980. .saturating_sub(315_532_800); @@ -567,9 +577,12 @@ impl Library { /// This notebook read again, the way it was opened, for changing its structure. pub fn reopen(&self) -> Result> { Ok(match &self.server { + #[cfg(not(target_arch = "wasm32"))] Some(server) => { Notebook::open_smb(Arc::new(server.connect()?), &server.mount.root, &self.cache)? } + #[cfg(target_arch = "wasm32")] + Some(_) => unreachable!("The browser opens no notebook on a server"), None => Notebook::open(&self.location, &self.cache)?, }) } @@ -665,13 +678,16 @@ impl Library { return Ok(kept); } let opened = match (&self.notebook, &self.server) { + #[cfg(target_arch = "wasm32")] + (Ok(Some(_)), Some(_)) => unreachable!("The browser opens no notebook on a server"), + #[cfg(not(target_arch = "wasm32"))] (Ok(Some(notebook)), Some(server)) => { let file = match server.mount.root.as_str() { "" => path.to_owned(), root => format!("{root}/{path}"), }; let cache = notebook.replica_path(path)?; - std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?; + notebook::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?; let replica = notebook::Replica::open_or_create(&cache, key.as_ref(), || { Ok(server.connect()?.read_storage(&file, LIMIT)?) }); @@ -735,7 +751,7 @@ impl Library { Err(_) => vec![section], }; if !gone.is_empty() { - std::thread::spawn(move || close(gone)); + crate::spawn(move || close(gone)); } } @@ -984,7 +1000,7 @@ impl Library { if let Ok(mut kept) = self.kept.lock() { for path in &locked { if let Some(section) = kept.take(path) { - std::thread::spawn(move || close(vec![section])); + crate::spawn(move || close(vec![section])); } } } @@ -1013,7 +1029,7 @@ fn display_names(cache: &Path) -> PathBuf { } fn read_display_names(file: &Path) -> std::collections::BTreeMap { - std::fs::read(file) + notebook::fs::read(file) .ok() .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default() @@ -1119,7 +1135,7 @@ fn close(sections: Vec
) { } pub fn file_name(path: &Path) -> String { - path.canonicalize() + notebook::fs::canonicalize(path) .ok() .as_deref() .and_then(Path::file_name) @@ -1181,7 +1197,7 @@ pub enum Located { /// Whether `folder` holds a table of contents, as a notebook's or a section group's does. fn has_toc(folder: &Path) -> bool { - std::fs::read_dir(folder).is_ok_and(|entries| { + notebook::fs::read_dir(folder).is_ok_and(|entries| { entries.flatten().any(|entry| { entry .path() @@ -1199,10 +1215,12 @@ pub fn locate(path: &Path) -> Located { .extension() .map(|extension| extension.to_string_lossy().to_ascii_lowercase()); match extension.as_deref() { - _ if path.is_dir() => Located::Notebook { - root: path.to_owned(), - section: None, - }, + _ if notebook::fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) => { + Located::Notebook { + root: path.to_owned(), + section: None, + } + } Some("onepkg") => Located::Package(path.to_owned()), Some("onetoc2") => match path.parent() { Some(root) => Located::Notebook { @@ -1245,14 +1263,14 @@ mod tests { #[test] fn kept_sections_open_at_once() { let root = std::env::temp_dir().join(format!("snowbound-kept-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); + let _ = notebook::fs::remove_dir_all(&root); let notebook = root.join("Personal"); - std::fs::create_dir_all(¬ebook).unwrap(); + notebook::fs::create_dir_all(¬ebook).unwrap(); let sample = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/sample-notebook/Personal"); - for entry in std::fs::read_dir(sample).unwrap() { + for entry in notebook::fs::read_dir(sample).unwrap() { let entry = entry.unwrap(); - std::fs::copy(entry.path(), notebook.join(entry.file_name())).unwrap(); + notebook::fs::copy(entry.path(), notebook.join(entry.file_name())).unwrap(); } let library = Library::notebook(notebook.to_str().unwrap(), &root.join("cache")); let [first, second] = [0, 1].map(|tab| library.tabs("")[tab].path.clone()); @@ -1268,7 +1286,7 @@ mod tests { library.close_kept(); library.open(&first, || {}).unwrap().close().unwrap(); drop(library); - std::fs::remove_dir_all(&root).unwrap(); + notebook::fs::remove_dir_all(&root).unwrap(); } #[cfg(unix)] @@ -1276,13 +1294,13 @@ mod tests { fn within_mount_resolves_symlinks() { let base = std::env::temp_dir().join(format!("snowbound-within-{}", std::process::id())); let point = base.join("point"); - std::fs::create_dir_all(point.join("notes")).unwrap(); + notebook::fs::create_dir_all(point.join("notes")).unwrap(); std::os::unix::fs::symlink(&point, base.join("link")).unwrap(); - let point = std::fs::canonicalize(&point).unwrap(); + let point = notebook::fs::canonicalize(&point).unwrap(); let point = point.to_str().unwrap(); let found = within_mount(&base.join("link/notes"), point); let missing = within_mount(&base.join("link/gone"), point); - std::fs::remove_dir_all(&base).unwrap(); + notebook::fs::remove_dir_all(&base).unwrap(); assert_eq!(found.as_deref(), Some("notes")); assert_eq!(missing, None); } @@ -1310,8 +1328,8 @@ mod tests { fn a_notebook_on_a_share_opens_through_the_embedded_client() { let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); let root = std::env::var("ONESTORE_SMB_LAB_ROOT").unwrap_or_else(|_| { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let nanos = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .unwrap() .subsec_nanos(); format!("snowbound-{}-{nanos}", std::process::id()) @@ -1400,7 +1418,7 @@ mod tests { } section.close().unwrap(); drop(library); - let _ = std::fs::remove_dir_all(&cache); + let _ = notebook::fs::remove_dir_all(&cache); if std::env::var_os("ONESTORE_SMB_LAB_KEEP").is_none() { remove_tree(&client, &root); assert!(client.read_dir(&root, 1).is_err(), "the folder is gone"); @@ -1488,18 +1506,18 @@ mod tests { #[test] fn chosen_paths_open_their_notebook_or_section() { let root = std::env::temp_dir().join(format!("snowbound-locate-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); + let _ = notebook::fs::remove_dir_all(&root); let notebook = root.join("Notebook"); - std::fs::create_dir_all(notebook.join("Group")).unwrap(); + notebook::fs::create_dir_all(notebook.join("Group")).unwrap(); for file in [ "Open Notebook.onetoc2", "Group/Open Notebook.onetoc2", "Group/Inner.one", "Top.one", ] { - std::fs::write(notebook.join(file), b"").unwrap(); + notebook::fs::write(notebook.join(file), b"").unwrap(); } - std::fs::write(root.join("Loose.one"), b"").unwrap(); + notebook::fs::write(root.join("Loose.one"), b"").unwrap(); let at = |root: &Path, section: Option<&str>| Located::Notebook { root: root.to_owned(), section: section.map(str::to_owned), @@ -1526,6 +1544,6 @@ mod tests { Located::Package(root.join("Shared.onepkg")) ); assert_eq!(locate(&root.join("missing.txt")), Located::Nothing); - std::fs::remove_dir_all(&root).unwrap(); + notebook::fs::remove_dir_all(&root).unwrap(); } } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 0325d758b262b6dc3fd7239b145f4058d2719a42..2c7f02854f5173a8b2e245145058f402c8639a25 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1,4 +1,6 @@ #![cfg_attr(windows, windows_subsystem = "windows")] +// The browser drives `State` from `web.rs`; launch, replays and screenshots are the desktop's. +#![cfg_attr(target_arch = "wasm32", allow(dead_code))] #[cfg(target_os = "macos")] mod aqua; mod art; @@ -18,6 +20,7 @@ mod history; #[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")] #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] mod icloud; +#[cfg(not(target_arch = "wasm32"))] #[cfg_attr(target_os = "macos", allow(dead_code))] mod instance; mod keys; @@ -30,6 +33,7 @@ mod manage; #[cfg_attr(target_os = "linux", path = "media_linux.rs")] #[cfg_attr(target_os = "macos", path = "media_macos.rs")] #[cfg_attr(windows, path = "media_windows.rs")] +#[cfg_attr(target_arch = "wasm32", path = "media_web.rs")] mod media; mod meeting; #[cfg(target_os = "macos")] @@ -43,12 +47,14 @@ mod paste; #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] #[cfg_attr(windows, path = "windows.rs")] +#[cfg_attr(target_arch = "wasm32", path = "web.rs")] mod platform; mod prefetch; mod print; #[cfg_attr(target_os = "linux", path = "print_linux.rs")] #[cfg_attr(target_os = "macos", path = "print_macos.rs")] #[cfg_attr(windows, path = "print_windows.rs")] +#[cfg_attr(target_arch = "wasm32", path = "print_web.rs")] mod printer; mod properties; mod protection; @@ -64,6 +70,7 @@ mod sidebar; #[cfg_attr(target_os = "linux", path = "spell_linux.rs")] #[cfg_attr(target_os = "macos", path = "spell_macos.rs")] #[cfg_attr(windows, path = "spell_windows.rs")] +#[cfg_attr(target_arch = "wasm32", path = "spell_web.rs")] mod spell; #[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")] #[cfg_attr(windows, path = "surface_windows.rs")] @@ -78,17 +85,17 @@ mod unpack; mod unread; mod update; mod video; +#[cfg_attr(target_arch = "wasm32", path = "watch_web.rs")] mod watch; +#[cfg(not(target_arch = "wasm32"))] +use accesskit_winit::Adapter as AccessAdapter; +#[cfg(not(target_arch = "wasm32"))] +use canvas::editor::{DEFAULT_OUTLINE_WIDTH, TextOutline}; use canvas::gpu::colorref; use canvas::gpu::page::PageScene; use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility}; -use canvas::{ - date::DateField, - document::TextDocument, - editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline}, - layout::TextEngine, -}; +use canvas::{date::DateField, document::TextDocument, editor::CanvasEditor, layout::TextEngine}; use draw::Renderer; use library::Library; use onestore::ExGuid; @@ -96,21 +103,29 @@ use onestore::document::Format; use onestore::page::Page; use onestore::page::ink::ShapeKind; use onestore::page::text::Paragraph; +#[cfg(target_arch = "wasm32")] +use platform::{AccessAdapter, ActiveEventLoop, EventLoopProxy, Window}; use std::{ collections::{HashMap, HashSet}, error::Error, path::{Path, PathBuf}, sync::{Arc, Mutex, mpsc}, - time::Instant, }; use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px}; +use web_time::Instant; +#[cfg(not(target_arch = "wasm32"))] use winit::{ application::ApplicationHandler, - dpi::{LogicalPosition, LogicalSize}, - event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, + event::{ElementState, MouseScrollDelta, WindowEvent}, event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, - keyboard::{Key, ModifiersState, NamedKey}, - window::{CursorIcon, Window, WindowId}, + keyboard::ModifiersState, + window::{Window, WindowId}, +}; +use winit::{ + dpi::{LogicalPosition, LogicalSize}, + event::{Ime, MouseButton}, + keyboard::{Key, NamedKey}, + window::CursorIcon, }; /// Height of the toolbar's row, which is the title bar where the platform lets it: a @@ -207,6 +222,7 @@ enum UserEvent { /// A picture to insert at the caret, such as a screen clipping. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] Picture(Vec), + #[cfg(not(target_arch = "wasm32"))] Accessibility(accesskit_winit::Event), /// The section's synchronization thread reported an event. Sync, @@ -298,6 +314,7 @@ enum Replay { Quit, } +#[cfg(not(target_arch = "wasm32"))] impl From for UserEvent { fn from(event: accesskit_winit::Event) -> Self { Self::Accessibility(event) @@ -314,7 +331,7 @@ fn lap(phase: &str, start: Instant) { fn trace_input(event: &impl std::fmt::Debug) { if std::env::var_os("SNOWBOUND_TRACE_INPUT").is_some() { - eprintln!("Input {:?}: {event:?}", std::time::SystemTime::now()); + eprintln!("Input {:?}: {event:?}", web_time::SystemTime::now()); } } @@ -334,6 +351,7 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon { } } +#[cfg(not(target_arch = "wasm32"))] struct App { proxy: EventLoopProxy, input: Option, @@ -373,7 +391,7 @@ impl Input { /// Lists the notebook folder `root` and shows it first, where the window shows notebooks. fn show(&mut self, root: &Path) -> std::io::Result<()> { if let Input::Notebooks { locations, current } = self { - let location = std::path::absolute(root)?.to_string_lossy().into_owned(); + let location = notebook::fs::absolute(root)?.to_string_lossy().into_owned(); if !locations.contains(&location) { locations.push(location.clone()); } @@ -816,7 +834,7 @@ struct State { min_width: f32, ime_allowed: bool, clipboard: Clipboard, - access_adapter: accesskit_winit::Adapter, + access_adapter: AccessAdapter, accessibility: accessibility::Accessibility, /// Assistive technology holds the page's tree, grafted into the interface's. page_grafted: bool, @@ -889,7 +907,7 @@ impl State { commands::Keymap::from_saved(&stored.keys).install(); platform::install_menu(); let access_adapter = - accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); + AccessAdapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); window.set_visible(visible); let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?; let size = window.inner_size(); @@ -897,8 +915,9 @@ impl State { let fallbacks = platform::symbol_fonts(); draw::fall_back_to(&mut engine.fonts.collection, &fallbacks); for path in substitutes { - let target = engine - .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; + let target = engine.register_substitute(parley::fontique::Blob::new(Arc::new( + notebook::fs::read(path)?, + )))?; eprintln!("Using {} for {target}", path.display()); } let layouts = Arc::new(Mutex::new(engine.clone())); @@ -1028,7 +1047,6 @@ impl State { .unwrap_or([ui.theme.strip; 2]); // A window shown but never focused hears no focus event; a hidden one draws as focused. ui.window_focused = !visible || window.has_focus(); - platform::system_interface(&mut ui); ui.fall_back_to(&fallbacks); ui.set_focus(Some(page())); for family in FONTS { @@ -1036,6 +1054,7 @@ impl State { ui.preview_font(face.clone(), family); } } + platform::system_interface(&mut ui); let mut fonts: Vec = engine .fonts .collection @@ -3089,7 +3108,7 @@ impl State { let library = Arc::clone(&session.library); let path = session.tabs[tab].path.clone(); let (notify, author) = (notify(self.proxy.clone()), self.author.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let copied = library.open(&path, notify).and_then(|section| { section.import_page(&page, &author)?; Ok(section.close()?) @@ -3161,7 +3180,7 @@ impl State { let requested = Instant::now(); let scenes = Arc::clone(&self.prefetch.scenes); let open = self.session.as_ref().map(Session::key); - std::thread::spawn(move || { + crate::spawn(move || { let laid = read().and_then(|loaded| { lap("switch read", requested); let (shown, page) = match loaded { @@ -3283,7 +3302,7 @@ impl State { library.keep(&previous.tabs[previous.tab].path, previous.section); } else { let section = previous.section; - std::thread::spawn(move || { + crate::spawn(move || { if let Err(error) = section.close() { eprintln!("Synchronization stopped: {error}"); } @@ -3914,7 +3933,7 @@ impl State { } let mut out = String::new(); write(&tree.state().root(), 0, &mut out); - std::fs::write(path, out)?; + notebook::fs::write(path, out)?; Ok(()) } @@ -4000,7 +4019,7 @@ impl State { let pixels = self.capture()?; let size = self.surface.size; // Encoding off the frame keeps the frames at the pace they are drawn. - std::thread::spawn(move || { + crate::spawn(move || { if let Err(error) = write_png(&path, size, &pixels) { eprintln!("{error}"); } @@ -5436,14 +5455,143 @@ fn page_text(editor: &CanvasEditor) -> String { .join("\n\n") } +/// Runs `work` beside the frame: on a thread of its own, or in the browser, which gives the +/// page one thread, once the frame under way is done. +fn spawn(work: impl FnOnce() + Send + 'static) { + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(work); + #[cfg(target_arch = "wasm32")] + platform::defer(work); +} + /// FILETIME now: when an edit happened, which its modification times record. fn filetime() -> u64 { - let unix = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unix = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .unwrap_or_default(); (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) } +impl State { + /// Does what an event sent to the event loop asks of the open window. + fn user_event(&mut self, event: UserEvent) { + match event { + // The event loop quits; a page stays open. + UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {} + UserEvent::Picture(bytes) => { + if let Err(error) = self.insert_picture(bytes, None) { + eprintln!("{error}"); + } + self.window.request_redraw(); + } + UserEvent::InsertText(text) => { + if self.ui.focused() == Some(page()) { + match self.view.insert_text(text) { + Ok(response) => self.respond(response), + Err(error) => eprintln!("{error}"), + } + self.window.request_redraw(); + } else { + self.input(ui::Event::Ime(Ime::Commit(text))); + } + } + UserEvent::Then(then) => { + if let Err(error) = then(self) { + eprintln!("{error}"); + } + self.window.request_redraw(); + } + UserEvent::ICloudFolder => self.list_icloud(), + UserEvent::ICloudAccount => { + self.icloud_account_changed(); + self.window.request_redraw(); + } + UserEvent::Open(paths) => { + for path in paths { + self.open_path(&path); + } + self.window.set_minimized(false); + self.window.focus_window(); + self.window.request_redraw(); + } + UserEvent::Update => { + self.updated(); + self.window.request_redraw(); + } + UserEvent::Sync => { + if let Err(error) = self.synced() { + eprintln!("{error}"); + } + } + UserEvent::Choose(choice) => self.choose(choice), + UserEvent::Appearance => { + self.follow_color_scheme(); + self.window.request_redraw(); + } + UserEvent::Redraw => self.window.request_redraw(), + UserEvent::Replay(replay) => { + match replay { + Replay::Input(event) => self.input(event), + Replay::Pinch(factor) => { + if let Err(error) = self.pinch(factor) { + eprintln!("{error}"); + } + } + Replay::Snapshot(path) => self.snapshot = Some(path), + Replay::Accessibility(path) => { + if let Err(error) = self.write_accessibility(&path) { + eprintln!("{error}"); + } + } + Replay::Tick | Replay::Quit => {} + Replay::Appearance(appearance) => { + self.window.set_theme(Some(appearance)); + self.set_appearance(appearance); + } + Replay::Resize([width, height]) => { + let _ = self + .window + .request_inner_size(LogicalSize::new(width, height)); + } + } + // A covered window gets no redraws, so each step draws its own frame. + if let Err(error) = self.frame() { + eprintln!("{error}"); + } + } + #[cfg(not(target_arch = "wasm32"))] + UserEvent::Accessibility(event) => self.access_event(event), + } + } + + /// Does what assistive technology asked of the window. + #[cfg(not(target_arch = "wasm32"))] + fn access_event(&mut self, event: accesskit_winit::Event) { + if event.window_id != self.window.id() { + return; + } + let was_marked = self.view.editor.marked_range().is_some(); + let result = match event.window_event { + accesskit_winit::WindowEvent::InitialTreeRequested => { + self.deactivate_accessibility(); + self.update_accessibility(true) + } + accesskit_winit::WindowEvent::ActionRequested(request) => self.access_action(request), + accesskit_winit::WindowEvent::AccessibilityDeactivated => { + self.deactivate_accessibility(); + Ok(()) + } + }; + if was_marked && self.view.editor.marked_range().is_none() { + platform::clear_marked_text(&self.window); + } + if let Err(error) = result { + eprintln!("{error}"); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] impl App { fn close(&self, event_loop: &ActiveEventLoop) { if self.state.as_ref().is_none_or(|state| { @@ -5480,8 +5628,10 @@ impl App { /// How long quitting waits for the open section's edits to reach its file; they stay in the /// replica and publish at the next launch otherwise. +#[cfg(not(target_arch = "wasm32"))] const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3); +#[cfg(not(target_arch = "wasm32"))] impl ApplicationHandler for App { fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { let Some(state) = &mut self.state else { @@ -5493,114 +5643,9 @@ impl ApplicationHandler for App { } return; }; - let event = match event { - UserEvent::Quit | UserEvent::Replay(Replay::Quit) => return self.close(event_loop), - UserEvent::Picture(bytes) => { - if let Err(error) = state.insert_picture(bytes, None) { - eprintln!("{error}"); - } - return state.window.request_redraw(); - } - UserEvent::InsertText(text) => { - if state.ui.focused() == Some(page()) { - match state.view.insert_text(text) { - Ok(response) => state.respond(response), - Err(error) => eprintln!("{error}"), - } - state.window.request_redraw(); - } else { - state.input(ui::Event::Ime(Ime::Commit(text))); - } - return; - } - UserEvent::Then(then) => { - if let Err(error) = then(state) { - eprintln!("{error}"); - } - return state.window.request_redraw(); - } - UserEvent::ICloudFolder => return state.list_icloud(), - UserEvent::ICloudAccount => { - state.icloud_account_changed(); - return state.window.request_redraw(); - } - UserEvent::Open(paths) => { - for path in paths { - state.open_path(&path); - } - state.window.set_minimized(false); - state.window.focus_window(); - return state.window.request_redraw(); - } - UserEvent::Update => { - state.updated(); - return state.window.request_redraw(); - } - UserEvent::Sync => { - if let Err(error) = state.synced() { - eprintln!("{error}"); - } - return; - } - UserEvent::Choose(choice) => return state.choose(choice), - UserEvent::Appearance => { - state.follow_color_scheme(); - return state.window.request_redraw(); - } - UserEvent::Redraw => return state.window.request_redraw(), - UserEvent::Replay(replay) => { - match replay { - Replay::Input(event) => state.input(event), - Replay::Pinch(factor) => { - if let Err(error) = state.pinch(factor) { - eprintln!("{error}"); - } - } - Replay::Snapshot(path) => state.snapshot = Some(path), - Replay::Accessibility(path) => { - if let Err(error) = state.write_accessibility(&path) { - eprintln!("{error}"); - } - } - Replay::Tick | Replay::Quit => {} - Replay::Appearance(appearance) => { - state.window.set_theme(Some(appearance)); - state.set_appearance(appearance); - } - Replay::Resize([width, height]) => { - let _ = state - .window - .request_inner_size(LogicalSize::new(width, height)); - } - } - // A covered window gets no redraws, so each step draws its own frame. - if let Err(error) = state.frame() { - eprintln!("{error}"); - } - return; - } - UserEvent::Accessibility(event) => event, - }; - if event.window_id != state.window.id() { - return; - } - let was_marked = state.view.editor.marked_range().is_some(); - let result = match event.window_event { - accesskit_winit::WindowEvent::InitialTreeRequested => { - state.deactivate_accessibility(); - state.update_accessibility(true) - } - accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request), - accesskit_winit::WindowEvent::AccessibilityDeactivated => { - state.deactivate_accessibility(); - Ok(()) - } - }; - if was_marked && state.view.editor.marked_range().is_none() { - platform::clear_marked_text(&state.window); - } - if let Err(error) = result { - eprintln!("{error}"); + match event { + UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop), + event => state.user_event(event), } } @@ -5810,8 +5855,8 @@ impl ApplicationHandler for App { fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box> { let partial = path.with_extension("partial"); - std::fs::write(&partial, paste::png(size, pixels)?)?; - std::fs::rename(partial, path)?; + notebook::fs::write(&partial, paste::png(size, pixels)?)?; + notebook::fs::rename(partial, path)?; Ok(()) } @@ -5820,6 +5865,7 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box) -> Result<(), Box> { let mut steps = Vec::new(); for line in script.lines().filter(|line| !line.trim().is_empty()) { @@ -5926,6 +5972,7 @@ fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box Result<(), Box> { #[cfg(target_os = "linux")] if std::env::args_os() @@ -5947,6 +5994,11 @@ fn main() -> Result<(), Box> { platform::with_pool(launch) } +/// The page starts the window through `web::start` once it has fetched the fonts. +#[cfg(target_arch = "wasm32")] +fn main() {} + +#[cfg(not(target_arch = "wasm32"))] fn launch() -> Result<(), Box> { let mut args = std::env::args_os().skip(1); if args.next().is_some_and(|arg| arg == update::FINISH) { @@ -6014,7 +6066,7 @@ fn launch() -> Result<(), Box> { let title = args .next() .ok_or("Provide a page title after the section file.")?; - let bytes = std::fs::read(path)?; + let bytes = notebook::fs::read(path)?; let store = onestore::Store::parse(&bytes)?; let index = onestore::RevisionIndex::parse(&store)?; reference = Some(Page::from_document( @@ -6046,7 +6098,7 @@ fn launch() -> Result<(), Box> { } let text = positional .first() - .map(std::fs::read_to_string) + .map(notebook::fs::read_to_string) .transpose()? .unwrap_or_default(); let width = positional @@ -6108,7 +6160,10 @@ fn launch() -> Result<(), Box> { instance.serve(event_loop.create_proxy()); } if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") { - replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?; + replay( + notebook::fs::read_to_string(script)?, + event_loop.create_proxy(), + )?; } let mut app = App { proxy: event_loop.create_proxy(), @@ -6164,10 +6219,10 @@ mod tests { fn recorded_ops_reach_the_section() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let directory = std::env::temp_dir().join(format!("snowbound-ops-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&directory); - std::fs::create_dir_all(directory.join("cache")).unwrap(); + let _ = notebook::fs::remove_dir_all(&directory); + notebook::fs::create_dir_all(directory.join("cache")).unwrap(); let file = directory.join("section.one"); - std::fs::copy( + notebook::fs::copy( root.join("corpus/paragraph-edit/before/notebook/synthetic.one"), &file, ) @@ -6215,7 +6270,7 @@ mod tests { .any(|event| matches!(event, notebook::session::Event::Rejected { .. })) ); section.close().unwrap(); - std::fs::remove_dir_all(&directory).unwrap(); + notebook::fs::remove_dir_all(&directory).unwrap(); } use notebook::session::{Event, Section}; @@ -6267,10 +6322,10 @@ mod tests { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let directory = std::env::temp_dir().join(format!("snowbound-changed-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&directory); - std::fs::create_dir_all(&directory).unwrap(); + let _ = notebook::fs::remove_dir_all(&directory); + notebook::fs::create_dir_all(&directory).unwrap(); let file = directory.join("section.one"); - std::fs::copy( + notebook::fs::copy( root.join("corpus/paragraph-edit/before/notebook/synthetic.one"), &file, ) @@ -6345,7 +6400,7 @@ mod tests { ); ours.close().unwrap(); theirs.close().unwrap(); - std::fs::remove_dir_all(&directory).unwrap(); + notebook::fs::remove_dir_all(&directory).unwrap(); } /// Times opening each section of a notebook and each of its pages, phase by phase, as a @@ -6356,7 +6411,7 @@ mod tests { #[ignore = "measures a notebook named by the environment"] fn switch_timings() { let cache = std::env::temp_dir().join(format!("snowbound-switch-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&cache); + let _ = notebook::fs::remove_dir_all(&cache); let open = || match std::env::var("SNOWBOUND_SWITCH_NOTEBOOK") { Ok(folder) => Library::notebook(&folder, &cache), Err(_) => { @@ -6421,6 +6476,6 @@ mod tests { session.section.close().unwrap(); } } - let _ = std::fs::remove_dir_all(&cache); + let _ = notebook::fs::remove_dir_all(&cache); } } diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index 4aebad7a5cc7ef9215f3cdd2fa316f11c85e7d4c..ef3dfceb84a4c38cce1fea230638da59aa032450 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -211,7 +211,7 @@ impl State { }; let space = self.session.as_ref().ok_or("No section is open")?.space; let proxy = self.proxy.clone(); - std::thread::spawn(move || { + crate::spawn(move || { let art = template.pictures().map_err(|error| error.to_string()); let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { if state @@ -399,9 +399,9 @@ impl State { pub(crate) fn list_icloud(&mut self) { let proxy = self.proxy.clone(); // Listing a folder iCloud Drive has not brought down yet waits for it. - std::thread::spawn(move || { + crate::spawn(move || { let listed = crate::icloud::folder().and_then(|root| { - let mut listed: Vec = std::fs::read_dir(&root) + let mut listed: Vec = notebook::fs::read_dir(&root) .ok()? .flatten() .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) @@ -446,7 +446,7 @@ impl State { continue; } let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let library = Arc::new(Library::notebook(&location, &cache)); let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { state.icloud_reading.remove(&location); @@ -494,7 +494,7 @@ impl State { return; } let proxy = self.proxy.clone(); - std::thread::spawn(move || { + crate::spawn(move || { if missing.is_some() { std::thread::sleep(std::time::Duration::from_secs(2)); } @@ -544,11 +544,16 @@ impl State { } /// Creates a notebook in the new folder `root` and opens it. - fn create_notebook(&mut self, root: std::path::PathBuf) -> Result<(), Box> { + pub(crate) fn create_notebook( + &mut self, + root: std::path::PathBuf, + ) -> Result<(), Box> { let page = self.dated_page(None)?; let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); self.load(move || { - let location = std::path::absolute(&root)?.to_string_lossy().into_owned(); + let location = notebook::fs::absolute(&root)? + .to_string_lossy() + .into_owned(); let notebook = Notebook::create(&location, &cache, Notebook::NEW_COLOR, &page)?; let library = Arc::new(Library::created(&location, notebook, &cache)); let path = library @@ -584,7 +589,7 @@ impl State { session.library.name, crate::filetime() )); - let exported = std::fs::create_dir_all(&folder) + let exported = notebook::fs::create_dir_all(&folder) .map_err(notebook::Error::from) .and_then(|()| session.section.export_recovery(&archive)); if let Err(error) = exported { @@ -936,13 +941,13 @@ mod tests { } fn copy(from: &Path, to: &Path) { - std::fs::create_dir_all(to).unwrap(); - for entry in std::fs::read_dir(from).unwrap().flatten() { + notebook::fs::create_dir_all(to).unwrap(); + for entry in notebook::fs::read_dir(from).unwrap().flatten() { let target = to.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { copy(&entry.path(), &target); } else { - std::fs::copy(entry.path(), target).unwrap(); + notebook::fs::copy(entry.path(), target).unwrap(); } } } @@ -956,8 +961,8 @@ mod tests { fn a_notebook_is_managed_as_onenote_manages_one() { let temporary = std::env::temp_dir().join(format!("snowbound-manage-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); - std::fs::create_dir_all(&temporary).unwrap(); + let _ = notebook::fs::remove_dir_all(&temporary); + notebook::fs::create_dir_all(&temporary).unwrap(); let root = temporary.join("Managed"); let cache = temporary.join("cache"); let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &dated()).unwrap(); @@ -1182,7 +1187,7 @@ mod tests { if let Some(directory) = std::env::var_os("SNOWBOUND_MANAGEMENT_EXPORT") { copy(&root, Path::new(&directory)); } - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } /// Deleting a group's last section shows the notebook's other one; deleting that too @@ -1191,8 +1196,8 @@ mod tests { fn deleting_every_section_leaves_the_notebook_showing_none() { let temporary = std::env::temp_dir().join(format!("snowbound-empty-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); - std::fs::create_dir_all(&temporary).unwrap(); + let _ = notebook::fs::remove_dir_all(&temporary); + notebook::fs::create_dir_all(&temporary).unwrap(); let root = temporary.join("Emptied"); let location = root.to_str().unwrap(); let cache = temporary.join("cache"); @@ -1213,7 +1218,7 @@ mod tests { let library = library.with(notebook); assert_eq!(shown(&library, Some("New Section 1.one".into())), None); assert!(library.tabs("").is_empty()); - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } #[test] @@ -1243,7 +1248,7 @@ mod tests { fn backgrounds_go_on_and_off_pages_that_have_content() { let temporary = std::env::temp_dir().join(format!("snowbound-background-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); copy( Path::new(concat!( env!("CARGO_MANIFEST_DIR"), @@ -1329,6 +1334,6 @@ mod tests { if let Some(directory) = std::env::var_os("SNOWBOUND_BACKGROUND_EXPORT") { copy(&temporary, Path::new(&directory)); } - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } } diff --git a/crates/snowbound/src/media_web.rs b/crates/snowbound/src/media_web.rs new file mode 100644 index 0000000000000000000000000000000000000000..abcff381d158864a3ad3e288d4adcb5243b1dcbc --- /dev/null +++ b/crates/snowbound/src/media_web.rs @@ -0,0 +1,61 @@ +//! Recording and playback in the browser, which come with `MediaRecorder` in a later phase; +//! until then recordings neither start nor play, and say why. + +use std::{convert::Infallible, path::Path}; + +/// Why the browser does not record yet. +const UNAVAILABLE: &str = "Recording isn't available in the browser yet."; + +pub struct Player(Infallible); + +impl Player { + pub fn open(_: &Path, _: bool) -> Option { + None + } + + pub fn play(&mut self) { + match self.0 {} + } + + pub fn pause(&mut self) { + match self.0 {} + } + + pub fn playing(&self) -> bool { + match self.0 {} + } + + pub fn position_ms(&self) -> u32 { + match self.0 {} + } + + pub fn duration_ms(&self) -> u32 { + match self.0 {} + } + + pub fn seek(&mut self, _: u32) { + match self.0 {} + } +} + +pub struct Recorder(Infallible); + +impl Recorder { + pub fn audio(_: &Path, _: u32) -> Result { + Err(UNAVAILABLE.into()) + } + + pub fn video(_: &Path) -> Result { + Err(UNAVAILABLE.into()) + } + + pub fn pause(&mut self, _: bool) { + match self.0 {} + } + + pub fn stop(self) -> Result Result<(), String> + Send, String> { + match self.0 {} + #[allow(unreachable_code)] + Ok(|| Ok(())) + } +} diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 47d44af69a5686045ae4ace76a985b5d9cfc728d..826a14a5e14f0824a827b437e6c179ed4f167db5 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -1046,10 +1046,10 @@ mod tests { fn sections_and_the_notebook_take_onenotes_colours() { let temporary = std::env::temp_dir().join(format!("snowbound-colors-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); let root = temporary.join("Colors"); let cache = temporary.join("cache"); - std::fs::create_dir_all(&temporary).unwrap(); + notebook::fs::create_dir_all(&temporary).unwrap(); let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap(); let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page()).unwrap(); let named: Vec<(Option, &str)> = (SECTION_COLORS.iter()) @@ -1080,14 +1080,14 @@ mod tests { assert_eq!(colors, expected); if let Some(directory) = std::env::var_os("SNOWBOUND_SECTION_COLOR_EXPORT") { let directory = std::path::Path::new(&directory); - std::fs::create_dir_all(directory).unwrap(); - for entry in std::fs::read_dir(&root).unwrap() { + notebook::fs::create_dir_all(directory).unwrap(); + for entry in notebook::fs::read_dir(&root).unwrap() { let entry = entry.unwrap(); if entry.file_type().unwrap().is_file() { - std::fs::copy(entry.path(), directory.join(entry.file_name())).unwrap(); + notebook::fs::copy(entry.path(), directory.join(entry.file_name())).unwrap(); } } } - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } } diff --git a/crates/snowbound/src/pane.rs b/crates/snowbound/src/pane.rs index af0eebc0ed1b29aa2f6cac3cbd101de2d13c530d..6a9989eb51d90369388bcb7fffcf777a05329c83 100644 --- a/crates/snowbound/src/pane.rs +++ b/crates/snowbound/src/pane.rs @@ -169,7 +169,7 @@ fn local_day(time: u64) -> i64 { libc::localtime_r(&unix, &mut tm); tm.tm_gmtoff as i64 }; - #[cfg(windows)] + #[cfg(any(windows, target_arch = "wasm32"))] let offset = crate::platform::utc_offset(unix); (unix + offset).div_euclid(86_400) } diff --git a/crates/snowbound/src/paste.rs b/crates/snowbound/src/paste.rs index 193e1ae69a47cfb82c40060b72febf017e43572f..db042b40d44493dab41b38abc4c9d5c8c7c2df69 100644 --- a/crates/snowbound/src/paste.rs +++ b/crates/snowbound/src/paste.rs @@ -5,7 +5,6 @@ use canvas::editor::Piece; use std::{ error::Error, path::{Path, PathBuf}, - time::Duration, }; /// What Paste takes from the clipboard. Files come first, as Finder also offers their names @@ -45,7 +44,7 @@ impl State { self.respond(response); for (place, (source, css)) in places.into_iter().zip(fetched) { let proxy = self.proxy.clone(); - std::thread::spawn(move || { + crate::spawn(move || { let Some(bytes) = fetch(&source) else { return; }; @@ -78,7 +77,7 @@ impl State { path: &Path, at: Option<[f32; 2]>, ) -> Result<(), Box> { - match std::fs::read(path) { + match notebook::fs::read(path) { Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => { self.insert_picture(bytes, at) } @@ -315,15 +314,22 @@ fn load(source: &str) -> Option> { Some(drive) if cfg!(windows) => drive.to_owned(), _ => path, }; - return std::fs::read(path).ok(); + return notebook::fs::read(path).ok(); } None } +/// None in the browser, whose pages rarely let another site read their pictures. +#[cfg(target_arch = "wasm32")] +fn fetch(_: &str) -> Option> { + None +} + /// A web picture's bytes, on a thread of its own. +#[cfg(not(target_arch = "wasm32"))] fn fetch(source: &str) -> Option> { let agent: ureq::Agent = ureq::Agent::config_builder() - .timeout_global(Some(Duration::from_secs(10))) + .timeout_global(Some(std::time::Duration::from_secs(10))) .build() .into(); agent @@ -512,7 +518,7 @@ fn exif_resolution(tiff: &[u8]) -> Option<[f32; 2]> { /// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes /// 96 dpi as OneNote 2010 gives a pasted bitmap. -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_arch = "wasm32")))] pub(crate) fn bitmap(image: arboard::ImageData) -> Option> { png( [image.width, image.height].map(|side| side as u32), @@ -667,12 +673,12 @@ mod tests { assert_eq!(load("relative/pic.png"), None); let directory = std::env::temp_dir().join(format!("snowbound-paste-{}", std::process::id())); - std::fs::create_dir_all(&directory).unwrap(); + notebook::fs::create_dir_all(&directory).unwrap(); let file = directory.join("a picture.png"); - std::fs::write(&file, b"bytes").unwrap(); + notebook::fs::write(&file, b"bytes").unwrap(); let address = format!("file://{}", file.to_str().unwrap().replace(' ', "%20")); assert_eq!(load(&address).unwrap(), b"bytes"); - std::fs::remove_dir_all(&directory).unwrap(); + notebook::fs::remove_dir_all(&directory).unwrap(); } fn close(size: [f32; 2], expected: [f32; 2]) -> bool { diff --git a/crates/snowbound/src/prefetch.rs b/crates/snowbound/src/prefetch.rs index 73d5df0ff3a348272e78c300c31ec2464c11fda3..17c6764037d229aa0a756664785be0df8cbbff1e 100644 --- a/crates/snowbound/src/prefetch.rs +++ b/crates/snowbound/src/prefetch.rs @@ -109,6 +109,10 @@ impl Prefetch { let scenes = Scenes::new(Mutex::new(Recent::new(SCENES))); let (jobs, queue) = mpsc::channel(); let kept = Arc::clone(&scenes); + // With one thread the browser lays a page out as it opens rather than ahead of time. + #[cfg(target_arch = "wasm32")] + drop((queue, layouts, kept, redraw)); + #[cfg(not(target_arch = "wasm32"))] std::thread::Builder::new() .name("prefetch".into()) .spawn(move || { diff --git a/crates/snowbound/src/print.rs b/crates/snowbound/src/print.rs index c7dcf65a55e7129922ed7857df19861638baf7f9..68ba85bd48b28fa32887d4ee20197574d0c7de2f 100644 --- a/crates/snowbound/src/print.rs +++ b/crates/snowbound/src/print.rs @@ -343,7 +343,7 @@ impl State { footer: setup.footer, }; let whole = matches!(setup.scope, Scope::Section | Scope::Notebook); - std::thread::spawn(move || { + crate::spawn(move || { let made = (|| -> Result, Box> { let mut open = Some(open); let this = spaces @@ -380,7 +380,9 @@ impl State { Ok(canvas::print::pdf(sections, &mut engine, &layout)?) })(); let done = made.and_then(|pdf| match &destination { - Some(path) => std::fs::write(path, pdf).map(|()| None).map_err(Into::into), + Some(path) => notebook::fs::write(path, pdf) + .map(|()| None) + .map_err(Into::into), None => Ok(Some(pdf)), }); let failed = if export { @@ -468,9 +470,9 @@ pub(crate) fn spooled(pdf: &[u8], title: &str) -> Result let folder = platform::cache_dir() .unwrap_or_else(std::env::temp_dir) .join("Printing"); - std::fs::create_dir_all(&folder)?; + notebook::fs::create_dir_all(&folder)?; let path = folder.join(format!("{}.pdf", file_name(title))); - std::fs::write(&path, pdf)?; + notebook::fs::write(&path, pdf)?; Ok(path) } @@ -502,8 +504,8 @@ mod tests { PageCreation, op::{Edit, Op, SectionOp}, }; - let _ = std::fs::remove_dir_all(temporary); - std::fs::create_dir_all(temporary).unwrap(); + let _ = notebook::fs::remove_dir_all(temporary); + notebook::fs::create_dir_all(temporary).unwrap(); let root = temporary.join("Ordered"); let cache = temporary.join("cache"); let page = |title: &str| { @@ -558,7 +560,7 @@ mod tests { let tabs = notebook_tabs(&library); library.close_kept(); drop(library); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); let paths: Vec<&str> = tabs.iter().map(|(path, _)| path.as_str()).collect(); assert_eq!(paths, ["A.one", "G/C.one", "B.one"]); } @@ -576,7 +578,7 @@ mod tests { .collect(); section.close().unwrap(); drop(library); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); assert_eq!(pages.len(), 3); // A page's title as typed, which its title object holds. let typed = |page: &Page| { @@ -607,8 +609,8 @@ mod tests { use notebook::session::Notebook; let temporary = std::env::temp_dir().join(format!("snowbound-print-bin-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); - std::fs::create_dir_all(&temporary).unwrap(); + let _ = notebook::fs::remove_dir_all(&temporary); + notebook::fs::create_dir_all(&temporary).unwrap(); let root = temporary.join("Printed"); let cache = temporary.join("cache"); let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap(); @@ -622,7 +624,7 @@ mod tests { .into_iter() .map(|(path, _)| path) .collect(); - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); assert_eq!(paths, ["New Section 1.one", "Group/Inner.one"]); } } diff --git a/crates/snowbound/src/print_web.rs b/crates/snowbound/src/print_web.rs new file mode 100644 index 0000000000000000000000000000000000000000..54552fe5729a82f6e46c20b6d0a4f6782de98500 --- /dev/null +++ b/crates/snowbound/src/print_web.rs @@ -0,0 +1,18 @@ +//! Printing in the browser: the PDF Snowbound prints downloads, to print from the viewer. + +use crate::platform::{Window, download}; +use std::error::Error; + +/// Letter where the browser's language is American English, A4 elsewhere. +pub fn paper() -> [f32; 2] { + if crate::platform::input_language() == "en-US" { + canvas::print::LETTER + } else { + canvas::print::A4 + } +} + +pub fn print(_: &Window, pdf: &[u8], title: &str) -> Result<(), Box> { + download(&format!("{title}.pdf"), pdf, "application/pdf"); + Ok(()) +} diff --git a/crates/snowbound/src/recording.rs b/crates/snowbound/src/recording.rs index 49ab42f3d2fbb75a50d9bf7770c473e9f4006768..a20c608909ca9d25f957b9068e95ea50269cc414 100644 --- a/crates/snowbound/src/recording.rs +++ b/crates/snowbound/src/recording.rs @@ -73,7 +73,7 @@ impl State { _ => self.media = Media::Idle, } let folder = std::env::temp_dir().join("Snowbound Recordings"); - std::fs::create_dir_all(&folder)?; + notebook::fs::create_dir_all(&folder)?; let extension = if video { "avi" } else { "wav" }; let path = folder.join(format!("{}.{extension}", std::process::id())); let recorder = if video { @@ -131,7 +131,8 @@ impl State { let recorded = path.clone(); let job = std::thread::spawn(move || { finish()?; - let recorded = std::fs::read(&recorded).map_err(|error| error.to_string())?; + let recorded = + notebook::fs::read(&recorded).map_err(|error| error.to_string())?; Ok(if video { recorded } else { @@ -168,7 +169,7 @@ impl State { Wave::parse(&bytes).map(|wave| wave.duration_ms()) }; let preview = platform::file_icon(&path); - std::fs::remove_file(&path)?; + notebook::fs::remove_file(&path)?; let title = self.view.editor.page()?.title; let name: String = title .chars() @@ -1042,10 +1043,10 @@ pub(crate) mod tests { if let Some(directory) = std::env::var_os(export) { let directory = PathBuf::from(directory); - std::fs::create_dir_all(&directory).unwrap(); - std::fs::write(directory.join("Recorded.one"), &image).unwrap(); + notebook::fs::create_dir_all(&directory).unwrap(); + notebook::fs::write(directory.join("Recorded.one"), &image).unwrap(); let file_id = Store::parse(&image).unwrap().header.file_id; - std::fs::write( + notebook::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( "Open Notebook.onetoc2", diff --git a/crates/snowbound/src/recycle.rs b/crates/snowbound/src/recycle.rs index f1cf7d9dd74bd5c062d4e2cb95df0d1824d3c73b..b413aed4dba433d88726cc6c3a280fcd65cb12d7 100644 --- a/crates/snowbound/src/recycle.rs +++ b/crates/snowbound/src/recycle.rs @@ -261,7 +261,7 @@ mod tests { } fn titles(root: &std::path::Path, path: &str) -> Vec<(String, Option<[u8; 16]>)> { - let pages = stored_pages(&std::fs::read(root.join(path)).unwrap()).unwrap(); + let pages = stored_pages(¬ebook::fs::read(root.join(path)).unwrap()).unwrap(); (pages.into_iter()) .map(|page| (page.page.title, page.page.identity)) .collect() @@ -275,8 +275,8 @@ mod tests { #[test] fn pages_and_sections_leave_the_recycle_bin_as_onenote_restores_them() { let folder = std::env::temp_dir().join(format!("snowbound-recycle-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&folder); - std::fs::create_dir_all(&folder).unwrap(); + let _ = notebook::fs::remove_dir_all(&folder); + notebook::fs::create_dir_all(&folder).unwrap(); let (root, cache) = (folder.join("Recycled"), folder.join("cache")); let page = |title: &str| PageCreation::new(None, Some(title), AUTHOR).unwrap(); let mut notebook = @@ -376,17 +376,17 @@ mod tests { if let Some(directory) = &export { copy(&root, &directory.join("after")); } - std::fs::remove_dir_all(&folder).unwrap(); + notebook::fs::remove_dir_all(&folder).unwrap(); } fn copy(from: &std::path::Path, to: &std::path::Path) { - std::fs::create_dir_all(to).unwrap(); - for entry in std::fs::read_dir(from).unwrap().flatten() { + notebook::fs::create_dir_all(to).unwrap(); + for entry in notebook::fs::read_dir(from).unwrap().flatten() { let target = to.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { copy(&entry.path(), &target); } else { - std::fs::copy(entry.path(), target).unwrap(); + notebook::fs::copy(entry.path(), target).unwrap(); } } } diff --git a/crates/snowbound/src/save_as.rs b/crates/snowbound/src/save_as.rs index e72867bfc7f68fc050757221c42df5ff8acaaadc..ace385c461fd631edf6002d4679c41ea464ebdc9 100644 --- a/crates/snowbound/src/save_as.rs +++ b/crates/snowbound/src/save_as.rs @@ -187,7 +187,7 @@ impl State { .into_iter()) .find(|tab| tab.path == section) .and_then(|tab| tab.color); - std::thread::spawn(move || { + crate::spawn(move || { let written = (|| -> Result<(), Box> { let image = |path: &str| { let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?; @@ -212,7 +212,7 @@ impl State { package::pack(package::notebook_files(¬ebook, image)?)? } }; - std::fs::write(&path, bytes)?; + notebook::fs::write(&path, bytes)?; Ok(()) })(); let written = written.map_err(|error| error.to_string()); diff --git a/crates/snowbound/src/search.rs b/crates/snowbound/src/search.rs index 7c35021267de88f920737a5dec07179b7898123c..711e730efd8f1c4c65a0bcc615af76854bcc2c5d 100644 --- a/crates/snowbound/src/search.rs +++ b/crates/snowbound/src/search.rs @@ -17,9 +17,10 @@ use std::{ atomic::{AtomicBool, AtomicU64, Ordering}, mpsc, }, - time::{Duration, Instant, SystemTime}, + time::{Duration, SystemTime}, }; use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px}; +use web_time::Instant; use winit::keyboard::NamedKey; /// Where a search looks, as OneNote's scope menu offers it. @@ -116,6 +117,10 @@ pub struct Search { version: Arc, pub(crate) busy: Arc, jobs: mpsc::Sender, + /// The index's own state, which the browser, having no thread for it, keeps here to run + /// the jobs sent after each frame. + #[cfg(target_arch = "wasm32")] + indexing: Arc>, pub(crate) found: Vec, found_for: Option<(Query, Scope, u64, String)>, pub(crate) selected: Option, @@ -136,10 +141,18 @@ impl Search { let index = Arc::new(Mutex::new(Index::default())); let version = Arc::new(AtomicU64::new(0)); let busy = Arc::new(AtomicBool::new(false)); - let shared = (Arc::clone(&index), Arc::clone(&version), Arc::clone(&busy)); + let indexing = Indexing { + jobs: receiver, + shared: (Arc::clone(&index), Arc::clone(&version), Arc::clone(&busy)), + redraw, + stamps: HashMap::new(), + }; + #[cfg(target_arch = "wasm32")] + let indexing = Arc::new(Mutex::new(indexing)); + #[cfg(not(target_arch = "wasm32"))] std::thread::Builder::new() .name("search-index".into()) - .spawn(move || run(receiver, shared, redraw)) + .spawn(move || indexing.run()) .expect("the index thread starts"); Self { query: String::new(), @@ -152,6 +165,8 @@ impl Search { version, busy, jobs, + #[cfg(target_arch = "wasm32")] + indexing, found: Vec::new(), found_for: None, selected: None, @@ -166,13 +181,28 @@ impl Search { /// Asks the index to read `spaces` of the open section again, as edits or another /// writer changed them, and its page list. pub fn changed(&self, key: String, replica: &Arc, spaces: Vec) { - let _ = self.jobs.send(Job::Pages { + self.send(Job::Pages { key, replica: Arc::downgrade(replica), spaces, }); } + fn send(&self, job: Job) { + let _ = self.jobs.send(job); + #[cfg(target_arch = "wasm32")] + { + let indexing = Arc::clone(&self.indexing); + crate::spawn(move || { + if let Ok(mut indexing) = indexing.lock() { + while let Ok(first) = indexing.jobs.try_recv() { + indexing.answer(first); + } + } + }); + } + } + /// Pages of section `key` among the results, which the page list marks. pub fn found_in(&self, key: &str) -> HashSet { if self.finding || self.query.trim().is_empty() { @@ -195,31 +225,45 @@ impl Search { type Stamp = Option<(u64, SystemTime)>; fn stamp(file: &Path) -> Stamp { - let metadata = std::fs::metadata(file).ok()?; + let metadata = notebook::fs::metadata(file).ok()?; Some((metadata.len(), metadata.modified().ok()?)) } /// Now in Time32, seconds since 1980, as page modification times are kept. pub(crate) fn now() -> u64 { - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) + web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .map_or(0, |since| since.as_secs().saturating_sub(315_532_800)) } -/// The index thread: answers jobs, the newest notebooks first, then page changes, and -/// wakes the window once the index changed. -fn run( +/// The index thread's jobs and what it keeps between them. +struct Indexing { jobs: mpsc::Receiver, - (index, version, busy): (Arc>, Arc, Arc), + shared: (Arc>, Arc, Arc), redraw: std::task::Waker, -) { - let mut stamps: HashMap = HashMap::new(); - while let Ok(first) = jobs.recv() { + stamps: HashMap, +} + +impl Indexing { + /// The index thread: answers jobs, gathering those typing sends a keystroke apart. + #[cfg(not(target_arch = "wasm32"))] + fn run(mut self) { + while let Ok(first) = self.jobs.recv() { + self.shared.2.store(true, Ordering::Relaxed); + // Typing sends a job a keystroke; a pause gathers them into one read. + if matches!(first, Job::Pages { .. }) { + std::thread::sleep(SETTLE); + } + self.answer(first); + } + } + + /// Answers `first` and the jobs waiting behind it, the newest notebooks first, then page + /// changes, and wakes the window once the index changed. + fn answer(&mut self, first: Job) { + let (index, version, busy) = &self.shared; + let (jobs, stamps) = (&self.jobs, &mut self.stamps); busy.store(true, Ordering::Relaxed); - // Typing sends a job a keystroke; a pause gathers them into one read. - if matches!(first, Job::Pages { .. }) { - std::thread::sleep(SETTLE); - } let mut notebooks = None; let mut changed = HashSet::new(); let mut pages: HashMap, HashSet)> = HashMap::new(); @@ -246,25 +290,18 @@ fn run( } let start = Instant::now(); if let Some((libraries, open)) = notebooks { - sync( - &index, - &version, - &mut stamps, - &libraries, - open.as_ref(), - &changed, - ); + sync(index, version, stamps, &libraries, open.as_ref(), &changed); lap("index notebooks", start); } for (key, (replica, spaces)) in pages { - if let Err(error) = reread(&index, &key, &replica, &spaces) { + if let Err(error) = reread(index, &key, &replica, &spaces) { eprintln!("Cannot index the open section: {error}"); } version.fetch_add(1, Ordering::Relaxed); } lap("index", start); busy.store(false, Ordering::Relaxed); - redraw.wake_by_ref(); + self.redraw.wake_by_ref(); } } @@ -329,7 +366,7 @@ fn sync( let read = || -> Result, Box> { let bytes = match notebook { Some(notebook) => notebook.read_section(&path)?, - None => onestore::read_file(&file)?, + None => notebook::fs::read_file(&file)?, }; let stored = match library.unlocked(&path) { Some(key) => notebook::session::stored_pages_unlocked(&bytes, &key)?, @@ -494,7 +531,7 @@ impl State { return; } self.search.synced = identity; - let _ = self.search.jobs.send(Job::Notebooks { + self.search.send(Job::Notebooks { libraries: self.notebooks.clone(), open, changed, diff --git a/crates/snowbound/src/server.rs b/crates/snowbound/src/server.rs index 3b8deb5eac464120ea22e55909cb097fa6bbf7f5..520b44a6e231999ec867957af8a5129257b39b5c 100644 --- a/crates/snowbound/src/server.rs +++ b/crates/snowbound/src/server.rs @@ -3,9 +3,12 @@ //! where the system's file sharing can't reach the server. use crate::library::{Library, Login, Mount}; +#[cfg(target_arch = "wasm32")] +use crate::platform::smb::{self, Client, Credentials, DirectoryEntry, Refusal}; use crate::{State, art, platform}; use accesskit::Role; -use notebook::smb::{Client, Credentials, DirectoryEntry, Refusal}; +#[cfg(not(target_arch = "wasm32"))] +use notebook::smb::{self, Client, Credentials, DirectoryEntry, Refusal}; use std::{io, path::Path, sync::mpsc, time::Duration}; use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, px}; use winit::keyboard::NamedKey; @@ -98,7 +101,7 @@ fn credentials(login: &Login) -> Credentials<'_> { impl Reach for Embedded { fn shares(&self, mount: &Mount, login: &Login) -> io::Result> { - notebook::smb::shares(&mount.endpoint(), credentials(login), TIMEOUT) + smb::shares(&mount.endpoint(), credentials(login), TIMEOUT) } fn list(&self, mount: &Mount, login: &Login) -> io::Result> { @@ -512,7 +515,7 @@ impl State { return; }; let (replies, redraw) = (connect.replies.0.clone(), self.redraw.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let _ = replies.send(request.run(&Embedded)); redraw.wake(); }); diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index 081271a6535c540fce6772fe2c3585630688605a..569cd42bed2fe5b6812132af90cd4e9785c81d0f 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -126,7 +126,7 @@ impl ColorScheme { impl Settings { /// The settings at `path`; missing or unreadable ones are the defaults, reported. pub fn load(path: &Path) -> Self { - match std::fs::read(path) { + match notebook::fs::read(path) { Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| { eprintln!("Ignoring the settings in {}: {error}", path.display()); Self::default() @@ -142,11 +142,11 @@ impl Settings { /// Replaces the file at `path` whole, so a crash leaves the old settings or the new. pub fn save(&self, path: &Path) -> std::io::Result<()> { if let Some(folder) = path.parent() { - std::fs::create_dir_all(folder)?; + notebook::fs::create_dir_all(folder)?; } let partial = path.with_extension("partial"); - std::fs::write(&partial, serde_json::to_vec_pretty(self)?)?; - std::fs::rename(partial, path) + notebook::fs::write(&partial, serde_json::to_vec_pretty(self)?)?; + notebook::fs::rename(partial, path) } } @@ -277,7 +277,7 @@ mod tests { }; settings.save(&path).unwrap(); assert_eq!(Settings::load(&path), settings); - std::fs::write(&path, br#"{"sidebar": true, "unknown": 1}"#).unwrap(); + notebook::fs::write(&path, br#"{"sidebar": true, "unknown": 1}"#).unwrap(); assert_eq!( Settings::load(&path), Settings { @@ -286,10 +286,10 @@ mod tests { } ); // A picked "No color" stays picked; what was never picked keeps Office's default. - std::fs::write(&path, br#"{"toolbar": {"highlight": null}}"#).unwrap(); + notebook::fs::write(&path, br#"{"toolbar": {"highlight": null}}"#).unwrap(); let toolbar = Settings::load(&path).toolbar; assert_eq!(toolbar.highlight, None); assert_eq!(toolbar.font_color, Toolbar::default().font_color); - std::fs::remove_dir_all(&directory).unwrap(); + notebook::fs::remove_dir_all(&directory).unwrap(); } } diff --git a/crates/snowbound/src/spell_web.rs b/crates/snowbound/src/spell_web.rs new file mode 100644 index 0000000000000000000000000000000000000000..d2cb30da44c38cb94516abb5682def02ebc90572 --- /dev/null +++ b/crates/snowbound/src/spell_web.rs @@ -0,0 +1,8 @@ +//! Spelling in the browser, which offers pages no checker to ask: words stay unmarked until +//! Hunspell dictionaries load in wasm. + +use canvas::spelling::Dictionary; + +pub fn dictionary() -> Option> { + None +} diff --git a/crates/snowbound/src/surface.rs b/crates/snowbound/src/surface.rs index 8ea91147a806353a77e589209d03fbb35eeff3cf..bf07c045b5b273c71ef0851c99da37794a54b570 100644 --- a/crates/snowbound/src/surface.rs +++ b/crates/snowbound/src/surface.rs @@ -1,9 +1,8 @@ //! Frames reach the window through wgpu. -use crate::{platform, trace_input}; +use crate::{Window, platform, trace_input}; use draw::Renderer; use std::{error::Error, sync::Arc}; -use winit::window::Window; pub struct Surface { /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. @@ -14,6 +13,9 @@ pub struct Surface { instance: wgpu::Instance, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, + /// The sRGB view of the surface's format, which the renderer blends in, where the surface + /// itself is not sRGB, as a browser's canvas is not. + view_format: wgpu::TextureFormat, /// Carries frames to the window where the system's backdrop shows through the strip. translucent: Option, } @@ -36,6 +38,13 @@ impl Surface { window: Arc, backdrop: bool, ) -> Result<(Self, Renderer), Box> { + #[cfg(target_arch = "wasm32")] + let instance = { + let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle(); + descriptor.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL; + wgpu::util::new_instance_with_webgpu_detection(descriptor).await + }; + #[cfg(not(target_arch = "wasm32"))] #[cfg_attr(not(any(windows, target_os = "linux")), expect(unused_mut))] let mut descriptor = wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())); @@ -61,8 +70,9 @@ impl Surface { wgpu::Dx12SwapchainKind::DxgiFromVisual; } } + #[cfg(not(target_arch = "wasm32"))] let instance = wgpu::Instance::new(descriptor); - let surface = instance.create_surface(window.clone())?; + let surface = instance.create_surface(target(&window))?; platform::configure_presentation(&surface); let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { @@ -70,7 +80,18 @@ impl Surface { ..Default::default() }) .await?; - let (device, queue) = adapter.request_device(&Default::default()).await?; + // A browser without WebGPU draws through WebGL2, whose limits are lower. + #[cfg(target_arch = "wasm32")] + let required_limits = + wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()); + #[cfg(not(target_arch = "wasm32"))] + let required_limits = wgpu::Limits::default(); + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + required_limits, + ..Default::default() + }) + .await?; let size = window.inner_size(); let mut config = surface .get_default_config(&adapter, size.width, size.height) @@ -92,9 +113,16 @@ impl Surface { if platform::cuts_corners() && alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied; } + #[cfg(target_arch = "wasm32")] + let view_format = config.format.add_srgb_suffix(); + #[cfg(not(target_arch = "wasm32"))] + let view_format = config.format; + if view_format != config.format { + config.view_formats.push(view_format); + } surface.configure(&device, &config); eprintln!("Canvas GPU: {:?}", adapter.get_info()); - let renderer = Renderer::new(device.clone(), queue.clone(), config.format); + let renderer = Renderer::new(device.clone(), queue.clone(), view_format); Ok(( Self { size: [config.width, config.height], @@ -104,6 +132,7 @@ impl Surface { instance, surface, config, + view_format, translucent, }, renderer, @@ -134,7 +163,7 @@ impl Surface { return Ok(None); } wgpu::CurrentSurfaceTexture::Lost => { - self.surface = self.instance.create_surface(self.window.clone())?; + self.surface = self.instance.create_surface(target(&self.window))?; platform::configure_presentation(&self.surface); self.configure(renderer); self.window.request_redraw(); @@ -154,7 +183,10 @@ impl Surface { }; let target = match &mut self.translucent { Some(translucent) => translucent.target(&self.device, self.size), - None => texture.texture.create_view(&Default::default()), + None => texture.texture.create_view(&wgpu::TextureViewDescriptor { + format: Some(self.view_format), + ..Default::default() + }), }; let target = target_of(target); Ok(Some(Frame { @@ -195,7 +227,7 @@ impl Surface { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: self.config.format, + format: self.view_format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); @@ -235,7 +267,7 @@ impl Surface { timeout: Some(std::time::Duration::from_secs(5)), })?; let bgra = matches!( - self.config.format, + self.view_format, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb ); let mut pixels = Vec::with_capacity(size[0] as usize * size[1] as usize * 4); @@ -251,6 +283,17 @@ impl Surface { } } +/// What the surface presents to: the window, or in the browser the page's canvas. +fn target(window: &Arc) -> wgpu::SurfaceTarget<'static> { + #[cfg(target_arch = "wasm32")] + return { + let _ = window; + wgpu::SurfaceTarget::Canvas(platform::canvas()) + }; + #[cfg(not(target_arch = "wasm32"))] + window.clone().into() +} + /// `view` as what the renderer draws into, which is the view itself unless `draw` also /// paints through OpenGL, as on Windows. #[cfg_attr(not(windows), expect(clippy::useless_conversion))] diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index ead43de22709b19b0129ea75e550977b44fb3123..32b77cc537aed680af39796a6b5e8621e5cb89a3 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -1285,7 +1285,7 @@ mod tests { .flat_map(|line| &line[..size[0] as usize * 4]) .copied() .collect(); - let file = std::fs::File::create(path).unwrap(); + let file = notebook::fs::File::create(path).unwrap(); let mut encoder = png::Encoder::new(file, size[0], height); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); @@ -1458,7 +1458,7 @@ mod tests { fn popup_renders_each_state() { let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from); let mut gpu = output.as_ref().map(|output| { - std::fs::create_dir_all(output).unwrap(); + notebook::fs::create_dir_all(output).unwrap(); Gpu::new() }); let states = states(); @@ -1479,7 +1479,7 @@ mod tests { continue; }; let frames = output.join(format!("frames-{theme}")); - std::fs::create_dir_all(&frames).unwrap(); + notebook::fs::create_dir_all(&frames).unwrap(); let mut ui = ui(appearance); let mut now = Instant::now(); settle( @@ -1511,7 +1511,7 @@ mod tests { } // Work offline on, an edit waiting, then off: the edit sends and nothing moves. let frames = output.join(format!("toggle-{theme}")); - std::fs::create_dir_all(&frames).unwrap(); + notebook::fs::create_dir_all(&frames).unwrap(); let mut ui = self::ui(appearance); let mut now = Instant::now(); settle( diff --git a/crates/snowbound/src/tags.rs b/crates/snowbound/src/tags.rs index fe2b3e2745f8fcaf0500ddf8d622db711c92a9bd..13807c71965bb4039f01b8f1dffadda83b0928ac 100644 --- a/crates/snowbound/src/tags.rs +++ b/crates/snowbound/src/tags.rs @@ -104,7 +104,7 @@ fn pictures() -> Option { pub(crate) fn art_bytes(art: &str) -> Option> { match extra_art().iter().position(|name| name == art) { Some(extra) => Some(EXTRAS[extra].2.as_bytes().to_vec()), - None => std::fs::read(pictures()?.join(art)).ok(), + None => notebook::fs::read(pictures()?.join(art)).ok(), } } @@ -595,7 +595,7 @@ impl State { let Some(path) = platform::pick_file("Custom Image", &["png", "svg"]) else { return; }; - let kept = std::fs::read(&path) + let kept = notebook::fs::read(&path) .ok() .and_then(|bytes| canvas::gpu::import_tag_art(&bytes)); let Some((bytes, extension)) = kept else { @@ -608,8 +608,8 @@ impl State { let written = pictures() .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound)) .and_then(|folder| { - std::fs::create_dir_all(&folder)?; - match std::fs::File::create_new(folder.join(&art)) { + notebook::fs::create_dir_all(&folder)?; + match notebook::fs::File::create_new(folder.join(&art)) { Ok(mut file) => std::io::Write::write_all(&mut file, &bytes), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), Err(error) => Err(error), @@ -1185,16 +1185,16 @@ mod tests { let written = section.image(); let temporary = std::env::temp_dir().join(format!("snowbound-custom-art-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); let root = temporary.join("Custom Art"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write(root.join("Custom art.one"), &written).unwrap(); + notebook::fs::create_dir_all(&root).unwrap(); + notebook::fs::write(root.join("Custom art.one"), &written).unwrap(); let file_id = Store::parse(&written).unwrap().header.file_id; let toc = onestore::create_table_of_contents( "Open Notebook.onetoc2", &[("Custom art.one", file_id)], ); - std::fs::write(root.join("Open Notebook.onetoc2"), toc.unwrap()).unwrap(); + notebook::fs::write(root.join("Open Notebook.onetoc2"), toc.unwrap()).unwrap(); let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")); let notebook = notebook.unwrap(); for tag in &list[..3] { @@ -1242,7 +1242,7 @@ mod tests { .unwrap(); assert!(status.success()); } - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } /// The gallery offers each symbol OneNote 2010's does once: every one MS-ONE lists but @@ -1267,8 +1267,8 @@ mod tests { let sources = canvas::gpu::art_sources(&mapping.art, bytes).unwrap(); art.map(&mapping.name, mapping.shape, &mapping.art, sources); } - std::fs::remove_dir_all(&cache).unwrap(); - let bytes = std::fs::read(corpus.join("cold/notebook/Custom art.one")).unwrap(); + notebook::fs::remove_dir_all(&cache).unwrap(); + let bytes = notebook::fs::read(corpus.join("cold/notebook/Custom art.one")).unwrap(); let store = Store::parse(&bytes).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); diff --git a/crates/snowbound/src/themes.rs b/crates/snowbound/src/themes.rs index 94d5d6d81592bd3ae8b26e6d3e78290748afc973..ace5be3ce0b8e68da5730c8ca194fc4e49750b79 100644 --- a/crates/snowbound/src/themes.rs +++ b/crates/snowbound/src/themes.rs @@ -775,9 +775,9 @@ mod tests { fn themed_pages_hold_their_theme_under_onenote_s_names() { let temporary = std::env::temp_dir().join(format!("snowbound-styles-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); let root = temporary.join("Styles"); - std::fs::create_dir_all(&root).unwrap(); + notebook::fs::create_dir_all(&root).unwrap(); let mut engine = TextEngine::default(); let mut sections = Vec::new(); for theme in built_in() { @@ -880,7 +880,7 @@ mod tests { } assert_eq!(definition, other[&name], "{} to modern {name}", theme.name); } - std::fs::write(root.join(&file), &written).unwrap(); + notebook::fs::write(root.join(&file), &written).unwrap(); let file_id = Store::parse(&written).unwrap().header.file_id; sections.push((file, file_id, theme.id)); } @@ -889,7 +889,7 @@ mod tests { .map(|(file, id, _)| (file.as_str(), *id)) .collect(); let toc = onestore::create_table_of_contents("Open Notebook.onetoc2", &listed).unwrap(); - std::fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap(); + notebook::fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap(); let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")).unwrap(); let themes = notebook .save_themes(Themes { @@ -916,6 +916,6 @@ mod tests { .unwrap(); assert!(status.success()); } - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } } diff --git a/crates/snowbound/src/undo.rs b/crates/snowbound/src/undo.rs index 8e6f22084509255f261e4135b5e16945b5c3e219..04b842ee04abd1ffe30633d26d369262932a03ed 100644 --- a/crates/snowbound/src/undo.rs +++ b/crates/snowbound/src/undo.rs @@ -1124,8 +1124,8 @@ mod tests { fn new(name: &str, titles: &[&str]) -> Self { let folder = std::env::temp_dir().join(format!("snowbound-undo-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&folder); - std::fs::create_dir_all(&folder).unwrap(); + let _ = notebook::fs::remove_dir_all(&folder); + notebook::fs::create_dir_all(&folder).unwrap(); let root = folder.join("Notebook"); let mut notebook = Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()) @@ -1211,7 +1211,7 @@ mod tests { let file = self .folder .join("Notebook/OneNote_RecycleBin/OneNote_DeletedPages.one"); - std::fs::read(file).map_or_else( + notebook::fs::read(file).map_or_else( |_| Vec::new(), |bytes| { notebook::session::stored_pages(&bytes) @@ -1235,7 +1235,7 @@ mod tests { if let Some(section) = self.section.take() { let _ = section.close(); } - let _ = std::fs::remove_dir_all(&self.folder); + let _ = notebook::fs::remove_dir_all(&self.folder); } } @@ -1434,8 +1434,8 @@ mod tests { use crate::manage::Structure; let folder = std::env::temp_dir().join(format!("snowbound-undo-sections-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&folder); - std::fs::create_dir_all(&folder).unwrap(); + let _ = notebook::fs::remove_dir_all(&folder); + notebook::fs::create_dir_all(&folder).unwrap(); let root = folder.join("Notebook"); let mut notebook = Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()).unwrap(); @@ -1495,7 +1495,7 @@ mod tests { carry(&mut notebook, structure); notebook.rename("Kitchen.one", "Pantry").unwrap(); assert!(restructuring(&library(notebook), &undo.change).is_none()); - let _ = std::fs::remove_dir_all(&folder); + let _ = notebook::fs::remove_dir_all(&folder); } /// Every kind of step taken back and redone in one notebook, written as the app writes @@ -1596,13 +1596,13 @@ mod tests { } fn copy(from: &std::path::Path, to: &std::path::Path) { - std::fs::create_dir_all(to).unwrap(); - for entry in std::fs::read_dir(from).unwrap().flatten() { + notebook::fs::create_dir_all(to).unwrap(); + for entry in notebook::fs::read_dir(from).unwrap().flatten() { let target = to.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { copy(&entry.path(), &target); } else { - std::fs::copy(entry.path(), target).unwrap(); + notebook::fs::copy(entry.path(), target).unwrap(); } } } diff --git a/crates/snowbound/src/unpack.rs b/crates/snowbound/src/unpack.rs index c33cb6f78f47122ce367ca5fdc7dee4fc6d42f14..35cc0e059efc48494e2cb06423550f4a1799c383 100644 --- a/crates/snowbound/src/unpack.rs +++ b/crates/snowbound/src/unpack.rs @@ -189,7 +189,7 @@ impl State { if create && !valid { return platform::alert("Couldn't unpack the notebook", "Give the notebook a name."); } - if create && root.exists() { + if create && notebook::fs::metadata(&root).is_ok() { return platform::alert( "Couldn't unpack the notebook", &format!( @@ -212,11 +212,13 @@ impl State { /// `color` where one was picked, and opens it. fn unpack(&mut self, package: PathBuf, root: PathBuf, color: Option) { let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let unpacked = (|| -> Result> { - let files = notebook::package::read(&std::fs::read(&package)?, LIMIT)?; + let files = notebook::package::read(¬ebook::fs::read(&package)?, LIMIT)?; notebook::package::unpack(files, &root)?; - let location = std::path::absolute(&root)?.to_string_lossy().into_owned(); + let location = notebook::fs::absolute(&root)? + .to_string_lossy() + .into_owned(); if let Some(color) = color { Notebook::open(&location, &cache)?.set_color(color)?; } diff --git a/crates/snowbound/src/unread.rs b/crates/snowbound/src/unread.rs index db7006f2c2b72b89e53b6c5feb698443073e9304..2b1043c8e9928a745c671561dfc42604b5a96131 100644 --- a/crates/snowbound/src/unread.rs +++ b/crates/snowbound/src/unread.rs @@ -51,8 +51,8 @@ fn hex(identity: [u8; 16]) -> String { } fn now() -> u32 { - let unix = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unix = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .map_or(0, |since| since.as_secs()); u32::try_from(unix.saturating_sub(TIME32)).unwrap_or(u32::MAX) } @@ -61,7 +61,7 @@ impl Reads { /// What the cache folder `cache` keeps. pub fn load(cache: &Path) -> Self { let file = cache.join("read.json"); - let reads: Self = (std::fs::read(&file).ok()) + let reads: Self = (notebook::fs::read(&file).ok()) .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default(); Self { file, ..reads } @@ -71,8 +71,8 @@ impl Reads { let partial = self.file.with_extension("partial"); let saved = serde_json::to_vec(self) .map_err(std::io::Error::from) - .and_then(|bytes| std::fs::write(&partial, bytes)) - .and_then(|()| std::fs::rename(&partial, &self.file)); + .and_then(|bytes| notebook::fs::write(&partial, bytes)) + .and_then(|()| notebook::fs::rename(&partial, &self.file)); if let Err(error) = saved { eprintln!("Keeping what was read failed: {error}"); } @@ -254,7 +254,7 @@ impl State { /// pages are read on a thread of their own, those changed since unread. pub(crate) fn sections_changed(&self, library: &Arc, paths: Vec) { let (library, proxy) = (Arc::clone(library), self.proxy.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let Ok(Some(notebook)) = &library.notebook else { return; }; @@ -387,7 +387,7 @@ mod tests { #[test] fn pages_changed_elsewhere_stay_unread_until_read() { let folder = std::env::temp_dir().join(format!("snowbound-unread-{}", std::process::id())); - std::fs::create_dir_all(&folder).unwrap(); + notebook::fs::create_dir_all(&folder).unwrap(); let (notebook, section) = ("/notebook", [3; 16]); let mut reads = Reads::load(&folder); let known = reads.section_mut(notebook, section).read; @@ -426,6 +426,6 @@ mod tests { ); reads.notebooks.get_mut(notebook).unwrap().hidden = true; assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook)); - std::fs::remove_dir_all(&folder).unwrap(); + notebook::fs::remove_dir_all(&folder).unwrap(); } } diff --git a/crates/snowbound/src/update.rs b/crates/snowbound/src/update.rs index cc0ffd2f091a9e1bcb3964ffb31e289aa4817032..f49c497d0c162513642771dec651baa29edfc6a2 100644 --- a/crates/snowbound/src/update.rs +++ b/crates/snowbound/src/update.rs @@ -2,11 +2,14 @@ //! `build.json` the release key signs; `latest.json` names each platform's newest build. A //! thread checks on launch and daily, downloads a newer build for this platform, verifies //! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits. -//! `tools/RELEASE.md` describes the publishing side. +//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload, +//! so nothing is fetched or installed there. +#![cfg_attr(target_arch = "wasm32", allow(dead_code))] #[cfg(target_os = "linux")] use crate::loader::executable; -use crate::{State, UserEvent, platform}; +use crate::{EventLoopProxy, State, UserEvent, platform}; +#[cfg(not(target_arch = "wasm32"))] use ring::signature::{ED25519, UnparsedPublicKey}; use serde::Deserialize; use std::collections::HashMap; @@ -16,8 +19,9 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; -use winit::event_loop::EventLoopProxy; +use std::time::Duration; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Instant; /// Where the builds are published. const BASE: &str = "https://file.paperclover.net/shr/snowbound/"; @@ -206,6 +210,16 @@ fn unhex(text: &str) -> Option> { .flatten() } +#[cfg(target_arch = "wasm32")] +fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> { + Err(BROWSER.to_owned()) +} + +/// Why the browser installs no builds: it loads the newest each time the page opens. +#[cfg(target_arch = "wasm32")] +const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens."; + +#[cfg(not(target_arch = "wasm32"))] fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> { let signature = unhex(signature).ok_or("The signature isn’t hex")?; UnparsedPublicKey::new(&ED25519, key) @@ -271,9 +285,12 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri archive.size )); } - let digest = ring::digest::digest(&ring::digest::SHA256, bytes); - if unhex(&archive.sha256).as_deref() != Some(digest.as_ref()) { - return Err(format!("{}’s SHA-256 doesn’t match", archive.file)); + #[cfg(not(target_arch = "wasm32"))] + { + let digest = ring::digest::digest(&ring::digest::SHA256, bytes); + if unhex(&archive.sha256).as_deref() != Some(digest.as_ref()) { + return Err(format!("{}’s SHA-256 doesn’t match", archive.file)); + } } verify(key, bytes, &archive.signature) } @@ -448,7 +465,13 @@ fn check( result.unwrap_or_else(Status::Failed) } +#[cfg(target_arch = "wasm32")] +fn download(_: &str, _: u64) -> Result, String> { + Err(BROWSER.to_owned()) +} + /// The `Fetch` the app uses. +#[cfg(not(target_arch = "wasm32"))] fn download(path: &str, limit: u64) -> Result, String> { let agent: ureq::Agent = ureq::Agent::config_builder() .timeout_global(Some(Duration::from_secs(10 * 60))) @@ -498,6 +521,12 @@ impl Updates { ..Shared::default() })); let key = unhex(KEY).expect("release-key.pub holds a key in hex"); + #[cfg(target_arch = "wasm32")] + let thread = { + let _ = (key, proxy); + std::thread::current() + }; + #[cfg(not(target_arch = "wasm32"))] let thread = { let shared = Arc::clone(&shared); std::thread::Builder::new() @@ -570,8 +599,13 @@ impl Updates { /// Checks now, and shows what the check finds when it finishes. pub fn check_now(&self) { - self.shared.lock().unwrap().asked = true; - self.thread.unpark(); + #[cfg(target_arch = "wasm32")] + platform::inform("Snowbound is up to date", BROWSER); + #[cfg(not(target_arch = "wasm32"))] + { + self.shared.lock().unwrap().asked = true; + self.thread.unpark(); + } } /// The staged build to swap in, once Restart to Update has quit the app. @@ -715,6 +749,11 @@ fn quits(pid: u32, patience: Duration) -> bool { true } +#[cfg(target_arch = "wasm32")] +fn quits(_: u32, _: Duration) -> bool { + false +} + #[cfg(windows)] fn quits(pid: u32, patience: Duration) -> bool { use windows_sys::Win32::{ diff --git a/crates/snowbound/src/watch_web.rs b/crates/snowbound/src/watch_web.rs new file mode 100644 index 0000000000000000000000000000000000000000..b499b2971e4953a5520aee7ebbc139e14813fd99 --- /dev/null +++ b/crates/snowbound/src/watch_web.rs @@ -0,0 +1,13 @@ +//! The browser's notebooks change only through this page, so nothing reports their changes. + +use std::path::Path; + +pub struct Watch; + +pub fn watch(_: &Path, _: impl Fn(Vec) + Send + Sync + 'static) -> Option { + None +} + +pub fn on_this_computer(_: &Path) -> bool { + true +} diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs new file mode 100644 index 0000000000000000000000000000000000000000..88ad9b1f7286c72fe830a018cb71dcd2ed02fd05 --- /dev/null +++ b/crates/snowbound/src/web.rs @@ -0,0 +1,1126 @@ +//! The browser: one canvas, its input through `web/glue.js`, and notebooks in the files +//! `notebook::fs` keeps, which the glue loads from IndexedDB and writes back. The window and +//! event loop are stand-ins with the calls `State` makes of winit's; menus are the kit's own, +//! as on Linux; the browser's chords stay the browser's. See arc/platforms.md. + +use crate::{State, UserEvent, commands, page, settings}; +use canvas::date::DateField; +use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + error::Error, + marker::PhantomData, + path::{Path, PathBuf}, + time::Duration, +}; +use ui::Ui; +use wasm_bindgen::prelude::*; +use web_time::Instant; +use winit::{ + dpi::{PhysicalPosition, PhysicalSize, Size}, + event::{Ime, MouseButton}, + event_loop::EventLoopClosed, + keyboard::{Key, ModifiersState, NamedKey}, + window::{CursorIcon, ResizeDirection, Theme, WindowAttributes}, +}; + +#[wasm_bindgen(module = "/web/glue.js")] +extern "C" { + fn attach(module: JsValue); + /// The files IndexedDB kept: `[path, bytes or null for a folder, modified]`. + #[wasm_bindgen(js_name = loadFiles, catch)] + async fn load_files() -> Result; + #[wasm_bindgen(js_name = requestFrame)] + fn request_frame(); + #[wasm_bindgen(js_name = wakeIn)] + fn wake_in(ms: f64); + #[wasm_bindgen(js_name = setCursor)] + fn set_cursor(name: &str); + #[wasm_bindgen(js_name = setTitle)] + fn set_title(title: &str); + #[wasm_bindgen(js_name = placeInput)] + fn place_input(x: f32, y: f32, height: f32, text: bool); + #[wasm_bindgen(js_name = writeClipboard)] + fn write_clipboard(text: &str); + /// Hands `bytes` to the browser to save as `name`, of MIME type `kind`. + pub fn download(name: &str, bytes: &[u8], kind: &str); + #[wasm_bindgen(js_name = pickFiles)] + fn pick_files(purpose: &str, accept: &str); + #[wasm_bindgen(js_name = dateText)] + fn date_strings(ms: f64) -> Vec; + #[wasm_bindgen(js_name = shortDate)] + fn short_date_string(ms: f64) -> String; + #[wasm_bindgen(js_name = askConfirm)] + fn ask_confirm(message: &str) -> bool; + #[wasm_bindgen(js_name = askText)] + fn ask_text(message: &str, value: &str) -> Option; + #[wasm_bindgen(js_name = tell)] + fn tell(message: &str); + #[wasm_bindgen(js_name = openLink)] + fn open_link(url: &str); + /// Writes changed files to IndexedDB: `[path, bytes or null for a folder or undefined + /// for a removal, modified]` triples. + #[wasm_bindgen(js_name = storeFiles)] + fn store_files(changes: js_sys::Array); +} + +/// Where the browser keeps its notebooks, which a first visit makes one in. +const NOTEBOOKS: &str = "/Notebooks"; +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"; +/// 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. +const STORE_AFTER: Duration = Duration::from_millis(500); + +/// What the page's input brought, waiting for the next frame. +enum Input { + Ui(ui::Event), + /// Zooms the page by the factor about a point in the window. + Pinch(f32, [f32; 2]), + /// The canvas's device pixels and device pixels per CSS pixel. + Resize([u32; 2], f32), + Focus(bool), +} + +/// The browser window's state as the canvas reports it. +struct Host { + size: PhysicalSize, + ratio: f64, + focused: bool, + title: String, + theme: Theme, + redraw: bool, +} + +thread_local! { + static HOST: RefCell = const { + RefCell::new(Host { + size: PhysicalSize::new(1, 1), + ratio: 1.0, + focused: true, + title: String::new(), + theme: Theme::Light, + redraw: true, + }) + }; + static STATE: RefCell> = const { RefCell::new(None) }; + static INPUT: RefCell> = const { RefCell::new(Vec::new()) }; + static EVENTS: RefCell> = const { RefCell::new(VecDeque::new()) }; + /// Work deferred to after the frame, as threads run it elsewhere. + static LATER: RefCell>> = const { RefCell::new(Vec::new()) }; + /// Input waits a frame for what a chord opened. + static HOLDING: Cell = const { Cell::new(false) }; + /// Modifier bits as the page's input last reported them. + static HELD: Cell = const { Cell::new(0) }; + /// Whether Command, not Control, takes the editing chords. + static MAC: Cell = const { Cell::new(false) }; + static PASTED: RefCell = RefCell::new(Pasted::default()); + /// When changed files are next written out. + static STORE_DUE: Cell> = const { Cell::new(None) }; + static LANGUAGE: RefCell = const { RefCell::new(String::new()) }; +} + +/// The page's canvas, which frames are drawn into. +pub fn canvas() -> web_sys::HtmlCanvasElement { + use wasm_bindgen::JsCast; + web_sys::window() + .and_then(|window| window.document()) + .and_then(|document| document.get_element_by_id("page")) + .and_then(|canvas| canvas.dyn_into().ok()) + .expect("index.html has a canvas called page") +} + +fn host(act: impl FnOnce(&mut Host) -> T) -> T { + HOST.with_borrow_mut(act) +} + +fn report(error: impl std::fmt::Display) { + web_sys::console::error_1(&error.to_string().into()); +} + +/// Runs `work` once the frame under way is done, as a thread would run it beside the frame. +pub fn defer(work: impl FnOnce() + 'static) { + LATER.with_borrow_mut(|later| later.push(Box::new(work))); + wake_in(0.0); +} + +/// The event loop `State` opens its window in. +pub struct ActiveEventLoop; + +impl ActiveEventLoop { + pub fn create_window(&self, attributes: WindowAttributes) -> Result> { + let window = Window(()); + window.set_title(&attributes.title); + Ok(window) + } +} + +/// The page's canvas as a window. +pub struct Window(()); + +impl Window { + pub fn request_redraw(&self) { + host(|host| host.redraw = true); + request_frame(); + } + + pub fn scale_factor(&self) -> f64 { + host(|host| host.ratio) + } + + pub fn inner_size(&self) -> PhysicalSize { + host(|host| host.size) + } + + pub fn request_inner_size(&self, _: impl Into) -> Option> { + None + } + + pub fn title(&self) -> String { + host(|host| host.title.clone()) + } + + pub fn set_title(&self, title: &str) { + set_title(title); + host(|host| host.title = title.to_owned()); + } + + /// The colour scheme Options chooses is `State`'s; the page follows the browser's. + pub fn set_theme(&self, _: Option) {} + + pub fn has_focus(&self) -> bool { + host(|host| host.focused) + } + + pub fn focus_window(&self) {} + + pub fn set_visible(&self, _: bool) {} + + pub fn set_minimized(&self, _: bool) {} + + /// The browser moves and sizes its own window. + pub fn drag_window(&self) -> Result<(), ()> { + Err(()) + } + + pub fn drag_resize_window(&self, _: ResizeDirection) -> Result<(), ()> { + Err(()) + } + + pub fn set_cursor(&self, cursor: impl Into) { + set_cursor(match cursor.into() { + winit::window::Cursor::Icon(icon) => icon.name(), + winit::window::Cursor::Custom(_) => "default", + }); + } + + pub fn set_ime_allowed(&self, _: bool) {} + + /// Puts the text area at the caret, so an input method's candidates show beside it. + pub fn set_ime_cursor_area( + &self, + position: impl Into, + size: impl Into, + ) { + let ratio = self.scale_factor(); + let position: PhysicalPosition = position.into().to_physical(ratio); + let size: PhysicalSize = size.into().to_physical(ratio); + place_input( + (position.x / ratio) as f32, + (position.y / ratio) as f32, + ((size.height / ratio) as f32).max(1.0), + true, + ); + } + + pub fn pre_present_notify(&self) {} +} + +/// Sends events to the page's loop, from wherever `State` hands it. +pub struct EventLoopProxy(PhantomData); + +impl Clone for EventLoopProxy { + fn clone(&self) -> Self { + Self(PhantomData) + } +} + +impl EventLoopProxy { + pub fn send_event(&self, event: UserEvent) -> Result<(), EventLoopClosed> { + EVENTS.with_borrow_mut(|events| events.push_back(event)); + request_frame(); + Ok(()) + } +} + +/// Assistive technology's view of the window, which a DOM mirror takes in a later phase; +/// until then no tree is asked for. +pub struct AccessAdapter; + +impl AccessAdapter { + pub fn with_event_loop_proxy( + _: &ActiveEventLoop, + _: &Window, + _: EventLoopProxy, + ) -> Self { + Self + } + + pub fn update_if_active(&mut self, _: impl FnOnce() -> accesskit::TreeUpdate) {} +} + +/// The browser's clipboard: text out through `navigator.clipboard`, and in through the page's +/// paste event, which the glue keeps here before the paste runs. +pub struct Clipboard; + +#[derive(Default)] +struct Pasted { + text: Option, + html: Option, + files: Vec, +} + +impl Clipboard { + pub fn new(_: &Window) -> Result> { + Ok(Self) + } + + pub fn set_text(&mut self, text: String) -> Result<(), Box> { + write_clipboard(&text); + Ok(()) + } + + pub fn get_text(&mut self) -> Result> { + PASTED + .with_borrow(|pasted| pasted.text.clone()) + .ok_or_else(|| "Nothing is on the clipboard".into()) + } + + pub fn get_files(&mut self) -> Vec { + PASTED.with_borrow(|pasted| pasted.files.clone()) + } + + pub fn get_html(&mut self) -> Option { + PASTED.with_borrow(|pasted| pasted.html.clone()) + } + + pub fn get_picture(&mut self) -> Option> { + None + } +} + +pub const LEADING: f32 = 8.0; +pub const TRAILING: f32 = 8.0; + +pub fn window_attributes() -> WindowAttributes { + WindowAttributes::default() +} + +pub fn install_title_bar(_: &Window) {} + +pub fn install_text_input(_: &Window) {} + +pub fn install_backdrop(_: &Window) -> bool { + false +} + +pub fn install_menu() {} + +pub fn update_menu(_: impl FnOnce() -> Vec) {} + +pub fn update_tag_menu(_: &[canvas::editor::NoteTag]) {} + +/// The browser draws the tab's title; the toolbar's row is the page's top. +pub fn system_titlebar(_: &Window) -> bool { + true +} + +pub fn represent(_: &Window, _: Option<&Path>) {} + +pub fn titlebar(_: Theme) -> Option<[[f32; 4]; 2]> { + None +} + +pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme { + theme.over_backdrop() +} + +/// The kit's own menus. +pub fn menu(_: Theme) -> Option { + None +} + +pub fn follow_appearance(_: &Window, _: Theme) {} + +pub fn appearance(_: &Window) -> Theme { + host(|host| host.theme) +} + +pub fn corner_radius(_: &Window) -> f32 { + 0.0 +} + +pub fn cuts_corners() -> bool { + false +} + +pub fn window_controls(_: &mut Ui, _: &Window) {} + +pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option { + None +} + +pub fn set_min_size(_: &Window, _: [f32; 2]) {} + +pub fn zoom(_: &Window) {} + +pub fn move_cursor() -> CursorIcon { + CursorIcon::Move +} + +pub fn cover_border_line(_: &mut Ui, _: f32) {} + +/// The interface in Carlito as Calibri, the page's default face, as the browser has no +/// system font to offer. +pub fn system_interface(ui: &mut Ui) { + ui.set_system_font("Calibri"); +} + +pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} + +pub fn clear_marked_text(_: &Window) {} + +pub fn configure_presentation(_: &wgpu::Surface<'_>) {} + +pub fn commit_presentation(_: &Window) {} + +pub fn double_click_interval() -> Duration { + Duration::from_millis(500) +} + +/// Pages fall back through the fonts the browser module carries. +pub fn symbol_fonts() -> Vec { + Vec::new() +} + +pub fn file_icon(_: &Path) -> Option> { + None +} + +/// The browser has no file manager to show a file in. +pub const SHOW_FILE: &str = "Download"; + +/// Downloads `file`, the nearest the browser comes to showing it. +pub fn show_file(file: &Path) { + open_file(file); +} + +/// Downloads a copy of the file at `path` from the browser's files. +pub fn open_file(path: &Path) { + match notebook::fs::read(path) { + Ok(bytes) => download( + &crate::library::file_name(path), + &bytes, + "application/octet-stream", + ), + Err(error) => alert("Couldn't open the file", &error.to_string()), + } +} + +/// Opens a link's URL in a new tab; a folder of the browser's files has nothing to show. +pub fn reveal(target: impl AsRef) { + let target = target.as_ref().to_string_lossy(); + if target.contains("://") || target.starts_with("mailto:") { + open_link(&target); + } +} + +pub fn cache_dir() -> Option { + Some(CACHE.into()) +} + +pub fn settings_dir() -> Option { + Some(SETTINGS.into()) +} + +pub fn documents_dir() -> Option { + Some(NOTEBOOKS.into()) +} + +/// The browser names no user; edits are stored under the name Options sets. +pub fn user_name() -> String { + String::new() +} + +pub fn input_language() -> String { + LANGUAGE.with_borrow(Clone::clone) +} + +/// Milliseconds since 1970 at FILETIME `filetime`. +fn unix_ms(filetime: u64) -> f64 { + (filetime / 10_000) as f64 - 11_644_473_600_000.0 +} + +/// Seconds east of UTC at `unix` seconds since 1970, as the browser's zone has it. +pub fn utc_offset(unix: i64) -> i64 { + -(js_sys::Date::new(&(unix as f64 * 1e3).into()).get_timezone_offset() as i64) * 60 +} + +pub fn short_date(filetime: u64) -> String { + short_date_string(unix_ms(filetime)) +} + +pub fn date_text(filetime: u64) -> [String; 2] { + let mut text = date_strings(unix_ms(filetime)).into_iter(); + [ + text.next().unwrap_or_default(), + text.next().unwrap_or_default(), + ] +} + +/// Asks for the page's date as `YYYY-MM-DD`, or its time as `HH:MM`. +pub fn edit_date( + timestamp: u64, + field: DateField, + title: &str, +) -> Result, &'static str> { + let ms = unix_ms(timestamp); + let date = js_sys::Date::new(&ms.into()); + let current = match field { + DateField::Date => format!( + "{:04}-{:02}-{:02}", + date.get_full_year(), + date.get_month() + 1, + date.get_date() + ), + DateField::Time => format!("{:02}:{:02}", date.get_hours(), date.get_minutes()), + }; + let Some(answer) = ask_text(title, ¤t) else { + return Ok(None); + }; + let numbers: Vec = answer + .split(|c: char| !c.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse().ok()) + .collect(); + match (field, numbers.as_slice()) { + (DateField::Date, &[year, month, day]) + if (1..=12).contains(&month) && (1..=31).contains(&day) => + { + date.set_full_year_with_month_date(year, month as i32 - 1, day as i32); + } + (DateField::Time, &[hour, minute]) if hour < 24 && minute < 60 => { + date.set_hours(hour); + date.set_minutes(minute); + } + _ => return Err(crate::DATE_UNCHOSEN), + } + let seconds = (date.get_time() / 1e3).floor(); + let updated = ((seconds as i64 + 11_644_473_600) as u64) + .checked_mul(10_000_000) + .and_then(|ticks| ticks.checked_add(timestamp % 10_000_000)) + .ok_or(crate::DATE_OUT_OF_RANGE)?; + Ok(Some((updated, date_text(updated)))) +} + +/// Asks for a file to insert; once chosen it goes to the caret, as a dropped file does. +pub fn pick_file(_: &str, types: &[&str]) -> Option { + let accept: Vec = types.iter().map(|kind| format!(".{kind}")).collect(); + pick_files("place", &accept.join(",")); + None +} + +/// Asks for notebooks, sections or packages to open; they open once read. +pub fn pick_notebook(_: &str) -> Option { + pick_files("open", ".one,.onetoc2,.onepkg"); + None +} + +/// A new notebook goes in the browser's notebooks, under a name not yet taken; anything else +/// asked a place goes there too, then downloads. +pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>) -> Option { + let name = ask_text(title, name)?; + let name = name.trim().replace(['/', '\\'], " "); + let folder = Path::new(NOTEBOOKS); + let _ = notebook::fs::create_dir_all(folder); + let mut path = folder.join(&name); + let mut number = 2; + while notebook::fs::metadata(&path).is_ok() { + path = folder.join(format!("{name} {number}")); + number += 1; + } + Some(path) +} + +pub fn confirm(message: &str, detail: &str, _: &str, _: &str) -> bool { + ask_confirm(&format!("{message}\n\n{detail}")) +} + +pub fn alert(message: &str, detail: &str) { + tell(&format!("{message}\n\n{detail}")); +} + +pub fn inform(message: &str, detail: &str) { + alert(message, detail); +} + +/// Servers are reached through a relay in a later phase; none is mounted here. +pub fn smb_mount(_: &Path) -> Option { + None +} + +pub fn smb_login(_: &crate::library::Mount) -> Result { + Err("Servers aren't reachable from the browser yet.".into()) +} + +pub fn remember_label() -> Option<&'static str> { + None +} + +pub fn save_login(_: &crate::library::Mount, _: &crate::library::Login) -> Result<(), String> { + Err("The browser keeps no passwords.".into()) +} + +/// The embedded SMB client's face, until a WebSocket relay carries SMB to the browser: +/// every server is out of reach. +pub mod smb { + use std::{convert::Infallible, fmt, io, time::Duration}; + + fn unreachable() -> io::Error { + io::Error::new(io::ErrorKind::Unsupported, Refusal::Unreachable) + } + + #[derive(Default)] + pub struct Credentials<'a> { + pub username: &'a str, + pub password: &'a str, + pub domain: &'a str, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct DirectoryEntry { + pub name: String, + pub size: u64, + pub modified: u64, + pub attributes: u32, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum Refusal { + Unreachable, + Smb1, + SignIn, + NoShare, + Denied, + NoFolder, + Other, + } + + impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Servers aren't reachable from the browser yet") + } + } + + impl std::error::Error for Refusal {} + + impl Refusal { + pub fn of(_: &io::Error) -> Self { + Self::Unreachable + } + } + + pub struct Client(Infallible); + + impl Client { + pub fn connect(_: &str, _: &str, _: Credentials, _: Duration) -> io::Result { + Err(unreachable()) + } + + pub fn read_dir(&self, _: &str, _: usize) -> io::Result> { + match self.0 {} + } + } + + pub fn shares(_: &str, _: Credentials, _: Duration) -> io::Result> { + Err(unreachable()) + } +} + +/// Starts Snowbound on the page's canvas, `index.html` having fetched `fonts`. `module` is +/// the module's own exports, which the glue calls. +#[wasm_bindgen] +pub async fn start(module: JsValue, fonts: Vec) -> Result<(), JsValue> { + std::panic::set_hook(Box::new(|info| report(info))); + let window = web_sys::window().ok_or("No window")?; + let navigator = window.navigator(); + MAC.set(navigator.platform().is_ok_and(|platform| { + ["Mac", "iPhone", "iPad"] + .iter() + .any(|name| platform.contains(name)) + })); + LANGUAGE.with_borrow_mut(|language| *language = navigator.language().unwrap_or_default()); + let dark = window + .match_media("(prefers-color-scheme: dark)") + .ok() + .flatten() + .is_some_and(|query| query.matches()); + let ratio = window.device_pixel_ratio(); + let canvas = canvas(); + host(|host| { + host.theme = if dark { Theme::Dark } else { Theme::Light }; + host.size = PhysicalSize::new( + ((f64::from(canvas.client_width()) * ratio) as u32).max(1), + ((f64::from(canvas.client_height()) * ratio) as u32).max(1), + ); + host.ratio = ratio; + }); + canvas.set_width(host(|host| host.size.width)); + canvas.set_height(host(|host| host.size.height)); + restore(load_files().await?); + let state = open(fonts) + .await + .map_err(|error| JsValue::from_str(&error.to_string()))?; + STATE.with_borrow_mut(|slot| *slot = Some(state)); + attach(module); + request_frame(); + Ok(()) +} + +/// Puts back the files IndexedDB kept: `[path, bytes or null for a folder, modified]`. +fn restore(files: js_sys::Array) { + for entry in files.iter() { + let entry = js_sys::Array::from(&entry); + let Some(path) = entry.get(0).as_string() else { + continue; + }; + let bytes = entry.get(1); + let saved = if bytes.is_null() { + notebook::fs::Saved::Directory + } else { + notebook::fs::Saved::File( + js_sys::Uint8Array::new(&bytes).to_vec(), + entry.get(2).as_f64().unwrap_or(0.0), + ) + }; + notebook::fs::restore(path, saved); + } +} + +/// Writes the files changed since the last time out to IndexedDB. +fn store() { + STORE_DUE.set(None); + let changes = js_sys::Array::new(); + for (path, saved) in notebook::fs::changes() { + let path = JsValue::from_str(&path.to_string_lossy()); + let entry = match saved { + None => js_sys::Array::of1(&path), + Some(notebook::fs::Saved::Directory) => js_sys::Array::of2(&path, &JsValue::NULL), + Some(notebook::fs::Saved::File(bytes, modified)) => js_sys::Array::of3( + &path, + &js_sys::Uint8Array::from(bytes.as_slice()), + &modified.into(), + ), + }; + changes.push(&entry); + } + if changes.length() > 0 { + store_files(changes); + } +} + +/// The window, as `launch` opens it with the notebooks the settings list, making the +/// browser's first notebook on a first visit. +async fn open(fonts: Vec) -> Result> { + let cache = PathBuf::from(CACHE); + for folder in [NOTEBOOKS, CACHE, SETTINGS, CHOSEN] { + notebook::fs::create_dir_all(folder)?; + } + notebook::fs::restore(FONTS, notebook::fs::Saved::Directory); + let file = settings::default_path(); + let saved = file + .as_deref() + .map(settings::Settings::load) + .unwrap_or_default(); + let first = saved.notebooks.is_empty(); + let input = crate::Input::Notebooks { + locations: saved.notebooks.clone(), + current: saved.current.clone(), + }; + let launch = settings::Launch { file, saved, cache }; + // Where `State` reads them from, kept out of what IndexedDB stores. + let substitutes: Vec = fonts + .iter() + .enumerate() + .map(|(number, font)| { + let path = PathBuf::from(format!("{FONTS}/{number}.ttf")); + notebook::fs::restore(&path, notebook::fs::Saved::File(font.to_vec(), 0.0)); + path + }) + .collect(); + let mut state = State::new( + &ActiveEventLoop, + EventLoopProxy(PhantomData), + input, + &substitutes, + true, + launch, + ) + .await?; + // The first web build's sections, moved by the glue, or else a notebook of the browser's own. + if first { + let [moved, own] = + ["Web Notebook", "My Notebook"].map(|name| Path::new(NOTEBOOKS).join(name)); + match [&moved, &own] + .into_iter() + .find(|root| notebook::fs::metadata(root).is_ok()) + { + Some(root) => state.open_path(root), + None => state.create_notebook(own.clone())?, + } + } + Ok(state) +} + +fn queue(input: Input) { + INPUT.with_borrow_mut(|queue| queue.push(input)); + request_frame(); +} + +/// Whether the page takes the key `key` with modifier bits `held` (Shift, Control, Alt, Meta +/// from the lowest), rather than leaving it to the browser: the browser keeps its own tab +/// and window chords, and pastes through its paste event. +#[wasm_bindgen] +pub fn takes(key: &str, held: u8) -> bool { + let command = held & if MAC.get() { 8 } else { 2 } != 0; + if !command { + return true; + } + !matches!( + key.to_ascii_lowercase().as_str(), + "v" | "w" | "t" | "n" | "q" | "r" | "l" | "tab" + ) +} + +/// Reports modifier bits `held` where they changed. +fn follow_modifiers(held: u8) { + if HELD.replace(held) == held { + return; + } + let [shift, control, alt, meta] = [1, 2, 4, 8].map(|bit| held & bit != 0); + let mac = MAC.get(); + let mut state = ModifiersState::empty(); + state.set(ModifiersState::SHIFT, shift); + state.set(ModifiersState::ALT, alt); + // The command table's chords are the PC's here; on a Mac, Command is Control. + state.set(ModifiersState::CONTROL, if mac { meta } else { control }); + state.set(ModifiersState::SUPER, if mac { control } else { meta }); + queue(Input::Ui(ui::Event::Modifiers(state))); +} + +/// Pointer `kind` (moved, pressed, released, left) at `x`, `y` in CSS pixels. +#[wasm_bindgen] +pub fn pointer(kind: u8, x: f32, y: f32, button: i16, pressure: f32, held: u8) { + follow_modifiers(held); + queue(Input::Ui(ui::Event::Pressure( + (!pressure.is_nan()).then_some(pressure), + ))); + let button = match button { + 1 => MouseButton::Middle, + 2 => MouseButton::Right, + _ => MouseButton::Left, + }; + queue(Input::Ui(match kind { + 0 => ui::Event::PointerMoved([x, y]), + 1 | 2 => ui::Event::Button { + button, + pressed: kind == 1, + at: Instant::now(), + }, + _ => ui::Event::PointerLeft, + })); +} + +#[wasm_bindgen] +pub fn key(key: &str, text: Option, held: u8) { + follow_modifiers(held); + queue(Input::Ui(ui::Event::Key { + key: logical_key(key), + text, + })); +} + +#[wasm_bindgen] +pub fn modifiers(held: u8) { + follow_modifiers(held); +} + +#[wasm_bindgen] +pub fn compose(text: String) { + let end = text.len(); + queue(Input::Ui(ui::Event::Ime(Ime::Preedit( + text, + Some((end, end)), + )))); +} + +#[wasm_bindgen] +pub fn commit(text: String) { + queue(Input::Ui(ui::Event::Ime(Ime::Commit(text)))); +} + +/// The page's paste event: its text, HTML and files, kept for the paste it runs. +#[wasm_bindgen] +pub fn paste(text: Option, html: Option, files: js_sys::Array) { + let files = keep(files, CHOSEN); + PASTED.with_borrow_mut(|pasted| *pasted = Pasted { text, html, files }); + send(UserEvent::Choose(commands::Choice::Command( + commands::Id::Paste, + ))); +} + +#[wasm_bindgen] +pub fn wheel(x: f32, y: f32) { + queue(Input::Ui(ui::Event::Wheel([x, y]))); +} + +#[wasm_bindgen] +pub fn pinch(factor: f32, x: f32, y: f32) { + queue(Input::Pinch(factor, [x, y])); +} + +#[wasm_bindgen] +pub fn resize(width: u32, height: u32, ratio: f32) { + queue(Input::Resize([width, height], ratio)); +} + +#[wasm_bindgen] +pub fn focus(focused: bool) { + queue(Input::Focus(focused)); +} + +#[wasm_bindgen] +pub fn appearance_changed(dark: bool) { + host(|host| host.theme = if dark { Theme::Dark } else { Theme::Light }); + send(UserEvent::Appearance); +} + +/// Files chosen or dropped, as `[name, bytes]` pairs, for `purpose`: `open` opens them as +/// notebooks, sections or packages, `place` puts them at the caret. +#[wasm_bindgen] +pub fn files(purpose: &str, files: js_sys::Array) { + match purpose { + "open" => { + let paths = keep(files, NOTEBOOKS); + send(UserEvent::Open(paths)); + } + _ => { + for path in keep(files, CHOSEN) { + send(UserEvent::Then(Box::new(move |state| { + state.place_file(&path, None) + }))); + } + } + } +} + +/// Writes `[name, bytes]` pairs into `folder` under names not yet taken, returning the paths. +fn keep(files: js_sys::Array, folder: &str) -> Vec { + files + .iter() + .filter_map(|pair| { + let pair = js_sys::Array::from(&pair); + let name = pair.get(0).as_string()?; + let bytes = js_sys::Uint8Array::new(&pair.get(1)).to_vec(); + let name = Path::new(&name).file_name()?.to_owned(); + let mut path = Path::new(folder).join(&name); + let (stem, extension) = ( + Path::new(&name).file_stem()?.to_string_lossy().into_owned(), + Path::new(&name) + .extension() + .map(|extension| format!(".{}", extension.to_string_lossy())) + .unwrap_or_default(), + ); + let mut number = 2; + while notebook::fs::metadata(&path).is_ok() { + path = Path::new(folder).join(format!("{stem} {number}{extension}")); + number += 1; + } + notebook::fs::write(&path, bytes) + .inspect_err(|error| report(error)) + .ok()?; + Some(path) + }) + .collect() +} + +fn send(event: UserEvent) { + EVENTS.with_borrow_mut(|events| events.push_back(event)); + request_frame(); +} + +/// Writes every changed file out now, as the page goes away. +#[wasm_bindgen] +pub fn flush() { + STATE.with(|state| { + if let Ok(mut state) = state.try_borrow_mut() + && let Some(state) = state.as_mut() + && let Err(error) = state.persist() + { + report(error); + } + }); + store(); +} + +#[wasm_bindgen] +pub fn frame() { + STATE.with(|state| { + let Ok(mut state) = state.try_borrow_mut() else { + return request_frame(); + }; + let Some(state) = state.as_mut() else { + return; + }; + if let Err(error) = turn(state) { + report(error); + } + }); + for work in LATER.take() { + work(); + } +} + +/// One turn of the loop: the page's input, the events sent to the loop, a frame where one +/// was asked for, then when to come back. +fn turn(state: &mut State) -> Result<(), Box> { + // Input after a chord waits for the frame that runs it and the one that builds what it + // opened, as the palette takes the keys typed next. + let mut inputs = match HOLDING.take() { + true => Vec::new(), + false => INPUT.take(), + } + .into_iter(); + while state.commands.is_empty() + && let Some(input) = inputs.next() + { + match input { + Input::Ui(event) => state.input(event), + Input::Pinch(factor, point) => { + state.input(ui::Event::PointerMoved(point)); + state.pinch(factor)?; + } + Input::Resize(size, ratio) => { + let ratio = f64::from(ratio); + let changed = host(|host| { + let changed = host.ratio != ratio; + host.size = PhysicalSize::new(size[0].max(1), size[1].max(1)); + host.ratio = ratio; + changed + }); + if changed { + state.renderer.clear_glyph_cache(); + } + state.surface.size = size.map(|side| side.max(1)); + state.surface.configure(&state.renderer); + host(|host| host.redraw = true); + } + Input::Focus(focused) => { + state.ui.window_focused = focused; + host(|host| host.focused = focused); + if !focused { + state.publish_now(Duration::ZERO); + } + host(|host| host.redraw = true); + } + } + } + let waiting: Vec = inputs.collect(); + if !waiting.is_empty() { + INPUT.with_borrow_mut(|queue| queue.splice(0..0, waiting).for_each(drop)); + HOLDING.set(true); + } + if HOLDING.get() || !INPUT.with_borrow(Vec::is_empty) { + host(|host| host.redraw = true); + request_frame(); + } + while let Some(event) = EVENTS.with_borrow_mut(VecDeque::pop_front) { + state.user_event(event); + } + if host(|host| std::mem::take(&mut host.redraw)) { + state.frame()?; + } + park_input(state); + let now = Instant::now(); + let (repaint, blink) = state.view.blink(now); + let wake = state.ui.wake_at(); + if repaint || wake.is_some_and(|wake| wake <= now) { + state.window.request_redraw(); + } + if STORE_DUE.get().is_none() && notebook::fs::changed() { + STORE_DUE.set(Some(now + STORE_AFTER)); + } + if STORE_DUE.get().is_some_and(|due| due <= now) { + store(); + } + let next = [blink, wake.filter(|wake| *wake > now), STORE_DUE.get()] + .into_iter() + .flatten() + .min(); + if let Some(next) = next { + wake_in(next.saturating_duration_since(now).as_secs_f64() * 1e3); + } + Ok(()) +} + +/// Parks the text area while the page takes no text, keeping it writable for the +/// interface's fields. +fn park_input(state: &State) { + if state.ui.focused() != Some(page()) || !state.view.accepts_text() { + place_input(0.0, 0.0, 1.0, state.ui.focused().is_some()); + } +} + +/// The `KeyboardEvent.key` value `key` as winit names it. +fn logical_key(key: &str) -> Key { + let named = match key { + "Enter" => NamedKey::Enter, + "Tab" => NamedKey::Tab, + " " => NamedKey::Space, + "Backspace" => NamedKey::Backspace, + "Delete" => NamedKey::Delete, + "Escape" => NamedKey::Escape, + "ArrowLeft" => NamedKey::ArrowLeft, + "ArrowRight" => NamedKey::ArrowRight, + "ArrowUp" => NamedKey::ArrowUp, + "ArrowDown" => NamedKey::ArrowDown, + "Home" => NamedKey::Home, + "End" => NamedKey::End, + "PageUp" => NamedKey::PageUp, + "PageDown" => NamedKey::PageDown, + "Insert" => NamedKey::Insert, + "Shift" => NamedKey::Shift, + "Control" => NamedKey::Control, + "Alt" => NamedKey::Alt, + "AltGraph" => NamedKey::AltGraph, + "Meta" => NamedKey::Meta, + "ContextMenu" => NamedKey::ContextMenu, + "F1" => NamedKey::F1, + "F2" => NamedKey::F2, + "F3" => NamedKey::F3, + "F4" => NamedKey::F4, + "F5" => NamedKey::F5, + "F6" => NamedKey::F6, + "F7" => NamedKey::F7, + "F8" => NamedKey::F8, + "F9" => NamedKey::F9, + "F10" => NamedKey::F10, + "F11" => NamedKey::F11, + "F12" => NamedKey::F12, + _ => return Key::Character(key.into()), + }; + Key::Named(named) +} diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js new file mode 100644 index 0000000000000000000000000000000000000000..39922340f7521d04e2887f6bf4e1062f345b30ab --- /dev/null +++ b/crates/snowbound/web/glue.js @@ -0,0 +1,342 @@ +// The browser's half of Snowbound (src/web.rs): the page's input, its dialogs and files, and +// IndexedDB, which keeps the files `notebook::fs` holds between visits. + +let wasm; +let canvas; +let input; +let picker; +let framePending = false; +let wakeTimer; + +const DB = "snowbound"; +// `files` holds every path: `{path, bytes}`, `bytes` null for a folder. `sections` is the first +// web build's store, whose sections move into a notebook once. +const FILES = "files"; +const SECTIONS = "sections"; +const MIGRATED = "/Notebooks/Web Notebook"; + +function database() { + return new Promise((resolve, reject) => { + const open = indexedDB.open(DB, 2); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains(FILES)) db.createObjectStore(FILES, { keyPath: "path" }); + if (!db.objectStoreNames.contains(SECTIONS)) + db.createObjectStore(SECTIONS, { keyPath: "file" }); + }; + open.onsuccess = () => resolve(open.result); + open.onerror = () => reject(open.error); + }); +} + +function done(transaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); +} + +/** Every file kept, as `[path, bytes or null, modified]`, the first web build's sections moved + * into a notebook of their own first. */ +export async function loadFiles() { + const db = await database(); + const read = db.transaction([FILES, SECTIONS], "readonly"); + const files = read.objectStore(FILES).getAll(); + const sections = read.objectStore(SECTIONS).getAll(); + await done(read); + let rows = files.result; + if (!rows.length && sections.result.length) { + const moved = [ + { path: "/Notebooks", bytes: null }, + { path: MIGRATED, bytes: null }, + ...sections.result + .sort((a, b) => a.order - b.order) + .map(({ file, bytes }) => ({ path: `${MIGRATED}/${file}`, bytes, modified: Date.now() })), + ]; + const write = db.transaction([FILES, SECTIONS], "readwrite"); + for (const row of moved) write.objectStore(FILES).put(row); + write.objectStore(SECTIONS).clear(); + await done(write); + rows = moved; + } + return rows.map(({ path, bytes, modified }) => [path, bytes, modified ?? 0]); +} + +/** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, bytes, modified]` entries. */ +export function storeFiles(changes) { + database() + .then((db) => { + const transaction = db.transaction(FILES, "readwrite"); + const store = transaction.objectStore(FILES); + for (const [path, bytes, modified] of changes) { + if (bytes === undefined) store.delete(path); + else store.put({ path, bytes, modified }); + } + return done(transaction); + }) + .catch((error) => console.error("Keeping files", error)); +} + +export function requestFrame() { + if (!framePending) { + framePending = true; + requestAnimationFrame(() => { + framePending = false; + wasm.frame(); + }); + } +} + +export function wakeIn(ms) { + clearTimeout(wakeTimer); + wakeTimer = setTimeout(requestFrame, Math.max(0, ms)); +} + +export function setCursor(name) { + if (canvas.style.cursor !== name) canvas.style.cursor = name; +} + +export function setTitle(title) { + document.title = title; +} + +/** Puts the text area at the caret, so an input method's candidates show beside it. */ +export function placeInput(x, y, height, text) { + input.style.left = `${x}px`; + input.style.top = `${y}px`; + input.style.height = `${height}px`; + input.readOnly = !text; +} + +export function writeClipboard(text) { + navigator.clipboard.writeText(text).catch((error) => console.warn("Copy", error)); +} + +export function download(name, bytes, kind) { + const url = URL.createObjectURL(new Blob([bytes], { type: kind })); + const link = Object.assign(document.createElement("a"), { href: url, download: name }); + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); +} + +/** Asks for files for `purpose` (`open` or `place`), of the extensions `accept` lists. */ +export function pickFiles(purpose, accept) { + picker.dataset.purpose = purpose; + picker.accept = accept; + picker.multiple = purpose === "open"; + picker.click(); +} + +/** The long date and short time a new page's title shows, as OneNote dates one. */ +export function dateText(ms) { + const date = new Date(ms); + return [ + date.toLocaleDateString(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }), + date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), + ]; +} + +export function shortDate(ms) { + return new Date(ms).toLocaleDateString(); +} + +export function askConfirm(message) { + return confirm(message); +} + +export function askText(message, value) { + return prompt(message, value) ?? undefined; +} + +export function tell(message) { + alert(message); +} + +export function openLink(url) { + open(url, "_blank", "noopener"); +} + +function modifiers(event) { + return ( + (event.shiftKey ? 1 : 0) | + (event.ctrlKey ? 2 : 0) | + (event.altKey ? 4 : 0) | + (event.metaKey ? 8 : 0) + ); +} + +function read(list) { + return Promise.all( + [...list].map(async (file) => [file.name, new Uint8Array(await file.arrayBuffer())]), + ); +} + +const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i; + +/** Wires the page's canvas, text area and file input to `module`'s exports. */ +export function attach(module) { + wasm = module; + canvas = document.getElementById("page"); + input = document.getElementById("input"); + picker = document.getElementById("files"); + const point = (event) => { + const rect = canvas.getBoundingClientRect(); + return [event.clientX - rect.left, event.clientY - rect.top]; + }; + const pressure = (event) => (event.pointerType === "pen" ? event.pressure : NaN); + + // A finger pans the page or, with a second, pinches it; one that doesn't move taps it. + const touches = new Map(); + let gesture = null; + const touchMoved = (event) => { + const last = touches.get(event.pointerId); + const now = point(event); + touches.set(event.pointerId, now); + if (touches.size === 1) { + if (!gesture.panning && Math.hypot(now[0] - gesture.start[0], now[1] - gesture.start[1]) > 8) + gesture.panning = true; + if (gesture.panning) { + wasm.pointer(0, ...now, -1, NaN, 0); + wasm.wheel(now[0] - last[0], now[1] - last[1]); + } + } else if (touches.size === 2) { + const [a, b] = [...touches.values()]; + const distance = Math.hypot(a[0] - b[0], a[1] - b[1]); + const centre = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + if (gesture.distance) { + wasm.pinch(distance / gesture.distance, centre[0], centre[1]); + wasm.wheel(centre[0] - gesture.centre[0], centre[1] - gesture.centre[1]); + } + Object.assign(gesture, { distance, centre, panning: true }); + } + }; + + canvas.addEventListener("pointerdown", (event) => { + event.preventDefault(); + input.focus({ preventScroll: true }); + canvas.setPointerCapture(event.pointerId); + if (event.pointerType === "touch") { + touches.set(event.pointerId, point(event)); + if (touches.size === 1) gesture = { start: point(event), panning: false }; + else gesture.distance = 0; + return; + } + wasm.pointer(0, ...point(event), -1, pressure(event), modifiers(event)); + wasm.pointer(1, ...point(event), event.button, pressure(event), modifiers(event)); + }); + canvas.addEventListener("pointermove", (event) => { + if (event.pointerType === "touch") { + if (touches.has(event.pointerId)) touchMoved(event); + return; + } + for (const each of event.getCoalescedEvents?.() ?? [event]) + wasm.pointer(0, ...point(each), -1, pressure(each), modifiers(event)); + }); + const up = (event) => { + if (event.pointerType === "touch") { + if (!touches.delete(event.pointerId)) return; + if (touches.size === 0 && !gesture.panning && event.type === "pointerup") { + wasm.pointer(0, ...point(event), -1, NaN, 0); + wasm.pointer(1, ...point(event), 0, NaN, 0); + wasm.pointer(2, ...point(event), 0, NaN, 0); + wasm.pointer(3, 0, 0, -1, NaN, 0); + } + return; + } + wasm.pointer(2, ...point(event), event.button, pressure(event), modifiers(event)); + }; + canvas.addEventListener("pointerup", up); + canvas.addEventListener("pointercancel", up); + canvas.addEventListener("pointerleave", (event) => { + if (event.pointerType !== "touch") wasm.pointer(3, 0, 0, -1, NaN, modifiers(event)); + }); + canvas.addEventListener("contextmenu", (event) => event.preventDefault()); + canvas.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const unit = [1, 32, canvas.clientHeight][event.deltaMode]; + // The page scrolls whatever lies under the pointer, which a wheel alone doesn't move. + wasm.pointer(0, ...point(event), -1, NaN, modifiers(event)); + // Chrome and Safari report a trackpad pinch as a wheel with Control held. + if (event.ctrlKey) wasm.pinch(Math.exp(-event.deltaY * unit * 0.005), ...point(event)); + else wasm.wheel(-event.deltaX * unit, -event.deltaY * unit); + }, + { passive: false }, + ); + + input.addEventListener("keydown", (event) => { + if (event.isComposing || ["Process", "Dead", "Unidentified"].includes(event.key)) return; + const bits = modifiers(event); + if (!wasm.takes(event.key, bits)) return; + event.preventDefault(); + const shortcut = event.ctrlKey || event.metaKey; + const text = [...event.key].length === 1 && !shortcut ? event.key : undefined; + wasm.key(event.key, text, bits); + }); + input.addEventListener("keyup", (event) => wasm.modifiers(modifiers(event))); + input.addEventListener("compositionupdate", (event) => wasm.compose(event.data)); + input.addEventListener("compositionend", (event) => { + wasm.commit(event.data); + input.value = ""; + }); + // Text arriving outside keys and composition, as from a phone's keyboard or dictation. + input.addEventListener("input", (event) => { + if (event.isComposing) return; + if (event.inputType === "insertText" && event.data) wasm.commit(event.data); + input.value = ""; + }); + input.addEventListener("paste", async (event) => { + event.preventDefault(); + const data = event.clipboardData; + const text = data.getData("text/plain") || undefined; + const html = data.getData("text/html") || undefined; + wasm.paste(text, html, await read(data.files)); + }); + input.addEventListener("focus", () => wasm.focus(true)); + input.addEventListener("blur", () => wasm.focus(false)); + + picker.addEventListener("change", async () => { + const files = [...picker.files]; + const purpose = picker.dataset.purpose ?? (files.every((file) => NOTEBOOK_FILES.test(file.name)) ? "open" : "place"); + wasm.files(purpose, await read(files)); + picker.value = ""; + }); + document.addEventListener("dragover", (event) => event.preventDefault()); + document.addEventListener("drop", async (event) => { + event.preventDefault(); + const dropped = [...event.dataTransfer.files]; + const notebooks = dropped.filter((file) => NOTEBOOK_FILES.test(file.name)); + const others = dropped.filter((file) => !NOTEBOOK_FILES.test(file.name)); + if (notebooks.length) wasm.files("open", await read(notebooks)); + if (others.length) wasm.files("place", await read(others)); + }); + addEventListener("pagehide", () => wasm.flush()); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") wasm.flush(); + }); + matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (query) => + wasm.appearance_changed(query.matches), + ); + + const resized = (width, height) => { + canvas.width = width; + canvas.height = height; + wasm.resize(width, height, devicePixelRatio); + }; + // Device pixels from the CSS size: emulated pixel ratios leave devicePixelContentBoxSize + // at the CSS size. + new ResizeObserver(([entry]) => + resized( + Math.round(entry.contentRect.width * devicePixelRatio), + Math.round(entry.contentRect.height * devicePixelRatio), + ), + ).observe(canvas); + input.focus({ preventScroll: true }); +} diff --git a/crates/snowbound/web/index.html b/crates/snowbound/web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..a19aaf2a480635aa64ced4243fdd9a95e2952424 --- /dev/null +++ b/crates/snowbound/web/index.html @@ -0,0 +1,77 @@ + + + + + +Snowbound + + + + + + +
Loading Snowbound…
+ + + diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 826f7dd315e021263108fe1c53c77cec815cb160..9d4029f2803aa961ea0cc600fce6241b2b39f625 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -10,6 +10,8 @@ draw = { path = "../draw", default-features = false, features = ["render"] } nucleo-matcher = "0.3.1" parley.workspace = true winit = "0.30.13" +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" [dev-dependencies] accesskit_consumer = "0.39.0" diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index c2f8d4c6969b13f8ab528be0d2e3cd605bda9c81..64e9604ab0c933d64339386d0954cfcb76c055ff 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -29,9 +29,10 @@ use std::{ hash::{DefaultHasher, Hash, Hasher}, ops::BitOr, rc::Rc, - time::{Duration, Instant}, + time::Duration, }; use text::{Label, Texts}; +use web_time::Instant; use winit::{ event::{Ime, MouseButton}, keyboard::{Key, ModifiersState, NamedKey}, diff --git a/tools/TESTING.md b/tools/TESTING.md index eaa79291ae04552d6fb833611517dc1e60b065fd..3ee0b31210300802f28fe0e41e2b2e280ee44568 100644 --- a/tools/TESTING.md +++ b/tools/TESTING.md @@ -19,6 +19,7 @@ another, and the exit status is the result. | `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) | | `windows-aarch64`, `linux-*` | Clippy `-D warnings` on what ships (libraries and binaries but `mobile`), then the `snowbound` build; Linux through `platform/linux/cargo.sh`, which links with zig against glibc 2.17 | | `ios` | `xcodebuild` of the simulator app, unsigned | +| `web` | Clippy `-D warnings` on `snowbound` for `wasm32-unknown-unknown`, SQLite built by nixpkgs' clang; `release_web.py` links, optimizes and publishes the static folder | | `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` | ```sh diff --git a/tools/canvas/README.md b/tools/canvas/README.md index 6fad891c252e1a44913a08e369820c25512fb06c..c7f389f0ea27c34782888313992311a7a4d8f3f3 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -90,7 +90,7 @@ A paragraph stored as an equation draws in two dimensions from the tree `onestor `TextEngine::default()` lays out Calibri, Arial, Times New Roman and Courier New in the bundled Carlito 1.104, Arimo 1.341, Tinos 1.340 and Cousine 1.241 (`crates/canvas/assets/fonts`, SIL Open Font License) wherever the system lacks them. The font box names such a family with its substitute, as "Carlito (Calibri)"; stored `Format.font` values keep the document's name. -The host and `layout-probe` also accept repeated `--substitute-font FONT_FILE` options for another Arimo, Carlito, Tinos or Cousine file. Each file is registered only in this process, as its family's counterpart; this explicitly replaces that family even when a system copy exists. No font files are installed. +The host and `layout-probe` also accept repeated `--substitute-font FONT_FILE` options for another Arimo, Carlito, Caladea, Tinos or Cousine file. Each file is registered only in this process, as its family's counterpart; this explicitly replaces that family even when a system copy exists. No font files are installed. ```sh "target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE diff --git a/tools/ci.py b/tools/ci.py index afec281160fd465df6e754763f0dbbb95c84108e..e19fcdbe2520313dbd2796631217d74a11e09ac1 100644 --- a/tools/ci.py +++ b/tools/ci.py @@ -75,6 +75,15 @@ def lanes(): commands=[cross(linux, arch, *cross_clippy), cross(linux, arch, 'build', '-p', 'snowbound')], environment={'CARGO_TARGET_DIR': str(TARGET / 'linux')}, missing=None if shutil.which('zig') else 'needs zig')) + targets = subprocess.run(['rustup', 'target', 'list', '--installed'], capture_output=True, text=True).stdout + wasm = web_environment() + result.append(dict( + name='web', minutes=20, packages=['snowbound'], paths=('crates/snowbound/web/', 'tools/release_web.py'), + # Clippy builds it; the module itself is linked by release_web.py. + commands=[cargo('clippy', '-p', 'snowbound', '--target', 'wasm32-unknown-unknown', '--', '-D', 'warnings')], + environment={**(wasm or {}), 'CARGO_TARGET_DIR': str(TARGET / 'wasm')}, + missing='needs the wasm32-unknown-unknown target' if 'wasm32-unknown-unknown' not in targets.split() + else None if wasm else 'needs nix for a clang that builds for wasm32')) result.append(dict( name='ios', minutes=30, packages=['mobile'], paths=('apps/ios/',), # build-rust.sh builds the Rust half into target/ios. @@ -92,6 +101,17 @@ def lanes(): return result +def web_environment(): + """A clang and llvm-ar that build SQLite for wasm32, as release_web.py finds them; none + where it finds none.""" + import release_web + try: + environment = release_web.environment() + except SystemExit: + return None + return {key: environment[key] for key in ('CC_wasm32_unknown_unknown', 'AR_wasm32_unknown_unknown')} + + def jj(*args, cwd=ROOT): return subprocess.run(['jj', *args], cwd=cwd, check=True, capture_output=True, text=True).stdout diff --git a/tools/release_web.py b/tools/release_web.py new file mode 100644 index 0000000000000000000000000000000000000000..8a71104bbc4b5ab948d12e361a5606baaf292c11 --- /dev/null +++ b/tools/release_web.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Builds Snowbound for the web (crates/snowbound for wasm32, with crates/snowbound/web) as a +static folder and publishes it to the share's web/ folder. + + python3 tools/release_web.py --out /tmp/snowbound-web/dist # build only + python3 tools/release_web.py --publish # build, then replace web/ +""" +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +# The one target folder wasm builds share, apart from the native builds in target/. +TARGET = ROOT / 'target/wasm' +WEB = ROOT / 'crates/snowbound/web' +FONTS = ROOT / 'crates/canvas/assets/fonts' +PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web') +# copyparty lists the folder itself, so the app is at its index.html. +URL = 'https://file.paperclover.net/shr/snowbound/web/index.html' +# Size over speed where it costs little: the module is most of the first load. +PROFILE = ['--config', 'profile.release.lto=true', '--config', 'profile.release.codegen-units=1', + '--config', 'profile.release.opt-level="s"'] + + +def run(command, **kwargs): + print('+', ' '.join(map(str, command)), flush=True) + subprocess.run(command, cwd=ROOT, check=True, **kwargs) + + +def tool(name, nix=None, wasm=False): + """`name` from PATH or Cargo's bin folder, or else from nixpkgs package `nix`; with `wasm`, + from nixpkgs only, as the system's clang may not build for wasm32.""" + found = None if wasm else shutil.which(name) or shutil.which(name, path=str(Path.home() / '.cargo/bin')) + if found: + return found + if nix and shutil.which('nix'): + store = subprocess.check_output(['nix', 'build', f'nixpkgs#{nix}', '--no-link', '--print-out-paths'], + text=True).split()[-1] + return str(Path(store) / 'bin' / name) + sys.exit(f'{name} is missing: cargo install wasm-bindgen-cli at the version Cargo.lock pins, ' + 'and binaryen for wasm-opt') + + +def environment(): + """Cargo's environment for wasm32: the wasm target folder, and a clang and llvm-ar that + build for wasm32, which SQLite (through sqlite-wasm-rs) needs and Apple's clang lacks.""" + tools = {} + for variable, package, name in (('CC_wasm32_unknown_unknown', 'llvmPackages.clang-unwrapped', 'clang'), + ('AR_wasm32_unknown_unknown', 'llvmPackages.llvm', 'llvm-ar')): + tools[variable] = os.environ.get(variable) or tool(name, package, wasm=True) + return {**os.environ, 'CARGO_TARGET_DIR': str(TARGET), **tools} + + +def build(out): + """Writes index.html, the module, its JavaScript and the fonts to `out`.""" + run(['cargo', 'build', '--locked', '-p', 'snowbound', '--release', '--target', 'wasm32-unknown-unknown', + *PROFILE], env=environment()) + if out.exists(): + shutil.rmtree(out) + out.mkdir(parents=True) + module = TARGET / 'wasm32-unknown-unknown/release/snowbound.wasm' + run([tool('wasm-bindgen'), '--target', 'web', '--no-typescript', '--out-name', 'snowbound_web', + '--out-dir', out, module]) + bound = out / 'snowbound_web_bg.wasm' + run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound]) + shutil.copy(WEB / 'index.html', out) + (out / 'fonts').mkdir() + for font in sorted(FONTS.glob('*')): + if font.suffix in ('.ttf', '.txt'): + shutil.copy(font, out / 'fonts') + for path in sorted(out.rglob('*')): + if path.is_file(): + print(f'{path.stat().st_size:>12,} {path.relative_to(out)}') + + +def publish(built): + """Replaces the share's web/ folder with `built`, leaving every other folder alone.""" + if not PUBLISHED.parent.is_dir(): + sys.exit(f'{PUBLISHED.parent} is not mounted') + staging = PUBLISHED.with_name('.web-staging') + if staging.exists(): + shutil.rmtree(staging) + shutil.copytree(built, staging) + if PUBLISHED.exists(): + shutil.rmtree(PUBLISHED) + staging.rename(PUBLISHED) + print(f'Published {URL}') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--out', type=Path, help='Build into this folder (default: a temporary one)') + parser.add_argument('--publish', action='store_true', help=f'Copy the build to {PUBLISHED}') + args = parser.parse_args() + if not args.out and not args.publish: + parser.error('give --out, --publish or both') + with tempfile.TemporaryDirectory() as scratch: + out = args.out or Path(scratch) / 'web' + build(out) + if args.publish: + publish(out) + + +if __name__ == '__main__': + main()