authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 16:19:15-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 19:43:20-07:00
log563a6671858bb6446fcc24546f424b3e379f9063
tree674c0b6eb27a01a9888e21b28e670d4e954cf29d
parentd9fc5cc413b64a5ce5699007860952ea911fded2
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the web build is the desktop app, built for wasm32

Snowbound in the browser is now crates/snowbound itself, compiled for wasm32-unknown-unknown with web.rs as its platform module beside macos.rs, linux.rs and windows.rs, and web/index.html and web/glue.js as the page around it. The welcome, sidebar, toolbar, menus, command palette, Options and every dialog are the desktop's; crates/web and its own shell are gone. notebook runs there whole: sessions, the replica, publishing, merging, conflict pages and the background. The page has one thread, so the section thread is served as each request is sent and the sync worker and background are async loops the page's event loop runs (notebook::task); natively each still runs on a thread of its own with blocking waits. Files go through notebook::fs, std's natively and in the browser files kept in memory and in IndexedDB, with a SQLite VFS over the same files, so a replica lives beside its notebook. The first web build's sections move into a notebook of their own. Assisted-by: claude-opus-5.5

75 files changed, 4080 insertions(+), 749 deletions(-)

Cargo.lock+27
......@@ -664,6 +664,7 @@ dependencies = [
664664 "serde_json",
665665 "sha2",
666666 "skrifa",
667 "web-time",
667668 "wgpu",
668669]
669670
......@@ -1010,6 +1011,7 @@ dependencies = [
10101011 "roxmltree",
10111012 "subsetter",
10121013 "swash",
1014 "web-time",
10131015 "wgpu",
10141016 "windows",
10151017]
......@@ -1316,9 +1318,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
13161318checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
13171319dependencies = [
13181320 "cfg-if",
1321 "js-sys",
13191322 "libc",
13201323 "r-efi 6.0.0",
13211324 "rand_core 0.10.1",
1325 "wasm-bindgen",
13221326]
13231327
13241328[[package]]
......@@ -2079,9 +2083,11 @@ dependencies = [
20792083 "cab",
20802084 "getrandom 0.4.3",
20812085 "hmac",
2086 "js-sys",
20822087 "libc",
20832088 "nix",
20842089 "onestore",
2090 "rsqlite-vfs",
20852091 "rusqlite",
20862092 "serde",
20872093 "serde_json",
......@@ -2090,6 +2096,9 @@ dependencies = [
20902096 "tempfile",
20912097 "thiserror 2.0.20",
20922098 "tokio",
2099 "wasm-bindgen",
2100 "wasm-bindgen-futures",
2101 "web-time",
20932102 "windows-sys 0.61.2",
20942103 "zeroize",
20952104]
......@@ -2471,6 +2480,7 @@ dependencies = [
24712480 "serde_json",
24722481 "sha1",
24732482 "subtle",
2483 "web-time",
24742484 "zeroize",
24752485]
24762486
......@@ -3500,7 +3510,9 @@ dependencies = [
35003510 "canvas",
35013511 "draw",
35023512 "fontique",
3513 "getrandom 0.4.3",
35033514 "image",
3515 "js-sys",
35043516 "libc",
35053517 "notebook",
35063518 "objc2 0.5.2",
......@@ -3519,9 +3531,13 @@ dependencies = [
35193531 "ui",
35203532 "unicode_names2",
35213533 "ureq",
3534 "wasm-bindgen",
3535 "wasm-bindgen-futures",
35223536 "wayland-backend",
35233537 "wayland-client",
35243538 "wayland-protocols",
3539 "web-sys",
3540 "web-time",
35253541 "wgpu",
35263542 "windows-sys 0.61.2",
35273543 "winit",
......@@ -3923,6 +3939,7 @@ dependencies = [
39233939 "parley",
39243940 "png",
39253941 "pollster",
3942 "web-time",
39263943 "wgpu",
39273944 "winit",
39283945]
......@@ -4377,6 +4394,7 @@ dependencies = [
43774394 "thiserror 2.0.20",
43784395 "wgpu-core-deps-apple",
43794396 "wgpu-core-deps-emscripten",
4397 "wgpu-core-deps-wasm",
43804398 "wgpu-core-deps-windows-linux-android",
43814399 "wgpu-hal",
43824400 "wgpu-naga-bridge",
......@@ -4401,6 +4419,15 @@ dependencies = [
44014419 "wgpu-hal",
44024420]
44034421
4422[[package]]
4423name = "wgpu-core-deps-wasm"
4424version = "30.0.1"
4425source = "registry+https://github.com/rust-lang/crates.io-index"
4426checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae"
4427dependencies = [
4428 "wgpu-hal",
4429]
4430
44044431[[package]]
44054432name = "wgpu-core-deps-windows-linux-android"
44064433version = "30.0.1"
arc/platforms.md+18
......@@ -146,6 +146,24 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
146146 Windows has no menu bar: the toolbar and the command palette run the
147147 command table, with OneNote 2010's Ctrl chords.
148148
149### The browser
150
151- The same `snowbound`, built for `wasm32-unknown-unknown`: `web.rs` is its platform
152 module, and `web/index.html` and `web/glue.js` are the page around it. `glue.js` brings the
153 canvas's pointer, wheel and touch, and a hidden text area's keys, composition and paste, as
154 the `ui::Event`s winit would; `web.rs` stands in for winit's window and event loop and runs
155 `State` a turn per animation frame. `tools/release_web.py` builds and publishes it.
156- The page has one thread. The notebook's section thread, sync worker and background run as
157 tasks on its event loop (`notebook::task`), and work the desktop gives a thread runs once
158 the frame is done (`spawn`).
159- Files are `notebook::fs`'s: std's elsewhere, here held in memory and kept in IndexedDB,
160 with SQLite's VFS over the same files, so replicas and notebooks live side by side under
161 `/Notebooks` and `/Cache`. One tab at a time holds them.
162- Menus are the kit's own, as on Linux, with the PC's chords and ⌘ for Ctrl on a Mac; the
163 browser keeps its own window and tab chords. Dialogs are the browser's; Open and Insert
164 ask for files to copy in; printing downloads the PDF. Servers, recording and accessibility
165 are still to come.
166
149167## iOS: native around the canvas
150168
151169On iOS the split moves. Touch text editing depends on affordances that users
crates/canvas/Cargo.toml+2
......@@ -19,6 +19,8 @@ serde = { version = "1.0.229", features = ["derive"] }
1919serde_json = "1.0.151"
2020icu_normalizer = "2.3"
2121skrifa = "0.44.0"
22# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none.
23web-time = "1.1"
2224
2325accesskit = { version = "0.25.0", optional = true }
2426color_quant = { version = "1.1", optional = true }
crates/canvas/assets/fonts/Caladea-Bold.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Caladea-Bold.ttf differ
crates/canvas/assets/fonts/Caladea-BoldItalic.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Caladea-BoldItalic.ttf differ
crates/canvas/assets/fonts/Caladea-Italic.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Caladea-Italic.ttf differ
crates/canvas/assets/fonts/Caladea-OFL.txt created+93
......@@ -0,0 +1,93 @@
1Copyright 2012 The Caladea Project Authors (https://github.com/huertatipografica/Caladea)
2
3This Font Software is licensed under the SIL Open Font License, Version 1.1.
4This license is copied below, and is also available with a FAQ at:
5http://scripts.sil.org/OFL
6
7
8-----------------------------------------------------------
9SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10-----------------------------------------------------------
11
12PREAMBLE
13The goals of the Open Font License (OFL) are to stimulate worldwide
14development of collaborative font projects, to support the font creation
15efforts of academic and linguistic communities, and to provide a free and
16open framework in which fonts may be shared and improved in partnership
17with others.
18
19The OFL allows the licensed fonts to be used, studied, modified and
20redistributed freely as long as they are not sold by themselves. The
21fonts, including any derivative works, can be bundled, embedded,
22redistributed and/or sold with any software provided that any reserved
23names are not used by derivative works. The fonts and derivatives,
24however, cannot be released under any other type of license. The
25requirement for fonts to remain under this license does not apply
26to any document created using the fonts or their derivatives.
27
28DEFINITIONS
29"Font Software" refers to the set of files released by the Copyright
30Holder(s) under this license and clearly marked as such. This may
31include source files, build scripts and documentation.
32
33"Reserved Font Name" refers to any names specified as such after the
34copyright statement(s).
35
36"Original Version" refers to the collection of Font Software components as
37distributed by the Copyright Holder(s).
38
39"Modified Version" refers to any derivative made by adding to, deleting,
40or substituting -- in part or in whole -- any of the components of the
41Original Version, by changing formats or by porting the Font Software to a
42new environment.
43
44"Author" refers to any designer, engineer, programmer, technical
45writer or other person who contributed to the Font Software.
46
47PERMISSION & CONDITIONS
48Permission is hereby granted, free of charge, to any person obtaining
49a copy of the Font Software, to use, study, copy, merge, embed, modify,
50redistribute, and sell modified and unmodified copies of the Font
51Software, subject to the following conditions:
52
531) Neither the Font Software nor any of its individual components,
54in Original or Modified Versions, may be sold by itself.
55
562) Original or Modified Versions of the Font Software may be bundled,
57redistributed and/or sold with any software, provided that each copy
58contains the above copyright notice and this license. These can be
59included either as stand-alone text files, human-readable headers or
60in the appropriate machine-readable metadata fields within text or
61binary files as long as those fields can be easily viewed by the user.
62
633) No Modified Version of the Font Software may use the Reserved Font
64Name(s) unless explicit written permission is granted by the corresponding
65Copyright Holder. This restriction only applies to the primary font name as
66presented to the users.
67
684) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69Software shall not be used to promote, endorse or advertise any
70Modified Version, except to acknowledge the contribution(s) of the
71Copyright Holder(s) and the Author(s) or with their explicit written
72permission.
73
745) The Font Software, modified or unmodified, in part or in whole,
75must be distributed entirely under this license, and must not be
76distributed under any other license. The requirement for fonts to
77remain under this license does not apply to any document created
78using the Font Software.
79
80TERMINATION
81This license becomes null and void if any of the above conditions are
82not met.
83
84DISCLAIMER
85THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93OTHER DEALINGS IN THE FONT SOFTWARE.
crates/canvas/assets/fonts/Caladea-Regular.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Caladea-Regular.ttf differ
crates/canvas/src/editor/format.rs+2-2
......@@ -565,8 +565,8 @@ fn followed(format: &Format, old: &Format, new: &Format) -> Format {
565565
566566/// Seconds since 1980, as note tags date themselves.
567567fn time32() -> Option<u32> {
568 let now = std::time::SystemTime::now()
569 .duration_since(std::time::UNIX_EPOCH)
568 let now = web_time::SystemTime::now()
569 .duration_since(web_time::UNIX_EPOCH)
570570 .ok()?
571571 .as_secs();
572572 u32::try_from(now.checked_sub(315_532_800)?).ok()
crates/canvas/src/editor/recording.rs+1-1
......@@ -5,7 +5,7 @@
55
66use super::*;
77use onestore::page::{Attachment, MediaIndex};
8use std::time::Instant;
8use web_time::Instant;
99
1010/// A recording in progress.
1111pub(super) struct Live {
crates/canvas/src/gpu/page.rs+17-2
......@@ -16,10 +16,11 @@ use onestore::page::Page;
1616use picture::Picture;
1717use std::{
1818 fmt,
19 sync::{Arc, Mutex, OnceLock, mpsc},
19 sync::{Arc, Mutex},
2020 task::Waker,
21 time::{Duration, Instant},
21 time::Duration,
2222};
23use web_time::Instant;
2324
2425/// Retained drawing data in the source page's coordinate system.
2526pub struct PageScene {
......@@ -40,11 +41,13 @@ type Slot<T> = Arc<Mutex<Option<T>>>;
4041
4142/// Runs `work` on the scenes' one raster thread once `start` has passed, unless the slot
4243/// returned has been dropped by then; the result lands in the slot and wakes `waker`.
44#[cfg(not(target_arch = "wasm32"))]
4345fn queue<T: Send + 'static>(
4446 start: Instant,
4547 waker: &Waker,
4648 work: impl FnOnce() -> T + Send + 'static,
4749) -> Slot<T> {
50 use std::sync::{OnceLock, mpsc};
4851 static WORKER: OnceLock<mpsc::Sender<Box<dyn FnOnce() + Send>>> = OnceLock::new();
4952 let slot = Slot::default();
5053 let requester = Arc::downgrade(&slot);
......@@ -69,6 +72,17 @@ fn queue<T: Send + 'static>(
6972 slot
7073}
7174
75/// `queue` without threads, as in the browser: `work` runs now.
76#[cfg(target_arch = "wasm32")]
77fn queue<T: Send + 'static>(
78 _: Instant,
79 waker: &Waker,
80 work: impl FnOnce() -> T + Send + 'static,
81) -> Slot<T> {
82 waker.wake_by_ref();
83 Arc::new(Mutex::new(Some(work())))
84}
85
7286/// Device pixels per point a raster is made at for `scale`: the next half power of two,
7387/// so zooming makes rasters again in steps.
7488fn density(scale: f32) -> f32 {
......@@ -793,6 +807,7 @@ impl PageScene {
793807 /// Where the page read without an editor lies, in page coordinates: the rectangle all
794808 /// its content covers, and the top and bottom of each line of text and each picture, which
795809 /// a sheet of paper does not cut through where it can help it.
810 #[cfg(feature = "pdf")]
796811 pub(crate) fn printed_extent(&self) -> Result<([f32; 4], Vec<[f32; 2]>), SceneError> {
797812 let mut bounds = [
798813 f32::INFINITY,
crates/canvas/src/gpu/page/background.rs+2-4
......@@ -6,10 +6,8 @@ use crate::gpu::Paper;
66use draw::RasterImage;
77use resvg::{tiny_skia, usvg};
88use sha2::{Digest, Sha256};
9use std::{
10 task::Waker,
11 time::{Duration, Instant},
12};
9use std::{task::Waker, time::Duration};
10use web_time::Instant;
1311
1412/// One of OneNote 2010's page templates: its art recreated for white paper in `#rrggbb`
1513/// colours over a view box the size in pixels of the picture OneNote stores, that picture's
crates/canvas/src/gpu/page/picture.rs+2-6
......@@ -4,12 +4,8 @@
44use super::{SETTLE, Slot, density, queue};
55use draw::RasterImage;
66use onestore::ExGuid;
7use std::{
8 collections::BTreeMap,
9 sync::Arc,
10 task::Waker,
11 time::{Duration, Instant},
12};
7use std::{collections::BTreeMap, sync::Arc, task::Waker, time::Duration};
8use web_time::Instant;
139
1410/// Decoded bytes a scene's pictures keep at once. Pictures in view shrink to fit it, and
1511/// the rest of the renderer's frame budget stays for template art and the interface.
crates/canvas/src/interaction/mod.rs+2-4
......@@ -26,10 +26,8 @@ use draw::{
2626 Primitive, Stroke,
2727 edit::{self, Clicks, Command, Key, Modifiers, Movement, NamedKey, SelectionUnit},
2828};
29use std::{
30 error::Error,
31 time::{Duration, Instant},
32};
29use std::{error::Error, time::Duration};
30use web_time::Instant;
3331
3432const HANDLE_HEIGHT: f32 = 6.75;
3533/// Accessible names of the page date's fields.
crates/canvas/src/layout.rs+13-4
......@@ -96,7 +96,10 @@ impl fmt::Display for LayoutError {
9696 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
9797 match self {
9898 Self::UnsupportedSubstituteFont => {
99 write!(f, "Choose an Arimo, Carlito, Tinos or Cousine font file.")
99 write!(
100 f,
101 "Choose an Arimo, Carlito, Caladea, Tinos or Cousine font file."
102 )
100103 }
101104 _ => write!(f, "Text layout failed: {self:?}"),
102105 }
......@@ -110,7 +113,9 @@ pub(crate) const DEFAULT_FONT: &str = "Arial";
110113pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0;
111114
112115/// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font
113/// Licence files beside them, by the family each stands in for.
116/// Licence files beside them, by the family each stands in for. The browser fetches them
117/// beside the module instead (`register_substitute`), keeping them out of it.
118#[cfg(not(target_arch = "wasm32"))]
114119const BUNDLED: [(&str, &[&[u8]]); 4] = [
115120 (
116121 "Calibri",
......@@ -151,6 +156,7 @@ const BUNDLED: [(&str, &[&[u8]]); 4] = [
151156impl Default for TextEngine {
152157 /// An engine that lays out each bundled family in its substitute where it is missing.
153158 fn default() -> Self {
159 #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
154160 let mut engine = Self {
155161 // Clones share loaded font files, so glyphs one lays out draw from the same cache.
156162 fonts: FontContext {
......@@ -161,6 +167,7 @@ impl Default for TextEngine {
161167 arial_substitutes: BTreeSet::new(),
162168 substitutes: BTreeMap::new(),
163169 };
170 #[cfg(not(target_arch = "wasm32"))]
164171 for (family, faces) in BUNDLED {
165172 if engine.fonts.collection.family_id(family).is_none() {
166173 for face in faces {
......@@ -187,8 +194,9 @@ impl Clone for TextEngine {
187194}
188195
189196impl TextEngine {
190 /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as
191 /// Courier New before creating layouts, in place of the bundled substitute.
197 /// Register Arimo as Arial, Carlito as Calibri, Caladea as Cambria, Tinos as Times New
198 /// Roman or Cousine as Courier New before creating layouts, in place of the bundled
199 /// substitute.
192200 pub fn register_substitute(&mut self, data: Blob<u8>) -> Result<&'static str, LayoutError> {
193201 self.register(data, false)
194202 }
......@@ -208,6 +216,7 @@ impl TextEngine {
208216 let (substitute, target) = match family.as_str() {
209217 "Arimo" => ("Arimo", "Arial"),
210218 "Carlito" => ("Carlito", "Calibri"),
219 "Caladea" => ("Caladea", "Cambria"),
211220 "Tinos" => ("Tinos", "Times New Roman"),
212221 "Cousine" => ("Cousine", "Courier New"),
213222 _ => return Err(LayoutError::UnsupportedSubstituteFont),
crates/draw/Cargo.toml+2
......@@ -16,6 +16,8 @@ pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"]
1616
1717[dependencies]
1818parley.workspace = true
19# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none.
20web-time = "1.1"
1921
2022base64 = { version = "0.22.1", optional = true }
2123bytemuck = { version = "1.25", features = ["derive"], optional = true }
crates/draw/src/edit.rs+2-4
......@@ -5,10 +5,8 @@ use parley::{
55 Affinity, Brush, Layout,
66 editing::{Cursor, Selection},
77};
8use std::{
9 collections::BTreeSet,
10 time::{Duration, Instant},
11};
8use std::{collections::BTreeSet, time::Duration};
9use web_time::Instant;
1210
1311#[derive(Clone, Debug, PartialEq)]
1412pub enum Key {
crates/notebook/Cargo.toml+11
......@@ -25,6 +25,17 @@ getrandom = "0.4.3"
2525hmac = "0.13.0"
2626# OneNote packages (.onepkg) are cabinet files.
2727cab = "0.6"
28# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none.
29web-time = "1.1"
30
31# The browser: its randomness, clock, timers and event loop, and SQLite's files kept with
32# the notebooks' (`fs`).
33[target.'cfg(target_arch = "wasm32")'.dependencies]
34getrandom = { version = "0.4.3", features = ["wasm_js"] }
35js-sys = "0.3"
36wasm-bindgen = "0.2"
37wasm-bindgen-futures = "0.4"
38rsqlite-vfs = "0.1.1"
2839
2940[target.'cfg(target_vendor = "apple")'.dependencies]
3041nix = { version = "0.31", default-features = false, features = ["fs"] }
crates/notebook/src/background.rs+146-150
......@@ -2,6 +2,7 @@
22//! keeps every section of an open notebook: queued edits publish and other clients' changes
33//! are noticed without the section being open.
44
5use crate::fs;
56use crate::{
67 EditStatus, Error, Remote, Replica, Result,
78 discover::{Entry, Listed},
......@@ -17,9 +18,9 @@ use std::{
1718 Arc, Mutex, Weak,
1819 atomic::{AtomicBool, Ordering},
1920 },
20 thread,
21 time::{Duration, Instant},
21 time::Duration,
2222};
23use web_time::Instant;
2324
2425/// How long a section that could not be reached waits to be tried again; OneNote 2010
2526/// retries a failing section about every 31 seconds.
......@@ -137,168 +138,163 @@ impl Background {
137138 });
138139 let weak = Arc::downgrade(&shared);
139140 let owner = Arc::clone(&shared);
140 thread::Builder::new()
141 .name("onestore-background".into())
142 .spawn(move || {
143 let signal = &owner.signal;
144 let mut bound: Option<(B, L)> = None;
145 let mut news = false;
146 while !signal.stopped.load(Ordering::Acquire) {
147 if signal.offline.load(Ordering::Acquire)
148 && !signal.requested.load(Ordering::Acquire)
149 {
150 // Working online again lists every folder again.
151 if let Ok(mut watched) = owner.watched.lock() {
152 watched.watching(false);
153 watched.lost = true;
154 }
141 crate::task::spawn("onestore-background", move || async move {
142 let signal = &owner.signal;
143 let mut bound: Option<(B, L)> = None;
144 let mut news = false;
145 while !signal.stopped.load(Ordering::Acquire) {
146 if signal.offline.load(Ordering::Acquire)
147 && !signal.requested.load(Ordering::Acquire)
148 {
149 // Working online again lists every folder again.
150 if let Ok(mut watched) = owner.watched.lock() {
151 watched.watching(false);
152 watched.lost = true;
153 }
154 bound = None;
155 crate::task::wait(&receiver, None).await;
156 continue;
157 }
158 let now = Instant::now();
159 let next = {
160 let Ok(mut watched) = owner.watched.lock() else {
161 return;
162 };
163 if std::mem::take(&mut watched.lost) {
155164 bound = None;
156 let _ = receiver.recv();
165 watched.watching(false);
166 for watch in &mut watched.sections {
167 watch.due = now;
168 }
169 }
170 watched.next(now, bound.is_some())
171 };
172 let (path, replica, seen, current, image) = match next {
173 Next::Wait(wait) => {
174 signal.requested.store(false, Ordering::Release);
175 if std::mem::take(&mut news) {
176 notify();
177 }
178 crate::task::wait(&receiver, wait).await;
157179 continue;
158180 }
159 let now = Instant::now();
160 let next = {
181 Next::Connect(connection) => {
182 let reports = Reports {
183 shared: weak.clone(),
184 connection: connection + 1,
185 };
186 let connected = connect(reports);
161187 let Ok(mut watched) = owner.watched.lock() else {
162188 return;
163189 };
164 if std::mem::take(&mut watched.lost) {
165 bound = None;
166 watched.watching(false);
167 for watch in &mut watched.sections {
168 watch.due = now;
169 }
170 }
171 watched.next(now, bound.is_some())
172 };
173 let (path, replica, seen, current, image) = match next {
174 Next::Wait(wait) => {
175 signal.requested.store(false, Ordering::Release);
176 if std::mem::take(&mut news) {
177 notify();
190 // Whatever ended before now ended with the last connection.
191 watched.connection += 1;
192 watched.lost = false;
193 match connected {
194 Ok((mut files, reported)) => {
195 watched.watching(reported);
196 let folders = watched.rescanning();
197 drop(watched);
198 let listings = list(&mut files.1, folders);
199 let Ok(mut watched) = owner.watched.lock() else {
200 return;
201 };
202 watched.listed(&listings, Some(copies), Instant::now());
203 bound = Some(files);
178204 }
179 let _ = match wait {
180 Some(wait) => receiver.recv_timeout(wait).ok(),
181 None => receiver.recv().ok(),
182 };
183 continue;
184 }
185 Next::Connect(connection) => {
186 let reports = Reports {
187 shared: weak.clone(),
188 connection: connection + 1,
189 };
190 let connected = connect(reports);
191 let Ok(mut watched) = owner.watched.lock() else {
192 return;
193 };
194 // Whatever ended before now ended with the last connection.
195 watched.connection += 1;
196 watched.lost = false;
197 match connected {
198 Ok((mut files, reported)) => {
199 watched.watching(reported);
200 let folders = watched.rescanning();
201 drop(watched);
202 let listings = list(&mut files.1, folders);
203 let Ok(mut watched) = owner.watched.lock() else {
204 return;
205 };
206 watched.listed(&listings, Some(copies), Instant::now());
207 bound = Some(files);
208 }
209 Err(error) => {
210 let error = Error::RemoteIo(error);
211 let retry = now + RETRY;
212 for watch in &mut watched.sections {
213 news |= watch.fail(&error, None);
214 watch.due = watch.due.max(retry);
215 }
216 watched.relist = watched.relist.map(|due| due.max(retry));
205 Err(error) => {
206 let error = Error::RemoteIo(error);
207 let retry = now + RETRY;
208 for watch in &mut watched.sections {
209 news |= watch.fail(&error, None);
210 watch.due = watch.due.max(retry);
217211 }
212 watched.relist = watched.relist.map(|due| due.max(retry));
218213 }
219 continue;
220214 }
221 Next::List(folders) => {
222 let Some((_, list_folder)) = &mut bound else {
223 continue;
224 };
225 let listings = list(list_folder, folders);
226 let Ok(mut watched) = owner.watched.lock() else {
227 return;
228 };
229 watched.listed(&listings, None, Instant::now());
215 continue;
216 }
217 Next::List(folders) => {
218 let Some((_, list_folder)) = &mut bound else {
230219 continue;
231 }
232 Next::Check {
233 path,
234 replica,
235 seen,
236 current,
237 image,
238 } => (path, replica, seen, current, image),
239 };
240 let Some((bind, _)) = &mut bound else {
220 };
221 let listings = list(list_folder, folders);
222 let Ok(mut watched) = owner.watched.lock() else {
223 return;
224 };
225 watched.listed(&listings, None, Instant::now());
241226 continue;
242 };
243 let (queued, outcome) = step(
244 &mut bind(&path),
245 replica.as_deref(),
246 seen.as_deref(),
227 }
228 Next::Check {
229 path,
230 replica,
231 seen,
247232 current,
248233 image,
249 copies,
250 );
251 if outcome.as_ref().is_err_and(disconnected) {
252 bound = None;
253 }
254 let Ok(mut watched) = owner.watched.lock() else {
255 return;
256 };
257 let interval = watched.interval();
258 let Watched {
259 sections, changed, ..
260 } = &mut *watched;
261 let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else {
262 continue;
263 };
264 let now = Instant::now();
265 watch.current = false;
266 news |= match outcome {
267 Ok((stamp, moved)) => {
268 watch.stamp = Some(stamp);
269 watch.due = now + interval;
270 if moved {
271 changed.push(path);
272 }
273 let before = summary(&watch.status);
274 if let Some(queued) = queued {
275 watch.status = SyncStatus {
276 synced: Some(crate::now()),
277 error: None,
278 queued,
279 };
280 }
281 moved || before != summary(&watch.status)
282 }
283 Err(error) => {
284 watch.due = now + RETRY;
285 watch.fail(&error, queued)
286 }
287 };
234 } => (path, replica, seen, current, image),
235 };
236 let Some((bind, _)) = &mut bound else {
237 continue;
238 };
239 let (queued, outcome) = step(
240 &mut bind(&path),
241 replica.as_deref(),
242 seen.as_deref(),
243 current,
244 image,
245 copies,
246 );
247 if outcome.as_ref().is_err_and(disconnected) {
248 bound = None;
288249 }
289 if let Ok(mut watched) = owner.watched.lock() {
290 watched.watching(false);
291 if owner.discard.load(Ordering::Acquire) {
292 for replica in watched
293 .sections
294 .iter()
295 .filter_map(|watch| watch.replica.as_ref())
296 {
297 discard(replica);
250 let Ok(mut watched) = owner.watched.lock() else {
251 return;
252 };
253 let interval = watched.interval();
254 let Watched {
255 sections, changed, ..
256 } = &mut *watched;
257 let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else {
258 continue;
259 };
260 let now = Instant::now();
261 watch.current = false;
262 news |= match outcome {
263 Ok((stamp, moved)) => {
264 watch.stamp = Some(stamp);
265 watch.due = now + interval;
266 if moved {
267 changed.push(path);
268 }
269 let before = summary(&watch.status);
270 if let Some(queued) = queued {
271 watch.status = SyncStatus {
272 synced: Some(crate::now()),
273 error: None,
274 queued,
275 };
298276 }
277 moved || before != summary(&watch.status)
278 }
279 Err(error) => {
280 watch.due = now + RETRY;
281 watch.fail(&error, queued)
282 }
283 };
284 }
285 if let Ok(mut watched) = owner.watched.lock() {
286 watched.watching(false);
287 if owner.discard.load(Ordering::Acquire) {
288 for replica in watched
289 .sections
290 .iter()
291 .filter_map(|watch| watch.replica.as_ref())
292 {
293 discard(replica);
299294 }
300295 }
301 })?;
296 }
297 })?;
302298 Ok(Self(shared))
303299 }
304300
......@@ -668,7 +664,7 @@ impl Watched {
668664 && watch
669665 .replica
670666 .as_ref()
671 .is_some_and(|replica| !replica.exists());
667 .is_some_and(|replica| fs::metadata(replica).is_err());
672668 watch.due = if read {
673669 staggered += 1;
674670 now + STAGGER * (staggered - 1)
......@@ -750,7 +746,7 @@ fn discard(replica: &Path) {
750746 for suffix in ["-wal", "-shm", ""] {
751747 let mut file = replica.as_os_str().to_owned();
752748 file.push(suffix);
753 let _ = std::fs::remove_file(file);
749 let _ = fs::remove_file(file);
754750 }
755751 }
756752}
......@@ -833,14 +829,14 @@ fn step<R: Remote>(
833829 let Some(replica) = replica else {
834830 return (Some(0), Ok((stamp, moved)));
835831 };
836 if !replica.exists() {
832 if fs::metadata(replica).is_err() {
837833 if !copies {
838834 return (Some(0), Ok((stamp, moved)));
839835 }
840836 let copied = (|| {
841837 let image = remote.read().map_err(Error::RemoteIo)?;
842838 if let Some(folder) = replica.parent() {
843 std::fs::create_dir_all(folder)?;
839 fs::create_dir_all(folder)?;
844840 }
845841 Replica::seed(replica, &image, None)?;
846842 Ok(Stamp::of(&image)?)
crates/notebook/src/bin/onestore-diagnostic.rs+4-3
......@@ -1,3 +1,4 @@
1use notebook::fs;
12use onestore::{
23 Arena, ExGuid, Section, TextAttribute,
34 document::{Format, Layout},
......@@ -9,7 +10,7 @@ use onestore::{
910use serde::Deserialize;
1011use serde_json::{Value, json};
1112use std::{
12 env, fs,
13 env,
1314 io::{self, Write},
1415};
1516
......@@ -96,7 +97,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>>
9697 return Err("Usage: onestore-diagnostic catalog ROOT | snapshot FILE NEW_SNAPSHOT | check SNAPSHOT - | commit FILE SNAPSHOT; edits arrive as JSON on stdin".into());
9798 }
9899 if args[0] == "snapshot" {
99 let bytes = onestore::read_file(&args[1])?;
100 let bytes = fs::read_file(&args[1])?;
100101 let mut file = fs::OpenOptions::new()
101102 .write(true)
102103 .create_new(true)
......@@ -199,7 +200,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>>
199200 let Some(transaction) = transaction else {
200201 return Ok(json!({"ok": true, "state": "Unchanged"}));
201202 };
202 Ok(match transaction.commit_file(&args[1]) {
203 Ok(match fs::commit_file(&transaction, &args[1]) {
203204 Ok(()) => json!({"ok": true, "state": "Committed"}),
204205 Err(error) => json!({"ok": false, "state": format!("{:?}", error.state),
205206 "kind": format!("{:?}", error.error.kind()), "error": error.error.to_string()}),
crates/notebook/src/discover/source.rs+8-7
......@@ -1,4 +1,5 @@
11use super::{Entry, EntryKind, Listed, Source, component};
2use crate::fs;
23use std::{
34 collections::BTreeSet,
45 io,
......@@ -14,7 +15,7 @@ fn evicted(name: &str) -> Option<&str> {
1415
1516/// Whether the system keeps the file's contents elsewhere until it is read, as macOS 14 and
1617/// later list a file iCloud Drive evicted (`SF_DATALESS`).
17fn dataless(metadata: &std::fs::Metadata) -> bool {
18fn dataless(metadata: &fs::Metadata) -> bool {
1819 const SF_DATALESS: u32 = 0x4000_0000;
1920 #[cfg(target_os = "macos")]
2021 let flags = std::os::macos::fs::MetadataExt::st_flags(metadata);
......@@ -41,8 +42,8 @@ pub struct Local {
4142
4243impl Local {
4344 pub fn open(root: impl AsRef<Path>) -> io::Result<Self> {
44 let root = root.as_ref().canonicalize()?;
45 if !root.is_dir() {
45 let root = fs::canonicalize(root)?;
46 if !fs::metadata(&root)?.is_dir() {
4647 return Err(io::ErrorKind::NotADirectory.into());
4748 }
4849 Ok(Self { root })
......@@ -52,7 +53,7 @@ impl Local {
5253 if !relative.is_empty() && !relative.split('/').all(component) {
5354 return Err(io::ErrorKind::InvalidInput.into());
5455 }
55 let path = self.root.join(relative).canonicalize()?;
56 let path = fs::canonicalize(self.root.join(relative))?;
5657 if !path.starts_with(&self.root) {
5758 return Err(io::ErrorKind::PermissionDenied.into());
5859 }
......@@ -63,7 +64,7 @@ impl Local {
6364impl Source for Local {
6465 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
6566 let mut entries = Vec::new();
66 for entry in std::fs::read_dir(self.path(path)?)? {
67 for entry in fs::read_dir(self.path(path)?)? {
6768 let entry = entry?;
6869 if entries.len() == limit {
6970 return Err(io::ErrorKind::FileTooLarge.into());
......@@ -108,7 +109,7 @@ impl Source for Local {
108109 }
109110
110111 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
111 onestore::read_file_limited(self.path(path)?, limit)
112 fs::read_file_limited(self.path(path)?, limit)
112113 }
113114}
114115
......@@ -190,7 +191,7 @@ impl Source for Smb<'_> {
190191 /// Reads the file's stamp, then its replica's base if that has the same.
191192 fn copy(&mut self, path: &str, known: [u8; 16]) -> Option<Vec<u8>> {
192193 let replica = crate::session::replica_file(self.copies.as_ref()?, &known);
193 if !replica.exists() {
194 if fs::metadata(&replica).is_err() {
194195 return None;
195196 }
196197 let stamp = self.client.stamp(&self.path(path).ok()?).ok()?;
crates/notebook/src/fs.rs created+21
......@@ -0,0 +1,21 @@
1//! The file system notebooks and their replicas live in: std's natively. In the browser,
2//! which gives a module none, it is `web`'s, which keeps SQLite's files beside the notebooks'.
3
4#[cfg(not(target_arch = "wasm32"))]
5pub use onestore::{confirm_file, place_file, read_file, read_file_limited, supersede_file};
6#[cfg(not(target_arch = "wasm32"))]
7pub use std::{fs::*, path::absolute, process::id as process_id};
8
9/// `Transaction::commit_file` through this file system.
10#[cfg(not(target_arch = "wasm32"))]
11pub fn commit_file(
12 transaction: &onestore::Transaction,
13 path: impl AsRef<std::path::Path>,
14) -> Result<(), onestore::CommitError> {
15 transaction.commit_file(path)
16}
17
18#[cfg(target_arch = "wasm32")]
19mod web;
20#[cfg(target_arch = "wasm32")]
21pub use web::*;
crates/notebook/src/fs/web.rs created+722
......@@ -0,0 +1,722 @@
1//! The browser's file system: std's calls over files held in memory, which the host loads
2//! before anything opens (`restore`) and writes out as they change (`changes`). SQLite reaches
3//! the same files through `sqlite`, its default VFS here, so a replica is a file like any other.
4//! One thread: no file is ever locked.
5
6use onestore::{CommitError, CommitIo, CommitState, Stamp, Transaction};
7use std::{
8 cell::RefCell,
9 collections::{BTreeMap, BTreeSet},
10 ffi::OsString,
11 io::{self, ErrorKind, Read, Seek, SeekFrom, Write},
12 path::{Component, Path, PathBuf},
13 rc::Rc,
14 time::{Duration, SystemTime, UNIX_EPOCH},
15};
16
17mod sqlite;
18pub(crate) use sqlite::install as install_sqlite;
19
20/// A file's bytes and when they last changed, in milliseconds since 1970.
21struct Data {
22 bytes: Vec<u8>,
23 modified: f64,
24 /// Where the file is, which a rename moves; none once it is removed.
25 path: Option<PathBuf>,
26}
27
28type Shared = Rc<RefCell<Data>>;
29
30enum Node {
31 Directory,
32 File(Shared),
33}
34
35#[derive(Default)]
36struct Files {
37 nodes: BTreeMap<PathBuf, Node>,
38 /// Paths written, made or removed since the host last asked.
39 changed: BTreeSet<PathBuf>,
40}
41
42thread_local! {
43 static FILES: RefCell<Files> = RefCell::new(Files {
44 nodes: BTreeMap::from([(PathBuf::from("/"), Node::Directory)]),
45 changed: BTreeSet::new(),
46 });
47}
48
49/// What a path holds, as the host keeps it.
50pub enum Saved {
51 Directory,
52 /// The bytes, and when they last changed in milliseconds since 1970.
53 File(Vec<u8>, f64),
54}
55
56/// Puts what the host kept at `path` back, as it was before the page last closed.
57pub fn restore(path: impl AsRef<Path>, saved: Saved) {
58 let path = normal(path.as_ref());
59 FILES.with_borrow_mut(|files| {
60 let node = match saved {
61 Saved::Directory => Node::Directory,
62 Saved::File(bytes, modified) => Node::File(Rc::new(RefCell::new(Data {
63 bytes,
64 modified,
65 path: Some(path.clone()),
66 }))),
67 };
68 files.nodes.insert(path, node);
69 });
70}
71
72/// Whether any path changed since `changes` was last called.
73pub fn changed() -> bool {
74 FILES.with_borrow(|files| !files.changed.is_empty())
75}
76
77/// The paths changed since the last call, each with what it holds now, if anything.
78pub fn changes() -> Vec<(PathBuf, Option<Saved>)> {
79 FILES.with_borrow_mut(|files| {
80 std::mem::take(&mut files.changed)
81 .into_iter()
82 .map(|path| {
83 let saved = files.nodes.get(&path).map(|node| match node {
84 Node::Directory => Saved::Directory,
85 Node::File(data) => {
86 let data = data.borrow();
87 Saved::File(data.bytes.clone(), data.modified)
88 }
89 });
90 (path, saved)
91 })
92 .collect()
93 })
94}
95
96fn now() -> f64 {
97 js_sys::Date::now()
98}
99
100/// `path` absolute, without `.` or `..`.
101fn normal(path: &Path) -> PathBuf {
102 let mut normal = PathBuf::from("/");
103 for component in path.components() {
104 match component {
105 Component::Normal(name) => normal.push(name),
106 Component::ParentDir => {
107 normal.pop();
108 }
109 Component::RootDir | Component::CurDir | Component::Prefix(_) => {}
110 }
111 }
112 normal
113}
114
115fn not_found(path: &Path) -> io::Error {
116 io::Error::new(ErrorKind::NotFound, format!("{} not found", path.display()))
117}
118
119impl Files {
120 fn file(&self, path: &Path) -> io::Result<Shared> {
121 match self.nodes.get(path) {
122 Some(Node::File(data)) => Ok(Rc::clone(data)),
123 Some(Node::Directory) => Err(ErrorKind::IsADirectory.into()),
124 None => Err(not_found(path)),
125 }
126 }
127
128 fn parent_exists(&self, path: &Path) -> io::Result<()> {
129 match path.parent().map(|parent| self.nodes.get(parent)) {
130 None | Some(Some(Node::Directory)) => Ok(()),
131 Some(Some(Node::File(_))) => Err(ErrorKind::NotADirectory.into()),
132 Some(None) => Err(not_found(path.parent().unwrap_or(path))),
133 }
134 }
135
136 /// A new empty file at `path`, whose folder exists.
137 fn create(&mut self, path: PathBuf) -> io::Result<Shared> {
138 self.parent_exists(&path)?;
139 if self.nodes.contains_key(&path) {
140 return Err(ErrorKind::AlreadyExists.into());
141 }
142 let data = Rc::new(RefCell::new(Data {
143 bytes: Vec::new(),
144 modified: now(),
145 path: Some(path.clone()),
146 }));
147 self.nodes
148 .insert(path.clone(), Node::File(Rc::clone(&data)));
149 self.changed.insert(path);
150 Ok(data)
151 }
152
153 fn children(&self, folder: &Path) -> impl Iterator<Item = (&PathBuf, &Node)> {
154 self.nodes
155 .range(folder.to_path_buf()..)
156 .skip(1)
157 .take_while(move |(path, _)| path.starts_with(folder))
158 .filter(move |(path, _)| path.parent() == Some(folder))
159 }
160}
161
162fn with<T>(act: impl FnOnce(&mut Files) -> T) -> T {
163 FILES.with_borrow_mut(act)
164}
165
166/// Marks `data` changed now, where it still has a path.
167fn touched(data: &mut Data) {
168 data.modified = now();
169 if let Some(path) = data.path.clone() {
170 with(|files| files.changed.insert(path));
171 }
172}
173
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175pub struct FileType(bool);
176
177impl FileType {
178 pub fn is_dir(&self) -> bool {
179 self.0
180 }
181
182 pub fn is_file(&self) -> bool {
183 !self.0
184 }
185
186 pub fn is_symlink(&self) -> bool {
187 false
188 }
189}
190
191#[derive(Clone, Debug)]
192pub struct Metadata {
193 directory: bool,
194 length: u64,
195 modified: f64,
196}
197
198impl Metadata {
199 pub fn is_dir(&self) -> bool {
200 self.directory
201 }
202
203 pub fn is_file(&self) -> bool {
204 !self.directory
205 }
206
207 pub fn file_type(&self) -> FileType {
208 FileType(self.directory)
209 }
210
211 #[allow(clippy::len_without_is_empty, reason = "std's Metadata has none")]
212 pub fn len(&self) -> u64 {
213 self.length
214 }
215
216 pub fn modified(&self) -> io::Result<SystemTime> {
217 Ok(UNIX_EPOCH + Duration::from_secs_f64(self.modified.max(0.0) / 1e3))
218 }
219}
220
221fn metadata_of(node: &Node) -> Metadata {
222 match node {
223 Node::Directory => Metadata {
224 directory: true,
225 length: 0,
226 modified: 0.0,
227 },
228 Node::File(data) => {
229 let data = data.borrow();
230 Metadata {
231 directory: false,
232 length: data.bytes.len() as u64,
233 modified: data.modified,
234 }
235 }
236 }
237}
238
239pub fn metadata(path: impl AsRef<Path>) -> io::Result<Metadata> {
240 let path = normal(path.as_ref());
241 with(|files| {
242 files
243 .nodes
244 .get(&path)
245 .map(metadata_of)
246 .ok_or_else(|| not_found(&path))
247 })
248}
249
250pub fn symlink_metadata(path: impl AsRef<Path>) -> io::Result<Metadata> {
251 metadata(path)
252}
253
254pub fn exists(path: impl AsRef<Path>) -> io::Result<bool> {
255 let path = normal(path.as_ref());
256 Ok(with(|files| files.nodes.contains_key(&path)))
257}
258
259/// This page's number among the processes that may share its files, as other tabs do.
260pub fn process_id() -> u32 {
261 use std::sync::OnceLock;
262 static ID: OnceLock<u32> = OnceLock::new();
263 *ID.get_or_init(|| {
264 let mut bytes = [0; 4];
265 let _ = getrandom::fill(&mut bytes);
266 u32::from_ne_bytes(bytes)
267 })
268}
269
270/// `path` from the root, as every path here is.
271pub fn absolute(path: impl AsRef<Path>) -> io::Result<PathBuf> {
272 Ok(normal(path.as_ref()))
273}
274
275pub fn canonicalize(path: impl AsRef<Path>) -> io::Result<PathBuf> {
276 let path = normal(path.as_ref());
277 match with(|files| files.nodes.contains_key(&path)) {
278 true => Ok(path),
279 false => Err(not_found(&path)),
280 }
281}
282
283pub fn read(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
284 let path = normal(path.as_ref());
285 let data = with(|files| files.file(&path))?;
286 Ok(data.borrow().bytes.clone())
287}
288
289pub fn read_to_string(path: impl AsRef<Path>) -> io::Result<String> {
290 String::from_utf8(read(path)?).map_err(|error| io::Error::new(ErrorKind::InvalidData, error))
291}
292
293pub fn write(path: impl AsRef<Path>, contents: impl AsRef<[u8]>) -> io::Result<()> {
294 let path = normal(path.as_ref());
295 let data = with(|files| files.file(&path).or_else(|_| files.create(path.clone())))?;
296 let mut data = data.borrow_mut();
297 data.bytes = contents.as_ref().to_vec();
298 touched(&mut data);
299 Ok(())
300}
301
302pub fn copy(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<u64> {
303 let bytes = read(from)?;
304 write(to, &bytes)?;
305 Ok(bytes.len() as u64)
306}
307
308/// A copy, as nothing here writes a file under two names at once: a link is made to put a
309/// finished file in place, then the first name removed.
310pub fn hard_link(original: impl AsRef<Path>, link: impl AsRef<Path>) -> io::Result<()> {
311 let bytes = read(original)?;
312 let link = normal(link.as_ref());
313 let data = with(|files| files.create(link))?;
314 data.borrow_mut().bytes = bytes;
315 Ok(())
316}
317
318pub fn create_dir(path: impl AsRef<Path>) -> io::Result<()> {
319 let path = normal(path.as_ref());
320 with(|files| {
321 files.parent_exists(&path)?;
322 if files.nodes.contains_key(&path) {
323 return Err(ErrorKind::AlreadyExists.into());
324 }
325 files.nodes.insert(path.clone(), Node::Directory);
326 files.changed.insert(path);
327 Ok(())
328 })
329}
330
331pub fn create_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
332 let path = normal(path.as_ref());
333 for folder in path.ancestors().collect::<Vec<_>>().into_iter().rev() {
334 match with(|files| {
335 files
336 .nodes
337 .get(folder)
338 .map(|node| matches!(node, Node::Directory))
339 }) {
340 Some(true) => {}
341 Some(false) => return Err(ErrorKind::NotADirectory.into()),
342 None => create_dir(folder)?,
343 }
344 }
345 Ok(())
346}
347
348pub fn remove_file(path: impl AsRef<Path>) -> io::Result<()> {
349 let path = normal(path.as_ref());
350 with(|files| {
351 let data = files.file(&path)?;
352 data.borrow_mut().path = None;
353 files.nodes.remove(&path);
354 files.changed.insert(path);
355 Ok(())
356 })
357}
358
359pub fn remove_dir(path: impl AsRef<Path>) -> io::Result<()> {
360 let path = normal(path.as_ref());
361 with(|files| {
362 match files.nodes.get(&path) {
363 Some(Node::Directory) => {}
364 Some(Node::File(_)) => return Err(ErrorKind::NotADirectory.into()),
365 None => return Err(not_found(&path)),
366 }
367 if files.children(&path).next().is_some() {
368 return Err(ErrorKind::DirectoryNotEmpty.into());
369 }
370 files.nodes.remove(&path);
371 files.changed.insert(path);
372 Ok(())
373 })
374}
375
376pub fn remove_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
377 let path = normal(path.as_ref());
378 with(|files| {
379 let gone: Vec<PathBuf> = files
380 .nodes
381 .range(path.clone()..)
382 .take_while(|(each, _)| each.starts_with(&path))
383 .map(|(each, _)| each.clone())
384 .collect();
385 if gone.is_empty() {
386 return Err(not_found(&path));
387 }
388 for each in gone {
389 if let Some(Node::File(data)) = files.nodes.remove(&each) {
390 data.borrow_mut().path = None;
391 }
392 files.changed.insert(each);
393 }
394 Ok(())
395 })
396}
397
398/// Moves a file or folder, replacing a file at `to`, as a POSIX rename does.
399pub fn rename(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
400 let (from, to) = (normal(from.as_ref()), normal(to.as_ref()));
401 with(|files| {
402 if !files.nodes.contains_key(&from) {
403 return Err(not_found(&from));
404 }
405 files.parent_exists(&to)?;
406 if from == to {
407 return Ok(());
408 }
409 match files.nodes.get(&to) {
410 Some(Node::Directory) if files.children(&to).next().is_some() => {
411 return Err(ErrorKind::DirectoryNotEmpty.into());
412 }
413 Some(Node::File(data)) => data.borrow_mut().path = None,
414 _ => {}
415 }
416 let moved: Vec<PathBuf> = files
417 .nodes
418 .range(from.clone()..)
419 .take_while(|(each, _)| each.starts_with(&from))
420 .map(|(each, _)| each.clone())
421 .collect();
422 for each in moved {
423 let Some(node) = files.nodes.remove(&each) else {
424 continue;
425 };
426 let target = to.join(each.strip_prefix(&from).unwrap_or(Path::new("")));
427 if let Node::File(data) = &node {
428 data.borrow_mut().path = Some(target.clone());
429 }
430 files.changed.insert(each);
431 files.changed.insert(target.clone());
432 files.nodes.insert(target, node);
433 }
434 Ok(())
435 })
436}
437
438pub struct DirEntry {
439 path: PathBuf,
440 metadata: Metadata,
441}
442
443impl DirEntry {
444 pub fn path(&self) -> PathBuf {
445 self.path.clone()
446 }
447
448 pub fn file_name(&self) -> OsString {
449 self.path.file_name().unwrap_or_default().to_owned()
450 }
451
452 pub fn file_type(&self) -> io::Result<FileType> {
453 Ok(self.metadata.file_type())
454 }
455
456 pub fn metadata(&self) -> io::Result<Metadata> {
457 Ok(self.metadata.clone())
458 }
459}
460
461pub struct ReadDir(std::vec::IntoIter<DirEntry>);
462
463impl Iterator for ReadDir {
464 type Item = io::Result<DirEntry>;
465
466 fn next(&mut self) -> Option<Self::Item> {
467 self.0.next().map(Ok)
468 }
469}
470
471pub fn read_dir(path: impl AsRef<Path>) -> io::Result<ReadDir> {
472 let path = normal(path.as_ref());
473 with(|files| {
474 match files.nodes.get(&path) {
475 Some(Node::Directory) => {}
476 Some(Node::File(_)) => return Err(ErrorKind::NotADirectory.into()),
477 None => return Err(not_found(&path)),
478 }
479 let entries: Vec<DirEntry> = files
480 .children(&path)
481 .map(|(child, node)| DirEntry {
482 path: child.clone(),
483 metadata: metadata_of(node),
484 })
485 .collect();
486 Ok(ReadDir(entries.into_iter()))
487 })
488}
489
490#[derive(Clone, Debug, Default)]
491pub struct OpenOptions {
492 read: bool,
493 write: bool,
494 append: bool,
495 truncate: bool,
496 create: bool,
497 create_new: bool,
498}
499
500impl OpenOptions {
501 pub fn new() -> Self {
502 Self::default()
503 }
504
505 pub fn read(&mut self, read: bool) -> &mut Self {
506 self.read = read;
507 self
508 }
509
510 pub fn write(&mut self, write: bool) -> &mut Self {
511 self.write = write;
512 self
513 }
514
515 pub fn append(&mut self, append: bool) -> &mut Self {
516 self.append = append;
517 self
518 }
519
520 pub fn truncate(&mut self, truncate: bool) -> &mut Self {
521 self.truncate = truncate;
522 self
523 }
524
525 pub fn create(&mut self, create: bool) -> &mut Self {
526 self.create = create;
527 self
528 }
529
530 pub fn create_new(&mut self, create_new: bool) -> &mut Self {
531 self.create_new = create_new;
532 self
533 }
534
535 pub fn open(&self, path: impl AsRef<Path>) -> io::Result<File> {
536 let path = normal(path.as_ref());
537 let data = with(|files| match files.file(&path) {
538 Ok(_) if self.create_new => Err(ErrorKind::AlreadyExists.into()),
539 Err(error)
540 if error.kind() == ErrorKind::NotFound && (self.create || self.create_new) =>
541 {
542 files.create(path.clone())
543 }
544 found => found,
545 })?;
546 if self.truncate && self.write {
547 let mut data = data.borrow_mut();
548 data.bytes.clear();
549 touched(&mut data);
550 }
551 Ok(File {
552 data,
553 position: 0,
554 append: self.append,
555 })
556 }
557}
558
559/// An open file, which reads and writes the file under whatever name it has since.
560pub struct File {
561 data: Shared,
562 position: u64,
563 append: bool,
564}
565
566impl File {
567 pub fn open(path: impl AsRef<Path>) -> io::Result<Self> {
568 OpenOptions::new().read(true).open(path)
569 }
570
571 pub fn create(path: impl AsRef<Path>) -> io::Result<Self> {
572 OpenOptions::new()
573 .write(true)
574 .create(true)
575 .truncate(true)
576 .open(path)
577 }
578
579 pub fn create_new(path: impl AsRef<Path>) -> io::Result<Self> {
580 OpenOptions::new()
581 .read(true)
582 .write(true)
583 .create_new(true)
584 .open(path)
585 }
586
587 pub fn metadata(&self) -> io::Result<Metadata> {
588 Ok(metadata_of(&Node::File(Rc::clone(&self.data))))
589 }
590
591 pub fn sync_all(&self) -> io::Result<()> {
592 Ok(())
593 }
594
595 pub fn sync_data(&self) -> io::Result<()> {
596 Ok(())
597 }
598
599 pub fn set_len(&self, size: u64) -> io::Result<()> {
600 let mut data = self.data.borrow_mut();
601 data.bytes.resize(size as usize, 0);
602 touched(&mut data);
603 Ok(())
604 }
605
606 fn read_from(&self, offset: u64, output: &mut [u8]) -> usize {
607 let data = self.data.borrow();
608 let rest = data.bytes.get(offset as usize..).unwrap_or_default();
609 let count = rest.len().min(output.len());
610 output[..count].copy_from_slice(&rest[..count]);
611 count
612 }
613
614 fn write_to(&self, offset: u64, input: &[u8]) {
615 let mut data = self.data.borrow_mut();
616 let end = offset as usize + input.len();
617 if data.bytes.len() < end {
618 data.bytes.resize(end, 0);
619 }
620 data.bytes[offset as usize..end].copy_from_slice(input);
621 touched(&mut data);
622 }
623}
624
625impl Read for File {
626 fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
627 let count = self.read_from(self.position, output);
628 self.position += count as u64;
629 Ok(count)
630 }
631}
632
633impl Write for File {
634 fn write(&mut self, input: &[u8]) -> io::Result<usize> {
635 if self.append {
636 self.position = self.data.borrow().bytes.len() as u64;
637 }
638 self.write_to(self.position, input);
639 self.position += input.len() as u64;
640 Ok(input.len())
641 }
642
643 fn flush(&mut self) -> io::Result<()> {
644 Ok(())
645 }
646}
647
648impl Seek for File {
649 fn seek(&mut self, position: SeekFrom) -> io::Result<u64> {
650 let length = self.data.borrow().bytes.len() as i128;
651 let target = match position {
652 SeekFrom::Start(offset) => i128::from(offset),
653 SeekFrom::End(offset) => length + i128::from(offset),
654 SeekFrom::Current(offset) => i128::from(self.position) + i128::from(offset),
655 };
656 self.position = u64::try_from(target).map_err(|_| ErrorKind::InvalidInput)?;
657 Ok(self.position)
658 }
659}
660
661impl CommitIo for File {
662 fn read_at(&mut self, offset: u64, bytes: &mut [u8]) -> io::Result<usize> {
663 Ok(self.read_from(offset, bytes))
664 }
665
666 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {
667 self.write_to(offset, bytes);
668 Ok(bytes.len())
669 }
670
671 fn flush(&mut self) -> io::Result<()> {
672 Ok(())
673 }
674}
675
676/// `onestore::read_file`: a read here is whole, as nothing commits while it runs.
677pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
678 read(path)
679}
680
681pub fn read_file_limited(path: impl AsRef<Path>, limit: usize) -> io::Result<Vec<u8>> {
682 let bytes = read(path)?;
683 if bytes.len() > limit {
684 return Err(ErrorKind::FileTooLarge.into());
685 }
686 Ok(bytes)
687}
688
689fn writable(path: impl AsRef<Path>) -> io::Result<File> {
690 OpenOptions::new().read(true).write(true).open(path)
691}
692
693fn not_committed(error: io::Error) -> CommitError {
694 CommitError {
695 state: CommitState::NotCommitted,
696 error,
697 }
698}
699
700pub fn commit_file(transaction: &Transaction, path: impl AsRef<Path>) -> Result<(), CommitError> {
701 transaction.commit(&mut writable(path).map_err(not_committed)?)
702}
703
704pub fn confirm_file(path: impl AsRef<Path>, base: &Stamp) -> Result<(), CommitError> {
705 onestore::confirm(&mut writable(path).map_err(not_committed)?, base)
706}
707
708pub fn place_file(path: impl AsRef<Path>, ancestor: [u8; 16], name: &str) -> io::Result<()> {
709 onestore::place(&mut writable(path)?, ancestor, name)
710}
711
712/// Puts the file at `with` in the place of the file at `path`, provided `path` still has
713/// `base`'s stamp.
714pub fn supersede_file(
715 path: impl AsRef<Path>,
716 base: &Stamp,
717 with: impl AsRef<Path>,
718) -> Result<(), CommitError> {
719 base.check(&mut writable(&path).map_err(not_committed)?)
720 .map_err(not_committed)?;
721 rename(with, path).map_err(not_committed)
722}
crates/notebook/src/fs/web/sqlite.rs created+143
......@@ -0,0 +1,143 @@
1//! SQLite's default VFS in the browser: the files of `fs`, so that a replica, its write-ahead
2//! log and the notebook it caches are kept and written out alike.
3#![allow(unsafe_code)]
4
5use super::{FILES, Node, Shared, normal, touched, with};
6use rsqlite_vfs::{
7 OsCallback, SQLiteIoMethods, SQLiteVfs, SQLiteVfsFile, VfsError, VfsFile, VfsResult, VfsStore,
8 ffi::{SQLITE_CANTOPEN, SQLITE_IOERR, SQLITE_IOERR_DELETE, sqlite3_vfs},
9};
10use std::{path::Path, rc::Rc, sync::Once, time::Duration};
11
12/// Registers the VFS as SQLite's default, once.
13pub(crate) fn install() {
14 static INSTALLED: Once = Once::new();
15 INSTALLED.call_once(|| {
16 rsqlite_vfs::register_vfs::<Methods, Vfs>("snowbound", (), true)
17 .expect("SQLite takes the browser's VFS");
18 });
19}
20
21struct File(Shared);
22
23impl VfsFile for File {
24 fn read(&self, output: &mut [u8], offset: usize) -> VfsResult<bool> {
25 let data = self.0.borrow();
26 let rest = data.bytes.get(offset..).unwrap_or_default();
27 let count = rest.len().min(output.len());
28 output[..count].copy_from_slice(&rest[..count]);
29 output[count..].fill(0);
30 Ok(count == output.len())
31 }
32
33 fn write(&mut self, input: &[u8], offset: usize) -> VfsResult<()> {
34 let mut data = self.0.borrow_mut();
35 let end = offset + input.len();
36 if data.bytes.len() < end {
37 data.bytes.resize(end, 0);
38 }
39 data.bytes[offset..end].copy_from_slice(input);
40 touched(&mut data);
41 Ok(())
42 }
43
44 fn truncate(&mut self, size: usize) -> VfsResult<()> {
45 let mut data = self.0.borrow_mut();
46 data.bytes.truncate(size);
47 touched(&mut data);
48 Ok(())
49 }
50
51 fn flush(&mut self) -> VfsResult<()> {
52 Ok(())
53 }
54
55 fn size(&self) -> VfsResult<usize> {
56 Ok(self.0.borrow().bytes.len())
57 }
58}
59
60fn error(code: i32, error: std::io::Error) -> VfsError {
61 VfsError::new(code, error.to_string())
62}
63
64struct Store;
65
66impl Store {
67 fn data(file: &SQLiteVfsFile) -> VfsResult<Shared> {
68 // SAFETY: SQLite hands back the file `xOpen` named, whose name lives until `xClose`.
69 let name = unsafe { file.name() };
70 with(|files| files.file(&normal(Path::new(name)))).map_err(|io| error(SQLITE_IOERR, io))
71 }
72}
73
74impl VfsStore<File, ()> for Store {
75 fn add_file(_: *mut sqlite3_vfs, name: &str, _: i32) -> VfsResult<()> {
76 // SQLite's own files beside a replica, such as its write-ahead log, need no folder made.
77 let path = normal(Path::new(name));
78 with(|files| files.create(path))
79 .map(drop)
80 .map_err(|io| error(SQLITE_CANTOPEN, io))
81 }
82
83 fn contains_file(_: *mut sqlite3_vfs, name: &str) -> VfsResult<bool> {
84 let path = normal(Path::new(name));
85 Ok(FILES.with_borrow(|files| matches!(files.nodes.get(&path), Some(Node::File(_)))))
86 }
87
88 fn delete_file(_: *mut sqlite3_vfs, name: &str) -> VfsResult<()> {
89 super::remove_file(name).map_err(|io| error(SQLITE_IOERR_DELETE, io))
90 }
91
92 fn with_file<F: Fn(&File) -> VfsResult<i32>>(file: &SQLiteVfsFile, act: F) -> VfsResult<i32> {
93 act(&File(Self::data(file)?))
94 }
95
96 fn with_file_mut<F: Fn(&mut File) -> VfsResult<i32>>(
97 file: &SQLiteVfsFile,
98 act: F,
99 ) -> VfsResult<i32> {
100 act(&mut File(Rc::clone(&Self::data(file)?)))
101 }
102}
103
104struct Methods;
105
106impl SQLiteIoMethods for Methods {
107 type File = File;
108 type AppData = ();
109 type Store = Store;
110
111 const VERSION: i32 = 1;
112}
113
114struct Vfs;
115
116impl OsCallback for Vfs {
117 /// Nothing else holds a lock to wait for.
118 fn sleep(_: Duration) {}
119
120 fn random(bytes: &mut [u8]) {
121 let _ = getrandom::fill(bytes);
122 }
123
124 fn epoch_timestamp_in_ms() -> i64 {
125 js_sys::Date::now() as i64
126 }
127}
128
129impl SQLiteVfs<Methods> for Vfs {
130 const VERSION: i32 = 1;
131
132 fn sleep(duration: Duration) {
133 <Self as OsCallback>::sleep(duration)
134 }
135
136 fn random(bytes: &mut [u8]) {
137 <Self as OsCallback>::random(bytes)
138 }
139
140 fn epoch_timestamp_in_ms() -> i64 {
141 <Self as OsCallback>::epoch_timestamp_in_ms()
142 }
143}
crates/notebook/src/lib.rs+11-7
......@@ -3,9 +3,11 @@
33#![doc = include_str!("../README.md")]
44
55pub mod discover;
6pub mod fs;
67#[cfg(feature = "smb")]
78pub mod smb;
89
10use fs::OpenOptions;
911use onestore::{
1012 ExGuid,
1113 op::{Edit, OpError},
......@@ -14,7 +16,6 @@ use onestore::{
1416};
1517use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
1618use std::{
17 fs::OpenOptions,
1819 io,
1920 path::{Path, PathBuf},
2021 sync::{Arc, Mutex, MutexGuard, mpsc},
......@@ -38,6 +39,7 @@ pub use recovery::{Recovery, RecoverySummary};
3839pub mod sidecar;
3940mod sync;
4041pub use sync::{EditStatus, Remote, Synced};
42mod task;
4143mod worker;
4244mod working;
4345#[cfg(feature = "smb")]
......@@ -137,7 +139,7 @@ impl Replica {
137139 source: impl FnOnce() -> Result<Vec<u8>>,
138140 ) -> Result<Self> {
139141 let path = path.as_ref();
140 if !path.exists() {
142 if fs::metadata(path).is_err() {
141143 match Self::seed(path, &source()?, key) {
142144 Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {}
143145 seeded => seeded?,
......@@ -154,14 +156,14 @@ impl Replica {
154156 static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
155157 let build = BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
156158 let mut building = path.as_os_str().to_owned();
157 building.push(format!(".creating-{}-{build}", std::process::id()));
159 building.push(format!(".creating-{}-{build}", fs::process_id()));
158160 let building = PathBuf::from(building);
159161 let built = Self::build(&building, source);
160 let linked = built.and_then(|()| Ok(std::fs::hard_link(&building, path)?));
162 let linked = built.and_then(|()| Ok(fs::hard_link(&building, path)?));
161163 for suffix in ["", "-wal", "-shm"] {
162164 let mut file = building.as_os_str().to_owned();
163165 file.push(suffix);
164 let _ = std::fs::remove_file(file);
166 let _ = fs::remove_file(file);
165167 }
166168 linked
167169 }
......@@ -463,6 +465,8 @@ fn signed(value: u64) -> Result<i64> {
463465/// Opens a cache without writing to it: exclusive locking, a full sync of every commit
464466/// (`F_FULLFSYNC` on macOS, the WAL's syncs included) and foreign keys, each queried back.
465467fn cache_connection(path: &Path) -> Result<Connection> {
468 #[cfg(target_arch = "wasm32")]
469 fs::install_sqlite();
466470 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
467471 connection.busy_timeout(Duration::ZERO)?;
468472 connection.execute_batch(
......@@ -512,8 +516,8 @@ fn write_ahead(connection: &Connection) -> Result<()> {
512516pub(crate) fn now() -> u64 {
513517 use std::sync::atomic::{AtomicU64, Ordering};
514518 static LAST: AtomicU64 = AtomicU64::new(0);
515 let unix = std::time::SystemTime::now()
516 .duration_since(std::time::UNIX_EPOCH)
519 let unix = web_time::SystemTime::now()
520 .duration_since(web_time::UNIX_EPOCH)
517521 .unwrap_or_default();
518522 let clock =
519523 (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100);
crates/notebook/src/location.rs+22-20
......@@ -6,6 +6,7 @@
66//! A location is a string naming where a notebook or lone section is reached: the canonical
77//! path of a local folder or file (`local`), or `smb` for one on a share.
88
9use crate::fs;
910use onestore::{Header, Stamp};
1011use std::{
1112 io,
......@@ -26,11 +27,10 @@ pub fn folder(cache: &Path, location: &str) -> PathBuf {
2627/// its canonical path, or its folder's with its name.
2728pub fn local(path: &Path) -> io::Result<String> {
2829 let canonical =
29 path.canonicalize()
30 .or_else(|error| match (path.parent(), path.file_name()) {
31 (Some(parent), Some(name)) => Ok(parent.canonicalize()?.join(name)),
32 _ => Err(error),
33 })?;
30 fs::canonicalize(path).or_else(|error| match (path.parent(), path.file_name()) {
31 (Some(parent), Some(name)) => Ok(fs::canonicalize(parent)?.join(name)),
32 _ => Err(error),
33 })?;
3434 Ok(canonical.to_string_lossy().into_owned())
3535}
3636
......@@ -46,13 +46,15 @@ pub fn smb(address: &str, share: &str, root: &str) -> String {
4646/// must be closed.
4747pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> {
4848 let old = folder(cache, from);
49 if !old.exists() {
49 if fs::metadata(&old).is_err() {
5050 return Ok(());
5151 }
5252 let new = claimed(cache, to)?;
53 for entry in std::fs::read_dir(&old)? {
53 for entry in fs::read_dir(&old)? {
5454 let name = entry?.file_name();
55 if Path::new(&name).extension() == Some("sqlite".as_ref()) && !new.join(&name).exists() {
55 if Path::new(&name).extension() == Some("sqlite".as_ref())
56 && fs::metadata(new.join(&name)).is_err()
57 {
5658 adopt(&old.join(&name), &new.join(&name))?;
5759 }
5860 }
......@@ -64,9 +66,9 @@ pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> {
6466fn claimed(cache: &Path, location: &str) -> io::Result<PathBuf> {
6567 let folder = folder(cache, location);
6668 let label = folder.join(LOCATION);
67 if !label.exists() {
68 std::fs::create_dir_all(&folder)?;
69 std::fs::write(&label, location)?;
69 if fs::metadata(&label).is_err() {
70 fs::create_dir_all(&folder)?;
71 fs::write(&label, location)?;
7072 }
7173 Ok(folder)
7274}
......@@ -93,7 +95,7 @@ pub(crate) fn claim(
9395 let folder = claimed(cache, location)?;
9496 let missing: Vec<&[u8; 16]> = sections
9597 .iter()
96 .filter(|identity| !crate::session::replica_file(&folder, identity).exists())
98 .filter(|identity| fs::metadata(crate::session::replica_file(&folder, identity)).is_err())
9799 .collect();
98100 if missing.is_empty() {
99101 return Ok(folder);
......@@ -107,7 +109,7 @@ pub(crate) fn claim(
107109 .iter()
108110 .map(|orphan| (crate::session::replica_file(orphan, identity), false));
109111 for (replica, legacy) in legacy.chain(left) {
110 if !replica.exists() {
112 if fs::metadata(&replica).is_err() {
111113 continue;
112114 }
113115 let adoptable = match crate::closed(&replica).and_then(|held| crate::peek(&held)) {
......@@ -132,15 +134,15 @@ pub(crate) fn claim(
132134
133135/// The replica folders of local locations that no longer exist.
134136fn orphans(cache: &Path) -> Vec<PathBuf> {
135 let Ok(entries) = std::fs::read_dir(cache.join("replicas")) else {
137 let Ok(entries) = fs::read_dir(cache.join("replicas")) else {
136138 return Vec::new();
137139 };
138140 entries
139141 .filter_map(|entry| {
140142 let folder = entry.ok()?.path();
141 let location = std::fs::read_to_string(folder.join(LOCATION)).ok()?;
143 let location = fs::read_to_string(folder.join(LOCATION)).ok()?;
142144 let path = Path::new(&location);
143 (path.is_absolute() && !path.exists()).then_some(folder)
145 (path.is_absolute() && fs::metadata(path).is_err()).then_some(folder)
144146 })
145147 .collect()
146148}
......@@ -160,7 +162,7 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> {
160162 let (mut source, mut target) = (from.as_os_str().to_owned(), to.as_os_str().to_owned());
161163 source.push(suffix);
162164 target.push(suffix);
163 match std::fs::rename(&source, &target) {
165 match fs::rename(&source, &target) {
164166 Err(error) if !suffix.is_empty() && error.kind() == io::ErrorKind::NotFound => {}
165167 moved => moved?,
166168 }
......@@ -170,12 +172,12 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> {
170172
171173/// Removes a replica folder that holds no replica.
172174fn tidy(folder: &Path) {
173 let empty = std::fs::read_dir(folder).is_ok_and(|mut entries| {
175 let empty = fs::read_dir(folder).is_ok_and(|mut entries| {
174176 entries.all(|entry| entry.is_ok_and(|entry| entry.file_name() == LOCATION))
175177 });
176178 if empty {
177 let _ = std::fs::remove_file(folder.join(LOCATION));
178 let _ = std::fs::remove_dir(folder);
179 let _ = fs::remove_file(folder.join(LOCATION));
180 let _ = fs::remove_dir(folder);
179181 }
180182}
181183
crates/notebook/src/package.rs+4-4
......@@ -7,7 +7,7 @@
77//! MSZIP, which every cabinet reader takes, and reads either.
88
99use crate::{
10 Error, Result,
10 Error, Result, fs,
1111 session::{Notebook, lists, moved},
1212};
1313use onestore::{TocEdit, op::Edit, page::Page};
......@@ -196,13 +196,13 @@ pub fn unpack(mut files: Files, root: &Path) -> Result<()> {
196196 transaction.apply(image)?;
197197 }
198198 }
199 std::fs::create_dir(root)?;
199 fs::create_dir(root)?;
200200 for (path, bytes) in &files {
201201 let file = root.join(path);
202202 if let Some(parent) = file.parent() {
203 std::fs::create_dir_all(parent)?;
203 fs::create_dir_all(parent)?;
204204 }
205 std::fs::File::create_new(file)?.write_all(bytes)?;
205 fs::File::create_new(file)?.write_all(bytes)?;
206206 }
207207 Ok(())
208208}
crates/notebook/src/session.rs+33-36
......@@ -3,7 +3,7 @@
33
44pub use crate::background::{Background, Known};
55use crate::{
6 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover,
6 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs,
77};
88use onestore::{
99 CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction,
......@@ -132,17 +132,17 @@ impl Storage for Directory {
132132 }
133133
134134 fn exists(&self, path: &str) -> bool {
135 self.path(path).exists()
135 fs::metadata(self.path(path)).is_ok()
136136 }
137137
138138 fn read(&self, path: &str) -> Result<Vec<u8>> {
139 Ok(onestore::read_file(self.path(path))?)
139 Ok(fs::read_file(self.path(path))?)
140140 }
141141
142142 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> {
143143 use std::io::Read;
144144 let mut bytes = Vec::new();
145 std::fs::File::open(self.path(path))?
145 fs::File::open(self.path(path))?
146146 .take(limit as u64 + 1)
147147 .read_to_end(&mut bytes)?;
148148 if bytes.len() > limit {
......@@ -153,15 +153,16 @@ impl Storage for Directory {
153153
154154 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {
155155 let path = self.path(path);
156 if discover::placeholder(&path).is_some_and(|placeholder| placeholder.exists()) {
156 if discover::placeholder(&path).is_some_and(|placeholder| fs::metadata(placeholder).is_ok())
157 {
157158 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
158159 }
159 std::fs::File::create_new(path)?.write_all(bytes)?;
160 fs::File::create_new(path)?.write_all(bytes)?;
160161 Ok(())
161162 }
162163
163164 fn create_directory(&self, path: &str) -> Result<()> {
164 Ok(std::fs::create_dir(self.path(path))?)
165 Ok(fs::create_dir(self.path(path))?)
165166 }
166167
167168 /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted;
......@@ -207,37 +208,33 @@ impl Storage for Directory {
207208 }
208209
209210 fn rename(&self, from: &str, to: &str) -> Result<()> {
210 Ok(std::fs::rename(self.path(from), self.path(to))?)
211 Ok(fs::rename(self.path(from), self.path(to))?)
211212 }
212213
213214 fn replace(&self, from: &str, to: &str) -> Result<()> {
214 Ok(std::fs::rename(self.path(from), self.path(to))?)
215 Ok(fs::rename(self.path(from), self.path(to))?)
215216 }
216217
217218 fn delete(&self, path: &str) -> Result<()> {
218219 let path = self.path(path);
219 if path.is_dir() {
220 std::fs::remove_dir(path)?;
220 if fs::metadata(&path).is_ok_and(|metadata| metadata.is_dir()) {
221 fs::remove_dir(path)?;
221222 } else {
222 std::fs::remove_file(path)?;
223 fs::remove_file(path)?;
223224 }
224225 Ok(())
225226 }
226227
227228 fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> {
228 Ok(onestore::place_file(self.path(path), ancestor, name)?)
229 Ok(fs::place_file(self.path(path), ancestor, name)?)
229230 }
230231
231232 fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> {
232 Ok(transaction.commit_file(self.path(path))?)
233 Ok(fs::commit_file(transaction, self.path(path))?)
233234 }
234235
235236 fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> {
236 Ok(onestore::supersede_file(
237 self.path(path),
238 base,
239 self.path(with),
240 )?)
237 Ok(fs::supersede_file(self.path(path), base, self.path(with))?)
241238 }
242239}
243240
......@@ -363,7 +360,7 @@ impl Notebook {
363360 pub const NEW_COLOR: u32 = 0x00aeba91;
364361
365362 pub fn open(root: impl AsRef<Path>, cache: impl AsRef<Path>) -> Result<Self> {
366 let root = root.as_ref().canonicalize()?;
363 let root = fs::canonicalize(root)?;
367364 Self::with(Box::new(Directory(root.clone())), Some(root), cache)
368365 }
369366
......@@ -376,7 +373,7 @@ impl Notebook {
376373 color: u32,
377374 page: &PageCreation,
378375 ) -> Result<Self> {
379 std::fs::create_dir(root.as_ref())?;
376 fs::create_dir(root.as_ref())?;
380377 let mut notebook = Self::open(root, cache)?;
381378 notebook.edit_toc("", &[onestore::TocEdit::Color(color)])?;
382379 notebook.refresh()?;
......@@ -412,13 +409,13 @@ impl Notebook {
412409 ) -> Result<Self> {
413410 let cache = cache.as_ref().to_path_buf();
414411 let listings = cache.join("listings");
415 std::fs::create_dir_all(&listings)?;
412 fs::create_dir_all(&listings)?;
416413 let name: String = <sha2::Sha256 as sha2::Digest>::digest(storage.location())[..16]
417414 .iter()
418415 .map(|byte| format!("{byte:02x}"))
419416 .collect();
420417 let listing = listings.join(format!("{name}.json"));
421 let mut read = std::fs::read(&listing)
418 let mut read = fs::read(&listing)
422419 .ok()
423420 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
424421 .unwrap_or_default();
......@@ -1271,7 +1268,7 @@ impl Notebook {
12711268 let folders: BTreeSet<&PathBuf> = placed.iter().map(|(.., folder)| folder).collect();
12721269 let mut superseded = Vec::new();
12731270 for folder in folders {
1274 let Ok(entries) = std::fs::read_dir(folder) else {
1271 let Ok(entries) = fs::read_dir(folder) else {
12751272 continue;
12761273 };
12771274 for path in entries.filter_map(|entry| Some(entry.ok()?.path())) {
......@@ -1337,8 +1334,8 @@ impl Notebook {
13371334 let section = self.section_path(path)?;
13381335 let replica = self.replica_path(path)?;
13391336 // Held until the file is replaced, so that no session queues edits to the old file.
1340 let held = replica
1341 .exists()
1337 let held = fs::metadata(&replica)
1338 .is_ok()
13421339 .then(|| crate::closed(&replica))
13431340 .transpose()?;
13441341 if let Some(held) = &held
......@@ -1525,7 +1522,7 @@ impl Notebook {
15251522 .into());
15261523 };
15271524 // A section replaced by a link out of the notebook is not the catalog's section.
1528 let file = root.join(path).canonicalize()?;
1525 let file = fs::canonicalize(root.join(path))?;
15291526 if !file.starts_with(root) {
15301527 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
15311528 }
......@@ -1533,14 +1530,14 @@ impl Notebook {
15331530 // worker checks next, as a share's does.
15341531 if let discover::SectionState::Readable { document, .. } = &section.state {
15351532 let cache = replica_file(&replicas, document);
1536 if cache.exists() {
1533 if fs::metadata(&cache).is_ok() {
15371534 let (replica, remote, mut connect) =
15381535 (Replica::open(&cache)?, file.clone(), connect);
15391536 return Section::start(file, replica, move || connect(&remote), notify);
15401537 }
15411538 }
15421539 let replica = |identity: &[u8; 16], _: &[u8]| {
1543 std::fs::create_dir_all(&replicas)?;
1540 fs::create_dir_all(&replicas)?;
15441541 Ok(replica_file(&replicas, identity))
15451542 };
15461543 Section::open_in(file, key, replica, connect, notify)
......@@ -1712,7 +1709,7 @@ fn remove_replica(replica: &Path) -> io::Result<()> {
17121709 for suffix in ["", "-wal", "-shm"] {
17131710 let mut file = replica.as_os_str().to_owned();
17141711 file.push(suffix);
1715 match std::fs::remove_file(file) {
1712 match fs::remove_file(file) {
17161713 Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
17171714 _ => {}
17181715 }
......@@ -1839,7 +1836,7 @@ impl Section {
18391836 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
18401837 notify: impl Fn() + Send + 'static,
18411838 ) -> Result<Self> {
1842 let file = file.as_ref().canonicalize()?;
1839 let file = fs::canonicalize(file)?;
18431840 let location = file.to_string_lossy().into_owned();
18441841 let replicas = |identity: &[u8; 16], source: &[u8]| {
18451842 let folder = crate::location::claim(cache.as_ref(), &location, &[*identity], |_| {
......@@ -1875,7 +1872,7 @@ impl Section {
18751872 replica: Replica,
18761873 notify: impl Fn() + Send + 'static,
18771874 ) -> Result<Self> {
1878 let file = std::path::absolute(file)?;
1875 let file = fs::absolute(file)?;
18791876 let remote = file.clone();
18801877 Self::start(
18811878 file,
......@@ -2200,14 +2197,14 @@ struct FileRemote(PathBuf);
22002197
22012198impl Remote for FileRemote {
22022199 fn read(&mut self) -> io::Result<Vec<u8>> {
2203 onestore::read_file(&self.0)
2200 fs::read_file(&self.0)
22042201 }
22052202
22062203 /// Read without the whole-file lock, which would block OneNote's readers: a change
22072204 /// detector, never a snapshot to edit.
22082205 fn stamp(&mut self) -> io::Result<Stamp> {
22092206 use std::io::Read;
2210 let mut file = std::fs::File::open(&self.0)?;
2207 let mut file = fs::File::open(&self.0)?;
22112208 let mut header = [0; 1024];
22122209 file.read_exact(&mut header)?;
22132210 let length = file.metadata()?.len();
......@@ -2215,11 +2212,11 @@ impl Remote for FileRemote {
22152212 }
22162213
22172214 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> {
2218 transaction.commit_file(&self.0)
2215 fs::commit_file(transaction, &self.0)
22192216 }
22202217
22212218 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {
2222 onestore::confirm_file(&self.0, base)
2219 fs::confirm_file(&self.0, base)
22232220 }
22242221}
22252222
crates/notebook/src/sidecar.rs+1-1
......@@ -158,7 +158,7 @@ pub(crate) fn map(
158158/// A name beside `path` no other writer picks.
159159fn temporary(path: &str) -> String {
160160 use std::hash::{BuildHasher, RandomState};
161 let unique = RandomState::new().hash_one((std::process::id(), crate::now()));
161 let unique = RandomState::new().hash_one((crate::fs::process_id(), crate::now()));
162162 format!("{path}.{unique:016x}.tmp")
163163}
164164
crates/notebook/src/task.rs created+159
......@@ -0,0 +1,159 @@
1//! The sync worker's and background's loops: threads natively, and in the browser, which
2//! gives a module one thread, tasks on its event loop. A loop is an `async` block whose only
3//! waits are `wait`; natively each wait blocks its thread, so `complete` runs the block to the
4//! end in one poll.
5
6use std::{io, time::Duration};
7
8#[cfg(not(target_arch = "wasm32"))]
9pub(crate) use std::{
10 sync::mpsc::{Receiver, SyncSender as Sender},
11 thread::JoinHandle,
12};
13
14/// A wake that waits, at most one, as `mpsc::sync_channel(1)` keeps one.
15#[cfg(not(target_arch = "wasm32"))]
16pub(crate) fn channel() -> (Sender<()>, Receiver<()>) {
17 std::sync::mpsc::sync_channel(1)
18}
19
20/// Waits for a wake or `timeout`, without one for ever.
21#[cfg(not(target_arch = "wasm32"))]
22pub(crate) async fn wait(receiver: &Receiver<()>, timeout: Option<Duration>) {
23 let _ = match timeout {
24 Some(timeout) => receiver.recv_timeout(timeout).ok(),
25 None => receiver.recv().ok(),
26 };
27}
28
29/// Runs the loop `start` makes, whose waits block, on a thread of its own named `name`.
30#[cfg(not(target_arch = "wasm32"))]
31pub(crate) fn spawn<T: Send + 'static, F: Future<Output = T>>(
32 name: &str,
33 start: impl FnOnce() -> F + Send + 'static,
34) -> io::Result<JoinHandle<T>> {
35 std::thread::Builder::new()
36 .name(name.into())
37 .spawn(move || complete(start()))
38}
39
40/// `work`'s output, where nothing it awaits is pending.
41#[cfg(not(target_arch = "wasm32"))]
42pub(crate) fn complete<T>(work: impl Future<Output = T>) -> T {
43 let mut work = std::pin::pin!(work);
44 match work
45 .as_mut()
46 .poll(&mut std::task::Context::from_waker(std::task::Waker::noop()))
47 {
48 std::task::Poll::Ready(output) => output,
49 std::task::Poll::Pending => unreachable!("Native waits block rather than pend"),
50 }
51}
52
53#[cfg(target_arch = "wasm32")]
54pub(crate) use web::*;
55
56#[cfg(target_arch = "wasm32")]
57mod web {
58 use super::*;
59 use std::{
60 sync::{Arc, Mutex},
61 task::{Poll, Waker},
62 };
63 use wasm_bindgen::{JsCast, prelude::*};
64
65 #[derive(Default)]
66 struct Bell {
67 rung: bool,
68 waiting: Option<Waker>,
69 }
70
71 pub(crate) struct Sender<T>(Arc<Mutex<Bell>>, std::marker::PhantomData<T>);
72 pub(crate) struct Receiver<T>(Arc<Mutex<Bell>>, std::marker::PhantomData<T>);
73
74 pub(crate) fn channel() -> (Sender<()>, Receiver<()>) {
75 let bell = Arc::new(Mutex::new(Bell::default()));
76 (
77 Sender(Arc::clone(&bell), Default::default()),
78 Receiver(bell, Default::default()),
79 )
80 }
81
82 impl Sender<()> {
83 pub(crate) fn try_send(&self, (): ()) -> Result<(), ()> {
84 let waiting = {
85 let mut bell = self.0.lock().map_err(|_| ())?;
86 bell.rung = true;
87 bell.waiting.take()
88 };
89 if let Some(waiting) = waiting {
90 waiting.wake();
91 }
92 Ok(())
93 }
94 }
95
96 /// Wakes `waker` after `delay` through the global scope's `setTimeout`.
97 fn after(delay: Duration, waker: Waker) {
98 let global = js_sys::global();
99 let Ok(set_timeout) = js_sys::Reflect::get(&global, &"setTimeout".into())
100 .and_then(|function| function.dyn_into::<js_sys::Function>())
101 else {
102 return waker.wake();
103 };
104 let wake = Closure::once_into_js(move || waker.wake());
105 let _ = set_timeout.call2(&global, &wake, &(delay.as_secs_f64() * 1e3).into());
106 }
107
108 pub(crate) async fn wait(receiver: &Receiver<()>, timeout: Option<Duration>) {
109 let deadline = timeout.map(|timeout| web_time::Instant::now() + timeout);
110 let mut armed = false;
111 std::future::poll_fn(|context| {
112 let Ok(mut bell) = receiver.0.lock() else {
113 return Poll::Ready(());
114 };
115 if std::mem::take(&mut bell.rung)
116 || deadline.is_some_and(|deadline| deadline <= web_time::Instant::now())
117 {
118 return Poll::Ready(());
119 }
120 bell.waiting = Some(context.waker().clone());
121 if let Some(deadline) = deadline
122 && !std::mem::replace(&mut armed, true)
123 {
124 after(
125 deadline.saturating_duration_since(web_time::Instant::now()),
126 context.waker().clone(),
127 );
128 }
129 Poll::Pending
130 })
131 .await
132 }
133
134 /// A task the browser's event loop runs. It cannot be waited for, and ends on its own.
135 pub(crate) struct JoinHandle<T>(Arc<Mutex<Option<T>>>);
136
137 impl<T> JoinHandle<T> {
138 /// What the task ended with, if it has ended.
139 pub(crate) fn finished(self) -> Option<T> {
140 self.0.lock().ok()?.take()
141 }
142 }
143
144 pub(crate) fn spawn<T: 'static, F: Future<Output = T> + 'static>(
145 _: &str,
146 start: impl FnOnce() -> F,
147 ) -> io::Result<JoinHandle<T>> {
148 let output = Arc::new(Mutex::new(None));
149 let ended = Arc::clone(&output);
150 let work = start();
151 wasm_bindgen_futures::spawn_local(async move {
152 let result = work.await;
153 if let Ok(mut ended) = ended.lock() {
154 *ended = Some(result);
155 }
156 });
157 Ok(JoinHandle(output))
158 }
159}
crates/notebook/src/worker.rs+110-111
......@@ -1,15 +1,14 @@
11use super::*;
2use crate::task::{self, JoinHandle};
23use std::{
34 collections::hash_map::RandomState,
45 hash::BuildHasher,
56 sync::{
67 Arc, Mutex,
78 atomic::{AtomicBool, AtomicU64, Ordering},
8 mpsc::{self, SyncSender},
99 },
10 thread::{self, JoinHandle},
11 time::Instant,
1210};
11use web_time::Instant;
1312
1413pub(super) struct Signal {
1514 pub(crate) stopped: AtomicBool,
......@@ -22,7 +21,7 @@ pub(super) struct Signal {
2221 /// waits for that instead of checking the file on its own (`Background::hold`).
2322 pub(crate) watched: AtomicBool,
2423 pause: Mutex<Pause>,
25 sender: SyncSender<()>,
24 sender: task::Sender<()>,
2625}
2726
2827/// How local edits wait before they publish (`SyncWorker::set_pause`).
......@@ -37,8 +36,8 @@ struct Pause {
3736const LONGEST: Duration = Duration::from_secs(30);
3837
3938impl Signal {
40 pub(crate) fn new() -> (Arc<Self>, mpsc::Receiver<()>) {
41 let (sender, receiver) = mpsc::sync_channel(1);
39 pub(crate) fn new() -> (Arc<Self>, task::Receiver<()>) {
40 let (sender, receiver) = task::channel();
4241 let signal = Self {
4342 stopped: AtomicBool::new(false),
4443 offline: AtomicBool::new(false),
......@@ -123,9 +122,12 @@ impl SyncWorker {
123122 pub fn stop(mut self) -> Result<()> {
124123 self.signal.stopped.store(true, Ordering::Release);
125124 self.signal.wake();
126 self.thread
127 .take()
128 .expect("Worker owns its thread")
125 let thread = self.thread.take().expect("Worker owns its thread");
126 // In the browser no step is in flight outside the worker's own callback.
127 #[cfg(target_arch = "wasm32")]
128 return thread.finished().unwrap_or(Ok(()));
129 #[cfg(not(target_arch = "wasm32"))]
130 thread
129131 .join()
130132 .map_err(|_| io::Error::other("Synchronization worker panicked"))?
131133 }
......@@ -176,118 +178,115 @@ impl Replica {
176178 let (signal, receiver) = Signal::new();
177179 let replica = Arc::clone(self);
178180 let worker_signal = Arc::clone(&signal);
179 let thread = thread::Builder::new()
180 .name("onestore-sync".into())
181 .spawn(move || {
182 let mut remote: Option<R> = None;
183 let jitter = RandomState::new();
184 let mut contention = 0_u32;
185 // The first step, and the first after a failure, always runs, so `observe`
186 // hears that the remote is reachable and what state the queue is in.
187 let mut reported = false;
188 // With nothing to do: a watched worker waits for the watch, any other looks
189 // again after `interval`.
190 let rest = || {
191 if worker_signal.watched.load(Ordering::Acquire) {
192 let _ = receiver.recv();
193 } else {
194 let _ = receiver.recv_timeout(interval);
181 let thread = task::spawn("onestore-sync", move || async move {
182 let mut remote: Option<R> = None;
183 let jitter = RandomState::new();
184 let mut contention = 0_u32;
185 // The first step, and the first after a failure, always runs, so `observe`
186 // hears that the remote is reachable and what state the queue is in.
187 let mut reported = false;
188 // With nothing to do: a watched worker waits for the watch, any other looks
189 // again after `interval`.
190 let rest = || {
191 task::wait(
192 &receiver,
193 (!worker_signal.watched.load(Ordering::Acquire)).then_some(interval),
194 )
195 };
196 while !worker_signal.stopped.load(Ordering::Acquire) {
197 if worker_signal.offline.load(Ordering::Acquire)
198 && !worker_signal.requested.load(Ordering::Acquire)
199 {
200 remote = None;
201 reported = false;
202 task::wait(&receiver, None).await;
203 continue;
204 }
205 if !worker_signal.requested.load(Ordering::Acquire)
206 && let Some(wait) = worker_signal.paused()
207 {
208 task::wait(&receiver, Some(wait)).await;
209 continue;
210 }
211 if let Ok(mut pause) = worker_signal.pause.lock() {
212 // The step publishes what waits; edits from now on wait anew.
213 pause.waiting = None;
214 }
215 let result = match remote.as_mut() {
216 Some(remote) => {
217 if reported && replica.settled(remote).unwrap_or(false) {
218 worker_signal.requested.store(false, Ordering::Release);
219 worker_signal.synced.store(crate::now(), Ordering::Release);
220 rest().await;
221 continue;
222 }
223 let result = replica.sync_once(remote);
224 reported = result.is_ok();
225 result
195226 }
227 None => match connect() {
228 Ok(connected) => {
229 remote = Some(connected);
230 continue;
231 }
232 Err(error) => Err(Error::RemoteIo(error)),
233 },
196234 };
197 while !worker_signal.stopped.load(Ordering::Acquire) {
198 if worker_signal.offline.load(Ordering::Acquire)
199 && !worker_signal.requested.load(Ordering::Acquire)
200 {
201 remote = None;
202 reported = false;
203 let _ = receiver.recv();
235 if result.is_ok() {
236 worker_signal.synced.store(crate::now(), Ordering::Release);
237 }
238 observe(&result);
239 let idle = matches!(result, Ok(Synced { edit: None, .. }));
240 match result {
241 Ok(Synced {
242 edit: Some((_, EditStatus::Published { .. })),
243 ..
244 }) => {
245 contention = 0;
204246 continue;
205247 }
206 if !worker_signal.requested.load(Ordering::Acquire)
207 && let Some(wait) = worker_signal.paused()
248 Ok(Synced { edit: None, .. }) => contention = 0,
249 Ok(_) => {}
250 Err(Error::Remote(onestore::CommitError {
251 state: onestore::CommitState::NotCommitted,
252 ref error,
253 })) if matches!(
254 error.kind(),
255 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
256 ) =>
208257 {
209 let _ = receiver.recv_timeout(wait);
210 continue;
211 }
212 if let Ok(mut pause) = worker_signal.pause.lock() {
213 // The step publishes what waits; edits from now on wait anew.
214 pause.waiting = None;
215 }
216 let result = match remote.as_mut() {
217 Some(remote) => {
218 if reported && replica.settled(remote).unwrap_or(false) {
219 worker_signal.requested.store(false, Ordering::Release);
220 worker_signal.synced.store(crate::now(), Ordering::Release);
221 rest();
222 continue;
223 }
224 let result = replica.sync_once(remote);
225 reported = result.is_ok();
226 result
258 contention = contention.saturating_add(1);
259 let ceiling = (50_u64 << contention.min(5)).min(1000);
260 let until = Instant::now()
261 + Duration::from_millis(jitter.hash_one(contention) % ceiling);
262 // Local wakes must not keep competing writers in the same retry phase.
263 while !worker_signal.stopped.load(Ordering::Acquire) {
264 let Some(remaining) = until.checked_duration_since(Instant::now())
265 else {
266 break;
267 };
268 task::wait(&receiver, Some(remaining)).await;
227269 }
228 None => match connect() {
229 Ok(connected) => {
230 remote = Some(connected);
231 continue;
232 }
233 Err(error) => Err(Error::RemoteIo(error)),
234 },
235 };
236 if result.is_ok() {
237 worker_signal.synced.store(crate::now(), Ordering::Release);
270 continue;
238271 }
239 observe(&result);
240 let idle = matches!(result, Ok(Synced { edit: None, .. }));
241 match result {
242 Ok(Synced {
243 edit: Some((_, EditStatus::Published { .. })),
244 ..
245 }) => {
246 contention = 0;
247 continue;
248 }
249 Ok(Synced { edit: None, .. }) => contention = 0,
250 Ok(_) => {}
251 Err(Error::Remote(onestore::CommitError {
252 state: onestore::CommitState::NotCommitted,
253 ref error,
254 })) if matches!(
272 Err(Error::RemoteIo(ref error))
273 if matches!(
255274 error.kind(),
256275 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
257 ) =>
258 {
259 contention = contention.saturating_add(1);
260 let ceiling = (50_u64 << contention.min(5)).min(1000);
261 let until = Instant::now()
262 + Duration::from_millis(jitter.hash_one(contention) % ceiling);
263 // Local wakes must not keep competing writers in the same retry phase.
264 while !worker_signal.stopped.load(Ordering::Acquire) {
265 let Some(remaining) = until.checked_duration_since(Instant::now())
266 else {
267 break;
268 };
269 let _ = receiver.recv_timeout(remaining);
270 }
271 continue;
272 }
273 Err(Error::RemoteIo(ref error))
274 if matches!(
275 error.kind(),
276 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
277 ) => {}
278 Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None,
279 Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {}
280 Err(error) => return Err(error),
281 }
282 worker_signal.requested.store(false, Ordering::Release);
283 if idle {
284 rest();
285 } else if !worker_signal.stopped.load(Ordering::Acquire) {
286 let _ = receiver.recv_timeout(interval);
287 }
276 ) => {}
277 Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None,
278 Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {}
279 Err(error) => return Err(error),
280 }
281 worker_signal.requested.store(false, Ordering::Release);
282 if idle {
283 rest().await;
284 } else if !worker_signal.stopped.load(Ordering::Acquire) {
285 task::wait(&receiver, Some(interval)).await;
288286 }
289 Ok(())
290 })?;
287 }
288 Ok(())
289 })?;
291290 *owner = Arc::downgrade(&signal);
292291 Ok(SyncWorker {
293292 signal,
crates/notebook/src/working.rs+132-24
......@@ -1,7 +1,8 @@
11//! The section thread: the only owner of the parsed section, which is the cached base
22//! image with each sealed batch replayed and the open batch's edits applied. Edits apply
33//! here and are written in one SQLite transaction per burst; the sync thread asks it to seal
4//! and, when the remote changed, to rebase the queue.
4//! and, when the remote changed, to rebase the queue. In the browser, which has one thread,
5//! the section is served as each request is sent, and a rebuild runs as it is asked for.
56
67use crate::{Result, base, lock, merge, queue, worker::Signal};
78use onestore::{
......@@ -15,7 +16,6 @@ use std::{
1516 collections::{BTreeMap, BTreeSet, VecDeque},
1617 io,
1718 sync::{Arc, Mutex, Weak, mpsc},
18 thread,
1919};
2020
2121pub(crate) type Reply<T> = Box<dyn FnOnce(Result<T>) + Send>;
......@@ -64,8 +64,10 @@ pub(crate) enum Request {
6464 reply: Reply<()>,
6565 },
6666 /// From the thread that rebuilt the section: hand it the requests.
67 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
6768 Handover(mpsc::SyncSender<Takeover>),
6869 /// From the thread that rebuilt nothing: carry on with the section as it is.
70 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
6971 Resume,
7072}
7173
......@@ -107,7 +109,8 @@ pub(crate) struct Thread {
107109 pub(crate) worker: Mutex<Weak<Signal>>,
108110 /// Taken when the replica drops, which ends every section thread.
109111 sender: Mutex<Option<mpsc::Sender<Request>>>,
110 threads: Mutex<Vec<thread::JoinHandle<()>>>,
112 #[cfg(not(target_arch = "wasm32"))]
113 threads: Mutex<Vec<std::thread::JoinHandle<()>>>,
111114 /// A password-protected section's key, under which its queue is sealed too.
112115 pub(crate) key: Option<Key>,
113116}
......@@ -118,7 +121,10 @@ impl Thread {
118121 .lock()
119122 .ok()
120123 .and_then(|sender| sender.as_ref()?.send(request).ok())
121 .ok_or_else(|| io::Error::other("The section thread stopped").into())
124 .ok_or_else(|| io::Error::other("The section thread stopped"))?;
125 #[cfg(target_arch = "wasm32")]
126 serving::serve(self);
127 Ok(())
122128 }
123129
124130 /// Ends the section threads and waits for them, so the cache is released.
......@@ -126,6 +132,9 @@ impl Thread {
126132 if let Ok(mut sender) = self.sender.lock() {
127133 sender.take();
128134 }
135 #[cfg(target_arch = "wasm32")]
136 serving::serve(self);
137 #[cfg(not(target_arch = "wasm32"))]
129138 while let Some(thread) = self
130139 .threads
131140 .lock()
......@@ -136,17 +145,81 @@ impl Thread {
136145 }
137146 }
138147
139 fn start(self: &Arc<Self>, run: impl FnOnce(Arc<Self>) + Send + 'static) -> Result<()> {
148 #[cfg(not(target_arch = "wasm32"))]
149 fn start<F: Future<Output = ()>>(
150 self: &Arc<Self>,
151 run: impl FnOnce(Arc<Self>) -> F + Send + 'static,
152 ) -> Result<()> {
140153 let shared = Arc::clone(self);
141 let thread = thread::Builder::new()
154 let thread = std::thread::Builder::new()
142155 .name("onestore-section".into())
143 .spawn(move || run(shared))?;
156 .spawn(move || crate::task::complete(run(shared)))?;
144157 self.threads
145158 .lock()
146159 .map_err(|_| io::Error::other("The section thread panicked"))?
147160 .push(thread);
148161 Ok(())
149162 }
163
164 #[cfg(target_arch = "wasm32")]
165 fn start<F: Future<Output = ()> + 'static>(
166 self: &Arc<Self>,
167 run: impl FnOnce(Arc<Self>) -> F,
168 ) -> Result<()> {
169 serving::start(self, Box::pin(run(Arc::clone(self))));
170 Ok(())
171 }
172}
173
174/// The browser's section threads: each replica's `run`, polled whenever a request is sent
175/// until it waits for the next.
176#[cfg(target_arch = "wasm32")]
177mod serving {
178 use super::Thread;
179 use std::{cell::RefCell, collections::BTreeMap, future::Future, pin::Pin, task};
180
181 type Serving = Pin<Box<dyn Future<Output = ()>>>;
182
183 thread_local! {
184 /// By the address of each replica's `Thread`, which its `run` keeps alive.
185 static SERVING: RefCell<BTreeMap<usize, Serving>> = const { RefCell::new(BTreeMap::new()) };
186 }
187
188 pub(super) fn start(thread: &Thread, run: Serving) {
189 SERVING.with_borrow_mut(|serving| serving.insert(std::ptr::from_ref(thread).addr(), run));
190 serve(thread);
191 }
192
193 /// Serves what `thread` was sent. A request sent while it is being served, as from a
194 /// reply, waits for the serving already under way, which takes it next.
195 pub(super) fn serve(thread: &Thread) {
196 let id = std::ptr::from_ref(thread).addr();
197 let Some(mut run) = SERVING.with_borrow_mut(|serving| serving.remove(&id)) else {
198 return;
199 };
200 let mut context = task::Context::from_waker(task::Waker::noop());
201 if run.as_mut().poll(&mut context).is_pending() {
202 SERVING.with_borrow_mut(|serving| serving.insert(id, run));
203 }
204 }
205}
206
207/// The next request, or none once the replica dropped.
208#[cfg(not(target_arch = "wasm32"))]
209async fn receive(requests: &mpsc::Receiver<Request>) -> Option<Request> {
210 requests.recv().ok()
211}
212
213/// The next request, waiting for `serving::serve` where none is queued, or none once the
214/// replica dropped.
215#[cfg(target_arch = "wasm32")]
216async fn receive(requests: &mpsc::Receiver<Request>) -> Option<Request> {
217 std::future::poll_fn(|_| match requests.try_recv() {
218 Ok(request) => std::task::Poll::Ready(Some(request)),
219 Err(mpsc::TryRecvError::Disconnected) => std::task::Poll::Ready(None),
220 Err(mpsc::TryRecvError::Empty) => std::task::Poll::Pending,
221 })
222 .await
150223}
151224
152225/// Starts the section thread once the queue opens.
......@@ -156,6 +229,7 @@ pub(crate) fn spawn(connection: Connection, key: Option<Key>) -> Result<(Arc<Thr
156229 connection: Mutex::new(connection),
157230 worker: Mutex::new(Weak::new()),
158231 sender: Mutex::new(Some(sender)),
232 #[cfg(not(target_arch = "wasm32"))]
159233 threads: Mutex::new(Vec::new()),
160234 key,
161235 });
......@@ -180,7 +254,7 @@ enum Next {
180254 Handover(mpsc::SyncSender<Takeover>, VecDeque<Request>),
181255}
182256
183fn run(
257async fn run(
184258 shared: Arc<Thread>,
185259 requests: mpsc::Receiver<Request>,
186260 mut backlog: VecDeque<Request>,
......@@ -202,7 +276,7 @@ fn run(
202276 if let Some(ready) = ready.take() {
203277 let _ = ready.send(Ok(working.section.root()));
204278 }
205 match working.serve(&shared, &requests, &mut backlog) {
279 match working.serve(&shared, &requests, &mut backlog).await {
206280 Next::Stop => return,
207281 Next::Reopen => {}
208282 Next::Handover(to, held) => {
......@@ -222,6 +296,7 @@ fn fail(error: crate::Error, backlog: VecDeque<Request>, requests: mpsc::Receive
222296}
223297
224298/// What a rebuilding thread does before it rereads the section.
299#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
225300enum Job {
226301 Rebase {
227302 image: Option<Vec<u8>>,
......@@ -234,6 +309,7 @@ enum Job {
234309
235310/// Runs `job`, rereads the section and takes the requests over from the thread that
236311/// started it; a failed rebase changed nothing that thread serves.
312#[cfg(not(target_arch = "wasm32"))]
237313fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) {
238314 let answer: Box<dyn FnOnce() + Send> = match job {
239315 Job::Reopen { reply } => Box::new(move || reply(Ok(()))),
......@@ -263,15 +339,34 @@ fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) {
263339 Ok(working) => working,
264340 Err(error) => return fail(error, backlog, requests),
265341 };
266 match working.serve(&shared, &requests, &mut backlog) {
342 match crate::task::complete(working.serve(&shared, &requests, &mut backlog)) {
267343 Next::Stop => {}
268 Next::Reopen => run(shared, requests, backlog, None),
344 Next::Reopen => crate::task::complete(run(shared, requests, backlog, None)),
269345 Next::Handover(to, held) => {
270346 let _ = to.send((requests, held));
271347 }
272348 }
273349}
274350
351/// `build` where there is one thread: runs `job` before the section is reread, which the
352/// serving thread does once true is returned; a failed rebase changed nothing it serves.
353#[cfg(target_arch = "wasm32")]
354fn inline(shared: &Thread, job: Job) -> bool {
355 match job {
356 Job::Reopen { reply } => reply(Ok(())),
357 Job::Rebase { image, reply } => {
358 match rebase(&shared.connection, image, shared.key.as_ref()) {
359 Ok(changed) => reply(Ok(changed)),
360 Err(error) => {
361 reply(Err(error));
362 return false;
363 }
364 }
365 }
366 }
367 true
368}
369
275370/// An edit applied to the section and not yet written, with who waits for it.
276371struct Accepted {
277372 author: String,
......@@ -297,7 +392,7 @@ impl<'a> Working<'a> {
297392 })
298393 }
299394
300 fn serve(
395 async fn serve(
301396 mut self,
302397 shared: &Arc<Thread>,
303398 requests: &mpsc::Receiver<Request>,
......@@ -310,9 +405,9 @@ impl<'a> Working<'a> {
310405 loop {
311406 let first = match backlog.pop_front() {
312407 Some(request) => request,
313 None => match requests.recv() {
314 Ok(request) => request,
315 Err(_) => return Next::Stop,
408 None => match receive(requests).await {
409 Some(request) => request,
410 None => return Next::Stop,
316411 },
317412 };
318413 let mut burst: VecDeque<Request> = VecDeque::from([first]);
......@@ -415,10 +510,16 @@ impl<'a> Working<'a> {
415510 }
416511 Request::Rebase { image, reply } => {
417512 if self.flush(shared, &mut accepted) {
418 held = self
419 .rebuild(shared, Job::Rebase { image, reply })
420 .then(VecDeque::new);
421 false
513 #[cfg(target_arch = "wasm32")]
514 let reopen = inline(shared, Job::Rebase { image, reply });
515 #[cfg(not(target_arch = "wasm32"))]
516 let reopen = {
517 held = self
518 .rebuild(shared, Job::Rebase { image, reply })
519 .then(VecDeque::new);
520 false
521 };
522 reopen
422523 } else {
423524 reply(Err(
424525 io::Error::other("The queue could not be written").into()
......@@ -428,10 +529,16 @@ impl<'a> Working<'a> {
428529 }
429530 Request::Reopen { reply } => {
430531 self.flush(shared, &mut accepted);
431 held = self
432 .rebuild(shared, Job::Reopen { reply })
433 .then(VecDeque::new);
434 false
532 #[cfg(target_arch = "wasm32")]
533 let reopen = inline(shared, Job::Reopen { reply });
534 #[cfg(not(target_arch = "wasm32"))]
535 let reopen = {
536 held = self
537 .rebuild(shared, Job::Reopen { reply })
538 .then(VecDeque::new);
539 false
540 };
541 reopen
435542 }
436543 Request::Handover(_) | Request::Resume => false,
437544 };
......@@ -448,6 +555,7 @@ impl<'a> Working<'a> {
448555
449556 /// Starts a thread that runs `job` and rereads the section; false when the replica is
450557 /// stopping, which answers the job's request.
558 #[cfg(not(target_arch = "wasm32"))]
451559 fn rebuild(&self, shared: &Arc<Thread>, job: Job) -> bool {
452560 let signal = shared
453561 .sender
......@@ -466,7 +574,7 @@ impl<'a> Working<'a> {
466574 return false;
467575 };
468576 shared
469 .start(move |shared| build(shared, job, signal))
577 .start(move |shared| async move { build(shared, job, signal) })
470578 .is_ok()
471579 }
472580
crates/onestore/Cargo.toml+2
......@@ -17,6 +17,8 @@ roxmltree = "0.21.1"
1717sha1 = { version = "0.11.0", features = ["zeroize"] }
1818subtle = "2.6.1"
1919zeroize = "1.9.0"
20# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none.
21web-time = "1.1"
2022
2123[target.'cfg(target_os = "macos")'.dependencies]
2224nix = { version = "0.31.3", default-features = false, features = ["fs"] }
crates/onestore/src/create.rs+1-1
......@@ -5,7 +5,7 @@ use crate::{
55 write::{append, append_list, fresh_guid, node},
66};
77use std::collections::BTreeMap;
8use std::time::{SystemTime, UNIX_EPOCH};
8use web_time::{SystemTime, UNIX_EPOCH};
99
1010type Result<T> = std::result::Result<T, Error>;
1111
crates/onestore/src/lib.rs+1
......@@ -8,6 +8,7 @@ mod create;
88pub mod document;
99mod edit;
1010mod files;
11#[cfg(any(unix, windows))]
1112mod flush;
1213mod formatting;
1314mod identity;
crates/snowbound/Cargo.toml+22-7
......@@ -17,24 +17,39 @@ png = "0.18"
1717# Encodes a video recording's pictures as JPEG, which OneNote 2010 plays.
1818image = { version = "0.25", default-features = false, features = ["jpeg"] }
1919onestore = { path = "../onestore" }
20notebook = { path = "../notebook", features = ["smb"] }
20notebook = { path = "../notebook" }
2121serde = { version = "1", features = ["derive"] }
2222serde_json = "1"
2323pollster = "0.4"
2424wgpu = { workspace = true, optional = true }
2525parley.workspace = true
2626winit = "0.30.13"
27arboard = { version = "3.6.1", default-features = false }
2827accesskit = "0.25.0"
2928accesskit_consumer = "0.39.0"
30accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
31# Updates: fetched over HTTPS, verified with ed25519 and SHA-256.
32ureq = { version = "3.4", default-features = false, features = ["rustls"] }
33ring = "0.17"
3429# Clears passwords typed into the password dialogs.
3530zeroize = "1.9.0"
3631# Names characters in More Symbols, Insert Symbol's dialog.
3732unicode_names2 = "4.0.0"
33# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none.
34web-time = "1.1"
35
36[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
37notebook = { path = "../notebook", features = ["smb"] }
38arboard = { version = "3.6.1", default-features = false }
39accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
40# Updates: fetched over HTTPS, verified with ed25519 and SHA-256. The browser updates by
41# reloading.
42ureq = { version = "3.4", default-features = false, features = ["rustls"] }
43ring = "0.17"
44
45# The browser: see arc/platforms.md.
46[target.'cfg(target_arch = "wasm32")'.dependencies]
47wgpu = { workspace = true, features = ["webgl"] }
48getrandom = { version = "0.4", features = ["wasm_js"] }
49wasm-bindgen = "0.2"
50wasm-bindgen-futures = "0.4"
51js-sys = "0.3"
52web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "MediaQueryList", "Navigator", "Window"] }
3853
3954[target.'cfg(target_os = "macos")'.dependencies]
4055libc = "0.2"
......@@ -43,7 +58,7 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl
4358objc2-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"] }
4459block2 = "0.5.1"
4560
46[target.'cfg(not(target_os = "macos"))'.dependencies]
61[target.'cfg(not(any(target_os = "macos", target_arch = "wasm32")))'.dependencies]
4762# Clipboard bitmaps; macOS reads the pasteboard's PNG and TIFF itself.
4863arboard = { version = "3.6.1", default-features = false, features = ["image-data"] }
4964
crates/snowbound/src/attachment.rs+5-5
......@@ -21,7 +21,7 @@ impl State {
2121 }
2222 let (Some(name), Ok(bytes)) = (
2323 path.file_name().and_then(|name| name.to_str()),
24 std::fs::read(path),
24 notebook::fs::read(path),
2525 ) else {
2626 platform::alert("Couldn't attach the file", "Choose a file you can open.");
2727 return Ok(());
......@@ -68,14 +68,14 @@ impl State {
6868 return Ok(None);
6969 };
7070 let root = std::env::temp_dir().join("Snowbound Attachments");
71 std::fs::create_dir_all(&root)?;
71 notebook::fs::create_dir_all(&root)?;
7272 // A copy opened earlier may still be open, edited, in its application.
7373 let folder = (0..1000)
7474 .map(|index| root.join(index.to_string()))
75 .find(|folder| std::fs::create_dir(folder).is_ok())
75 .find(|folder| notebook::fs::create_dir(folder).is_ok())
7676 .ok_or("No folder is free for a copy of the file")?;
7777 let path = folder.join(&file.filename);
78 std::fs::write(&path, bytes)?;
78 notebook::fs::write(&path, bytes)?;
7979 Ok(Some(path))
8080 }
8181
......@@ -86,7 +86,7 @@ impl State {
8686 return Ok(());
8787 };
8888 if let Some(path) = platform::pick_new("Save As", &file.filename, "Save", None) {
89 std::fs::write(path, bytes)?;
89 notebook::fs::write(path, bytes)?;
9090 }
9191 Ok(())
9292 }
crates/snowbound/src/commands.rs+1-1
......@@ -1562,7 +1562,7 @@ impl State {
15621562 else {
15631563 return Ok(());
15641564 };
1565 return self.insert_picture(std::fs::read(path)?, None);
1565 return self.insert_picture(notebook::fs::read(path)?, None);
15661566 }
15671567 Id::Attachment => {
15681568 let Some(path) = platform::pick_file("Attach File", &[]) else {
crates/snowbound/src/guide.rs+11-11
......@@ -35,8 +35,8 @@ impl State {
3535 /// Opens the guide in the user's documents, copying it there first unless it is there.
3636 pub(crate) fn open_guide(&mut self) -> Result<(), Box<dyn Error>> {
3737 let documents = platform::documents_dir().ok_or("There is no Documents folder")?;
38 let folder = std::path::absolute(documents.join(NAME))?;
39 if !folder.exists() {
38 let folder = notebook::fs::absolute(documents.join(NAME))?;
39 if notebook::fs::metadata(&folder).is_err() {
4040 copy(&folder, &self.cache)?;
4141 }
4242 self.open_notebook(folder.to_string_lossy().into_owned(), None);
......@@ -48,12 +48,12 @@ impl State {
4848/// short never passes for the guide.
4949fn copy(folder: &Path, cache: &Path) -> Result<(), Box<dyn Error>> {
5050 let partial = folder.with_file_name(format!("{NAME}.partial"));
51 if partial.exists() {
52 std::fs::remove_dir_all(&partial)?;
51 if notebook::fs::metadata(&partial).is_ok() {
52 notebook::fs::remove_dir_all(&partial)?;
5353 }
54 std::fs::create_dir_all(&partial)?;
54 notebook::fs::create_dir_all(&partial)?;
5555 for (name, bytes) in FILES {
56 std::fs::write(partial.join(name), bytes)?;
56 notebook::fs::write(partial.join(name), bytes)?;
5757 }
5858 // The snowflake tag's art, kept in the notebook as Customize Tags keeps it.
5959 Notebook::open(&partial, cache)?.map_tag_art(
......@@ -62,7 +62,7 @@ fn copy(folder: &Path, cache: &Path) -> Result<(), Box<dyn Error>> {
6262 include_bytes!("../assets/tags/snowflake.svg"),
6363 "svg",
6464 )?;
65 std::fs::rename(&partial, folder)?;
65 notebook::fs::rename(&partial, folder)?;
6666 Ok(())
6767}
6868
......@@ -75,7 +75,7 @@ mod tests {
7575 #[test]
7676 fn the_guide_copies_whole() {
7777 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/guide-notebook");
78 let mut committed: Vec<String> = std::fs::read_dir(root.join(NAME))
78 let mut committed: Vec<String> = notebook::fs::read_dir(root.join(NAME))
7979 .unwrap()
8080 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
8181 .filter(|name| !name.starts_with('.'))
......@@ -87,7 +87,7 @@ mod tests {
8787
8888 let temporary =
8989 std::env::temp_dir().join(format!("snowbound-guide-{}", std::process::id()));
90 let _ = std::fs::remove_dir_all(&temporary);
90 let _ = notebook::fs::remove_dir_all(&temporary);
9191 let folder = temporary.join(NAME);
9292 copy(&folder, &temporary.join("cache")).unwrap();
9393 let notebook = Notebook::open(&folder, temporary.join("cache")).unwrap();
......@@ -99,8 +99,8 @@ mod tests {
9999 .collect::<Vec<_>>(),
100100 [("Snowflake", 34)]
101101 );
102 let committed = std::fs::read_to_string(root.join(NAME).join(".snowbound/tags.json"));
102 let committed = notebook::fs::read_to_string(root.join(NAME).join(".snowbound/tags.json"));
103103 assert!(committed.unwrap().contains(&art[0].art));
104 std::fs::remove_dir_all(&temporary).unwrap();
104 notebook::fs::remove_dir_all(&temporary).unwrap();
105105 }
106106}
crates/snowbound/src/history.rs+1-1
......@@ -226,7 +226,7 @@ impl State {
226226 };
227227 let library = Arc::clone(&session.library);
228228 let proxy = self.proxy.clone();
229 std::thread::spawn(move || {
229 crate::spawn(move || {
230230 for path in sections(&library, &folder) {
231231 if path == open {
232232 continue;
crates/snowbound/src/library.rs+53-35
......@@ -4,6 +4,7 @@ use canvas::{editor::NoteTag, gpu::TagArt};
44use notebook::discover::{Folder, SectionState};
55use notebook::session::{Background, Notebook, Section};
66use notebook::sidecar::themes::Themes;
7#[cfg(not(target_arch = "wasm32"))]
78use notebook::smb::{Client, Credentials};
89use onestore::protected::Key;
910use std::{
......@@ -14,8 +15,9 @@ use std::{
1415 Arc, Mutex, OnceLock,
1516 atomic::{AtomicBool, Ordering},
1617 },
17 time::{Duration, Instant},
18 time::Duration,
1819};
20use web_time::Instant;
1921
2022/// OneNote's Work Offline, which like OneNote's holds for every notebook.
2123static OFFLINE: AtomicBool = AtomicBool::new(false);
......@@ -63,7 +65,7 @@ pub struct Mount {
6365/// the system reports mount points with symlinks (`/tmp`, `/var`) resolved.
6466#[cfg(unix)]
6567pub fn within_mount(path: &std::path::Path, point: &str) -> Option<String> {
66 let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned());
68 let path = notebook::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned());
6769 Some(
6870 path.strip_prefix(point)
6971 .ok()?
......@@ -271,6 +273,7 @@ struct Server {
271273 login: Login,
272274}
273275
276#[cfg(not(target_arch = "wasm32"))]
274277impl Server {
275278 fn connect(&self) -> io::Result<Client> {
276279 Client::connect(
......@@ -398,8 +401,15 @@ impl Library {
398401 }
399402 }
400403
404 /// No server is in reach of the browser until a relay carries SMB to it.
405 #[cfg(target_arch = "wasm32")]
406 pub fn on_share(_: &str, _: Mount, _: Login, _: &Path) -> Result<Self, String> {
407 Err(crate::platform::smb::Refusal::Unreachable.to_string())
408 }
409
401410 /// The notebook at `location`, which `mount` names on its server, opened through the
402411 /// embedded client signed in as `login`.
412 #[cfg(not(target_arch = "wasm32"))]
403413 pub fn on_share(
404414 location: &str,
405415 mount: Mount,
......@@ -443,8 +453,8 @@ impl Library {
443453 let mut names = read_display_names(&file);
444454 names.insert(self.location.clone(), name.to_owned());
445455 let partial = file.with_extension("partial");
446 std::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?;
447 std::fs::rename(partial, file)
456 notebook::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?;
457 notebook::fs::rename(partial, file)
448458 }
449459
450460 /// The art the notebook's tags draw with.
......@@ -478,7 +488,7 @@ impl Library {
478488 }
479489 let extension = art.rsplit('.').next().unwrap_or_default().to_owned();
480490 let (library, tag) = (Arc::clone(self), tag.clone());
481 std::thread::spawn(move || {
491 crate::spawn(move || {
482492 let kept = library.reopen().and_then(|notebook| {
483493 Ok(notebook.map_tag_art(&tag.label, tag.shape, &bytes, &extension)?)
484494 });
......@@ -529,7 +539,7 @@ impl Library {
529539 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
530540 Some((Instant::now(), Arc::new(themes)));
531541 let library = Arc::clone(self);
532 std::thread::spawn(move || {
542 crate::spawn(move || {
533543 let kept = library
534544 .reopen()
535545 .and_then(|notebook| Ok(notebook.save_themes(change)?));
......@@ -546,9 +556,9 @@ impl Library {
546556 return;
547557 }
548558 let library = Arc::clone(self);
549 std::thread::spawn(move || {
550 let now = std::time::SystemTime::now()
551 .duration_since(std::time::UNIX_EPOCH)
559 crate::spawn(move || {
560 let now = web_time::SystemTime::now()
561 .duration_since(web_time::UNIX_EPOCH)
552562 .map_or(0, |unix| unix.as_secs())
553563 // Time32 counts from 1980.
554564 .saturating_sub(315_532_800);
......@@ -567,9 +577,12 @@ impl Library {
567577 /// This notebook read again, the way it was opened, for changing its structure.
568578 pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> {
569579 Ok(match &self.server {
580 #[cfg(not(target_arch = "wasm32"))]
570581 Some(server) => {
571582 Notebook::open_smb(Arc::new(server.connect()?), &server.mount.root, &self.cache)?
572583 }
584 #[cfg(target_arch = "wasm32")]
585 Some(_) => unreachable!("The browser opens no notebook on a server"),
573586 None => Notebook::open(&self.location, &self.cache)?,
574587 })
575588 }
......@@ -665,13 +678,16 @@ impl Library {
665678 return Ok(kept);
666679 }
667680 let opened = match (&self.notebook, &self.server) {
681 #[cfg(target_arch = "wasm32")]
682 (Ok(Some(_)), Some(_)) => unreachable!("The browser opens no notebook on a server"),
683 #[cfg(not(target_arch = "wasm32"))]
668684 (Ok(Some(notebook)), Some(server)) => {
669685 let file = match server.mount.root.as_str() {
670686 "" => path.to_owned(),
671687 root => format!("{root}/{path}"),
672688 };
673689 let cache = notebook.replica_path(path)?;
674 std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?;
690 notebook::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?;
675691 let replica = notebook::Replica::open_or_create(&cache, key.as_ref(), || {
676692 Ok(server.connect()?.read_storage(&file, LIMIT)?)
677693 });
......@@ -735,7 +751,7 @@ impl Library {
735751 Err(_) => vec![section],
736752 };
737753 if !gone.is_empty() {
738 std::thread::spawn(move || close(gone));
754 crate::spawn(move || close(gone));
739755 }
740756 }
741757
......@@ -984,7 +1000,7 @@ impl Library {
9841000 if let Ok(mut kept) = self.kept.lock() {
9851001 for path in &locked {
9861002 if let Some(section) = kept.take(path) {
987 std::thread::spawn(move || close(vec![section]));
1003 crate::spawn(move || close(vec![section]));
9881004 }
9891005 }
9901006 }
......@@ -1013,7 +1029,7 @@ fn display_names(cache: &Path) -> PathBuf {
10131029}
10141030
10151031fn read_display_names(file: &Path) -> std::collections::BTreeMap<String, String> {
1016 std::fs::read(file)
1032 notebook::fs::read(file)
10171033 .ok()
10181034 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
10191035 .unwrap_or_default()
......@@ -1119,7 +1135,7 @@ fn close(sections: Vec<Section>) {
11191135}
11201136
11211137pub fn file_name(path: &Path) -> String {
1122 path.canonicalize()
1138 notebook::fs::canonicalize(path)
11231139 .ok()
11241140 .as_deref()
11251141 .and_then(Path::file_name)
......@@ -1181,7 +1197,7 @@ pub enum Located {
11811197
11821198/// Whether `folder` holds a table of contents, as a notebook's or a section group's does.
11831199fn has_toc(folder: &Path) -> bool {
1184 std::fs::read_dir(folder).is_ok_and(|entries| {
1200 notebook::fs::read_dir(folder).is_ok_and(|entries| {
11851201 entries.flatten().any(|entry| {
11861202 entry
11871203 .path()
......@@ -1199,10 +1215,12 @@ pub fn locate(path: &Path) -> Located {
11991215 .extension()
12001216 .map(|extension| extension.to_string_lossy().to_ascii_lowercase());
12011217 match extension.as_deref() {
1202 _ if path.is_dir() => Located::Notebook {
1203 root: path.to_owned(),
1204 section: None,
1205 },
1218 _ if notebook::fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) => {
1219 Located::Notebook {
1220 root: path.to_owned(),
1221 section: None,
1222 }
1223 }
12061224 Some("onepkg") => Located::Package(path.to_owned()),
12071225 Some("onetoc2") => match path.parent() {
12081226 Some(root) => Located::Notebook {
......@@ -1245,14 +1263,14 @@ mod tests {
12451263 #[test]
12461264 fn kept_sections_open_at_once() {
12471265 let root = std::env::temp_dir().join(format!("snowbound-kept-{}", std::process::id()));
1248 let _ = std::fs::remove_dir_all(&root);
1266 let _ = notebook::fs::remove_dir_all(&root);
12491267 let notebook = root.join("Personal");
1250 std::fs::create_dir_all(&notebook).unwrap();
1268 notebook::fs::create_dir_all(&notebook).unwrap();
12511269 let sample =
12521270 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/sample-notebook/Personal");
1253 for entry in std::fs::read_dir(sample).unwrap() {
1271 for entry in notebook::fs::read_dir(sample).unwrap() {
12541272 let entry = entry.unwrap();
1255 std::fs::copy(entry.path(), notebook.join(entry.file_name())).unwrap();
1273 notebook::fs::copy(entry.path(), notebook.join(entry.file_name())).unwrap();
12561274 }
12571275 let library = Library::notebook(notebook.to_str().unwrap(), &root.join("cache"));
12581276 let [first, second] = [0, 1].map(|tab| library.tabs("")[tab].path.clone());
......@@ -1268,7 +1286,7 @@ mod tests {
12681286 library.close_kept();
12691287 library.open(&first, || {}).unwrap().close().unwrap();
12701288 drop(library);
1271 std::fs::remove_dir_all(&root).unwrap();
1289 notebook::fs::remove_dir_all(&root).unwrap();
12721290 }
12731291
12741292 #[cfg(unix)]
......@@ -1276,13 +1294,13 @@ mod tests {
12761294 fn within_mount_resolves_symlinks() {
12771295 let base = std::env::temp_dir().join(format!("snowbound-within-{}", std::process::id()));
12781296 let point = base.join("point");
1279 std::fs::create_dir_all(point.join("notes")).unwrap();
1297 notebook::fs::create_dir_all(point.join("notes")).unwrap();
12801298 std::os::unix::fs::symlink(&point, base.join("link")).unwrap();
1281 let point = std::fs::canonicalize(&point).unwrap();
1299 let point = notebook::fs::canonicalize(&point).unwrap();
12821300 let point = point.to_str().unwrap();
12831301 let found = within_mount(&base.join("link/notes"), point);
12841302 let missing = within_mount(&base.join("link/gone"), point);
1285 std::fs::remove_dir_all(&base).unwrap();
1303 notebook::fs::remove_dir_all(&base).unwrap();
12861304 assert_eq!(found.as_deref(), Some("notes"));
12871305 assert_eq!(missing, None);
12881306 }
......@@ -1310,8 +1328,8 @@ mod tests {
13101328 fn a_notebook_on_a_share_opens_through_the_embedded_client() {
13111329 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
13121330 let root = std::env::var("ONESTORE_SMB_LAB_ROOT").unwrap_or_else(|_| {
1313 let nanos = std::time::SystemTime::now()
1314 .duration_since(std::time::UNIX_EPOCH)
1331 let nanos = web_time::SystemTime::now()
1332 .duration_since(web_time::UNIX_EPOCH)
13151333 .unwrap()
13161334 .subsec_nanos();
13171335 format!("snowbound-{}-{nanos}", std::process::id())
......@@ -1400,7 +1418,7 @@ mod tests {
14001418 }
14011419 section.close().unwrap();
14021420 drop(library);
1403 let _ = std::fs::remove_dir_all(&cache);
1421 let _ = notebook::fs::remove_dir_all(&cache);
14041422 if std::env::var_os("ONESTORE_SMB_LAB_KEEP").is_none() {
14051423 remove_tree(&client, &root);
14061424 assert!(client.read_dir(&root, 1).is_err(), "the folder is gone");
......@@ -1488,18 +1506,18 @@ mod tests {
14881506 #[test]
14891507 fn chosen_paths_open_their_notebook_or_section() {
14901508 let root = std::env::temp_dir().join(format!("snowbound-locate-{}", std::process::id()));
1491 let _ = std::fs::remove_dir_all(&root);
1509 let _ = notebook::fs::remove_dir_all(&root);
14921510 let notebook = root.join("Notebook");
1493 std::fs::create_dir_all(notebook.join("Group")).unwrap();
1511 notebook::fs::create_dir_all(notebook.join("Group")).unwrap();
14941512 for file in [
14951513 "Open Notebook.onetoc2",
14961514 "Group/Open Notebook.onetoc2",
14971515 "Group/Inner.one",
14981516 "Top.one",
14991517 ] {
1500 std::fs::write(notebook.join(file), b"").unwrap();
1518 notebook::fs::write(notebook.join(file), b"").unwrap();
15011519 }
1502 std::fs::write(root.join("Loose.one"), b"").unwrap();
1520 notebook::fs::write(root.join("Loose.one"), b"").unwrap();
15031521 let at = |root: &Path, section: Option<&str>| Located::Notebook {
15041522 root: root.to_owned(),
15051523 section: section.map(str::to_owned),
......@@ -1526,6 +1544,6 @@ mod tests {
15261544 Located::Package(root.join("Shared.onepkg"))
15271545 );
15281546 assert_eq!(locate(&root.join("missing.txt")), Located::Nothing);
1529 std::fs::remove_dir_all(&root).unwrap();
1547 notebook::fs::remove_dir_all(&root).unwrap();
15301548 }
15311549}
crates/snowbound/src/main.rs+190-135
......@@ -1,4 +1,6 @@
11#![cfg_attr(windows, windows_subsystem = "windows")]
2// The browser drives `State` from `web.rs`; launch, replays and screenshots are the desktop's.
3#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
24#[cfg(target_os = "macos")]
35mod aqua;
46mod art;
......@@ -18,6 +20,7 @@ mod history;
1820#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]
1921#[cfg_attr(target_os = "macos", path = "icloud_macos.rs")]
2022mod icloud;
23#[cfg(not(target_arch = "wasm32"))]
2124#[cfg_attr(target_os = "macos", allow(dead_code))]
2225mod instance;
2326mod keys;
......@@ -30,6 +33,7 @@ mod manage;
3033#[cfg_attr(target_os = "linux", path = "media_linux.rs")]
3134#[cfg_attr(target_os = "macos", path = "media_macos.rs")]
3235#[cfg_attr(windows, path = "media_windows.rs")]
36#[cfg_attr(target_arch = "wasm32", path = "media_web.rs")]
3337mod media;
3438mod meeting;
3539#[cfg(target_os = "macos")]
......@@ -43,12 +47,14 @@ mod paste;
4347#[cfg_attr(target_os = "linux", path = "linux.rs")]
4448#[cfg_attr(target_os = "macos", path = "macos.rs")]
4549#[cfg_attr(windows, path = "windows.rs")]
50#[cfg_attr(target_arch = "wasm32", path = "web.rs")]
4651mod platform;
4752mod prefetch;
4853mod print;
4954#[cfg_attr(target_os = "linux", path = "print_linux.rs")]
5055#[cfg_attr(target_os = "macos", path = "print_macos.rs")]
5156#[cfg_attr(windows, path = "print_windows.rs")]
57#[cfg_attr(target_arch = "wasm32", path = "print_web.rs")]
5258mod printer;
5359mod properties;
5460mod protection;
......@@ -64,6 +70,7 @@ mod sidebar;
6470#[cfg_attr(target_os = "linux", path = "spell_linux.rs")]
6571#[cfg_attr(target_os = "macos", path = "spell_macos.rs")]
6672#[cfg_attr(windows, path = "spell_windows.rs")]
73#[cfg_attr(target_arch = "wasm32", path = "spell_web.rs")]
6774mod spell;
6875#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")]
6976#[cfg_attr(windows, path = "surface_windows.rs")]
......@@ -78,17 +85,17 @@ mod unpack;
7885mod unread;
7986mod update;
8087mod video;
88#[cfg_attr(target_arch = "wasm32", path = "watch_web.rs")]
8189mod watch;
8290
91#[cfg(not(target_arch = "wasm32"))]
92use accesskit_winit::Adapter as AccessAdapter;
93#[cfg(not(target_arch = "wasm32"))]
94use canvas::editor::{DEFAULT_OUTLINE_WIDTH, TextOutline};
8395use canvas::gpu::colorref;
8496use canvas::gpu::page::PageScene;
8597use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility};
86use canvas::{
87 date::DateField,
88 document::TextDocument,
89 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline},
90 layout::TextEngine,
91};
98use canvas::{date::DateField, document::TextDocument, editor::CanvasEditor, layout::TextEngine};
9299use draw::Renderer;
93100use library::Library;
94101use onestore::ExGuid;
......@@ -96,21 +103,29 @@ use onestore::document::Format;
96103use onestore::page::Page;
97104use onestore::page::ink::ShapeKind;
98105use onestore::page::text::Paragraph;
106#[cfg(target_arch = "wasm32")]
107use platform::{AccessAdapter, ActiveEventLoop, EventLoopProxy, Window};
99108use std::{
100109 collections::{HashMap, HashSet},
101110 error::Error,
102111 path::{Path, PathBuf},
103112 sync::{Arc, Mutex, mpsc},
104 time::Instant,
105113};
106114use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};
115use web_time::Instant;
116#[cfg(not(target_arch = "wasm32"))]
107117use winit::{
108118 application::ApplicationHandler,
109 dpi::{LogicalPosition, LogicalSize},
110 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
119 event::{ElementState, MouseScrollDelta, WindowEvent},
111120 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},
112 keyboard::{Key, ModifiersState, NamedKey},
113 window::{CursorIcon, Window, WindowId},
121 keyboard::ModifiersState,
122 window::{Window, WindowId},
123};
124use winit::{
125 dpi::{LogicalPosition, LogicalSize},
126 event::{Ime, MouseButton},
127 keyboard::{Key, NamedKey},
128 window::CursorIcon,
114129};
115130
116131/// Height of the toolbar's row, which is the title bar where the platform lets it: a
......@@ -207,6 +222,7 @@ enum UserEvent {
207222 /// A picture to insert at the caret, such as a screen clipping.
208223 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
209224 Picture(Vec<u8>),
225 #[cfg(not(target_arch = "wasm32"))]
210226 Accessibility(accesskit_winit::Event),
211227 /// The section's synchronization thread reported an event.
212228 Sync,
......@@ -298,6 +314,7 @@ enum Replay {
298314 Quit,
299315}
300316
317#[cfg(not(target_arch = "wasm32"))]
301318impl From<accesskit_winit::Event> for UserEvent {
302319 fn from(event: accesskit_winit::Event) -> Self {
303320 Self::Accessibility(event)
......@@ -314,7 +331,7 @@ fn lap(phase: &str, start: Instant) {
314331
315332fn trace_input(event: &impl std::fmt::Debug) {
316333 if std::env::var_os("SNOWBOUND_TRACE_INPUT").is_some() {
317 eprintln!("Input {:?}: {event:?}", std::time::SystemTime::now());
334 eprintln!("Input {:?}: {event:?}", web_time::SystemTime::now());
318335 }
319336}
320337
......@@ -334,6 +351,7 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon {
334351 }
335352}
336353
354#[cfg(not(target_arch = "wasm32"))]
337355struct App {
338356 proxy: EventLoopProxy<UserEvent>,
339357 input: Option<Input>,
......@@ -373,7 +391,7 @@ impl Input {
373391 /// Lists the notebook folder `root` and shows it first, where the window shows notebooks.
374392 fn show(&mut self, root: &Path) -> std::io::Result<()> {
375393 if let Input::Notebooks { locations, current } = self {
376 let location = std::path::absolute(root)?.to_string_lossy().into_owned();
394 let location = notebook::fs::absolute(root)?.to_string_lossy().into_owned();
377395 if !locations.contains(&location) {
378396 locations.push(location.clone());
379397 }
......@@ -816,7 +834,7 @@ struct State {
816834 min_width: f32,
817835 ime_allowed: bool,
818836 clipboard: Clipboard,
819 access_adapter: accesskit_winit::Adapter,
837 access_adapter: AccessAdapter,
820838 accessibility: accessibility::Accessibility,
821839 /// Assistive technology holds the page's tree, grafted into the interface's.
822840 page_grafted: bool,
......@@ -889,7 +907,7 @@ impl State {
889907 commands::Keymap::from_saved(&stored.keys).install();
890908 platform::install_menu();
891909 let access_adapter =
892 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
910 AccessAdapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
893911 window.set_visible(visible);
894912 let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?;
895913 let size = window.inner_size();
......@@ -897,8 +915,9 @@ impl State {
897915 let fallbacks = platform::symbol_fonts();
898916 draw::fall_back_to(&mut engine.fonts.collection, &fallbacks);
899917 for path in substitutes {
900 let target = engine
901 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;
918 let target = engine.register_substitute(parley::fontique::Blob::new(Arc::new(
919 notebook::fs::read(path)?,
920 )))?;
902921 eprintln!("Using {} for {target}", path.display());
903922 }
904923 let layouts = Arc::new(Mutex::new(engine.clone()));
......@@ -1028,7 +1047,6 @@ impl State {
10281047 .unwrap_or([ui.theme.strip; 2]);
10291048 // A window shown but never focused hears no focus event; a hidden one draws as focused.
10301049 ui.window_focused = !visible || window.has_focus();
1031 platform::system_interface(&mut ui);
10321050 ui.fall_back_to(&fallbacks);
10331051 ui.set_focus(Some(page()));
10341052 for family in FONTS {
......@@ -1036,6 +1054,7 @@ impl State {
10361054 ui.preview_font(face.clone(), family);
10371055 }
10381056 }
1057 platform::system_interface(&mut ui);
10391058 let mut fonts: Vec<String> = engine
10401059 .fonts
10411060 .collection
......@@ -3089,7 +3108,7 @@ impl State {
30893108 let library = Arc::clone(&session.library);
30903109 let path = session.tabs[tab].path.clone();
30913110 let (notify, author) = (notify(self.proxy.clone()), self.author.clone());
3092 std::thread::spawn(move || {
3111 crate::spawn(move || {
30933112 let copied = library.open(&path, notify).and_then(|section| {
30943113 section.import_page(&page, &author)?;
30953114 Ok(section.close()?)
......@@ -3161,7 +3180,7 @@ impl State {
31613180 let requested = Instant::now();
31623181 let scenes = Arc::clone(&self.prefetch.scenes);
31633182 let open = self.session.as_ref().map(Session::key);
3164 std::thread::spawn(move || {
3183 crate::spawn(move || {
31653184 let laid = read().and_then(|loaded| {
31663185 lap("switch read", requested);
31673186 let (shown, page) = match loaded {
......@@ -3283,7 +3302,7 @@ impl State {
32833302 library.keep(&previous.tabs[previous.tab].path, previous.section);
32843303 } else {
32853304 let section = previous.section;
3286 std::thread::spawn(move || {
3305 crate::spawn(move || {
32873306 if let Err(error) = section.close() {
32883307 eprintln!("Synchronization stopped: {error}");
32893308 }
......@@ -3914,7 +3933,7 @@ impl State {
39143933 }
39153934 let mut out = String::new();
39163935 write(&tree.state().root(), 0, &mut out);
3917 std::fs::write(path, out)?;
3936 notebook::fs::write(path, out)?;
39183937 Ok(())
39193938 }
39203939
......@@ -4000,7 +4019,7 @@ impl State {
40004019 let pixels = self.capture()?;
40014020 let size = self.surface.size;
40024021 // Encoding off the frame keeps the frames at the pace they are drawn.
4003 std::thread::spawn(move || {
4022 crate::spawn(move || {
40044023 if let Err(error) = write_png(&path, size, &pixels) {
40054024 eprintln!("{error}");
40064025 }
......@@ -5436,173 +5455,199 @@ fn page_text(editor: &CanvasEditor) -> String {
54365455 .join("\n\n")
54375456}
54385457
5458/// Runs `work` beside the frame: on a thread of its own, or in the browser, which gives the
5459/// page one thread, once the frame under way is done.
5460fn spawn(work: impl FnOnce() + Send + 'static) {
5461 #[cfg(not(target_arch = "wasm32"))]
5462 std::thread::spawn(work);
5463 #[cfg(target_arch = "wasm32")]
5464 platform::defer(work);
5465}
5466
54395467/// FILETIME now: when an edit happened, which its modification times record.
54405468fn filetime() -> u64 {
5441 let unix = std::time::SystemTime::now()
5442 .duration_since(std::time::UNIX_EPOCH)
5469 let unix = web_time::SystemTime::now()
5470 .duration_since(web_time::UNIX_EPOCH)
54435471 .unwrap_or_default();
54445472 (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100)
54455473}
54465474
5447impl App {
5448 fn close(&self, event_loop: &ActiveEventLoop) {
5449 if self.state.as_ref().is_none_or(|state| {
5450 let editor = &state.view.editor;
5451 state.session.is_some()
5452 || editor.caret_outline().is_none_or(TextOutline::is_empty)
5453 && state.initial_date == editor.date().map(|date| date.timestamp())
5454 && state
5455 .initial_layouts
5456 .iter()
5457 .map(|(id, layout)| (*id, layout))
5458 .eq(editor.object_layouts())
5459 && state
5460 .initial
5461 .iter()
5462 .map(|(id, document)| (id, document))
5463 .eq(editor
5464 .outlines()
5465 .iter()
5466 .map(|outline| (&outline.id, outline.document())))
5467 }) || platform::confirm(
5468 "Discard this page?",
5469 "This temporary page has no saved copy. Closing it will discard your edits.",
5470 "Keep Editing",
5471 "Discard Changes",
5472 ) {
5473 if let Some(state) = &self.state {
5474 state.publish_now(QUIT_PUBLISH);
5475 }
5476 event_loop.exit();
5477 }
5478 }
5479}
5480
5481/// How long quitting waits for the open section's edits to reach its file; they stay in the
5482/// replica and publish at the next launch otherwise.
5483const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3);
5484
5485impl ApplicationHandler<UserEvent> for App {
5486 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
5487 let Some(state) = &mut self.state else {
5488 match event {
5489 UserEvent::Quit => self.close(event_loop),
5490 // A cold launch's documents arrive before the window opens.
5491 UserEvent::Open(paths) => self.opening.extend(paths),
5492 _ => {}
5493 }
5494 return;
5495 };
5496 let event = match event {
5497 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => return self.close(event_loop),
5475impl State {
5476 /// Does what an event sent to the event loop asks of the open window.
5477 fn user_event(&mut self, event: UserEvent) {
5478 match event {
5479 // The event loop quits; a page stays open.
5480 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {}
54985481 UserEvent::Picture(bytes) => {
5499 if let Err(error) = state.insert_picture(bytes, None) {
5482 if let Err(error) = self.insert_picture(bytes, None) {
55005483 eprintln!("{error}");
55015484 }
5502 return state.window.request_redraw();
5485 self.window.request_redraw();
55035486 }
55045487 UserEvent::InsertText(text) => {
5505 if state.ui.focused() == Some(page()) {
5506 match state.view.insert_text(text) {
5507 Ok(response) => state.respond(response),
5488 if self.ui.focused() == Some(page()) {
5489 match self.view.insert_text(text) {
5490 Ok(response) => self.respond(response),
55085491 Err(error) => eprintln!("{error}"),
55095492 }
5510 state.window.request_redraw();
5493 self.window.request_redraw();
55115494 } else {
5512 state.input(ui::Event::Ime(Ime::Commit(text)));
5495 self.input(ui::Event::Ime(Ime::Commit(text)));
55135496 }
5514 return;
55155497 }
55165498 UserEvent::Then(then) => {
5517 if let Err(error) = then(state) {
5499 if let Err(error) = then(self) {
55185500 eprintln!("{error}");
55195501 }
5520 return state.window.request_redraw();
5502 self.window.request_redraw();
55215503 }
5522 UserEvent::ICloudFolder => return state.list_icloud(),
5504 UserEvent::ICloudFolder => self.list_icloud(),
55235505 UserEvent::ICloudAccount => {
5524 state.icloud_account_changed();
5525 return state.window.request_redraw();
5506 self.icloud_account_changed();
5507 self.window.request_redraw();
55265508 }
55275509 UserEvent::Open(paths) => {
55285510 for path in paths {
5529 state.open_path(&path);
5511 self.open_path(&path);
55305512 }
5531 state.window.set_minimized(false);
5532 state.window.focus_window();
5533 return state.window.request_redraw();
5513 self.window.set_minimized(false);
5514 self.window.focus_window();
5515 self.window.request_redraw();
55345516 }
55355517 UserEvent::Update => {
5536 state.updated();
5537 return state.window.request_redraw();
5518 self.updated();
5519 self.window.request_redraw();
55385520 }
55395521 UserEvent::Sync => {
5540 if let Err(error) = state.synced() {
5522 if let Err(error) = self.synced() {
55415523 eprintln!("{error}");
55425524 }
5543 return;
55445525 }
5545 UserEvent::Choose(choice) => return state.choose(choice),
5526 UserEvent::Choose(choice) => self.choose(choice),
55465527 UserEvent::Appearance => {
5547 state.follow_color_scheme();
5548 return state.window.request_redraw();
5528 self.follow_color_scheme();
5529 self.window.request_redraw();
55495530 }
5550 UserEvent::Redraw => return state.window.request_redraw(),
5531 UserEvent::Redraw => self.window.request_redraw(),
55515532 UserEvent::Replay(replay) => {
55525533 match replay {
5553 Replay::Input(event) => state.input(event),
5534 Replay::Input(event) => self.input(event),
55545535 Replay::Pinch(factor) => {
5555 if let Err(error) = state.pinch(factor) {
5536 if let Err(error) = self.pinch(factor) {
55565537 eprintln!("{error}");
55575538 }
55585539 }
5559 Replay::Snapshot(path) => state.snapshot = Some(path),
5540 Replay::Snapshot(path) => self.snapshot = Some(path),
55605541 Replay::Accessibility(path) => {
5561 if let Err(error) = state.write_accessibility(&path) {
5542 if let Err(error) = self.write_accessibility(&path) {
55625543 eprintln!("{error}");
55635544 }
55645545 }
55655546 Replay::Tick | Replay::Quit => {}
55665547 Replay::Appearance(appearance) => {
5567 state.window.set_theme(Some(appearance));
5568 state.set_appearance(appearance);
5548 self.window.set_theme(Some(appearance));
5549 self.set_appearance(appearance);
55695550 }
55705551 Replay::Resize([width, height]) => {
5571 let _ = state
5552 let _ = self
55725553 .window
55735554 .request_inner_size(LogicalSize::new(width, height));
55745555 }
55755556 }
55765557 // A covered window gets no redraws, so each step draws its own frame.
5577 if let Err(error) = state.frame() {
5558 if let Err(error) = self.frame() {
55785559 eprintln!("{error}");
55795560 }
5580 return;
55815561 }
5582 UserEvent::Accessibility(event) => event,
5583 };
5584 if event.window_id != state.window.id() {
5562 #[cfg(not(target_arch = "wasm32"))]
5563 UserEvent::Accessibility(event) => self.access_event(event),
5564 }
5565 }
5566
5567 /// Does what assistive technology asked of the window.
5568 #[cfg(not(target_arch = "wasm32"))]
5569 fn access_event(&mut self, event: accesskit_winit::Event) {
5570 if event.window_id != self.window.id() {
55855571 return;
55865572 }
5587 let was_marked = state.view.editor.marked_range().is_some();
5573 let was_marked = self.view.editor.marked_range().is_some();
55885574 let result = match event.window_event {
55895575 accesskit_winit::WindowEvent::InitialTreeRequested => {
5590 state.deactivate_accessibility();
5591 state.update_accessibility(true)
5576 self.deactivate_accessibility();
5577 self.update_accessibility(true)
55925578 }
5593 accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request),
5579 accesskit_winit::WindowEvent::ActionRequested(request) => self.access_action(request),
55945580 accesskit_winit::WindowEvent::AccessibilityDeactivated => {
5595 state.deactivate_accessibility();
5581 self.deactivate_accessibility();
55965582 Ok(())
55975583 }
55985584 };
5599 if was_marked && state.view.editor.marked_range().is_none() {
5600 platform::clear_marked_text(&state.window);
5585 if was_marked && self.view.editor.marked_range().is_none() {
5586 platform::clear_marked_text(&self.window);
56015587 }
56025588 if let Err(error) = result {
56035589 eprintln!("{error}");
56045590 }
56055591 }
5592}
5593
5594#[cfg(not(target_arch = "wasm32"))]
5595impl App {
5596 fn close(&self, event_loop: &ActiveEventLoop) {
5597 if self.state.as_ref().is_none_or(|state| {
5598 let editor = &state.view.editor;
5599 state.session.is_some()
5600 || editor.caret_outline().is_none_or(TextOutline::is_empty)
5601 && state.initial_date == editor.date().map(|date| date.timestamp())
5602 && state
5603 .initial_layouts
5604 .iter()
5605 .map(|(id, layout)| (*id, layout))
5606 .eq(editor.object_layouts())
5607 && state
5608 .initial
5609 .iter()
5610 .map(|(id, document)| (id, document))
5611 .eq(editor
5612 .outlines()
5613 .iter()
5614 .map(|outline| (&outline.id, outline.document())))
5615 }) || platform::confirm(
5616 "Discard this page?",
5617 "This temporary page has no saved copy. Closing it will discard your edits.",
5618 "Keep Editing",
5619 "Discard Changes",
5620 ) {
5621 if let Some(state) = &self.state {
5622 state.publish_now(QUIT_PUBLISH);
5623 }
5624 event_loop.exit();
5625 }
5626 }
5627}
5628
5629/// How long quitting waits for the open section's edits to reach its file; they stay in the
5630/// replica and publish at the next launch otherwise.
5631#[cfg(not(target_arch = "wasm32"))]
5632const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3);
5633
5634#[cfg(not(target_arch = "wasm32"))]
5635impl ApplicationHandler<UserEvent> for App {
5636 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
5637 let Some(state) = &mut self.state else {
5638 match event {
5639 UserEvent::Quit => self.close(event_loop),
5640 // A cold launch's documents arrive before the window opens.
5641 UserEvent::Open(paths) => self.opening.extend(paths),
5642 _ => {}
5643 }
5644 return;
5645 };
5646 match event {
5647 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop),
5648 event => state.user_event(event),
5649 }
5650 }
56065651
56075652 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
56085653 if self.state.is_some() {
......@@ -5810,8 +5855,8 @@ impl ApplicationHandler<UserEvent> for App {
58105855
58115856fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn Error>> {
58125857 let partial = path.with_extension("partial");
5813 std::fs::write(&partial, paste::png(size, pixels)?)?;
5814 std::fs::rename(partial, path)?;
5858 notebook::fs::write(&partial, paste::png(size, pixels)?)?;
5859 notebook::fs::rename(partial, path)?;
58155860 Ok(())
58165861}
58175862
......@@ -5820,6 +5865,7 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E
58205865/// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift]
58215866/// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH`,
58225867/// `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`.
5868#[cfg(not(target_arch = "wasm32"))]
58235869fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
58245870 let mut steps = Vec::new();
58255871 for line in script.lines().filter(|line| !line.trim().is_empty()) {
......@@ -5926,6 +5972,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
59265972 Ok(())
59275973}
59285974
5975#[cfg(not(target_arch = "wasm32"))]
59295976fn main() -> Result<(), Box<dyn Error>> {
59305977 #[cfg(target_os = "linux")]
59315978 if std::env::args_os()
......@@ -5947,6 +5994,11 @@ fn main() -> Result<(), Box<dyn Error>> {
59475994 platform::with_pool(launch)
59485995}
59495996
5997/// The page starts the window through `web::start` once it has fetched the fonts.
5998#[cfg(target_arch = "wasm32")]
5999fn main() {}
6000
6001#[cfg(not(target_arch = "wasm32"))]
59506002fn launch() -> Result<(), Box<dyn Error>> {
59516003 let mut args = std::env::args_os().skip(1);
59526004 if args.next().is_some_and(|arg| arg == update::FINISH) {
......@@ -6014,7 +6066,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
60146066 let title = args
60156067 .next()
60166068 .ok_or("Provide a page title after the section file.")?;
6017 let bytes = std::fs::read(path)?;
6069 let bytes = notebook::fs::read(path)?;
60186070 let store = onestore::Store::parse(&bytes)?;
60196071 let index = onestore::RevisionIndex::parse(&store)?;
60206072 reference = Some(Page::from_document(
......@@ -6046,7 +6098,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
60466098 }
60476099 let text = positional
60486100 .first()
6049 .map(std::fs::read_to_string)
6101 .map(notebook::fs::read_to_string)
60506102 .transpose()?
60516103 .unwrap_or_default();
60526104 let width = positional
......@@ -6108,7 +6160,10 @@ fn launch() -> Result<(), Box<dyn Error>> {
61086160 instance.serve(event_loop.create_proxy());
61096161 }
61106162 if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") {
6111 replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?;
6163 replay(
6164 notebook::fs::read_to_string(script)?,
6165 event_loop.create_proxy(),
6166 )?;
61126167 }
61136168 let mut app = App {
61146169 proxy: event_loop.create_proxy(),
......@@ -6164,10 +6219,10 @@ mod tests {
61646219 fn recorded_ops_reach_the_section() {
61656220 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
61666221 let directory = std::env::temp_dir().join(format!("snowbound-ops-{}", std::process::id()));
6167 let _ = std::fs::remove_dir_all(&directory);
6168 std::fs::create_dir_all(directory.join("cache")).unwrap();
6222 let _ = notebook::fs::remove_dir_all(&directory);
6223 notebook::fs::create_dir_all(directory.join("cache")).unwrap();
61696224 let file = directory.join("section.one");
6170 std::fs::copy(
6225 notebook::fs::copy(
61716226 root.join("corpus/paragraph-edit/before/notebook/synthetic.one"),
61726227 &file,
61736228 )
......@@ -6215,7 +6270,7 @@ mod tests {
62156270 .any(|event| matches!(event, notebook::session::Event::Rejected { .. }))
62166271 );
62176272 section.close().unwrap();
6218 std::fs::remove_dir_all(&directory).unwrap();
6273 notebook::fs::remove_dir_all(&directory).unwrap();
62196274 }
62206275
62216276 use notebook::session::{Event, Section};
......@@ -6267,10 +6322,10 @@ mod tests {
62676322 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
62686323 let directory =
62696324 std::env::temp_dir().join(format!("snowbound-changed-{}", std::process::id()));
6270 let _ = std::fs::remove_dir_all(&directory);
6271 std::fs::create_dir_all(&directory).unwrap();
6325 let _ = notebook::fs::remove_dir_all(&directory);
6326 notebook::fs::create_dir_all(&directory).unwrap();
62726327 let file = directory.join("section.one");
6273 std::fs::copy(
6328 notebook::fs::copy(
62746329 root.join("corpus/paragraph-edit/before/notebook/synthetic.one"),
62756330 &file,
62766331 )
......@@ -6345,7 +6400,7 @@ mod tests {
63456400 );
63466401 ours.close().unwrap();
63476402 theirs.close().unwrap();
6348 std::fs::remove_dir_all(&directory).unwrap();
6403 notebook::fs::remove_dir_all(&directory).unwrap();
63496404 }
63506405
63516406 /// Times opening each section of a notebook and each of its pages, phase by phase, as a
......@@ -6356,7 +6411,7 @@ mod tests {
63566411 #[ignore = "measures a notebook named by the environment"]
63576412 fn switch_timings() {
63586413 let cache = std::env::temp_dir().join(format!("snowbound-switch-{}", std::process::id()));
6359 let _ = std::fs::remove_dir_all(&cache);
6414 let _ = notebook::fs::remove_dir_all(&cache);
63606415 let open = || match std::env::var("SNOWBOUND_SWITCH_NOTEBOOK") {
63616416 Ok(folder) => Library::notebook(&folder, &cache),
63626417 Err(_) => {
......@@ -6421,6 +6476,6 @@ mod tests {
64216476 session.section.close().unwrap();
64226477 }
64236478 }
6424 let _ = std::fs::remove_dir_all(&cache);
6479 let _ = notebook::fs::remove_dir_all(&cache);
64256480 }
64266481}
crates/snowbound/src/manage.rs+24-19
......@@ -211,7 +211,7 @@ impl State {
211211 };
212212 let space = self.session.as_ref().ok_or("No section is open")?.space;
213213 let proxy = self.proxy.clone();
214 std::thread::spawn(move || {
214 crate::spawn(move || {
215215 let art = template.pictures().map_err(|error| error.to_string());
216216 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
217217 if state
......@@ -399,9 +399,9 @@ impl State {
399399 pub(crate) fn list_icloud(&mut self) {
400400 let proxy = self.proxy.clone();
401401 // Listing a folder iCloud Drive has not brought down yet waits for it.
402 std::thread::spawn(move || {
402 crate::spawn(move || {
403403 let listed = crate::icloud::folder().and_then(|root| {
404 let mut listed: Vec<String> = std::fs::read_dir(&root)
404 let mut listed: Vec<String> = notebook::fs::read_dir(&root)
405405 .ok()?
406406 .flatten()
407407 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
......@@ -446,7 +446,7 @@ impl State {
446446 continue;
447447 }
448448 let (cache, proxy) = (self.cache.clone(), self.proxy.clone());
449 std::thread::spawn(move || {
449 crate::spawn(move || {
450450 let library = Arc::new(Library::notebook(&location, &cache));
451451 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
452452 state.icloud_reading.remove(&location);
......@@ -494,7 +494,7 @@ impl State {
494494 return;
495495 }
496496 let proxy = self.proxy.clone();
497 std::thread::spawn(move || {
497 crate::spawn(move || {
498498 if missing.is_some() {
499499 std::thread::sleep(std::time::Duration::from_secs(2));
500500 }
......@@ -544,11 +544,16 @@ impl State {
544544 }
545545
546546 /// Creates a notebook in the new folder `root` and opens it.
547 fn create_notebook(&mut self, root: std::path::PathBuf) -> Result<(), Box<dyn Error>> {
547 pub(crate) fn create_notebook(
548 &mut self,
549 root: std::path::PathBuf,
550 ) -> Result<(), Box<dyn Error>> {
548551 let page = self.dated_page(None)?;
549552 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));
550553 self.load(move || {
551 let location = std::path::absolute(&root)?.to_string_lossy().into_owned();
554 let location = notebook::fs::absolute(&root)?
555 .to_string_lossy()
556 .into_owned();
552557 let notebook = Notebook::create(&location, &cache, Notebook::NEW_COLOR, &page)?;
553558 let library = Arc::new(Library::created(&location, notebook, &cache));
554559 let path = library
......@@ -584,7 +589,7 @@ impl State {
584589 session.library.name,
585590 crate::filetime()
586591 ));
587 let exported = std::fs::create_dir_all(&folder)
592 let exported = notebook::fs::create_dir_all(&folder)
588593 .map_err(notebook::Error::from)
589594 .and_then(|()| session.section.export_recovery(&archive));
590595 if let Err(error) = exported {
......@@ -936,13 +941,13 @@ mod tests {
936941 }
937942
938943 fn copy(from: &Path, to: &Path) {
939 std::fs::create_dir_all(to).unwrap();
940 for entry in std::fs::read_dir(from).unwrap().flatten() {
944 notebook::fs::create_dir_all(to).unwrap();
945 for entry in notebook::fs::read_dir(from).unwrap().flatten() {
941946 let target = to.join(entry.file_name());
942947 if entry.file_type().unwrap().is_dir() {
943948 copy(&entry.path(), &target);
944949 } else {
945 std::fs::copy(entry.path(), target).unwrap();
950 notebook::fs::copy(entry.path(), target).unwrap();
946951 }
947952 }
948953 }
......@@ -956,8 +961,8 @@ mod tests {
956961 fn a_notebook_is_managed_as_onenote_manages_one() {
957962 let temporary =
958963 std::env::temp_dir().join(format!("snowbound-manage-{}", std::process::id()));
959 let _ = std::fs::remove_dir_all(&temporary);
960 std::fs::create_dir_all(&temporary).unwrap();
964 let _ = notebook::fs::remove_dir_all(&temporary);
965 notebook::fs::create_dir_all(&temporary).unwrap();
961966 let root = temporary.join("Managed");
962967 let cache = temporary.join("cache");
963968 let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &dated()).unwrap();
......@@ -1182,7 +1187,7 @@ mod tests {
11821187 if let Some(directory) = std::env::var_os("SNOWBOUND_MANAGEMENT_EXPORT") {
11831188 copy(&root, Path::new(&directory));
11841189 }
1185 std::fs::remove_dir_all(&temporary).unwrap();
1190 notebook::fs::remove_dir_all(&temporary).unwrap();
11861191 }
11871192
11881193 /// Deleting a group's last section shows the notebook's other one; deleting that too
......@@ -1191,8 +1196,8 @@ mod tests {
11911196 fn deleting_every_section_leaves_the_notebook_showing_none() {
11921197 let temporary =
11931198 std::env::temp_dir().join(format!("snowbound-empty-{}", std::process::id()));
1194 let _ = std::fs::remove_dir_all(&temporary);
1195 std::fs::create_dir_all(&temporary).unwrap();
1199 let _ = notebook::fs::remove_dir_all(&temporary);
1200 notebook::fs::create_dir_all(&temporary).unwrap();
11961201 let root = temporary.join("Emptied");
11971202 let location = root.to_str().unwrap();
11981203 let cache = temporary.join("cache");
......@@ -1213,7 +1218,7 @@ mod tests {
12131218 let library = library.with(notebook);
12141219 assert_eq!(shown(&library, Some("New Section 1.one".into())), None);
12151220 assert!(library.tabs("").is_empty());
1216 std::fs::remove_dir_all(&temporary).unwrap();
1221 notebook::fs::remove_dir_all(&temporary).unwrap();
12171222 }
12181223
12191224 #[test]
......@@ -1243,7 +1248,7 @@ mod tests {
12431248 fn backgrounds_go_on_and_off_pages_that_have_content() {
12441249 let temporary =
12451250 std::env::temp_dir().join(format!("snowbound-background-{}", std::process::id()));
1246 let _ = std::fs::remove_dir_all(&temporary);
1251 let _ = notebook::fs::remove_dir_all(&temporary);
12471252 copy(
12481253 Path::new(concat!(
12491254 env!("CARGO_MANIFEST_DIR"),
......@@ -1329,6 +1334,6 @@ mod tests {
13291334 if let Some(directory) = std::env::var_os("SNOWBOUND_BACKGROUND_EXPORT") {
13301335 copy(&temporary, Path::new(&directory));
13311336 }
1332 std::fs::remove_dir_all(&temporary).unwrap();
1337 notebook::fs::remove_dir_all(&temporary).unwrap();
13331338 }
13341339}
crates/snowbound/src/media_web.rs created+61
......@@ -0,0 +1,61 @@
1//! Recording and playback in the browser, which come with `MediaRecorder` in a later phase;
2//! until then recordings neither start nor play, and say why.
3
4use std::{convert::Infallible, path::Path};
5
6/// Why the browser does not record yet.
7const UNAVAILABLE: &str = "Recording isn't available in the browser yet.";
8
9pub struct Player(Infallible);
10
11impl Player {
12 pub fn open(_: &Path, _: bool) -> Option<Self> {
13 None
14 }
15
16 pub fn play(&mut self) {
17 match self.0 {}
18 }
19
20 pub fn pause(&mut self) {
21 match self.0 {}
22 }
23
24 pub fn playing(&self) -> bool {
25 match self.0 {}
26 }
27
28 pub fn position_ms(&self) -> u32 {
29 match self.0 {}
30 }
31
32 pub fn duration_ms(&self) -> u32 {
33 match self.0 {}
34 }
35
36 pub fn seek(&mut self, _: u32) {
37 match self.0 {}
38 }
39}
40
41pub struct Recorder(Infallible);
42
43impl Recorder {
44 pub fn audio(_: &Path, _: u32) -> Result<Self, String> {
45 Err(UNAVAILABLE.into())
46 }
47
48 pub fn video(_: &Path) -> Result<Self, String> {
49 Err(UNAVAILABLE.into())
50 }
51
52 pub fn pause(&mut self, _: bool) {
53 match self.0 {}
54 }
55
56 pub fn stop(self) -> Result<impl FnOnce() -> Result<(), String> + Send, String> {
57 match self.0 {}
58 #[allow(unreachable_code)]
59 Ok(|| Ok(()))
60 }
61}
crates/snowbound/src/menus.rs+6-6
......@@ -1046,10 +1046,10 @@ mod tests {
10461046 fn sections_and_the_notebook_take_onenotes_colours() {
10471047 let temporary =
10481048 std::env::temp_dir().join(format!("snowbound-colors-{}", std::process::id()));
1049 let _ = std::fs::remove_dir_all(&temporary);
1049 let _ = notebook::fs::remove_dir_all(&temporary);
10501050 let root = temporary.join("Colors");
10511051 let cache = temporary.join("cache");
1052 std::fs::create_dir_all(&temporary).unwrap();
1052 notebook::fs::create_dir_all(&temporary).unwrap();
10531053 let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap();
10541054 let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page()).unwrap();
10551055 let named: Vec<(Option<u32>, &str)> = (SECTION_COLORS.iter())
......@@ -1080,14 +1080,14 @@ mod tests {
10801080 assert_eq!(colors, expected);
10811081 if let Some(directory) = std::env::var_os("SNOWBOUND_SECTION_COLOR_EXPORT") {
10821082 let directory = std::path::Path::new(&directory);
1083 std::fs::create_dir_all(directory).unwrap();
1084 for entry in std::fs::read_dir(&root).unwrap() {
1083 notebook::fs::create_dir_all(directory).unwrap();
1084 for entry in notebook::fs::read_dir(&root).unwrap() {
10851085 let entry = entry.unwrap();
10861086 if entry.file_type().unwrap().is_file() {
1087 std::fs::copy(entry.path(), directory.join(entry.file_name())).unwrap();
1087 notebook::fs::copy(entry.path(), directory.join(entry.file_name())).unwrap();
10881088 }
10891089 }
10901090 }
1091 std::fs::remove_dir_all(&temporary).unwrap();
1091 notebook::fs::remove_dir_all(&temporary).unwrap();
10921092 }
10931093}
crates/snowbound/src/pane.rs+1-1
......@@ -169,7 +169,7 @@ fn local_day(time: u64) -> i64 {
169169 libc::localtime_r(&unix, &mut tm);
170170 tm.tm_gmtoff as i64
171171 };
172 #[cfg(windows)]
172 #[cfg(any(windows, target_arch = "wasm32"))]
173173 let offset = crate::platform::utc_offset(unix);
174174 (unix + offset).div_euclid(86_400)
175175}
crates/snowbound/src/paste.rs+15-9
......@@ -5,7 +5,6 @@ use canvas::editor::Piece;
55use std::{
66 error::Error,
77 path::{Path, PathBuf},
8 time::Duration,
98};
109
1110/// What Paste takes from the clipboard. Files come first, as Finder also offers their names
......@@ -45,7 +44,7 @@ impl State {
4544 self.respond(response);
4645 for (place, (source, css)) in places.into_iter().zip(fetched) {
4746 let proxy = self.proxy.clone();
48 std::thread::spawn(move || {
47 crate::spawn(move || {
4948 let Some(bytes) = fetch(&source) else {
5049 return;
5150 };
......@@ -78,7 +77,7 @@ impl State {
7877 path: &Path,
7978 at: Option<[f32; 2]>,
8079 ) -> Result<(), Box<dyn Error>> {
81 match std::fs::read(path) {
80 match notebook::fs::read(path) {
8281 Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => {
8382 self.insert_picture(bytes, at)
8483 }
......@@ -315,15 +314,22 @@ fn load(source: &str) -> Option<Vec<u8>> {
315314 Some(drive) if cfg!(windows) => drive.to_owned(),
316315 _ => path,
317316 };
318 return std::fs::read(path).ok();
317 return notebook::fs::read(path).ok();
319318 }
320319 None
321320}
322321
322/// None in the browser, whose pages rarely let another site read their pictures.
323#[cfg(target_arch = "wasm32")]
324fn fetch(_: &str) -> Option<Vec<u8>> {
325 None
326}
327
323328/// A web picture's bytes, on a thread of its own.
329#[cfg(not(target_arch = "wasm32"))]
324330fn fetch(source: &str) -> Option<Vec<u8>> {
325331 let agent: ureq::Agent = ureq::Agent::config_builder()
326 .timeout_global(Some(Duration::from_secs(10)))
332 .timeout_global(Some(std::time::Duration::from_secs(10)))
327333 .build()
328334 .into();
329335 agent
......@@ -512,7 +518,7 @@ fn exif_resolution(tiff: &[u8]) -> Option<[f32; 2]> {
512518
513519/// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes
514520/// 96 dpi as OneNote 2010 gives a pasted bitmap.
515#[cfg(not(target_os = "macos"))]
521#[cfg(not(any(target_os = "macos", target_arch = "wasm32")))]
516522pub(crate) fn bitmap(image: arboard::ImageData) -> Option<Vec<u8>> {
517523 png(
518524 [image.width, image.height].map(|side| side as u32),
......@@ -667,12 +673,12 @@ mod tests {
667673 assert_eq!(load("relative/pic.png"), None);
668674 let directory =
669675 std::env::temp_dir().join(format!("snowbound-paste-{}", std::process::id()));
670 std::fs::create_dir_all(&directory).unwrap();
676 notebook::fs::create_dir_all(&directory).unwrap();
671677 let file = directory.join("a picture.png");
672 std::fs::write(&file, b"bytes").unwrap();
678 notebook::fs::write(&file, b"bytes").unwrap();
673679 let address = format!("file://{}", file.to_str().unwrap().replace(' ', "%20"));
674680 assert_eq!(load(&address).unwrap(), b"bytes");
675 std::fs::remove_dir_all(&directory).unwrap();
681 notebook::fs::remove_dir_all(&directory).unwrap();
676682 }
677683
678684 fn close(size: [f32; 2], expected: [f32; 2]) -> bool {
crates/snowbound/src/prefetch.rs+4
......@@ -109,6 +109,10 @@ impl Prefetch {
109109 let scenes = Scenes::new(Mutex::new(Recent::new(SCENES)));
110110 let (jobs, queue) = mpsc::channel();
111111 let kept = Arc::clone(&scenes);
112 // With one thread the browser lays a page out as it opens rather than ahead of time.
113 #[cfg(target_arch = "wasm32")]
114 drop((queue, layouts, kept, redraw));
115 #[cfg(not(target_arch = "wasm32"))]
112116 std::thread::Builder::new()
113117 .name("prefetch".into())
114118 .spawn(move || {
crates/snowbound/src/print.rs+13-11
......@@ -343,7 +343,7 @@ impl State {
343343 footer: setup.footer,
344344 };
345345 let whole = matches!(setup.scope, Scope::Section | Scope::Notebook);
346 std::thread::spawn(move || {
346 crate::spawn(move || {
347347 let made = (|| -> Result<Vec<u8>, Box<dyn Error>> {
348348 let mut open = Some(open);
349349 let this = spaces
......@@ -380,7 +380,9 @@ impl State {
380380 Ok(canvas::print::pdf(sections, &mut engine, &layout)?)
381381 })();
382382 let done = made.and_then(|pdf| match &destination {
383 Some(path) => std::fs::write(path, pdf).map(|()| None).map_err(Into::into),
383 Some(path) => notebook::fs::write(path, pdf)
384 .map(|()| None)
385 .map_err(Into::into),
384386 None => Ok(Some(pdf)),
385387 });
386388 let failed = if export {
......@@ -468,9 +470,9 @@ pub(crate) fn spooled(pdf: &[u8], title: &str) -> Result<PathBuf, Box<dyn Error>
468470 let folder = platform::cache_dir()
469471 .unwrap_or_else(std::env::temp_dir)
470472 .join("Printing");
471 std::fs::create_dir_all(&folder)?;
473 notebook::fs::create_dir_all(&folder)?;
472474 let path = folder.join(format!("{}.pdf", file_name(title)));
473 std::fs::write(&path, pdf)?;
475 notebook::fs::write(&path, pdf)?;
474476 Ok(path)
475477}
476478
......@@ -502,8 +504,8 @@ mod tests {
502504 PageCreation,
503505 op::{Edit, Op, SectionOp},
504506 };
505 let _ = std::fs::remove_dir_all(temporary);
506 std::fs::create_dir_all(temporary).unwrap();
507 let _ = notebook::fs::remove_dir_all(temporary);
508 notebook::fs::create_dir_all(temporary).unwrap();
507509 let root = temporary.join("Ordered");
508510 let cache = temporary.join("cache");
509511 let page = |title: &str| {
......@@ -558,7 +560,7 @@ mod tests {
558560 let tabs = notebook_tabs(&library);
559561 library.close_kept();
560562 drop(library);
561 let _ = std::fs::remove_dir_all(&temporary);
563 let _ = notebook::fs::remove_dir_all(&temporary);
562564 let paths: Vec<&str> = tabs.iter().map(|(path, _)| path.as_str()).collect();
563565 assert_eq!(paths, ["A.one", "G/C.one", "B.one"]);
564566 }
......@@ -576,7 +578,7 @@ mod tests {
576578 .collect();
577579 section.close().unwrap();
578580 drop(library);
579 let _ = std::fs::remove_dir_all(&temporary);
581 let _ = notebook::fs::remove_dir_all(&temporary);
580582 assert_eq!(pages.len(), 3);
581583 // A page's title as typed, which its title object holds.
582584 let typed = |page: &Page| {
......@@ -607,8 +609,8 @@ mod tests {
607609 use notebook::session::Notebook;
608610 let temporary =
609611 std::env::temp_dir().join(format!("snowbound-print-bin-{}", std::process::id()));
610 let _ = std::fs::remove_dir_all(&temporary);
611 std::fs::create_dir_all(&temporary).unwrap();
612 let _ = notebook::fs::remove_dir_all(&temporary);
613 notebook::fs::create_dir_all(&temporary).unwrap();
612614 let root = temporary.join("Printed");
613615 let cache = temporary.join("cache");
614616 let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap();
......@@ -622,7 +624,7 @@ mod tests {
622624 .into_iter()
623625 .map(|(path, _)| path)
624626 .collect();
625 std::fs::remove_dir_all(&temporary).unwrap();
627 notebook::fs::remove_dir_all(&temporary).unwrap();
626628 assert_eq!(paths, ["New Section 1.one", "Group/Inner.one"]);
627629 }
628630}
crates/snowbound/src/print_web.rs created+18
......@@ -0,0 +1,18 @@
1//! Printing in the browser: the PDF Snowbound prints downloads, to print from the viewer.
2
3use crate::platform::{Window, download};
4use std::error::Error;
5
6/// Letter where the browser's language is American English, A4 elsewhere.
7pub fn paper() -> [f32; 2] {
8 if crate::platform::input_language() == "en-US" {
9 canvas::print::LETTER
10 } else {
11 canvas::print::A4
12 }
13}
14
15pub fn print(_: &Window, pdf: &[u8], title: &str) -> Result<(), Box<dyn Error>> {
16 download(&format!("{title}.pdf"), pdf, "application/pdf");
17 Ok(())
18}
crates/snowbound/src/recording.rs+7-6
......@@ -73,7 +73,7 @@ impl State {
7373 _ => self.media = Media::Idle,
7474 }
7575 let folder = std::env::temp_dir().join("Snowbound Recordings");
76 std::fs::create_dir_all(&folder)?;
76 notebook::fs::create_dir_all(&folder)?;
7777 let extension = if video { "avi" } else { "wav" };
7878 let path = folder.join(format!("{}.{extension}", std::process::id()));
7979 let recorder = if video {
......@@ -131,7 +131,8 @@ impl State {
131131 let recorded = path.clone();
132132 let job = std::thread::spawn(move || {
133133 finish()?;
134 let recorded = std::fs::read(&recorded).map_err(|error| error.to_string())?;
134 let recorded =
135 notebook::fs::read(&recorded).map_err(|error| error.to_string())?;
135136 Ok(if video {
136137 recorded
137138 } else {
......@@ -168,7 +169,7 @@ impl State {
168169 Wave::parse(&bytes).map(|wave| wave.duration_ms())
169170 };
170171 let preview = platform::file_icon(&path);
171 std::fs::remove_file(&path)?;
172 notebook::fs::remove_file(&path)?;
172173 let title = self.view.editor.page()?.title;
173174 let name: String = title
174175 .chars()
......@@ -1042,10 +1043,10 @@ pub(crate) mod tests {
10421043
10431044 if let Some(directory) = std::env::var_os(export) {
10441045 let directory = PathBuf::from(directory);
1045 std::fs::create_dir_all(&directory).unwrap();
1046 std::fs::write(directory.join("Recorded.one"), &image).unwrap();
1046 notebook::fs::create_dir_all(&directory).unwrap();
1047 notebook::fs::write(directory.join("Recorded.one"), &image).unwrap();
10471048 let file_id = Store::parse(&image).unwrap().header.file_id;
1048 std::fs::write(
1049 notebook::fs::write(
10491050 directory.join("Open Notebook.onetoc2"),
10501051 onestore::create_table_of_contents(
10511052 "Open Notebook.onetoc2",
crates/snowbound/src/recycle.rs+7-7
......@@ -261,7 +261,7 @@ mod tests {
261261 }
262262
263263 fn titles(root: &std::path::Path, path: &str) -> Vec<(String, Option<[u8; 16]>)> {
264 let pages = stored_pages(&std::fs::read(root.join(path)).unwrap()).unwrap();
264 let pages = stored_pages(&notebook::fs::read(root.join(path)).unwrap()).unwrap();
265265 (pages.into_iter())
266266 .map(|page| (page.page.title, page.page.identity))
267267 .collect()
......@@ -275,8 +275,8 @@ mod tests {
275275 #[test]
276276 fn pages_and_sections_leave_the_recycle_bin_as_onenote_restores_them() {
277277 let folder = std::env::temp_dir().join(format!("snowbound-recycle-{}", std::process::id()));
278 let _ = std::fs::remove_dir_all(&folder);
279 std::fs::create_dir_all(&folder).unwrap();
278 let _ = notebook::fs::remove_dir_all(&folder);
279 notebook::fs::create_dir_all(&folder).unwrap();
280280 let (root, cache) = (folder.join("Recycled"), folder.join("cache"));
281281 let page = |title: &str| PageCreation::new(None, Some(title), AUTHOR).unwrap();
282282 let mut notebook =
......@@ -376,17 +376,17 @@ mod tests {
376376 if let Some(directory) = &export {
377377 copy(&root, &directory.join("after"));
378378 }
379 std::fs::remove_dir_all(&folder).unwrap();
379 notebook::fs::remove_dir_all(&folder).unwrap();
380380 }
381381
382382 fn copy(from: &std::path::Path, to: &std::path::Path) {
383 std::fs::create_dir_all(to).unwrap();
384 for entry in std::fs::read_dir(from).unwrap().flatten() {
383 notebook::fs::create_dir_all(to).unwrap();
384 for entry in notebook::fs::read_dir(from).unwrap().flatten() {
385385 let target = to.join(entry.file_name());
386386 if entry.file_type().unwrap().is_dir() {
387387 copy(&entry.path(), &target);
388388 } else {
389 std::fs::copy(entry.path(), target).unwrap();
389 notebook::fs::copy(entry.path(), target).unwrap();
390390 }
391391 }
392392 }
crates/snowbound/src/save_as.rs+2-2
......@@ -187,7 +187,7 @@ impl State {
187187 .into_iter())
188188 .find(|tab| tab.path == section)
189189 .and_then(|tab| tab.color);
190 std::thread::spawn(move || {
190 crate::spawn(move || {
191191 let written = (|| -> Result<(), Box<dyn Error>> {
192192 let image = |path: &str| {
193193 let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?;
......@@ -212,7 +212,7 @@ impl State {
212212 package::pack(package::notebook_files(&notebook, image)?)?
213213 }
214214 };
215 std::fs::write(&path, bytes)?;
215 notebook::fs::write(&path, bytes)?;
216216 Ok(())
217217 })();
218218 let written = written.map_err(|error| error.to_string());
crates/snowbound/src/search.rs+67-30
......@@ -17,9 +17,10 @@ use std::{
1717 atomic::{AtomicBool, AtomicU64, Ordering},
1818 mpsc,
1919 },
20 time::{Duration, Instant, SystemTime},
20 time::{Duration, SystemTime},
2121};
2222use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px};
23use web_time::Instant;
2324use winit::keyboard::NamedKey;
2425
2526/// Where a search looks, as OneNote's scope menu offers it.
......@@ -116,6 +117,10 @@ pub struct Search {
116117 version: Arc<AtomicU64>,
117118 pub(crate) busy: Arc<AtomicBool>,
118119 jobs: mpsc::Sender<Job>,
120 /// The index's own state, which the browser, having no thread for it, keeps here to run
121 /// the jobs sent after each frame.
122 #[cfg(target_arch = "wasm32")]
123 indexing: Arc<Mutex<Indexing>>,
119124 pub(crate) found: Vec<Found>,
120125 found_for: Option<(Query, Scope, u64, String)>,
121126 pub(crate) selected: Option<u64>,
......@@ -136,10 +141,18 @@ impl Search {
136141 let index = Arc::new(Mutex::new(Index::default()));
137142 let version = Arc::new(AtomicU64::new(0));
138143 let busy = Arc::new(AtomicBool::new(false));
139 let shared = (Arc::clone(&index), Arc::clone(&version), Arc::clone(&busy));
144 let indexing = Indexing {
145 jobs: receiver,
146 shared: (Arc::clone(&index), Arc::clone(&version), Arc::clone(&busy)),
147 redraw,
148 stamps: HashMap::new(),
149 };
150 #[cfg(target_arch = "wasm32")]
151 let indexing = Arc::new(Mutex::new(indexing));
152 #[cfg(not(target_arch = "wasm32"))]
140153 std::thread::Builder::new()
141154 .name("search-index".into())
142 .spawn(move || run(receiver, shared, redraw))
155 .spawn(move || indexing.run())
143156 .expect("the index thread starts");
144157 Self {
145158 query: String::new(),
......@@ -152,6 +165,8 @@ impl Search {
152165 version,
153166 busy,
154167 jobs,
168 #[cfg(target_arch = "wasm32")]
169 indexing,
155170 found: Vec::new(),
156171 found_for: None,
157172 selected: None,
......@@ -166,13 +181,28 @@ impl Search {
166181 /// Asks the index to read `spaces` of the open section again, as edits or another
167182 /// writer changed them, and its page list.
168183 pub fn changed(&self, key: String, replica: &Arc<Replica>, spaces: Vec<ExGuid>) {
169 let _ = self.jobs.send(Job::Pages {
184 self.send(Job::Pages {
170185 key,
171186 replica: Arc::downgrade(replica),
172187 spaces,
173188 });
174189 }
175190
191 fn send(&self, job: Job) {
192 let _ = self.jobs.send(job);
193 #[cfg(target_arch = "wasm32")]
194 {
195 let indexing = Arc::clone(&self.indexing);
196 crate::spawn(move || {
197 if let Ok(mut indexing) = indexing.lock() {
198 while let Ok(first) = indexing.jobs.try_recv() {
199 indexing.answer(first);
200 }
201 }
202 });
203 }
204 }
205
176206 /// Pages of section `key` among the results, which the page list marks.
177207 pub fn found_in(&self, key: &str) -> HashSet<ExGuid> {
178208 if self.finding || self.query.trim().is_empty() {
......@@ -195,31 +225,45 @@ impl Search {
195225type Stamp = Option<(u64, SystemTime)>;
196226
197227fn stamp(file: &Path) -> Stamp {
198 let metadata = std::fs::metadata(file).ok()?;
228 let metadata = notebook::fs::metadata(file).ok()?;
199229 Some((metadata.len(), metadata.modified().ok()?))
200230}
201231
202232/// Now in Time32, seconds since 1980, as page modification times are kept.
203233pub(crate) fn now() -> u64 {
204 SystemTime::now()
205 .duration_since(SystemTime::UNIX_EPOCH)
234 web_time::SystemTime::now()
235 .duration_since(web_time::UNIX_EPOCH)
206236 .map_or(0, |since| since.as_secs().saturating_sub(315_532_800))
207237}
208238
209/// The index thread: answers jobs, the newest notebooks first, then page changes, and
210/// wakes the window once the index changed.
211fn run(
239/// The index thread's jobs and what it keeps between them.
240struct Indexing {
212241 jobs: mpsc::Receiver<Job>,
213 (index, version, busy): (Arc<Mutex<Index>>, Arc<AtomicU64>, Arc<AtomicBool>),
242 shared: (Arc<Mutex<Index>>, Arc<AtomicU64>, Arc<AtomicBool>),
214243 redraw: std::task::Waker,
215) {
216 let mut stamps: HashMap<String, Stamp> = HashMap::new();
217 while let Ok(first) = jobs.recv() {
218 busy.store(true, Ordering::Relaxed);
219 // Typing sends a job a keystroke; a pause gathers them into one read.
220 if matches!(first, Job::Pages { .. }) {
221 std::thread::sleep(SETTLE);
244 stamps: HashMap<String, Stamp>,
245}
246
247impl Indexing {
248 /// The index thread: answers jobs, gathering those typing sends a keystroke apart.
249 #[cfg(not(target_arch = "wasm32"))]
250 fn run(mut self) {
251 while let Ok(first) = self.jobs.recv() {
252 self.shared.2.store(true, Ordering::Relaxed);
253 // Typing sends a job a keystroke; a pause gathers them into one read.
254 if matches!(first, Job::Pages { .. }) {
255 std::thread::sleep(SETTLE);
256 }
257 self.answer(first);
222258 }
259 }
260
261 /// Answers `first` and the jobs waiting behind it, the newest notebooks first, then page
262 /// changes, and wakes the window once the index changed.
263 fn answer(&mut self, first: Job) {
264 let (index, version, busy) = &self.shared;
265 let (jobs, stamps) = (&self.jobs, &mut self.stamps);
266 busy.store(true, Ordering::Relaxed);
223267 let mut notebooks = None;
224268 let mut changed = HashSet::new();
225269 let mut pages: HashMap<String, (Weak<Replica>, HashSet<ExGuid>)> = HashMap::new();
......@@ -246,25 +290,18 @@ fn run(
246290 }
247291 let start = Instant::now();
248292 if let Some((libraries, open)) = notebooks {
249 sync(
250 &index,
251 &version,
252 &mut stamps,
253 &libraries,
254 open.as_ref(),
255 &changed,
256 );
293 sync(index, version, stamps, &libraries, open.as_ref(), &changed);
257294 lap("index notebooks", start);
258295 }
259296 for (key, (replica, spaces)) in pages {
260 if let Err(error) = reread(&index, &key, &replica, &spaces) {
297 if let Err(error) = reread(index, &key, &replica, &spaces) {
261298 eprintln!("Cannot index the open section: {error}");
262299 }
263300 version.fetch_add(1, Ordering::Relaxed);
264301 }
265302 lap("index", start);
266303 busy.store(false, Ordering::Relaxed);
267 redraw.wake_by_ref();
304 self.redraw.wake_by_ref();
268305 }
269306}
270307
......@@ -329,7 +366,7 @@ fn sync(
329366 let read = || -> Result<Vec<Entry>, Box<dyn Error>> {
330367 let bytes = match notebook {
331368 Some(notebook) => notebook.read_section(&path)?,
332 None => onestore::read_file(&file)?,
369 None => notebook::fs::read_file(&file)?,
333370 };
334371 let stored = match library.unlocked(&path) {
335372 Some(key) => notebook::session::stored_pages_unlocked(&bytes, &key)?,
......@@ -494,7 +531,7 @@ impl State {
494531 return;
495532 }
496533 self.search.synced = identity;
497 let _ = self.search.jobs.send(Job::Notebooks {
534 self.search.send(Job::Notebooks {
498535 libraries: self.notebooks.clone(),
499536 open,
500537 changed,
crates/snowbound/src/server.rs+6-3
......@@ -3,9 +3,12 @@
33//! where the system's file sharing can't reach the server.
44
55use crate::library::{Library, Login, Mount};
6#[cfg(target_arch = "wasm32")]
7use crate::platform::smb::{self, Client, Credentials, DirectoryEntry, Refusal};
68use crate::{State, art, platform};
79use accesskit::Role;
8use notebook::smb::{Client, Credentials, DirectoryEntry, Refusal};
10#[cfg(not(target_arch = "wasm32"))]
11use notebook::smb::{self, Client, Credentials, DirectoryEntry, Refusal};
912use std::{io, path::Path, sync::mpsc, time::Duration};
1013use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, px};
1114use winit::keyboard::NamedKey;
......@@ -98,7 +101,7 @@ fn credentials(login: &Login) -> Credentials<'_> {
98101
99102impl Reach for Embedded {
100103 fn shares(&self, mount: &Mount, login: &Login) -> io::Result<Vec<String>> {
101 notebook::smb::shares(&mount.endpoint(), credentials(login), TIMEOUT)
104 smb::shares(&mount.endpoint(), credentials(login), TIMEOUT)
102105 }
103106
104107 fn list(&self, mount: &Mount, login: &Login) -> io::Result<Vec<DirectoryEntry>> {
......@@ -512,7 +515,7 @@ impl State {
512515 return;
513516 };
514517 let (replies, redraw) = (connect.replies.0.clone(), self.redraw.clone());
515 std::thread::spawn(move || {
518 crate::spawn(move || {
516519 let _ = replies.send(request.run(&Embedded));
517520 redraw.wake();
518521 });
crates/snowbound/src/settings.rs+7-7
......@@ -126,7 +126,7 @@ impl ColorScheme {
126126impl Settings {
127127 /// The settings at `path`; missing or unreadable ones are the defaults, reported.
128128 pub fn load(path: &Path) -> Self {
129 match std::fs::read(path) {
129 match notebook::fs::read(path) {
130130 Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| {
131131 eprintln!("Ignoring the settings in {}: {error}", path.display());
132132 Self::default()
......@@ -142,11 +142,11 @@ impl Settings {
142142 /// Replaces the file at `path` whole, so a crash leaves the old settings or the new.
143143 pub fn save(&self, path: &Path) -> std::io::Result<()> {
144144 if let Some(folder) = path.parent() {
145 std::fs::create_dir_all(folder)?;
145 notebook::fs::create_dir_all(folder)?;
146146 }
147147 let partial = path.with_extension("partial");
148 std::fs::write(&partial, serde_json::to_vec_pretty(self)?)?;
149 std::fs::rename(partial, path)
148 notebook::fs::write(&partial, serde_json::to_vec_pretty(self)?)?;
149 notebook::fs::rename(partial, path)
150150 }
151151}
152152
......@@ -277,7 +277,7 @@ mod tests {
277277 };
278278 settings.save(&path).unwrap();
279279 assert_eq!(Settings::load(&path), settings);
280 std::fs::write(&path, br#"{"sidebar": true, "unknown": 1}"#).unwrap();
280 notebook::fs::write(&path, br#"{"sidebar": true, "unknown": 1}"#).unwrap();
281281 assert_eq!(
282282 Settings::load(&path),
283283 Settings {
......@@ -286,10 +286,10 @@ mod tests {
286286 }
287287 );
288288 // A picked "No color" stays picked; what was never picked keeps Office's default.
289 std::fs::write(&path, br#"{"toolbar": {"highlight": null}}"#).unwrap();
289 notebook::fs::write(&path, br#"{"toolbar": {"highlight": null}}"#).unwrap();
290290 let toolbar = Settings::load(&path).toolbar;
291291 assert_eq!(toolbar.highlight, None);
292292 assert_eq!(toolbar.font_color, Toolbar::default().font_color);
293 std::fs::remove_dir_all(&directory).unwrap();
293 notebook::fs::remove_dir_all(&directory).unwrap();
294294 }
295295}
crates/snowbound/src/spell_web.rs created+8
......@@ -0,0 +1,8 @@
1//! Spelling in the browser, which offers pages no checker to ask: words stay unmarked until
2//! Hunspell dictionaries load in wasm.
3
4use canvas::spelling::Dictionary;
5
6pub fn dictionary() -> Option<Box<dyn Dictionary>> {
7 None
8}
crates/snowbound/src/surface.rs+52-9
......@@ -1,9 +1,8 @@
11//! Frames reach the window through wgpu.
22
3use crate::{platform, trace_input};
3use crate::{Window, platform, trace_input};
44use draw::Renderer;
55use std::{error::Error, sync::Arc};
6use winit::window::Window;
76
87pub struct Surface {
98 /// Device pixels frames and snapshots are drawn at; `configure` gives the window it.
......@@ -14,6 +13,9 @@ pub struct Surface {
1413 instance: wgpu::Instance,
1514 surface: wgpu::Surface<'static>,
1615 config: wgpu::SurfaceConfiguration,
16 /// The sRGB view of the surface's format, which the renderer blends in, where the surface
17 /// itself is not sRGB, as a browser's canvas is not.
18 view_format: wgpu::TextureFormat,
1719 /// Carries frames to the window where the system's backdrop shows through the strip.
1820 translucent: Option<draw::Translucent>,
1921}
......@@ -36,6 +38,13 @@ impl Surface {
3638 window: Arc<Window>,
3739 backdrop: bool,
3840 ) -> Result<(Self, Renderer), Box<dyn Error>> {
41 #[cfg(target_arch = "wasm32")]
42 let instance = {
43 let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
44 descriptor.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL;
45 wgpu::util::new_instance_with_webgpu_detection(descriptor).await
46 };
47 #[cfg(not(target_arch = "wasm32"))]
3948 #[cfg_attr(not(any(windows, target_os = "linux")), expect(unused_mut))]
4049 let mut descriptor =
4150 wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone()));
......@@ -61,8 +70,9 @@ impl Surface {
6170 wgpu::Dx12SwapchainKind::DxgiFromVisual;
6271 }
6372 }
73 #[cfg(not(target_arch = "wasm32"))]
6474 let instance = wgpu::Instance::new(descriptor);
65 let surface = instance.create_surface(window.clone())?;
75 let surface = instance.create_surface(target(&window))?;
6676 platform::configure_presentation(&surface);
6777 let adapter = instance
6878 .request_adapter(&wgpu::RequestAdapterOptions {
......@@ -70,7 +80,18 @@ impl Surface {
7080 ..Default::default()
7181 })
7282 .await?;
73 let (device, queue) = adapter.request_device(&Default::default()).await?;
83 // A browser without WebGPU draws through WebGL2, whose limits are lower.
84 #[cfg(target_arch = "wasm32")]
85 let required_limits =
86 wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits());
87 #[cfg(not(target_arch = "wasm32"))]
88 let required_limits = wgpu::Limits::default();
89 let (device, queue) = adapter
90 .request_device(&wgpu::DeviceDescriptor {
91 required_limits,
92 ..Default::default()
93 })
94 .await?;
7495 let size = window.inner_size();
7596 let mut config = surface
7697 .get_default_config(&adapter, size.width, size.height)
......@@ -92,9 +113,16 @@ impl Surface {
92113 if platform::cuts_corners() && alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) {
93114 config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied;
94115 }
116 #[cfg(target_arch = "wasm32")]
117 let view_format = config.format.add_srgb_suffix();
118 #[cfg(not(target_arch = "wasm32"))]
119 let view_format = config.format;
120 if view_format != config.format {
121 config.view_formats.push(view_format);
122 }
95123 surface.configure(&device, &config);
96124 eprintln!("Canvas GPU: {:?}", adapter.get_info());
97 let renderer = Renderer::new(device.clone(), queue.clone(), config.format);
125 let renderer = Renderer::new(device.clone(), queue.clone(), view_format);
98126 Ok((
99127 Self {
100128 size: [config.width, config.height],
......@@ -104,6 +132,7 @@ impl Surface {
104132 instance,
105133 surface,
106134 config,
135 view_format,
107136 translucent,
108137 },
109138 renderer,
......@@ -134,7 +163,7 @@ impl Surface {
134163 return Ok(None);
135164 }
136165 wgpu::CurrentSurfaceTexture::Lost => {
137 self.surface = self.instance.create_surface(self.window.clone())?;
166 self.surface = self.instance.create_surface(target(&self.window))?;
138167 platform::configure_presentation(&self.surface);
139168 self.configure(renderer);
140169 self.window.request_redraw();
......@@ -154,7 +183,10 @@ impl Surface {
154183 };
155184 let target = match &mut self.translucent {
156185 Some(translucent) => translucent.target(&self.device, self.size),
157 None => texture.texture.create_view(&Default::default()),
186 None => texture.texture.create_view(&wgpu::TextureViewDescriptor {
187 format: Some(self.view_format),
188 ..Default::default()
189 }),
158190 };
159191 let target = target_of(target);
160192 Ok(Some(Frame {
......@@ -195,7 +227,7 @@ impl Surface {
195227 mip_level_count: 1,
196228 sample_count: 1,
197229 dimension: wgpu::TextureDimension::D2,
198 format: self.config.format,
230 format: self.view_format,
199231 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
200232 view_formats: &[],
201233 });
......@@ -235,7 +267,7 @@ impl Surface {
235267 timeout: Some(std::time::Duration::from_secs(5)),
236268 })?;
237269 let bgra = matches!(
238 self.config.format,
270 self.view_format,
239271 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
240272 );
241273 let mut pixels = Vec::with_capacity(size[0] as usize * size[1] as usize * 4);
......@@ -251,6 +283,17 @@ impl Surface {
251283 }
252284}
253285
286/// What the surface presents to: the window, or in the browser the page's canvas.
287fn target(window: &Arc<Window>) -> wgpu::SurfaceTarget<'static> {
288 #[cfg(target_arch = "wasm32")]
289 return {
290 let _ = window;
291 wgpu::SurfaceTarget::Canvas(platform::canvas())
292 };
293 #[cfg(not(target_arch = "wasm32"))]
294 window.clone().into()
295}
296
254297/// `view` as what the renderer draws into, which is the view itself unless `draw` also
255298/// paints through OpenGL, as on Windows.
256299#[cfg_attr(not(windows), expect(clippy::useless_conversion))]
crates/snowbound/src/sync.rs+4-4
......@@ -1285,7 +1285,7 @@ mod tests {
12851285 .flat_map(|line| &line[..size[0] as usize * 4])
12861286 .copied()
12871287 .collect();
1288 let file = std::fs::File::create(path).unwrap();
1288 let file = notebook::fs::File::create(path).unwrap();
12891289 let mut encoder = png::Encoder::new(file, size[0], height);
12901290 encoder.set_color(png::ColorType::Rgba);
12911291 encoder.set_depth(png::BitDepth::Eight);
......@@ -1458,7 +1458,7 @@ mod tests {
14581458 fn popup_renders_each_state() {
14591459 let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from);
14601460 let mut gpu = output.as_ref().map(|output| {
1461 std::fs::create_dir_all(output).unwrap();
1461 notebook::fs::create_dir_all(output).unwrap();
14621462 Gpu::new()
14631463 });
14641464 let states = states();
......@@ -1479,7 +1479,7 @@ mod tests {
14791479 continue;
14801480 };
14811481 let frames = output.join(format!("frames-{theme}"));
1482 std::fs::create_dir_all(&frames).unwrap();
1482 notebook::fs::create_dir_all(&frames).unwrap();
14831483 let mut ui = ui(appearance);
14841484 let mut now = Instant::now();
14851485 settle(
......@@ -1511,7 +1511,7 @@ mod tests {
15111511 }
15121512 // Work offline on, an edit waiting, then off: the edit sends and nothing moves.
15131513 let frames = output.join(format!("toggle-{theme}"));
1514 std::fs::create_dir_all(&frames).unwrap();
1514 notebook::fs::create_dir_all(&frames).unwrap();
15151515 let mut ui = self::ui(appearance);
15161516 let mut now = Instant::now();
15171517 settle(
crates/snowbound/src/tags.rs+11-11
......@@ -104,7 +104,7 @@ fn pictures() -> Option<std::path::PathBuf> {
104104pub(crate) fn art_bytes(art: &str) -> Option<Vec<u8>> {
105105 match extra_art().iter().position(|name| name == art) {
106106 Some(extra) => Some(EXTRAS[extra].2.as_bytes().to_vec()),
107 None => std::fs::read(pictures()?.join(art)).ok(),
107 None => notebook::fs::read(pictures()?.join(art)).ok(),
108108 }
109109}
110110
......@@ -595,7 +595,7 @@ impl State {
595595 let Some(path) = platform::pick_file("Custom Image", &["png", "svg"]) else {
596596 return;
597597 };
598 let kept = std::fs::read(&path)
598 let kept = notebook::fs::read(&path)
599599 .ok()
600600 .and_then(|bytes| canvas::gpu::import_tag_art(&bytes));
601601 let Some((bytes, extension)) = kept else {
......@@ -608,8 +608,8 @@ impl State {
608608 let written = pictures()
609609 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
610610 .and_then(|folder| {
611 std::fs::create_dir_all(&folder)?;
612 match std::fs::File::create_new(folder.join(&art)) {
611 notebook::fs::create_dir_all(&folder)?;
612 match notebook::fs::File::create_new(folder.join(&art)) {
613613 Ok(mut file) => std::io::Write::write_all(&mut file, &bytes),
614614 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
615615 Err(error) => Err(error),
......@@ -1185,16 +1185,16 @@ mod tests {
11851185 let written = section.image();
11861186 let temporary =
11871187 std::env::temp_dir().join(format!("snowbound-custom-art-{}", std::process::id()));
1188 let _ = std::fs::remove_dir_all(&temporary);
1188 let _ = notebook::fs::remove_dir_all(&temporary);
11891189 let root = temporary.join("Custom Art");
1190 std::fs::create_dir_all(&root).unwrap();
1191 std::fs::write(root.join("Custom art.one"), &written).unwrap();
1190 notebook::fs::create_dir_all(&root).unwrap();
1191 notebook::fs::write(root.join("Custom art.one"), &written).unwrap();
11921192 let file_id = Store::parse(&written).unwrap().header.file_id;
11931193 let toc = onestore::create_table_of_contents(
11941194 "Open Notebook.onetoc2",
11951195 &[("Custom art.one", file_id)],
11961196 );
1197 std::fs::write(root.join("Open Notebook.onetoc2"), toc.unwrap()).unwrap();
1197 notebook::fs::write(root.join("Open Notebook.onetoc2"), toc.unwrap()).unwrap();
11981198 let notebook = notebook::session::Notebook::open(&root, temporary.join("cache"));
11991199 let notebook = notebook.unwrap();
12001200 for tag in &list[..3] {
......@@ -1242,7 +1242,7 @@ mod tests {
12421242 .unwrap();
12431243 assert!(status.success());
12441244 }
1245 std::fs::remove_dir_all(&temporary).unwrap();
1245 notebook::fs::remove_dir_all(&temporary).unwrap();
12461246 }
12471247
12481248 /// The gallery offers each symbol OneNote 2010's does once: every one MS-ONE lists but
......@@ -1267,8 +1267,8 @@ mod tests {
12671267 let sources = canvas::gpu::art_sources(&mapping.art, bytes).unwrap();
12681268 art.map(&mapping.name, mapping.shape, &mapping.art, sources);
12691269 }
1270 std::fs::remove_dir_all(&cache).unwrap();
1271 let bytes = std::fs::read(corpus.join("cold/notebook/Custom art.one")).unwrap();
1270 notebook::fs::remove_dir_all(&cache).unwrap();
1271 let bytes = notebook::fs::read(corpus.join("cold/notebook/Custom art.one")).unwrap();
12721272 let store = Store::parse(&bytes).unwrap();
12731273 let index = RevisionIndex::parse(&store).unwrap();
12741274 let document = Document::parse(&index).unwrap();
crates/snowbound/src/themes.rs+5-5
......@@ -775,9 +775,9 @@ mod tests {
775775 fn themed_pages_hold_their_theme_under_onenote_s_names() {
776776 let temporary =
777777 std::env::temp_dir().join(format!("snowbound-styles-{}", std::process::id()));
778 let _ = std::fs::remove_dir_all(&temporary);
778 let _ = notebook::fs::remove_dir_all(&temporary);
779779 let root = temporary.join("Styles");
780 std::fs::create_dir_all(&root).unwrap();
780 notebook::fs::create_dir_all(&root).unwrap();
781781 let mut engine = TextEngine::default();
782782 let mut sections = Vec::new();
783783 for theme in built_in() {
......@@ -880,7 +880,7 @@ mod tests {
880880 }
881881 assert_eq!(definition, other[&name], "{} to modern {name}", theme.name);
882882 }
883 std::fs::write(root.join(&file), &written).unwrap();
883 notebook::fs::write(root.join(&file), &written).unwrap();
884884 let file_id = Store::parse(&written).unwrap().header.file_id;
885885 sections.push((file, file_id, theme.id));
886886 }
......@@ -889,7 +889,7 @@ mod tests {
889889 .map(|(file, id, _)| (file.as_str(), *id))
890890 .collect();
891891 let toc = onestore::create_table_of_contents("Open Notebook.onetoc2", &listed).unwrap();
892 std::fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap();
892 notebook::fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap();
893893 let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")).unwrap();
894894 let themes = notebook
895895 .save_themes(Themes {
......@@ -916,6 +916,6 @@ mod tests {
916916 .unwrap();
917917 assert!(status.success());
918918 }
919 std::fs::remove_dir_all(&temporary).unwrap();
919 notebook::fs::remove_dir_all(&temporary).unwrap();
920920 }
921921}
crates/snowbound/src/undo.rs+10-10
......@@ -1124,8 +1124,8 @@ mod tests {
11241124 fn new(name: &str, titles: &[&str]) -> Self {
11251125 let folder =
11261126 std::env::temp_dir().join(format!("snowbound-undo-{name}-{}", std::process::id()));
1127 let _ = std::fs::remove_dir_all(&folder);
1128 std::fs::create_dir_all(&folder).unwrap();
1127 let _ = notebook::fs::remove_dir_all(&folder);
1128 notebook::fs::create_dir_all(&folder).unwrap();
11291129 let root = folder.join("Notebook");
11301130 let mut notebook =
11311131 Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated())
......@@ -1211,7 +1211,7 @@ mod tests {
12111211 let file = self
12121212 .folder
12131213 .join("Notebook/OneNote_RecycleBin/OneNote_DeletedPages.one");
1214 std::fs::read(file).map_or_else(
1214 notebook::fs::read(file).map_or_else(
12151215 |_| Vec::new(),
12161216 |bytes| {
12171217 notebook::session::stored_pages(&bytes)
......@@ -1235,7 +1235,7 @@ mod tests {
12351235 if let Some(section) = self.section.take() {
12361236 let _ = section.close();
12371237 }
1238 let _ = std::fs::remove_dir_all(&self.folder);
1238 let _ = notebook::fs::remove_dir_all(&self.folder);
12391239 }
12401240 }
12411241
......@@ -1434,8 +1434,8 @@ mod tests {
14341434 use crate::manage::Structure;
14351435 let folder =
14361436 std::env::temp_dir().join(format!("snowbound-undo-sections-{}", std::process::id()));
1437 let _ = std::fs::remove_dir_all(&folder);
1438 std::fs::create_dir_all(&folder).unwrap();
1437 let _ = notebook::fs::remove_dir_all(&folder);
1438 notebook::fs::create_dir_all(&folder).unwrap();
14391439 let root = folder.join("Notebook");
14401440 let mut notebook =
14411441 Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()).unwrap();
......@@ -1495,7 +1495,7 @@ mod tests {
14951495 carry(&mut notebook, structure);
14961496 notebook.rename("Kitchen.one", "Pantry").unwrap();
14971497 assert!(restructuring(&library(notebook), &undo.change).is_none());
1498 let _ = std::fs::remove_dir_all(&folder);
1498 let _ = notebook::fs::remove_dir_all(&folder);
14991499 }
15001500
15011501 /// Every kind of step taken back and redone in one notebook, written as the app writes
......@@ -1596,13 +1596,13 @@ mod tests {
15961596 }
15971597
15981598 fn copy(from: &std::path::Path, to: &std::path::Path) {
1599 std::fs::create_dir_all(to).unwrap();
1600 for entry in std::fs::read_dir(from).unwrap().flatten() {
1599 notebook::fs::create_dir_all(to).unwrap();
1600 for entry in notebook::fs::read_dir(from).unwrap().flatten() {
16011601 let target = to.join(entry.file_name());
16021602 if entry.file_type().unwrap().is_dir() {
16031603 copy(&entry.path(), &target);
16041604 } else {
1605 std::fs::copy(entry.path(), target).unwrap();
1605 notebook::fs::copy(entry.path(), target).unwrap();
16061606 }
16071607 }
16081608 }
crates/snowbound/src/unpack.rs+6-4
......@@ -189,7 +189,7 @@ impl State {
189189 if create && !valid {
190190 return platform::alert("Couldn't unpack the notebook", "Give the notebook a name.");
191191 }
192 if create && root.exists() {
192 if create && notebook::fs::metadata(&root).is_ok() {
193193 return platform::alert(
194194 "Couldn't unpack the notebook",
195195 &format!(
......@@ -212,11 +212,13 @@ impl State {
212212 /// `color` where one was picked, and opens it.
213213 fn unpack(&mut self, package: PathBuf, root: PathBuf, color: Option<u32>) {
214214 let (cache, proxy) = (self.cache.clone(), self.proxy.clone());
215 std::thread::spawn(move || {
215 crate::spawn(move || {
216216 let unpacked = (|| -> Result<String, Box<dyn std::error::Error>> {
217 let files = notebook::package::read(&std::fs::read(&package)?, LIMIT)?;
217 let files = notebook::package::read(&notebook::fs::read(&package)?, LIMIT)?;
218218 notebook::package::unpack(files, &root)?;
219 let location = std::path::absolute(&root)?.to_string_lossy().into_owned();
219 let location = notebook::fs::absolute(&root)?
220 .to_string_lossy()
221 .into_owned();
220222 if let Some(color) = color {
221223 Notebook::open(&location, &cache)?.set_color(color)?;
222224 }
crates/snowbound/src/unread.rs+8-8
......@@ -51,8 +51,8 @@ fn hex(identity: [u8; 16]) -> String {
5151}
5252
5353fn now() -> u32 {
54 let unix = std::time::SystemTime::now()
55 .duration_since(std::time::UNIX_EPOCH)
54 let unix = web_time::SystemTime::now()
55 .duration_since(web_time::UNIX_EPOCH)
5656 .map_or(0, |since| since.as_secs());
5757 u32::try_from(unix.saturating_sub(TIME32)).unwrap_or(u32::MAX)
5858}
......@@ -61,7 +61,7 @@ impl Reads {
6161 /// What the cache folder `cache` keeps.
6262 pub fn load(cache: &Path) -> Self {
6363 let file = cache.join("read.json");
64 let reads: Self = (std::fs::read(&file).ok())
64 let reads: Self = (notebook::fs::read(&file).ok())
6565 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
6666 .unwrap_or_default();
6767 Self { file, ..reads }
......@@ -71,8 +71,8 @@ impl Reads {
7171 let partial = self.file.with_extension("partial");
7272 let saved = serde_json::to_vec(self)
7373 .map_err(std::io::Error::from)
74 .and_then(|bytes| std::fs::write(&partial, bytes))
75 .and_then(|()| std::fs::rename(&partial, &self.file));
74 .and_then(|bytes| notebook::fs::write(&partial, bytes))
75 .and_then(|()| notebook::fs::rename(&partial, &self.file));
7676 if let Err(error) = saved {
7777 eprintln!("Keeping what was read failed: {error}");
7878 }
......@@ -254,7 +254,7 @@ impl State {
254254 /// pages are read on a thread of their own, those changed since unread.
255255 pub(crate) fn sections_changed(&self, library: &Arc<Library>, paths: Vec<String>) {
256256 let (library, proxy) = (Arc::clone(library), self.proxy.clone());
257 std::thread::spawn(move || {
257 crate::spawn(move || {
258258 let Ok(Some(notebook)) = &library.notebook else {
259259 return;
260260 };
......@@ -387,7 +387,7 @@ mod tests {
387387 #[test]
388388 fn pages_changed_elsewhere_stay_unread_until_read() {
389389 let folder = std::env::temp_dir().join(format!("snowbound-unread-{}", std::process::id()));
390 std::fs::create_dir_all(&folder).unwrap();
390 notebook::fs::create_dir_all(&folder).unwrap();
391391 let (notebook, section) = ("/notebook", [3; 16]);
392392 let mut reads = Reads::load(&folder);
393393 let known = reads.section_mut(notebook, section).read;
......@@ -426,6 +426,6 @@ mod tests {
426426 );
427427 reads.notebooks.get_mut(notebook).unwrap().hidden = true;
428428 assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook));
429 std::fs::remove_dir_all(&folder).unwrap();
429 notebook::fs::remove_dir_all(&folder).unwrap();
430430 }
431431}
crates/snowbound/src/update.rs+48-9
......@@ -2,11 +2,14 @@
22//! `build.json` the release key signs; `latest.json` names each platform's newest build. A
33//! thread checks on launch and daily, downloads a newer build for this platform, verifies
44//! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits.
5//! `tools/RELEASE.md` describes the publishing side.
5//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload,
6//! so nothing is fetched or installed there.
7#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
68
79#[cfg(target_os = "linux")]
810use crate::loader::executable;
9use crate::{State, UserEvent, platform};
11use crate::{EventLoopProxy, State, UserEvent, platform};
12#[cfg(not(target_arch = "wasm32"))]
1013use ring::signature::{ED25519, UnparsedPublicKey};
1114use serde::Deserialize;
1215use std::collections::HashMap;
......@@ -16,8 +19,9 @@ use std::ffi::OsString;
1619use std::path::{Path, PathBuf};
1720use std::process::Command;
1821use std::sync::{Arc, Mutex};
19use std::time::{Duration, Instant};
20use winit::event_loop::EventLoopProxy;
22use std::time::Duration;
23#[cfg(not(target_arch = "wasm32"))]
24use std::time::Instant;
2125
2226/// Where the builds are published.
2327const BASE: &str = "https://file.paperclover.net/shr/snowbound/";
......@@ -206,6 +210,16 @@ fn unhex(text: &str) -> Option<Vec<u8>> {
206210 .flatten()
207211}
208212
213#[cfg(target_arch = "wasm32")]
214fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> {
215 Err(BROWSER.to_owned())
216}
217
218/// Why the browser installs no builds: it loads the newest each time the page opens.
219#[cfg(target_arch = "wasm32")]
220const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens.";
221
222#[cfg(not(target_arch = "wasm32"))]
209223fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> {
210224 let signature = unhex(signature).ok_or("The signature isn’t hex")?;
211225 UnparsedPublicKey::new(&ED25519, key)
......@@ -271,9 +285,12 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri
271285 archive.size
272286 ));
273287 }
274 let digest = ring::digest::digest(&ring::digest::SHA256, bytes);
275 if unhex(&archive.sha256).as_deref() != Some(digest.as_ref()) {
276 return Err(format!("{}’s SHA-256 doesn’t match", archive.file));
288 #[cfg(not(target_arch = "wasm32"))]
289 {
290 let digest = ring::digest::digest(&ring::digest::SHA256, bytes);
291 if unhex(&archive.sha256).as_deref() != Some(digest.as_ref()) {
292 return Err(format!("{}’s SHA-256 doesn’t match", archive.file));
293 }
277294 }
278295 verify(key, bytes, &archive.signature)
279296}
......@@ -448,7 +465,13 @@ fn check(
448465 result.unwrap_or_else(Status::Failed)
449466}
450467
468#[cfg(target_arch = "wasm32")]
469fn download(_: &str, _: u64) -> Result<Vec<u8>, String> {
470 Err(BROWSER.to_owned())
471}
472
451473/// The `Fetch` the app uses.
474#[cfg(not(target_arch = "wasm32"))]
452475fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {
453476 let agent: ureq::Agent = ureq::Agent::config_builder()
454477 .timeout_global(Some(Duration::from_secs(10 * 60)))
......@@ -498,6 +521,12 @@ impl Updates {
498521 ..Shared::default()
499522 }));
500523 let key = unhex(KEY).expect("release-key.pub holds a key in hex");
524 #[cfg(target_arch = "wasm32")]
525 let thread = {
526 let _ = (key, proxy);
527 std::thread::current()
528 };
529 #[cfg(not(target_arch = "wasm32"))]
501530 let thread = {
502531 let shared = Arc::clone(&shared);
503532 std::thread::Builder::new()
......@@ -570,8 +599,13 @@ impl Updates {
570599
571600 /// Checks now, and shows what the check finds when it finishes.
572601 pub fn check_now(&self) {
573 self.shared.lock().unwrap().asked = true;
574 self.thread.unpark();
602 #[cfg(target_arch = "wasm32")]
603 platform::inform("Snowbound is up to date", BROWSER);
604 #[cfg(not(target_arch = "wasm32"))]
605 {
606 self.shared.lock().unwrap().asked = true;
607 self.thread.unpark();
608 }
575609 }
576610
577611 /// 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 {
715749 true
716750}
717751
752#[cfg(target_arch = "wasm32")]
753fn quits(_: u32, _: Duration) -> bool {
754 false
755}
756
718757#[cfg(windows)]
719758fn quits(pid: u32, patience: Duration) -> bool {
720759 use windows_sys::Win32::{
crates/snowbound/src/watch_web.rs created+13
......@@ -0,0 +1,13 @@
1//! The browser's notebooks change only through this page, so nothing reports their changes.
2
3use std::path::Path;
4
5pub struct Watch;
6
7pub fn watch(_: &Path, _: impl Fn(Vec<String>) + Send + Sync + 'static) -> Option<Watch> {
8 None
9}
10
11pub fn on_this_computer(_: &Path) -> bool {
12 true
13}
crates/snowbound/src/web.rs created+1126
......@@ -0,0 +1,1126 @@
1//! The browser: one canvas, its input through `web/glue.js`, and notebooks in the files
2//! `notebook::fs` keeps, which the glue loads from IndexedDB and writes back. The window and
3//! event loop are stand-ins with the calls `State` makes of winit's; menus are the kit's own,
4//! as on Linux; the browser's chords stay the browser's. See arc/platforms.md.
5
6use crate::{State, UserEvent, commands, page, settings};
7use canvas::date::DateField;
8use std::{
9 cell::{Cell, RefCell},
10 collections::VecDeque,
11 error::Error,
12 marker::PhantomData,
13 path::{Path, PathBuf},
14 time::Duration,
15};
16use ui::Ui;
17use wasm_bindgen::prelude::*;
18use web_time::Instant;
19use winit::{
20 dpi::{PhysicalPosition, PhysicalSize, Size},
21 event::{Ime, MouseButton},
22 event_loop::EventLoopClosed,
23 keyboard::{Key, ModifiersState, NamedKey},
24 window::{CursorIcon, ResizeDirection, Theme, WindowAttributes},
25};
26
27#[wasm_bindgen(module = "/web/glue.js")]
28extern "C" {
29 fn attach(module: JsValue);
30 /// The files IndexedDB kept: `[path, bytes or null for a folder, modified]`.
31 #[wasm_bindgen(js_name = loadFiles, catch)]
32 async fn load_files() -> Result<js_sys::Array, JsValue>;
33 #[wasm_bindgen(js_name = requestFrame)]
34 fn request_frame();
35 #[wasm_bindgen(js_name = wakeIn)]
36 fn wake_in(ms: f64);
37 #[wasm_bindgen(js_name = setCursor)]
38 fn set_cursor(name: &str);
39 #[wasm_bindgen(js_name = setTitle)]
40 fn set_title(title: &str);
41 #[wasm_bindgen(js_name = placeInput)]
42 fn place_input(x: f32, y: f32, height: f32, text: bool);
43 #[wasm_bindgen(js_name = writeClipboard)]
44 fn write_clipboard(text: &str);
45 /// Hands `bytes` to the browser to save as `name`, of MIME type `kind`.
46 pub fn download(name: &str, bytes: &[u8], kind: &str);
47 #[wasm_bindgen(js_name = pickFiles)]
48 fn pick_files(purpose: &str, accept: &str);
49 #[wasm_bindgen(js_name = dateText)]
50 fn date_strings(ms: f64) -> Vec<String>;
51 #[wasm_bindgen(js_name = shortDate)]
52 fn short_date_string(ms: f64) -> String;
53 #[wasm_bindgen(js_name = askConfirm)]
54 fn ask_confirm(message: &str) -> bool;
55 #[wasm_bindgen(js_name = askText)]
56 fn ask_text(message: &str, value: &str) -> Option<String>;
57 #[wasm_bindgen(js_name = tell)]
58 fn tell(message: &str);
59 #[wasm_bindgen(js_name = openLink)]
60 fn open_link(url: &str);
61 /// Writes changed files to IndexedDB: `[path, bytes or null for a folder or undefined
62 /// for a removal, modified]` triples.
63 #[wasm_bindgen(js_name = storeFiles)]
64 fn store_files(changes: js_sys::Array);
65}
66
67/// Where the browser keeps its notebooks, which a first visit makes one in.
68const NOTEBOOKS: &str = "/Notebooks";
69const CACHE: &str = "/Cache";
70/// The metric-compatible faces `index.html` fetched, which only this page load holds.
71const FONTS: &str = "/Fonts";
72const SETTINGS: &str = "/Settings";
73/// Files chosen to insert, open or paste, kept where the page links them from.
74const CHOSEN: &str = "/Chosen";
75/// How long changed files wait to be written out, so a burst of edits writes once.
76const STORE_AFTER: Duration = Duration::from_millis(500);
77
78/// What the page's input brought, waiting for the next frame.
79enum Input {
80 Ui(ui::Event),
81 /// Zooms the page by the factor about a point in the window.
82 Pinch(f32, [f32; 2]),
83 /// The canvas's device pixels and device pixels per CSS pixel.
84 Resize([u32; 2], f32),
85 Focus(bool),
86}
87
88/// The browser window's state as the canvas reports it.
89struct Host {
90 size: PhysicalSize<u32>,
91 ratio: f64,
92 focused: bool,
93 title: String,
94 theme: Theme,
95 redraw: bool,
96}
97
98thread_local! {
99 static HOST: RefCell<Host> = const {
100 RefCell::new(Host {
101 size: PhysicalSize::new(1, 1),
102 ratio: 1.0,
103 focused: true,
104 title: String::new(),
105 theme: Theme::Light,
106 redraw: true,
107 })
108 };
109 static STATE: RefCell<Option<State>> = const { RefCell::new(None) };
110 static INPUT: RefCell<Vec<Input>> = const { RefCell::new(Vec::new()) };
111 static EVENTS: RefCell<VecDeque<UserEvent>> = const { RefCell::new(VecDeque::new()) };
112 /// Work deferred to after the frame, as threads run it elsewhere.
113 static LATER: RefCell<Vec<Box<dyn FnOnce()>>> = const { RefCell::new(Vec::new()) };
114 /// Input waits a frame for what a chord opened.
115 static HOLDING: Cell<bool> = const { Cell::new(false) };
116 /// Modifier bits as the page's input last reported them.
117 static HELD: Cell<u8> = const { Cell::new(0) };
118 /// Whether Command, not Control, takes the editing chords.
119 static MAC: Cell<bool> = const { Cell::new(false) };
120 static PASTED: RefCell<Pasted> = RefCell::new(Pasted::default());
121 /// When changed files are next written out.
122 static STORE_DUE: Cell<Option<Instant>> = const { Cell::new(None) };
123 static LANGUAGE: RefCell<String> = const { RefCell::new(String::new()) };
124}
125
126/// The page's canvas, which frames are drawn into.
127pub fn canvas() -> web_sys::HtmlCanvasElement {
128 use wasm_bindgen::JsCast;
129 web_sys::window()
130 .and_then(|window| window.document())
131 .and_then(|document| document.get_element_by_id("page"))
132 .and_then(|canvas| canvas.dyn_into().ok())
133 .expect("index.html has a canvas called page")
134}
135
136fn host<T>(act: impl FnOnce(&mut Host) -> T) -> T {
137 HOST.with_borrow_mut(act)
138}
139
140fn report(error: impl std::fmt::Display) {
141 web_sys::console::error_1(&error.to_string().into());
142}
143
144/// Runs `work` once the frame under way is done, as a thread would run it beside the frame.
145pub fn defer(work: impl FnOnce() + 'static) {
146 LATER.with_borrow_mut(|later| later.push(Box::new(work)));
147 wake_in(0.0);
148}
149
150/// The event loop `State` opens its window in.
151pub struct ActiveEventLoop;
152
153impl ActiveEventLoop {
154 pub fn create_window(&self, attributes: WindowAttributes) -> Result<Window, Box<dyn Error>> {
155 let window = Window(());
156 window.set_title(&attributes.title);
157 Ok(window)
158 }
159}
160
161/// The page's canvas as a window.
162pub struct Window(());
163
164impl Window {
165 pub fn request_redraw(&self) {
166 host(|host| host.redraw = true);
167 request_frame();
168 }
169
170 pub fn scale_factor(&self) -> f64 {
171 host(|host| host.ratio)
172 }
173
174 pub fn inner_size(&self) -> PhysicalSize<u32> {
175 host(|host| host.size)
176 }
177
178 pub fn request_inner_size(&self, _: impl Into<Size>) -> Option<PhysicalSize<u32>> {
179 None
180 }
181
182 pub fn title(&self) -> String {
183 host(|host| host.title.clone())
184 }
185
186 pub fn set_title(&self, title: &str) {
187 set_title(title);
188 host(|host| host.title = title.to_owned());
189 }
190
191 /// The colour scheme Options chooses is `State`'s; the page follows the browser's.
192 pub fn set_theme(&self, _: Option<Theme>) {}
193
194 pub fn has_focus(&self) -> bool {
195 host(|host| host.focused)
196 }
197
198 pub fn focus_window(&self) {}
199
200 pub fn set_visible(&self, _: bool) {}
201
202 pub fn set_minimized(&self, _: bool) {}
203
204 /// The browser moves and sizes its own window.
205 pub fn drag_window(&self) -> Result<(), ()> {
206 Err(())
207 }
208
209 pub fn drag_resize_window(&self, _: ResizeDirection) -> Result<(), ()> {
210 Err(())
211 }
212
213 pub fn set_cursor(&self, cursor: impl Into<winit::window::Cursor>) {
214 set_cursor(match cursor.into() {
215 winit::window::Cursor::Icon(icon) => icon.name(),
216 winit::window::Cursor::Custom(_) => "default",
217 });
218 }
219
220 pub fn set_ime_allowed(&self, _: bool) {}
221
222 /// Puts the text area at the caret, so an input method's candidates show beside it.
223 pub fn set_ime_cursor_area(
224 &self,
225 position: impl Into<winit::dpi::Position>,
226 size: impl Into<Size>,
227 ) {
228 let ratio = self.scale_factor();
229 let position: PhysicalPosition<f64> = position.into().to_physical(ratio);
230 let size: PhysicalSize<f64> = size.into().to_physical(ratio);
231 place_input(
232 (position.x / ratio) as f32,
233 (position.y / ratio) as f32,
234 ((size.height / ratio) as f32).max(1.0),
235 true,
236 );
237 }
238
239 pub fn pre_present_notify(&self) {}
240}
241
242/// Sends events to the page's loop, from wherever `State` hands it.
243pub struct EventLoopProxy<T>(PhantomData<fn(T)>);
244
245impl<T> Clone for EventLoopProxy<T> {
246 fn clone(&self) -> Self {
247 Self(PhantomData)
248 }
249}
250
251impl EventLoopProxy<UserEvent> {
252 pub fn send_event(&self, event: UserEvent) -> Result<(), EventLoopClosed<UserEvent>> {
253 EVENTS.with_borrow_mut(|events| events.push_back(event));
254 request_frame();
255 Ok(())
256 }
257}
258
259/// Assistive technology's view of the window, which a DOM mirror takes in a later phase;
260/// until then no tree is asked for.
261pub struct AccessAdapter;
262
263impl AccessAdapter {
264 pub fn with_event_loop_proxy(
265 _: &ActiveEventLoop,
266 _: &Window,
267 _: EventLoopProxy<UserEvent>,
268 ) -> Self {
269 Self
270 }
271
272 pub fn update_if_active(&mut self, _: impl FnOnce() -> accesskit::TreeUpdate) {}
273}
274
275/// The browser's clipboard: text out through `navigator.clipboard`, and in through the page's
276/// paste event, which the glue keeps here before the paste runs.
277pub struct Clipboard;
278
279#[derive(Default)]
280struct Pasted {
281 text: Option<String>,
282 html: Option<String>,
283 files: Vec<PathBuf>,
284}
285
286impl Clipboard {
287 pub fn new(_: &Window) -> Result<Self, Box<dyn Error>> {
288 Ok(Self)
289 }
290
291 pub fn set_text(&mut self, text: String) -> Result<(), Box<dyn Error>> {
292 write_clipboard(&text);
293 Ok(())
294 }
295
296 pub fn get_text(&mut self) -> Result<String, Box<dyn Error>> {
297 PASTED
298 .with_borrow(|pasted| pasted.text.clone())
299 .ok_or_else(|| "Nothing is on the clipboard".into())
300 }
301
302 pub fn get_files(&mut self) -> Vec<PathBuf> {
303 PASTED.with_borrow(|pasted| pasted.files.clone())
304 }
305
306 pub fn get_html(&mut self) -> Option<String> {
307 PASTED.with_borrow(|pasted| pasted.html.clone())
308 }
309
310 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
311 None
312 }
313}
314
315pub const LEADING: f32 = 8.0;
316pub const TRAILING: f32 = 8.0;
317
318pub fn window_attributes() -> WindowAttributes {
319 WindowAttributes::default()
320}
321
322pub fn install_title_bar(_: &Window) {}
323
324pub fn install_text_input(_: &Window) {}
325
326pub fn install_backdrop(_: &Window) -> bool {
327 false
328}
329
330pub fn install_menu() {}
331
332pub fn update_menu(_: impl FnOnce() -> Vec<commands::Status>) {}
333
334pub fn update_tag_menu(_: &[canvas::editor::NoteTag]) {}
335
336/// The browser draws the tab's title; the toolbar's row is the page's top.
337pub fn system_titlebar(_: &Window) -> bool {
338 true
339}
340
341pub fn represent(_: &Window, _: Option<&Path>) {}
342
343pub fn titlebar(_: Theme) -> Option<[[f32; 4]; 2]> {
344 None
345}
346
347pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme {
348 theme.over_backdrop()
349}
350
351/// The kit's own menus.
352pub fn menu(_: Theme) -> Option<ui::Menu> {
353 None
354}
355
356pub fn follow_appearance(_: &Window, _: Theme) {}
357
358pub fn appearance(_: &Window) -> Theme {
359 host(|host| host.theme)
360}
361
362pub fn corner_radius(_: &Window) -> f32 {
363 0.0
364}
365
366pub fn cuts_corners() -> bool {
367 false
368}
369
370pub fn window_controls(_: &mut Ui, _: &Window) {}
371
372pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<ResizeDirection> {
373 None
374}
375
376pub fn set_min_size(_: &Window, _: [f32; 2]) {}
377
378pub fn zoom(_: &Window) {}
379
380pub fn move_cursor() -> CursorIcon {
381 CursorIcon::Move
382}
383
384pub fn cover_border_line(_: &mut Ui, _: f32) {}
385
386/// The interface in Carlito as Calibri, the page's default face, as the browser has no
387/// system font to offer.
388pub fn system_interface(ui: &mut Ui) {
389 ui.set_system_font("Calibri");
390}
391
392pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {}
393
394pub fn clear_marked_text(_: &Window) {}
395
396pub fn configure_presentation(_: &wgpu::Surface<'_>) {}
397
398pub fn commit_presentation(_: &Window) {}
399
400pub fn double_click_interval() -> Duration {
401 Duration::from_millis(500)
402}
403
404/// Pages fall back through the fonts the browser module carries.
405pub fn symbol_fonts() -> Vec<String> {
406 Vec::new()
407}
408
409pub fn file_icon(_: &Path) -> Option<Vec<u8>> {
410 None
411}
412
413/// The browser has no file manager to show a file in.
414pub const SHOW_FILE: &str = "Download";
415
416/// Downloads `file`, the nearest the browser comes to showing it.
417pub fn show_file(file: &Path) {
418 open_file(file);
419}
420
421/// Downloads a copy of the file at `path` from the browser's files.
422pub fn open_file(path: &Path) {
423 match notebook::fs::read(path) {
424 Ok(bytes) => download(
425 &crate::library::file_name(path),
426 &bytes,
427 "application/octet-stream",
428 ),
429 Err(error) => alert("Couldn't open the file", &error.to_string()),
430 }
431}
432
433/// Opens a link's URL in a new tab; a folder of the browser's files has nothing to show.
434pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
435 let target = target.as_ref().to_string_lossy();
436 if target.contains("://") || target.starts_with("mailto:") {
437 open_link(&target);
438 }
439}
440
441pub fn cache_dir() -> Option<PathBuf> {
442 Some(CACHE.into())
443}
444
445pub fn settings_dir() -> Option<PathBuf> {
446 Some(SETTINGS.into())
447}
448
449pub fn documents_dir() -> Option<PathBuf> {
450 Some(NOTEBOOKS.into())
451}
452
453/// The browser names no user; edits are stored under the name Options sets.
454pub fn user_name() -> String {
455 String::new()
456}
457
458pub fn input_language() -> String {
459 LANGUAGE.with_borrow(Clone::clone)
460}
461
462/// Milliseconds since 1970 at FILETIME `filetime`.
463fn unix_ms(filetime: u64) -> f64 {
464 (filetime / 10_000) as f64 - 11_644_473_600_000.0
465}
466
467/// Seconds east of UTC at `unix` seconds since 1970, as the browser's zone has it.
468pub fn utc_offset(unix: i64) -> i64 {
469 -(js_sys::Date::new(&(unix as f64 * 1e3).into()).get_timezone_offset() as i64) * 60
470}
471
472pub fn short_date(filetime: u64) -> String {
473 short_date_string(unix_ms(filetime))
474}
475
476pub fn date_text(filetime: u64) -> [String; 2] {
477 let mut text = date_strings(unix_ms(filetime)).into_iter();
478 [
479 text.next().unwrap_or_default(),
480 text.next().unwrap_or_default(),
481 ]
482}
483
484/// Asks for the page's date as `YYYY-MM-DD`, or its time as `HH:MM`.
485pub fn edit_date(
486 timestamp: u64,
487 field: DateField,
488 title: &str,
489) -> Result<Option<(u64, [String; 2])>, &'static str> {
490 let ms = unix_ms(timestamp);
491 let date = js_sys::Date::new(&ms.into());
492 let current = match field {
493 DateField::Date => format!(
494 "{:04}-{:02}-{:02}",
495 date.get_full_year(),
496 date.get_month() + 1,
497 date.get_date()
498 ),
499 DateField::Time => format!("{:02}:{:02}", date.get_hours(), date.get_minutes()),
500 };
501 let Some(answer) = ask_text(title, &current) else {
502 return Ok(None);
503 };
504 let numbers: Vec<u32> = answer
505 .split(|c: char| !c.is_ascii_digit())
506 .filter(|part| !part.is_empty())
507 .filter_map(|part| part.parse().ok())
508 .collect();
509 match (field, numbers.as_slice()) {
510 (DateField::Date, &[year, month, day])
511 if (1..=12).contains(&month) && (1..=31).contains(&day) =>
512 {
513 date.set_full_year_with_month_date(year, month as i32 - 1, day as i32);
514 }
515 (DateField::Time, &[hour, minute]) if hour < 24 && minute < 60 => {
516 date.set_hours(hour);
517 date.set_minutes(minute);
518 }
519 _ => return Err(crate::DATE_UNCHOSEN),
520 }
521 let seconds = (date.get_time() / 1e3).floor();
522 let updated = ((seconds as i64 + 11_644_473_600) as u64)
523 .checked_mul(10_000_000)
524 .and_then(|ticks| ticks.checked_add(timestamp % 10_000_000))
525 .ok_or(crate::DATE_OUT_OF_RANGE)?;
526 Ok(Some((updated, date_text(updated))))
527}
528
529/// Asks for a file to insert; once chosen it goes to the caret, as a dropped file does.
530pub fn pick_file(_: &str, types: &[&str]) -> Option<PathBuf> {
531 let accept: Vec<String> = types.iter().map(|kind| format!(".{kind}")).collect();
532 pick_files("place", &accept.join(","));
533 None
534}
535
536/// Asks for notebooks, sections or packages to open; they open once read.
537pub fn pick_notebook(_: &str) -> Option<PathBuf> {
538 pick_files("open", ".one,.onetoc2,.onepkg");
539 None
540}
541
542/// A new notebook goes in the browser's notebooks, under a name not yet taken; anything else
543/// asked a place goes there too, then downloads.
544pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>) -> Option<PathBuf> {
545 let name = ask_text(title, name)?;
546 let name = name.trim().replace(['/', '\\'], " ");
547 let folder = Path::new(NOTEBOOKS);
548 let _ = notebook::fs::create_dir_all(folder);
549 let mut path = folder.join(&name);
550 let mut number = 2;
551 while notebook::fs::metadata(&path).is_ok() {
552 path = folder.join(format!("{name} {number}"));
553 number += 1;
554 }
555 Some(path)
556}
557
558pub fn confirm(message: &str, detail: &str, _: &str, _: &str) -> bool {
559 ask_confirm(&format!("{message}\n\n{detail}"))
560}
561
562pub fn alert(message: &str, detail: &str) {
563 tell(&format!("{message}\n\n{detail}"));
564}
565
566pub fn inform(message: &str, detail: &str) {
567 alert(message, detail);
568}
569
570/// Servers are reached through a relay in a later phase; none is mounted here.
571pub fn smb_mount(_: &Path) -> Option<crate::library::Mount> {
572 None
573}
574
575pub fn smb_login(_: &crate::library::Mount) -> Result<crate::library::Login, String> {
576 Err("Servers aren't reachable from the browser yet.".into())
577}
578
579pub fn remember_label() -> Option<&'static str> {
580 None
581}
582
583pub fn save_login(_: &crate::library::Mount, _: &crate::library::Login) -> Result<(), String> {
584 Err("The browser keeps no passwords.".into())
585}
586
587/// The embedded SMB client's face, until a WebSocket relay carries SMB to the browser:
588/// every server is out of reach.
589pub mod smb {
590 use std::{convert::Infallible, fmt, io, time::Duration};
591
592 fn unreachable() -> io::Error {
593 io::Error::new(io::ErrorKind::Unsupported, Refusal::Unreachable)
594 }
595
596 #[derive(Default)]
597 pub struct Credentials<'a> {
598 pub username: &'a str,
599 pub password: &'a str,
600 pub domain: &'a str,
601 }
602
603 #[derive(Debug, Clone, PartialEq, Eq)]
604 pub struct DirectoryEntry {
605 pub name: String,
606 pub size: u64,
607 pub modified: u64,
608 pub attributes: u32,
609 }
610
611 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
612 pub enum Refusal {
613 Unreachable,
614 Smb1,
615 SignIn,
616 NoShare,
617 Denied,
618 NoFolder,
619 Other,
620 }
621
622 impl fmt::Display for Refusal {
623 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
624 f.write_str("Servers aren't reachable from the browser yet")
625 }
626 }
627
628 impl std::error::Error for Refusal {}
629
630 impl Refusal {
631 pub fn of(_: &io::Error) -> Self {
632 Self::Unreachable
633 }
634 }
635
636 pub struct Client(Infallible);
637
638 impl Client {
639 pub fn connect(_: &str, _: &str, _: Credentials, _: Duration) -> io::Result<Self> {
640 Err(unreachable())
641 }
642
643 pub fn read_dir(&self, _: &str, _: usize) -> io::Result<Vec<DirectoryEntry>> {
644 match self.0 {}
645 }
646 }
647
648 pub fn shares(_: &str, _: Credentials, _: Duration) -> io::Result<Vec<String>> {
649 Err(unreachable())
650 }
651}
652
653/// Starts Snowbound on the page's canvas, `index.html` having fetched `fonts`. `module` is
654/// the module's own exports, which the glue calls.
655#[wasm_bindgen]
656pub async fn start(module: JsValue, fonts: Vec<js_sys::Uint8Array>) -> Result<(), JsValue> {
657 std::panic::set_hook(Box::new(|info| report(info)));
658 let window = web_sys::window().ok_or("No window")?;
659 let navigator = window.navigator();
660 MAC.set(navigator.platform().is_ok_and(|platform| {
661 ["Mac", "iPhone", "iPad"]
662 .iter()
663 .any(|name| platform.contains(name))
664 }));
665 LANGUAGE.with_borrow_mut(|language| *language = navigator.language().unwrap_or_default());
666 let dark = window
667 .match_media("(prefers-color-scheme: dark)")
668 .ok()
669 .flatten()
670 .is_some_and(|query| query.matches());
671 let ratio = window.device_pixel_ratio();
672 let canvas = canvas();
673 host(|host| {
674 host.theme = if dark { Theme::Dark } else { Theme::Light };
675 host.size = PhysicalSize::new(
676 ((f64::from(canvas.client_width()) * ratio) as u32).max(1),
677 ((f64::from(canvas.client_height()) * ratio) as u32).max(1),
678 );
679 host.ratio = ratio;
680 });
681 canvas.set_width(host(|host| host.size.width));
682 canvas.set_height(host(|host| host.size.height));
683 restore(load_files().await?);
684 let state = open(fonts)
685 .await
686 .map_err(|error| JsValue::from_str(&error.to_string()))?;
687 STATE.with_borrow_mut(|slot| *slot = Some(state));
688 attach(module);
689 request_frame();
690 Ok(())
691}
692
693/// Puts back the files IndexedDB kept: `[path, bytes or null for a folder, modified]`.
694fn restore(files: js_sys::Array) {
695 for entry in files.iter() {
696 let entry = js_sys::Array::from(&entry);
697 let Some(path) = entry.get(0).as_string() else {
698 continue;
699 };
700 let bytes = entry.get(1);
701 let saved = if bytes.is_null() {
702 notebook::fs::Saved::Directory
703 } else {
704 notebook::fs::Saved::File(
705 js_sys::Uint8Array::new(&bytes).to_vec(),
706 entry.get(2).as_f64().unwrap_or(0.0),
707 )
708 };
709 notebook::fs::restore(path, saved);
710 }
711}
712
713/// Writes the files changed since the last time out to IndexedDB.
714fn store() {
715 STORE_DUE.set(None);
716 let changes = js_sys::Array::new();
717 for (path, saved) in notebook::fs::changes() {
718 let path = JsValue::from_str(&path.to_string_lossy());
719 let entry = match saved {
720 None => js_sys::Array::of1(&path),
721 Some(notebook::fs::Saved::Directory) => js_sys::Array::of2(&path, &JsValue::NULL),
722 Some(notebook::fs::Saved::File(bytes, modified)) => js_sys::Array::of3(
723 &path,
724 &js_sys::Uint8Array::from(bytes.as_slice()),
725 &modified.into(),
726 ),
727 };
728 changes.push(&entry);
729 }
730 if changes.length() > 0 {
731 store_files(changes);
732 }
733}
734
735/// The window, as `launch` opens it with the notebooks the settings list, making the
736/// browser's first notebook on a first visit.
737async fn open(fonts: Vec<js_sys::Uint8Array>) -> Result<State, Box<dyn Error>> {
738 let cache = PathBuf::from(CACHE);
739 for folder in [NOTEBOOKS, CACHE, SETTINGS, CHOSEN] {
740 notebook::fs::create_dir_all(folder)?;
741 }
742 notebook::fs::restore(FONTS, notebook::fs::Saved::Directory);
743 let file = settings::default_path();
744 let saved = file
745 .as_deref()
746 .map(settings::Settings::load)
747 .unwrap_or_default();
748 let first = saved.notebooks.is_empty();
749 let input = crate::Input::Notebooks {
750 locations: saved.notebooks.clone(),
751 current: saved.current.clone(),
752 };
753 let launch = settings::Launch { file, saved, cache };
754 // Where `State` reads them from, kept out of what IndexedDB stores.
755 let substitutes: Vec<PathBuf> = fonts
756 .iter()
757 .enumerate()
758 .map(|(number, font)| {
759 let path = PathBuf::from(format!("{FONTS}/{number}.ttf"));
760 notebook::fs::restore(&path, notebook::fs::Saved::File(font.to_vec(), 0.0));
761 path
762 })
763 .collect();
764 let mut state = State::new(
765 &ActiveEventLoop,
766 EventLoopProxy(PhantomData),
767 input,
768 &substitutes,
769 true,
770 launch,
771 )
772 .await?;
773 // The first web build's sections, moved by the glue, or else a notebook of the browser's own.
774 if first {
775 let [moved, own] =
776 ["Web Notebook", "My Notebook"].map(|name| Path::new(NOTEBOOKS).join(name));
777 match [&moved, &own]
778 .into_iter()
779 .find(|root| notebook::fs::metadata(root).is_ok())
780 {
781 Some(root) => state.open_path(root),
782 None => state.create_notebook(own.clone())?,
783 }
784 }
785 Ok(state)
786}
787
788fn queue(input: Input) {
789 INPUT.with_borrow_mut(|queue| queue.push(input));
790 request_frame();
791}
792
793/// Whether the page takes the key `key` with modifier bits `held` (Shift, Control, Alt, Meta
794/// from the lowest), rather than leaving it to the browser: the browser keeps its own tab
795/// and window chords, and pastes through its paste event.
796#[wasm_bindgen]
797pub fn takes(key: &str, held: u8) -> bool {
798 let command = held & if MAC.get() { 8 } else { 2 } != 0;
799 if !command {
800 return true;
801 }
802 !matches!(
803 key.to_ascii_lowercase().as_str(),
804 "v" | "w" | "t" | "n" | "q" | "r" | "l" | "tab"
805 )
806}
807
808/// Reports modifier bits `held` where they changed.
809fn follow_modifiers(held: u8) {
810 if HELD.replace(held) == held {
811 return;
812 }
813 let [shift, control, alt, meta] = [1, 2, 4, 8].map(|bit| held & bit != 0);
814 let mac = MAC.get();
815 let mut state = ModifiersState::empty();
816 state.set(ModifiersState::SHIFT, shift);
817 state.set(ModifiersState::ALT, alt);
818 // The command table's chords are the PC's here; on a Mac, Command is Control.
819 state.set(ModifiersState::CONTROL, if mac { meta } else { control });
820 state.set(ModifiersState::SUPER, if mac { control } else { meta });
821 queue(Input::Ui(ui::Event::Modifiers(state)));
822}
823
824/// Pointer `kind` (moved, pressed, released, left) at `x`, `y` in CSS pixels.
825#[wasm_bindgen]
826pub fn pointer(kind: u8, x: f32, y: f32, button: i16, pressure: f32, held: u8) {
827 follow_modifiers(held);
828 queue(Input::Ui(ui::Event::Pressure(
829 (!pressure.is_nan()).then_some(pressure),
830 )));
831 let button = match button {
832 1 => MouseButton::Middle,
833 2 => MouseButton::Right,
834 _ => MouseButton::Left,
835 };
836 queue(Input::Ui(match kind {
837 0 => ui::Event::PointerMoved([x, y]),
838 1 | 2 => ui::Event::Button {
839 button,
840 pressed: kind == 1,
841 at: Instant::now(),
842 },
843 _ => ui::Event::PointerLeft,
844 }));
845}
846
847#[wasm_bindgen]
848pub fn key(key: &str, text: Option<String>, held: u8) {
849 follow_modifiers(held);
850 queue(Input::Ui(ui::Event::Key {
851 key: logical_key(key),
852 text,
853 }));
854}
855
856#[wasm_bindgen]
857pub fn modifiers(held: u8) {
858 follow_modifiers(held);
859}
860
861#[wasm_bindgen]
862pub fn compose(text: String) {
863 let end = text.len();
864 queue(Input::Ui(ui::Event::Ime(Ime::Preedit(
865 text,
866 Some((end, end)),
867 ))));
868}
869
870#[wasm_bindgen]
871pub fn commit(text: String) {
872 queue(Input::Ui(ui::Event::Ime(Ime::Commit(text))));
873}
874
875/// The page's paste event: its text, HTML and files, kept for the paste it runs.
876#[wasm_bindgen]
877pub fn paste(text: Option<String>, html: Option<String>, files: js_sys::Array) {
878 let files = keep(files, CHOSEN);
879 PASTED.with_borrow_mut(|pasted| *pasted = Pasted { text, html, files });
880 send(UserEvent::Choose(commands::Choice::Command(
881 commands::Id::Paste,
882 )));
883}
884
885#[wasm_bindgen]
886pub fn wheel(x: f32, y: f32) {
887 queue(Input::Ui(ui::Event::Wheel([x, y])));
888}
889
890#[wasm_bindgen]
891pub fn pinch(factor: f32, x: f32, y: f32) {
892 queue(Input::Pinch(factor, [x, y]));
893}
894
895#[wasm_bindgen]
896pub fn resize(width: u32, height: u32, ratio: f32) {
897 queue(Input::Resize([width, height], ratio));
898}
899
900#[wasm_bindgen]
901pub fn focus(focused: bool) {
902 queue(Input::Focus(focused));
903}
904
905#[wasm_bindgen]
906pub fn appearance_changed(dark: bool) {
907 host(|host| host.theme = if dark { Theme::Dark } else { Theme::Light });
908 send(UserEvent::Appearance);
909}
910
911/// Files chosen or dropped, as `[name, bytes]` pairs, for `purpose`: `open` opens them as
912/// notebooks, sections or packages, `place` puts them at the caret.
913#[wasm_bindgen]
914pub fn files(purpose: &str, files: js_sys::Array) {
915 match purpose {
916 "open" => {
917 let paths = keep(files, NOTEBOOKS);
918 send(UserEvent::Open(paths));
919 }
920 _ => {
921 for path in keep(files, CHOSEN) {
922 send(UserEvent::Then(Box::new(move |state| {
923 state.place_file(&path, None)
924 })));
925 }
926 }
927 }
928}
929
930/// Writes `[name, bytes]` pairs into `folder` under names not yet taken, returning the paths.
931fn keep(files: js_sys::Array, folder: &str) -> Vec<PathBuf> {
932 files
933 .iter()
934 .filter_map(|pair| {
935 let pair = js_sys::Array::from(&pair);
936 let name = pair.get(0).as_string()?;
937 let bytes = js_sys::Uint8Array::new(&pair.get(1)).to_vec();
938 let name = Path::new(&name).file_name()?.to_owned();
939 let mut path = Path::new(folder).join(&name);
940 let (stem, extension) = (
941 Path::new(&name).file_stem()?.to_string_lossy().into_owned(),
942 Path::new(&name)
943 .extension()
944 .map(|extension| format!(".{}", extension.to_string_lossy()))
945 .unwrap_or_default(),
946 );
947 let mut number = 2;
948 while notebook::fs::metadata(&path).is_ok() {
949 path = Path::new(folder).join(format!("{stem} {number}{extension}"));
950 number += 1;
951 }
952 notebook::fs::write(&path, bytes)
953 .inspect_err(|error| report(error))
954 .ok()?;
955 Some(path)
956 })
957 .collect()
958}
959
960fn send(event: UserEvent) {
961 EVENTS.with_borrow_mut(|events| events.push_back(event));
962 request_frame();
963}
964
965/// Writes every changed file out now, as the page goes away.
966#[wasm_bindgen]
967pub fn flush() {
968 STATE.with(|state| {
969 if let Ok(mut state) = state.try_borrow_mut()
970 && let Some(state) = state.as_mut()
971 && let Err(error) = state.persist()
972 {
973 report(error);
974 }
975 });
976 store();
977}
978
979#[wasm_bindgen]
980pub fn frame() {
981 STATE.with(|state| {
982 let Ok(mut state) = state.try_borrow_mut() else {
983 return request_frame();
984 };
985 let Some(state) = state.as_mut() else {
986 return;
987 };
988 if let Err(error) = turn(state) {
989 report(error);
990 }
991 });
992 for work in LATER.take() {
993 work();
994 }
995}
996
997/// One turn of the loop: the page's input, the events sent to the loop, a frame where one
998/// was asked for, then when to come back.
999fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {
1000 // Input after a chord waits for the frame that runs it and the one that builds what it
1001 // opened, as the palette takes the keys typed next.
1002 let mut inputs = match HOLDING.take() {
1003 true => Vec::new(),
1004 false => INPUT.take(),
1005 }
1006 .into_iter();
1007 while state.commands.is_empty()
1008 && let Some(input) = inputs.next()
1009 {
1010 match input {
1011 Input::Ui(event) => state.input(event),
1012 Input::Pinch(factor, point) => {
1013 state.input(ui::Event::PointerMoved(point));
1014 state.pinch(factor)?;
1015 }
1016 Input::Resize(size, ratio) => {
1017 let ratio = f64::from(ratio);
1018 let changed = host(|host| {
1019 let changed = host.ratio != ratio;
1020 host.size = PhysicalSize::new(size[0].max(1), size[1].max(1));
1021 host.ratio = ratio;
1022 changed
1023 });
1024 if changed {
1025 state.renderer.clear_glyph_cache();
1026 }
1027 state.surface.size = size.map(|side| side.max(1));
1028 state.surface.configure(&state.renderer);
1029 host(|host| host.redraw = true);
1030 }
1031 Input::Focus(focused) => {
1032 state.ui.window_focused = focused;
1033 host(|host| host.focused = focused);
1034 if !focused {
1035 state.publish_now(Duration::ZERO);
1036 }
1037 host(|host| host.redraw = true);
1038 }
1039 }
1040 }
1041 let waiting: Vec<Input> = inputs.collect();
1042 if !waiting.is_empty() {
1043 INPUT.with_borrow_mut(|queue| queue.splice(0..0, waiting).for_each(drop));
1044 HOLDING.set(true);
1045 }
1046 if HOLDING.get() || !INPUT.with_borrow(Vec::is_empty) {
1047 host(|host| host.redraw = true);
1048 request_frame();
1049 }
1050 while let Some(event) = EVENTS.with_borrow_mut(VecDeque::pop_front) {
1051 state.user_event(event);
1052 }
1053 if host(|host| std::mem::take(&mut host.redraw)) {
1054 state.frame()?;
1055 }
1056 park_input(state);
1057 let now = Instant::now();
1058 let (repaint, blink) = state.view.blink(now);
1059 let wake = state.ui.wake_at();
1060 if repaint || wake.is_some_and(|wake| wake <= now) {
1061 state.window.request_redraw();
1062 }
1063 if STORE_DUE.get().is_none() && notebook::fs::changed() {
1064 STORE_DUE.set(Some(now + STORE_AFTER));
1065 }
1066 if STORE_DUE.get().is_some_and(|due| due <= now) {
1067 store();
1068 }
1069 let next = [blink, wake.filter(|wake| *wake > now), STORE_DUE.get()]
1070 .into_iter()
1071 .flatten()
1072 .min();
1073 if let Some(next) = next {
1074 wake_in(next.saturating_duration_since(now).as_secs_f64() * 1e3);
1075 }
1076 Ok(())
1077}
1078
1079/// Parks the text area while the page takes no text, keeping it writable for the
1080/// interface's fields.
1081fn park_input(state: &State) {
1082 if state.ui.focused() != Some(page()) || !state.view.accepts_text() {
1083 place_input(0.0, 0.0, 1.0, state.ui.focused().is_some());
1084 }
1085}
1086
1087/// The `KeyboardEvent.key` value `key` as winit names it.
1088fn logical_key(key: &str) -> Key {
1089 let named = match key {
1090 "Enter" => NamedKey::Enter,
1091 "Tab" => NamedKey::Tab,
1092 " " => NamedKey::Space,
1093 "Backspace" => NamedKey::Backspace,
1094 "Delete" => NamedKey::Delete,
1095 "Escape" => NamedKey::Escape,
1096 "ArrowLeft" => NamedKey::ArrowLeft,
1097 "ArrowRight" => NamedKey::ArrowRight,
1098 "ArrowUp" => NamedKey::ArrowUp,
1099 "ArrowDown" => NamedKey::ArrowDown,
1100 "Home" => NamedKey::Home,
1101 "End" => NamedKey::End,
1102 "PageUp" => NamedKey::PageUp,
1103 "PageDown" => NamedKey::PageDown,
1104 "Insert" => NamedKey::Insert,
1105 "Shift" => NamedKey::Shift,
1106 "Control" => NamedKey::Control,
1107 "Alt" => NamedKey::Alt,
1108 "AltGraph" => NamedKey::AltGraph,
1109 "Meta" => NamedKey::Meta,
1110 "ContextMenu" => NamedKey::ContextMenu,
1111 "F1" => NamedKey::F1,
1112 "F2" => NamedKey::F2,
1113 "F3" => NamedKey::F3,
1114 "F4" => NamedKey::F4,
1115 "F5" => NamedKey::F5,
1116 "F6" => NamedKey::F6,
1117 "F7" => NamedKey::F7,
1118 "F8" => NamedKey::F8,
1119 "F9" => NamedKey::F9,
1120 "F10" => NamedKey::F10,
1121 "F11" => NamedKey::F11,
1122 "F12" => NamedKey::F12,
1123 _ => return Key::Character(key.into()),
1124 };
1125 Key::Named(named)
1126}
crates/snowbound/web/glue.js created+342
......@@ -0,0 +1,342 @@
1// The browser's half of Snowbound (src/web.rs): the page's input, its dialogs and files, and
2// IndexedDB, which keeps the files `notebook::fs` holds between visits.
3
4let wasm;
5let canvas;
6let input;
7let picker;
8let framePending = false;
9let wakeTimer;
10
11const DB = "snowbound";
12// `files` holds every path: `{path, bytes}`, `bytes` null for a folder. `sections` is the first
13// web build's store, whose sections move into a notebook once.
14const FILES = "files";
15const SECTIONS = "sections";
16const MIGRATED = "/Notebooks/Web Notebook";
17
18function database() {
19 return new Promise((resolve, reject) => {
20 const open = indexedDB.open(DB, 2);
21 open.onupgradeneeded = () => {
22 const db = open.result;
23 if (!db.objectStoreNames.contains(FILES)) db.createObjectStore(FILES, { keyPath: "path" });
24 if (!db.objectStoreNames.contains(SECTIONS))
25 db.createObjectStore(SECTIONS, { keyPath: "file" });
26 };
27 open.onsuccess = () => resolve(open.result);
28 open.onerror = () => reject(open.error);
29 });
30}
31
32function done(transaction) {
33 return new Promise((resolve, reject) => {
34 transaction.oncomplete = resolve;
35 transaction.onerror = () => reject(transaction.error);
36 transaction.onabort = () => reject(transaction.error);
37 });
38}
39
40/** Every file kept, as `[path, bytes or null, modified]`, the first web build's sections moved
41 * into a notebook of their own first. */
42export async function loadFiles() {
43 const db = await database();
44 const read = db.transaction([FILES, SECTIONS], "readonly");
45 const files = read.objectStore(FILES).getAll();
46 const sections = read.objectStore(SECTIONS).getAll();
47 await done(read);
48 let rows = files.result;
49 if (!rows.length && sections.result.length) {
50 const moved = [
51 { path: "/Notebooks", bytes: null },
52 { path: MIGRATED, bytes: null },
53 ...sections.result
54 .sort((a, b) => a.order - b.order)
55 .map(({ file, bytes }) => ({ path: `${MIGRATED}/${file}`, bytes, modified: Date.now() })),
56 ];
57 const write = db.transaction([FILES, SECTIONS], "readwrite");
58 for (const row of moved) write.objectStore(FILES).put(row);
59 write.objectStore(SECTIONS).clear();
60 await done(write);
61 rows = moved;
62 }
63 return rows.map(({ path, bytes, modified }) => [path, bytes, modified ?? 0]);
64}
65
66/** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, bytes, modified]` entries. */
67export function storeFiles(changes) {
68 database()
69 .then((db) => {
70 const transaction = db.transaction(FILES, "readwrite");
71 const store = transaction.objectStore(FILES);
72 for (const [path, bytes, modified] of changes) {
73 if (bytes === undefined) store.delete(path);
74 else store.put({ path, bytes, modified });
75 }
76 return done(transaction);
77 })
78 .catch((error) => console.error("Keeping files", error));
79}
80
81export function requestFrame() {
82 if (!framePending) {
83 framePending = true;
84 requestAnimationFrame(() => {
85 framePending = false;
86 wasm.frame();
87 });
88 }
89}
90
91export function wakeIn(ms) {
92 clearTimeout(wakeTimer);
93 wakeTimer = setTimeout(requestFrame, Math.max(0, ms));
94}
95
96export function setCursor(name) {
97 if (canvas.style.cursor !== name) canvas.style.cursor = name;
98}
99
100export function setTitle(title) {
101 document.title = title;
102}
103
104/** Puts the text area at the caret, so an input method's candidates show beside it. */
105export function placeInput(x, y, height, text) {
106 input.style.left = `${x}px`;
107 input.style.top = `${y}px`;
108 input.style.height = `${height}px`;
109 input.readOnly = !text;
110}
111
112export function writeClipboard(text) {
113 navigator.clipboard.writeText(text).catch((error) => console.warn("Copy", error));
114}
115
116export function download(name, bytes, kind) {
117 const url = URL.createObjectURL(new Blob([bytes], { type: kind }));
118 const link = Object.assign(document.createElement("a"), { href: url, download: name });
119 link.click();
120 setTimeout(() => URL.revokeObjectURL(url), 10_000);
121}
122
123/** Asks for files for `purpose` (`open` or `place`), of the extensions `accept` lists. */
124export function pickFiles(purpose, accept) {
125 picker.dataset.purpose = purpose;
126 picker.accept = accept;
127 picker.multiple = purpose === "open";
128 picker.click();
129}
130
131/** The long date and short time a new page's title shows, as OneNote dates one. */
132export function dateText(ms) {
133 const date = new Date(ms);
134 return [
135 date.toLocaleDateString(undefined, {
136 weekday: "long",
137 year: "numeric",
138 month: "long",
139 day: "numeric",
140 }),
141 date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }),
142 ];
143}
144
145export function shortDate(ms) {
146 return new Date(ms).toLocaleDateString();
147}
148
149export function askConfirm(message) {
150 return confirm(message);
151}
152
153export function askText(message, value) {
154 return prompt(message, value) ?? undefined;
155}
156
157export function tell(message) {
158 alert(message);
159}
160
161export function openLink(url) {
162 open(url, "_blank", "noopener");
163}
164
165function modifiers(event) {
166 return (
167 (event.shiftKey ? 1 : 0) |
168 (event.ctrlKey ? 2 : 0) |
169 (event.altKey ? 4 : 0) |
170 (event.metaKey ? 8 : 0)
171 );
172}
173
174function read(list) {
175 return Promise.all(
176 [...list].map(async (file) => [file.name, new Uint8Array(await file.arrayBuffer())]),
177 );
178}
179
180const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i;
181
182/** Wires the page's canvas, text area and file input to `module`'s exports. */
183export function attach(module) {
184 wasm = module;
185 canvas = document.getElementById("page");
186 input = document.getElementById("input");
187 picker = document.getElementById("files");
188 const point = (event) => {
189 const rect = canvas.getBoundingClientRect();
190 return [event.clientX - rect.left, event.clientY - rect.top];
191 };
192 const pressure = (event) => (event.pointerType === "pen" ? event.pressure : NaN);
193
194 // A finger pans the page or, with a second, pinches it; one that doesn't move taps it.
195 const touches = new Map();
196 let gesture = null;
197 const touchMoved = (event) => {
198 const last = touches.get(event.pointerId);
199 const now = point(event);
200 touches.set(event.pointerId, now);
201 if (touches.size === 1) {
202 if (!gesture.panning && Math.hypot(now[0] - gesture.start[0], now[1] - gesture.start[1]) > 8)
203 gesture.panning = true;
204 if (gesture.panning) {
205 wasm.pointer(0, ...now, -1, NaN, 0);
206 wasm.wheel(now[0] - last[0], now[1] - last[1]);
207 }
208 } else if (touches.size === 2) {
209 const [a, b] = [...touches.values()];
210 const distance = Math.hypot(a[0] - b[0], a[1] - b[1]);
211 const centre = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
212 if (gesture.distance) {
213 wasm.pinch(distance / gesture.distance, centre[0], centre[1]);
214 wasm.wheel(centre[0] - gesture.centre[0], centre[1] - gesture.centre[1]);
215 }
216 Object.assign(gesture, { distance, centre, panning: true });
217 }
218 };
219
220 canvas.addEventListener("pointerdown", (event) => {
221 event.preventDefault();
222 input.focus({ preventScroll: true });
223 canvas.setPointerCapture(event.pointerId);
224 if (event.pointerType === "touch") {
225 touches.set(event.pointerId, point(event));
226 if (touches.size === 1) gesture = { start: point(event), panning: false };
227 else gesture.distance = 0;
228 return;
229 }
230 wasm.pointer(0, ...point(event), -1, pressure(event), modifiers(event));
231 wasm.pointer(1, ...point(event), event.button, pressure(event), modifiers(event));
232 });
233 canvas.addEventListener("pointermove", (event) => {
234 if (event.pointerType === "touch") {
235 if (touches.has(event.pointerId)) touchMoved(event);
236 return;
237 }
238 for (const each of event.getCoalescedEvents?.() ?? [event])
239 wasm.pointer(0, ...point(each), -1, pressure(each), modifiers(event));
240 });
241 const up = (event) => {
242 if (event.pointerType === "touch") {
243 if (!touches.delete(event.pointerId)) return;
244 if (touches.size === 0 && !gesture.panning && event.type === "pointerup") {
245 wasm.pointer(0, ...point(event), -1, NaN, 0);
246 wasm.pointer(1, ...point(event), 0, NaN, 0);
247 wasm.pointer(2, ...point(event), 0, NaN, 0);
248 wasm.pointer(3, 0, 0, -1, NaN, 0);
249 }
250 return;
251 }
252 wasm.pointer(2, ...point(event), event.button, pressure(event), modifiers(event));
253 };
254 canvas.addEventListener("pointerup", up);
255 canvas.addEventListener("pointercancel", up);
256 canvas.addEventListener("pointerleave", (event) => {
257 if (event.pointerType !== "touch") wasm.pointer(3, 0, 0, -1, NaN, modifiers(event));
258 });
259 canvas.addEventListener("contextmenu", (event) => event.preventDefault());
260 canvas.addEventListener(
261 "wheel",
262 (event) => {
263 event.preventDefault();
264 const unit = [1, 32, canvas.clientHeight][event.deltaMode];
265 // The page scrolls whatever lies under the pointer, which a wheel alone doesn't move.
266 wasm.pointer(0, ...point(event), -1, NaN, modifiers(event));
267 // Chrome and Safari report a trackpad pinch as a wheel with Control held.
268 if (event.ctrlKey) wasm.pinch(Math.exp(-event.deltaY * unit * 0.005), ...point(event));
269 else wasm.wheel(-event.deltaX * unit, -event.deltaY * unit);
270 },
271 { passive: false },
272 );
273
274 input.addEventListener("keydown", (event) => {
275 if (event.isComposing || ["Process", "Dead", "Unidentified"].includes(event.key)) return;
276 const bits = modifiers(event);
277 if (!wasm.takes(event.key, bits)) return;
278 event.preventDefault();
279 const shortcut = event.ctrlKey || event.metaKey;
280 const text = [...event.key].length === 1 && !shortcut ? event.key : undefined;
281 wasm.key(event.key, text, bits);
282 });
283 input.addEventListener("keyup", (event) => wasm.modifiers(modifiers(event)));
284 input.addEventListener("compositionupdate", (event) => wasm.compose(event.data));
285 input.addEventListener("compositionend", (event) => {
286 wasm.commit(event.data);
287 input.value = "";
288 });
289 // Text arriving outside keys and composition, as from a phone's keyboard or dictation.
290 input.addEventListener("input", (event) => {
291 if (event.isComposing) return;
292 if (event.inputType === "insertText" && event.data) wasm.commit(event.data);
293 input.value = "";
294 });
295 input.addEventListener("paste", async (event) => {
296 event.preventDefault();
297 const data = event.clipboardData;
298 const text = data.getData("text/plain") || undefined;
299 const html = data.getData("text/html") || undefined;
300 wasm.paste(text, html, await read(data.files));
301 });
302 input.addEventListener("focus", () => wasm.focus(true));
303 input.addEventListener("blur", () => wasm.focus(false));
304
305 picker.addEventListener("change", async () => {
306 const files = [...picker.files];
307 const purpose = picker.dataset.purpose ?? (files.every((file) => NOTEBOOK_FILES.test(file.name)) ? "open" : "place");
308 wasm.files(purpose, await read(files));
309 picker.value = "";
310 });
311 document.addEventListener("dragover", (event) => event.preventDefault());
312 document.addEventListener("drop", async (event) => {
313 event.preventDefault();
314 const dropped = [...event.dataTransfer.files];
315 const notebooks = dropped.filter((file) => NOTEBOOK_FILES.test(file.name));
316 const others = dropped.filter((file) => !NOTEBOOK_FILES.test(file.name));
317 if (notebooks.length) wasm.files("open", await read(notebooks));
318 if (others.length) wasm.files("place", await read(others));
319 });
320 addEventListener("pagehide", () => wasm.flush());
321 document.addEventListener("visibilitychange", () => {
322 if (document.visibilityState === "hidden") wasm.flush();
323 });
324 matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (query) =>
325 wasm.appearance_changed(query.matches),
326 );
327
328 const resized = (width, height) => {
329 canvas.width = width;
330 canvas.height = height;
331 wasm.resize(width, height, devicePixelRatio);
332 };
333 // Device pixels from the CSS size: emulated pixel ratios leave devicePixelContentBoxSize
334 // at the CSS size.
335 new ResizeObserver(([entry]) =>
336 resized(
337 Math.round(entry.contentRect.width * devicePixelRatio),
338 Math.round(entry.contentRect.height * devicePixelRatio),
339 ),
340 ).observe(canvas);
341 input.focus({ preventScroll: true });
342}
crates/snowbound/web/index.html created+77
......@@ -0,0 +1,77 @@
1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
6<title>Snowbound</title>
7<style>
8 html, body { margin: 0; height: 100%; overflow: hidden; background: #f3f3f3; }
9 @media (prefers-color-scheme: dark) { html, body { background: #202022; } }
10 #page { display: block; width: 100vw; height: 100vh; height: 100dvh; touch-action: none; outline: none; }
11 /* The text area takes keys and input methods at the caret, unseen. */
12 #input {
13 position: fixed; left: 0; top: 0; width: 1px; height: 16px; padding: 0; border: 0;
14 opacity: 0; resize: none; overflow: hidden; white-space: pre; font-size: 16px;
15 caret-color: transparent; pointer-events: none;
16 }
17 #status {
18 position: fixed; inset: 0; display: grid; place-items: center;
19 font: 15px system-ui, sans-serif; color: #666; pointer-events: none;
20 }
21</style>
22</head>
23<body>
24<canvas id="page"></canvas>
25<textarea id="input" autocapitalize="off" autocomplete="off" spellcheck="false" aria-label="Page"></textarea>
26<input id="files" type="file" hidden>
27<div id="status">Loading Snowbound…</div>
28<script type="module">
29 import init, * as snowbound from "./snowbound_web.js";
30
31 // Open, metric-compatible faces for the fonts OneNote pages use, fetched beside the module.
32 const FONTS = [
33 "Carlito-Regular", "Carlito-Bold", "Carlito-Italic", "Carlito-BoldItalic",
34 "Caladea-Regular", "Caladea-Bold", "Caladea-Italic", "Caladea-BoldItalic",
35 "Arimo", "Arimo-Italic",
36 "Tinos-Regular", "Tinos-Bold", "Tinos-Italic", "Tinos-BoldItalic",
37 "Cousine-Regular", "Cousine-Bold", "Cousine-Italic", "Cousine-BoldItalic",
38 ];
39 const bytes = (url) =>
40 fetch(url).then((response) => {
41 if (!response.ok) throw new Error(`${url}: ${response.status}`);
42 return response.arrayBuffer();
43 });
44 // Servers that don't type .wasm as application/wasm can't stream it.
45 const module = fetch("snowbound_web_bg.wasm").then((response) =>
46 response.headers.get("Content-Type")?.startsWith("application/wasm")
47 ? response
48 : response.arrayBuffer(),
49 );
50 const status = document.getElementById("status");
51 // One tab at a time writes the notebooks this browser keeps; the lock lasts as long as the tab.
52 const owned = await new Promise((resolve) => {
53 if (!navigator.locks) return resolve(true);
54 navigator.locks.request("snowbound", { ifAvailable: true }, (lock) => {
55 resolve(Boolean(lock));
56 return lock && new Promise(() => {});
57 });
58 });
59 if (!owned) {
60 status.textContent = "Snowbound is open in another tab. Close it, then reload this one.";
61 throw new Error("Snowbound is open in another tab");
62 }
63 try {
64 const [, fonts] = await Promise.all([
65 init({ module_or_path: module }),
66 Promise.all(FONTS.map((name) => bytes(`fonts/${name}.ttf`).then((data) => new Uint8Array(data)))),
67 ]);
68 await snowbound.start(snowbound, fonts);
69 status.remove();
70 console.info(`Snowbound loaded in ${Math.round(performance.now())} ms`);
71 } catch (error) {
72 status.textContent = `Snowbound could not start: ${error.message ?? error}`;
73 throw error;
74 }
75</script>
76</body>
77</html>
crates/ui/Cargo.toml+2
......@@ -10,6 +10,8 @@ draw = { path = "../draw", default-features = false, features = ["render"] }
1010nucleo-matcher = "0.3.1"
1111parley.workspace = true
1212winit = "0.30.13"
13# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none.
14web-time = "1.1"
1315
1416[dev-dependencies]
1517accesskit_consumer = "0.39.0"
crates/ui/src/lib.rs+2-1
......@@ -29,9 +29,10 @@ use std::{
2929 hash::{DefaultHasher, Hash, Hasher},
3030 ops::BitOr,
3131 rc::Rc,
32 time::{Duration, Instant},
32 time::Duration,
3333};
3434use text::{Label, Texts};
35use web_time::Instant;
3536use winit::{
3637 event::{Ime, MouseButton},
3738 keyboard::{Key, ModifiersState, NamedKey},
tools/TESTING.md+1
......@@ -19,6 +19,7 @@ another, and the exit status is the result.
1919| `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) |
2020| `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 |
2121| `ios` | `xcodebuild` of the simulator app, unsigned |
22| `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 |
2223| `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` |
2324
2425```sh
tools/canvas/README.md+1-1
......@@ -90,7 +90,7 @@ A paragraph stored as an equation draws in two dimensions from the tree `onestor
9090
9191`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.
9292
93The 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.
93The 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.
9494
9595```sh
9696"target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE
tools/ci.py+20
......@@ -75,6 +75,15 @@ def lanes():
7575 commands=[cross(linux, arch, *cross_clippy), cross(linux, arch, 'build', '-p', 'snowbound')],
7676 environment={'CARGO_TARGET_DIR': str(TARGET / 'linux')},
7777 missing=None if shutil.which('zig') else 'needs zig'))
78 targets = subprocess.run(['rustup', 'target', 'list', '--installed'], capture_output=True, text=True).stdout
79 wasm = web_environment()
80 result.append(dict(
81 name='web', minutes=20, packages=['snowbound'], paths=('crates/snowbound/web/', 'tools/release_web.py'),
82 # Clippy builds it; the module itself is linked by release_web.py.
83 commands=[cargo('clippy', '-p', 'snowbound', '--target', 'wasm32-unknown-unknown', '--', '-D', 'warnings')],
84 environment={**(wasm or {}), 'CARGO_TARGET_DIR': str(TARGET / 'wasm')},
85 missing='needs the wasm32-unknown-unknown target' if 'wasm32-unknown-unknown' not in targets.split()
86 else None if wasm else 'needs nix for a clang that builds for wasm32'))
7887 result.append(dict(
7988 name='ios', minutes=30, packages=['mobile'], paths=('apps/ios/',),
8089 # build-rust.sh builds the Rust half into target/ios.
......@@ -92,6 +101,17 @@ def lanes():
92101 return result
93102
94103
104def web_environment():
105 """A clang and llvm-ar that build SQLite for wasm32, as release_web.py finds them; none
106 where it finds none."""
107 import release_web
108 try:
109 environment = release_web.environment()
110 except SystemExit:
111 return None
112 return {key: environment[key] for key in ('CC_wasm32_unknown_unknown', 'AR_wasm32_unknown_unknown')}
113
114
95115def jj(*args, cwd=ROOT):
96116 return subprocess.run(['jj', *args], cwd=cwd, check=True, capture_output=True, text=True).stdout
97117
tools/release_web.py created+109
......@@ -0,0 +1,109 @@
1#!/usr/bin/env python3
2"""Builds Snowbound for the web (crates/snowbound for wasm32, with crates/snowbound/web) as a
3static folder and publishes it to the share's web/ folder.
4
5 python3 tools/release_web.py --out /tmp/snowbound-web/dist # build only
6 python3 tools/release_web.py --publish # build, then replace web/
7"""
8import argparse
9import os
10from pathlib import Path
11import shutil
12import subprocess
13import sys
14import tempfile
15
16ROOT = Path(__file__).resolve().parents[1]
17# The one target folder wasm builds share, apart from the native builds in target/.
18TARGET = ROOT / 'target/wasm'
19WEB = ROOT / 'crates/snowbound/web'
20FONTS = ROOT / 'crates/canvas/assets/fonts'
21PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web')
22# copyparty lists the folder itself, so the app is at its index.html.
23URL = 'https://file.paperclover.net/shr/snowbound/web/index.html'
24# Size over speed where it costs little: the module is most of the first load.
25PROFILE = ['--config', 'profile.release.lto=true', '--config', 'profile.release.codegen-units=1',
26 '--config', 'profile.release.opt-level="s"']
27
28
29def run(command, **kwargs):
30 print('+', ' '.join(map(str, command)), flush=True)
31 subprocess.run(command, cwd=ROOT, check=True, **kwargs)
32
33
34def tool(name, nix=None, wasm=False):
35 """`name` from PATH or Cargo's bin folder, or else from nixpkgs package `nix`; with `wasm`,
36 from nixpkgs only, as the system's clang may not build for wasm32."""
37 found = None if wasm else shutil.which(name) or shutil.which(name, path=str(Path.home() / '.cargo/bin'))
38 if found:
39 return found
40 if nix and shutil.which('nix'):
41 store = subprocess.check_output(['nix', 'build', f'nixpkgs#{nix}', '--no-link', '--print-out-paths'],
42 text=True).split()[-1]
43 return str(Path(store) / 'bin' / name)
44 sys.exit(f'{name} is missing: cargo install wasm-bindgen-cli at the version Cargo.lock pins, '
45 'and binaryen for wasm-opt')
46
47
48def environment():
49 """Cargo's environment for wasm32: the wasm target folder, and a clang and llvm-ar that
50 build for wasm32, which SQLite (through sqlite-wasm-rs) needs and Apple's clang lacks."""
51 tools = {}
52 for variable, package, name in (('CC_wasm32_unknown_unknown', 'llvmPackages.clang-unwrapped', 'clang'),
53 ('AR_wasm32_unknown_unknown', 'llvmPackages.llvm', 'llvm-ar')):
54 tools[variable] = os.environ.get(variable) or tool(name, package, wasm=True)
55 return {**os.environ, 'CARGO_TARGET_DIR': str(TARGET), **tools}
56
57
58def build(out):
59 """Writes index.html, the module, its JavaScript and the fonts to `out`."""
60 run(['cargo', 'build', '--locked', '-p', 'snowbound', '--release', '--target', 'wasm32-unknown-unknown',
61 *PROFILE], env=environment())
62 if out.exists():
63 shutil.rmtree(out)
64 out.mkdir(parents=True)
65 module = TARGET / 'wasm32-unknown-unknown/release/snowbound.wasm'
66 run([tool('wasm-bindgen'), '--target', 'web', '--no-typescript', '--out-name', 'snowbound_web',
67 '--out-dir', out, module])
68 bound = out / 'snowbound_web_bg.wasm'
69 run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound])
70 shutil.copy(WEB / 'index.html', out)
71 (out / 'fonts').mkdir()
72 for font in sorted(FONTS.glob('*')):
73 if font.suffix in ('.ttf', '.txt'):
74 shutil.copy(font, out / 'fonts')
75 for path in sorted(out.rglob('*')):
76 if path.is_file():
77 print(f'{path.stat().st_size:>12,} {path.relative_to(out)}')
78
79
80def publish(built):
81 """Replaces the share's web/ folder with `built`, leaving every other folder alone."""
82 if not PUBLISHED.parent.is_dir():
83 sys.exit(f'{PUBLISHED.parent} is not mounted')
84 staging = PUBLISHED.with_name('.web-staging')
85 if staging.exists():
86 shutil.rmtree(staging)
87 shutil.copytree(built, staging)
88 if PUBLISHED.exists():
89 shutil.rmtree(PUBLISHED)
90 staging.rename(PUBLISHED)
91 print(f'Published {URL}')
92
93
94def main():
95 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
96 parser.add_argument('--out', type=Path, help='Build into this folder (default: a temporary one)')
97 parser.add_argument('--publish', action='store_true', help=f'Copy the build to {PUBLISHED}')
98 args = parser.parse_args()
99 if not args.out and not args.publish:
100 parser.error('give --out, --publish or both')
101 with tempfile.TemporaryDirectory() as scratch:
102 out = args.out or Path(scratch) / 'web'
103 build(out)
104 if args.publish:
105 publish(out)
106
107
108if __name__ == '__main__':
109 main()