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 = [...@@ -664,6 +664,7 @@ dependencies = [
664 "serde_json",664 "serde_json",
665 "sha2",665 "sha2",
666 "skrifa",666 "skrifa",
667 "web-time",
667 "wgpu",668 "wgpu",
668]669]
669670
...@@ -1010,6 +1011,7 @@ dependencies = [...@@ -1010,6 +1011,7 @@ dependencies = [
1010 "roxmltree",1011 "roxmltree",
1011 "subsetter",1012 "subsetter",
1012 "swash",1013 "swash",
1014 "web-time",
1013 "wgpu",1015 "wgpu",
1014 "windows",1016 "windows",
1015]1017]
...@@ -1316,9 +1318,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -1316,9 +1318,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1316checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"1318checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
1317dependencies = [1319dependencies = [
1318 "cfg-if",1320 "cfg-if",
1321 "js-sys",
1319 "libc",1322 "libc",
1320 "r-efi 6.0.0",1323 "r-efi 6.0.0",
1321 "rand_core 0.10.1",1324 "rand_core 0.10.1",
1325 "wasm-bindgen",
1322]1326]
13231327
1324[[package]]1328[[package]]
...@@ -2079,9 +2083,11 @@ dependencies = [...@@ -2079,9 +2083,11 @@ dependencies = [
2079 "cab",2083 "cab",
2080 "getrandom 0.4.3",2084 "getrandom 0.4.3",
2081 "hmac",2085 "hmac",
2086 "js-sys",
2082 "libc",2087 "libc",
2083 "nix",2088 "nix",
2084 "onestore",2089 "onestore",
2090 "rsqlite-vfs",
2085 "rusqlite",2091 "rusqlite",
2086 "serde",2092 "serde",
2087 "serde_json",2093 "serde_json",
...@@ -2090,6 +2096,9 @@ dependencies = [...@@ -2090,6 +2096,9 @@ dependencies = [
2090 "tempfile",2096 "tempfile",
2091 "thiserror 2.0.20",2097 "thiserror 2.0.20",
2092 "tokio",2098 "tokio",
2099 "wasm-bindgen",
2100 "wasm-bindgen-futures",
2101 "web-time",
2093 "windows-sys 0.61.2",2102 "windows-sys 0.61.2",
2094 "zeroize",2103 "zeroize",
2095]2104]
...@@ -2471,6 +2480,7 @@ dependencies = [...@@ -2471,6 +2480,7 @@ dependencies = [
2471 "serde_json",2480 "serde_json",
2472 "sha1",2481 "sha1",
2473 "subtle",2482 "subtle",
2483 "web-time",
2474 "zeroize",2484 "zeroize",
2475]2485]
24762486
...@@ -3500,7 +3510,9 @@ dependencies = [...@@ -3500,7 +3510,9 @@ dependencies = [
3500 "canvas",3510 "canvas",
3501 "draw",3511 "draw",
3502 "fontique",3512 "fontique",
3513 "getrandom 0.4.3",
3503 "image",3514 "image",
3515 "js-sys",
3504 "libc",3516 "libc",
3505 "notebook",3517 "notebook",
3506 "objc2 0.5.2",3518 "objc2 0.5.2",
...@@ -3519,9 +3531,13 @@ dependencies = [...@@ -3519,9 +3531,13 @@ dependencies = [
3519 "ui",3531 "ui",
3520 "unicode_names2",3532 "unicode_names2",
3521 "ureq",3533 "ureq",
3534 "wasm-bindgen",
3535 "wasm-bindgen-futures",
3522 "wayland-backend",3536 "wayland-backend",
3523 "wayland-client",3537 "wayland-client",
3524 "wayland-protocols",3538 "wayland-protocols",
3539 "web-sys",
3540 "web-time",
3525 "wgpu",3541 "wgpu",
3526 "windows-sys 0.61.2",3542 "windows-sys 0.61.2",
3527 "winit",3543 "winit",
...@@ -3923,6 +3939,7 @@ dependencies = [...@@ -3923,6 +3939,7 @@ dependencies = [
3923 "parley",3939 "parley",
3924 "png",3940 "png",
3925 "pollster",3941 "pollster",
3942 "web-time",
3926 "wgpu",3943 "wgpu",
3927 "winit",3944 "winit",
3928]3945]
...@@ -4377,6 +4394,7 @@ dependencies = [...@@ -4377,6 +4394,7 @@ dependencies = [
4377 "thiserror 2.0.20",4394 "thiserror 2.0.20",
4378 "wgpu-core-deps-apple",4395 "wgpu-core-deps-apple",
4379 "wgpu-core-deps-emscripten",4396 "wgpu-core-deps-emscripten",
4397 "wgpu-core-deps-wasm",
4380 "wgpu-core-deps-windows-linux-android",4398 "wgpu-core-deps-windows-linux-android",
4381 "wgpu-hal",4399 "wgpu-hal",
4382 "wgpu-naga-bridge",4400 "wgpu-naga-bridge",
...@@ -4401,6 +4419,15 @@ dependencies = [...@@ -4401,6 +4419,15 @@ dependencies = [
4401 "wgpu-hal",4419 "wgpu-hal",
4402]4420]
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
4404[[package]]4431[[package]]
4405name = "wgpu-core-deps-windows-linux-android"4432name = "wgpu-core-deps-windows-linux-android"
4406version = "30.0.1"4433version = "30.0.1"
arc/platforms.md+18
...@@ -146,6 +146,24 @@ keyboard, the toolbar and the macOS menu bar all run commands from it....@@ -146,6 +146,24 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
146 Windows has no menu bar: the toolbar and the command palette run the146 Windows has no menu bar: the toolbar and the command palette run the
147 command table, with OneNote 2010's Ctrl chords.147 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
149## iOS: native around the canvas167## iOS: native around the canvas
150168
151On iOS the split moves. Touch text editing depends on affordances that users169On 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"] }...@@ -19,6 +19,8 @@ serde = { version = "1.0.229", features = ["derive"] }
19serde_json = "1.0.151"19serde_json = "1.0.151"
20icu_normalizer = "2.3"20icu_normalizer = "2.3"
21skrifa = "0.44.0"21skrifa = "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
23accesskit = { version = "0.25.0", optional = true }25accesskit = { version = "0.25.0", optional = true }
24color_quant = { version = "1.1", optional = true }26color_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 {...@@ -565,8 +565,8 @@ fn followed(format: &Format, old: &Format, new: &Format) -> Format {
565565
566/// Seconds since 1980, as note tags date themselves.566/// Seconds since 1980, as note tags date themselves.
567fn time32() -> Option<u32> {567fn time32() -> Option<u32> {
568 let now = std::time::SystemTime::now()568 let now = web_time::SystemTime::now()
569 .duration_since(std::time::UNIX_EPOCH)569 .duration_since(web_time::UNIX_EPOCH)
570 .ok()?570 .ok()?
571 .as_secs();571 .as_secs();
572 u32::try_from(now.checked_sub(315_532_800)?).ok()572 u32::try_from(now.checked_sub(315_532_800)?).ok()
crates/canvas/src/editor/recording.rs+1-1
...@@ -5,7 +5,7 @@...@@ -5,7 +5,7 @@
55
6use super::*;6use super::*;
7use onestore::page::{Attachment, MediaIndex};7use onestore::page::{Attachment, MediaIndex};
8use std::time::Instant;8use web_time::Instant;
99
10/// A recording in progress.10/// A recording in progress.
11pub(super) struct Live {11pub(super) struct Live {
crates/canvas/src/gpu/page.rs+17-2
...@@ -16,10 +16,11 @@ use onestore::page::Page;...@@ -16,10 +16,11 @@ use onestore::page::Page;
16use picture::Picture;16use picture::Picture;
17use std::{17use std::{
18 fmt,18 fmt,
19 sync::{Arc, Mutex, OnceLock, mpsc},19 sync::{Arc, Mutex},
20 task::Waker,20 task::Waker,
21 time::{Duration, Instant},21 time::Duration,
22};22};
23use web_time::Instant;
2324
24/// Retained drawing data in the source page's coordinate system.25/// Retained drawing data in the source page's coordinate system.
25pub struct PageScene {26pub struct PageScene {
...@@ -40,11 +41,13 @@ type Slot<T> = Arc<Mutex<Option<T>>>;...@@ -40,11 +41,13 @@ type Slot<T> = Arc<Mutex<Option<T>>>;
4041
41/// Runs `work` on the scenes' one raster thread once `start` has passed, unless the slot42/// Runs `work` on the scenes' one raster thread once `start` has passed, unless the slot
42/// returned has been dropped by then; the result lands in the slot and wakes `waker`.43/// returned has been dropped by then; the result lands in the slot and wakes `waker`.
44#[cfg(not(target_arch = "wasm32"))]
43fn queue<T: Send + 'static>(45fn queue<T: Send + 'static>(
44 start: Instant,46 start: Instant,
45 waker: &Waker,47 waker: &Waker,
46 work: impl FnOnce() -> T + Send + 'static,48 work: impl FnOnce() -> T + Send + 'static,
47) -> Slot<T> {49) -> Slot<T> {
50 use std::sync::{OnceLock, mpsc};
48 static WORKER: OnceLock<mpsc::Sender<Box<dyn FnOnce() + Send>>> = OnceLock::new();51 static WORKER: OnceLock<mpsc::Sender<Box<dyn FnOnce() + Send>>> = OnceLock::new();
49 let slot = Slot::default();52 let slot = Slot::default();
50 let requester = Arc::downgrade(&slot);53 let requester = Arc::downgrade(&slot);
...@@ -69,6 +72,17 @@ fn queue<T: Send + 'static>(...@@ -69,6 +72,17 @@ fn queue<T: Send + 'static>(
69 slot72 slot
70}73}
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
72/// Device pixels per point a raster is made at for `scale`: the next half power of two,86/// Device pixels per point a raster is made at for `scale`: the next half power of two,
73/// so zooming makes rasters again in steps.87/// so zooming makes rasters again in steps.
74fn density(scale: f32) -> f32 {88fn density(scale: f32) -> f32 {
...@@ -793,6 +807,7 @@ impl PageScene {...@@ -793,6 +807,7 @@ impl PageScene {
793 /// Where the page read without an editor lies, in page coordinates: the rectangle all807 /// Where the page read without an editor lies, in page coordinates: the rectangle all
794 /// its content covers, and the top and bottom of each line of text and each picture, which808 /// its content covers, and the top and bottom of each line of text and each picture, which
795 /// a sheet of paper does not cut through where it can help it.809 /// a sheet of paper does not cut through where it can help it.
810 #[cfg(feature = "pdf")]
796 pub(crate) fn printed_extent(&self) -> Result<([f32; 4], Vec<[f32; 2]>), SceneError> {811 pub(crate) fn printed_extent(&self) -> Result<([f32; 4], Vec<[f32; 2]>), SceneError> {
797 let mut bounds = [812 let mut bounds = [
798 f32::INFINITY,813 f32::INFINITY,
crates/canvas/src/gpu/page/background.rs+2-4
...@@ -6,10 +6,8 @@ use crate::gpu::Paper;...@@ -6,10 +6,8 @@ use crate::gpu::Paper;
6use draw::RasterImage;6use draw::RasterImage;
7use resvg::{tiny_skia, usvg};7use resvg::{tiny_skia, usvg};
8use sha2::{Digest, Sha256};8use sha2::{Digest, Sha256};
9use std::{9use std::{task::Waker, time::Duration};
10 task::Waker,10use web_time::Instant;
11 time::{Duration, Instant},
12};
1311
14/// One of OneNote 2010's page templates: its art recreated for white paper in `#rrggbb`12/// One of OneNote 2010's page templates: its art recreated for white paper in `#rrggbb`
15/// colours over a view box the size in pixels of the picture OneNote stores, that picture's13/// 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 @@...@@ -4,12 +4,8 @@
4use super::{SETTLE, Slot, density, queue};4use super::{SETTLE, Slot, density, queue};
5use draw::RasterImage;5use draw::RasterImage;
6use onestore::ExGuid;6use onestore::ExGuid;
7use std::{7use std::{collections::BTreeMap, sync::Arc, task::Waker, time::Duration};
8 collections::BTreeMap,8use web_time::Instant;
9 sync::Arc,
10 task::Waker,
11 time::{Duration, Instant},
12};
139
14/// Decoded bytes a scene's pictures keep at once. Pictures in view shrink to fit it, and10/// Decoded bytes a scene's pictures keep at once. Pictures in view shrink to fit it, and
15/// the rest of the renderer's frame budget stays for template art and the interface.11/// 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::{...@@ -26,10 +26,8 @@ use draw::{
26 Primitive, Stroke,26 Primitive, Stroke,
27 edit::{self, Clicks, Command, Key, Modifiers, Movement, NamedKey, SelectionUnit},27 edit::{self, Clicks, Command, Key, Modifiers, Movement, NamedKey, SelectionUnit},
28};28};
29use std::{29use std::{error::Error, time::Duration};
30 error::Error,30use web_time::Instant;
31 time::{Duration, Instant},
32};
3331
34const HANDLE_HEIGHT: f32 = 6.75;32const HANDLE_HEIGHT: f32 = 6.75;
35/// Accessible names of the page date's fields.33/// Accessible names of the page date's fields.
crates/canvas/src/layout.rs+13-4
...@@ -96,7 +96,10 @@ impl fmt::Display for LayoutError {...@@ -96,7 +96,10 @@ impl fmt::Display for LayoutError {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 match self {97 match self {
98 Self::UnsupportedSubstituteFont => {98 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 )
100 }103 }
101 _ => write!(f, "Text layout failed: {self:?}"),104 _ => write!(f, "Text layout failed: {self:?}"),
102 }105 }
...@@ -110,7 +113,9 @@ pub(crate) const DEFAULT_FONT: &str = "Arial";...@@ -110,7 +113,9 @@ pub(crate) const DEFAULT_FONT: &str = "Arial";
110pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0;113pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0;
111114
112/// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font115/// 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"))]
114const BUNDLED: [(&str, &[&[u8]]); 4] = [119const BUNDLED: [(&str, &[&[u8]]); 4] = [
115 (120 (
116 "Calibri",121 "Calibri",
...@@ -151,6 +156,7 @@ const BUNDLED: [(&str, &[&[u8]]); 4] = [...@@ -151,6 +156,7 @@ const BUNDLED: [(&str, &[&[u8]]); 4] = [
151impl Default for TextEngine {156impl Default for TextEngine {
152 /// An engine that lays out each bundled family in its substitute where it is missing.157 /// An engine that lays out each bundled family in its substitute where it is missing.
153 fn default() -> Self {158 fn default() -> Self {
159 #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
154 let mut engine = Self {160 let mut engine = Self {
155 // Clones share loaded font files, so glyphs one lays out draw from the same cache.161 // Clones share loaded font files, so glyphs one lays out draw from the same cache.
156 fonts: FontContext {162 fonts: FontContext {
...@@ -161,6 +167,7 @@ impl Default for TextEngine {...@@ -161,6 +167,7 @@ impl Default for TextEngine {
161 arial_substitutes: BTreeSet::new(),167 arial_substitutes: BTreeSet::new(),
162 substitutes: BTreeMap::new(),168 substitutes: BTreeMap::new(),
163 };169 };
170 #[cfg(not(target_arch = "wasm32"))]
164 for (family, faces) in BUNDLED {171 for (family, faces) in BUNDLED {
165 if engine.fonts.collection.family_id(family).is_none() {172 if engine.fonts.collection.family_id(family).is_none() {
166 for face in faces {173 for face in faces {
...@@ -187,8 +194,9 @@ impl Clone for TextEngine {...@@ -187,8 +194,9 @@ impl Clone for TextEngine {
187}194}
188195
189impl TextEngine {196impl TextEngine {
190 /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as197 /// Register Arimo as Arial, Carlito as Calibri, Caladea as Cambria, Tinos as Times New
191 /// Courier New before creating layouts, in place of the bundled substitute.198 /// Roman or Cousine as Courier New before creating layouts, in place of the bundled
199 /// substitute.
192 pub fn register_substitute(&mut self, data: Blob<u8>) -> Result<&'static str, LayoutError> {200 pub fn register_substitute(&mut self, data: Blob<u8>) -> Result<&'static str, LayoutError> {
193 self.register(data, false)201 self.register(data, false)
194 }202 }
...@@ -208,6 +216,7 @@ impl TextEngine {...@@ -208,6 +216,7 @@ impl TextEngine {
208 let (substitute, target) = match family.as_str() {216 let (substitute, target) = match family.as_str() {
209 "Arimo" => ("Arimo", "Arial"),217 "Arimo" => ("Arimo", "Arial"),
210 "Carlito" => ("Carlito", "Calibri"),218 "Carlito" => ("Carlito", "Calibri"),
219 "Caladea" => ("Caladea", "Cambria"),
211 "Tinos" => ("Tinos", "Times New Roman"),220 "Tinos" => ("Tinos", "Times New Roman"),
212 "Cousine" => ("Cousine", "Courier New"),221 "Cousine" => ("Cousine", "Courier New"),
213 _ => return Err(LayoutError::UnsupportedSubstituteFont),222 _ => return Err(LayoutError::UnsupportedSubstituteFont),
crates/draw/Cargo.toml+2
...@@ -16,6 +16,8 @@ pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"]...@@ -16,6 +16,8 @@ pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"]
1616
17[dependencies]17[dependencies]
18parley.workspace = true18parley.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
20base64 = { version = "0.22.1", optional = true }22base64 = { version = "0.22.1", optional = true }
21bytemuck = { version = "1.25", features = ["derive"], optional = true }23bytemuck = { version = "1.25", features = ["derive"], optional = true }
crates/draw/src/edit.rs+2-4
...@@ -5,10 +5,8 @@ use parley::{...@@ -5,10 +5,8 @@ use parley::{
5 Affinity, Brush, Layout,5 Affinity, Brush, Layout,
6 editing::{Cursor, Selection},6 editing::{Cursor, Selection},
7};7};
8use std::{8use std::{collections::BTreeSet, time::Duration};
9 collections::BTreeSet,9use web_time::Instant;
10 time::{Duration, Instant},
11};
1210
13#[derive(Clone, Debug, PartialEq)]11#[derive(Clone, Debug, PartialEq)]
14pub enum Key {12pub enum Key {
crates/notebook/Cargo.toml+11
...@@ -25,6 +25,17 @@ getrandom = "0.4.3"...@@ -25,6 +25,17 @@ getrandom = "0.4.3"
25hmac = "0.13.0"25hmac = "0.13.0"
26# OneNote packages (.onepkg) are cabinet files.26# OneNote packages (.onepkg) are cabinet files.
27cab = "0.6"27cab = "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
29[target.'cfg(target_vendor = "apple")'.dependencies]40[target.'cfg(target_vendor = "apple")'.dependencies]
30nix = { version = "0.31", default-features = false, features = ["fs"] }41nix = { version = "0.31", default-features = false, features = ["fs"] }
crates/notebook/src/background.rs+146-150
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
2//! keeps every section of an open notebook: queued edits publish and other clients' changes2//! keeps every section of an open notebook: queued edits publish and other clients' changes
3//! are noticed without the section being open.3//! are noticed without the section being open.
44
5use crate::fs;
5use crate::{6use crate::{
6 EditStatus, Error, Remote, Replica, Result,7 EditStatus, Error, Remote, Replica, Result,
7 discover::{Entry, Listed},8 discover::{Entry, Listed},
...@@ -17,9 +18,9 @@ use std::{...@@ -17,9 +18,9 @@ use std::{
17 Arc, Mutex, Weak,18 Arc, Mutex, Weak,
18 atomic::{AtomicBool, Ordering},19 atomic::{AtomicBool, Ordering},
19 },20 },
20 thread,21 time::Duration,
21 time::{Duration, Instant},
22};22};
23use web_time::Instant;
2324
24/// How long a section that could not be reached waits to be tried again; OneNote 201025/// How long a section that could not be reached waits to be tried again; OneNote 2010
25/// retries a failing section about every 31 seconds.26/// retries a failing section about every 31 seconds.
...@@ -137,168 +138,163 @@ impl Background {...@@ -137,168 +138,163 @@ impl Background {
137 });138 });
138 let weak = Arc::downgrade(&shared);139 let weak = Arc::downgrade(&shared);
139 let owner = Arc::clone(&shared);140 let owner = Arc::clone(&shared);
140 thread::Builder::new()141 crate::task::spawn("onestore-background", move || async move {
141 .name("onestore-background".into())142 let signal = &owner.signal;
142 .spawn(move || {143 let mut bound: Option<(B, L)> = None;
143 let signal = &owner.signal;144 let mut news = false;
144 let mut bound: Option<(B, L)> = None;145 while !signal.stopped.load(Ordering::Acquire) {
145 let mut news = false;146 if signal.offline.load(Ordering::Acquire)
146 while !signal.stopped.load(Ordering::Acquire) {147 && !signal.requested.load(Ordering::Acquire)
147 if signal.offline.load(Ordering::Acquire)148 {
148 && !signal.requested.load(Ordering::Acquire)149 // Working online again lists every folder again.
149 {150 if let Ok(mut watched) = owner.watched.lock() {
150 // Working online again lists every folder again.151 watched.watching(false);
151 if let Ok(mut watched) = owner.watched.lock() {152 watched.lost = true;
152 watched.watching(false);153 }
153 watched.lost = true;154 bound = None;
154 }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) {
155 bound = None;164 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;
157 continue;179 continue;
158 }180 }
159 let now = Instant::now();181 Next::Connect(connection) => {
160 let next = {182 let reports = Reports {
183 shared: weak.clone(),
184 connection: connection + 1,
185 };
186 let connected = connect(reports);
161 let Ok(mut watched) = owner.watched.lock() else {187 let Ok(mut watched) = owner.watched.lock() else {
162 return;188 return;
163 };189 };
164 if std::mem::take(&mut watched.lost) {190 // Whatever ended before now ended with the last connection.
165 bound = None;191 watched.connection += 1;
166 watched.watching(false);192 watched.lost = false;
167 for watch in &mut watched.sections {193 match connected {
168 watch.due = now;194 Ok((mut files, reported)) => {
169 }195 watched.watching(reported);
170 }196 let folders = watched.rescanning();
171 watched.next(now, bound.is_some())197 drop(watched);
172 };198 let listings = list(&mut files.1, folders);
173 let (path, replica, seen, current, image) = match next {199 let Ok(mut watched) = owner.watched.lock() else {
174 Next::Wait(wait) => {200 return;
175 signal.requested.store(false, Ordering::Release);201 };
176 if std::mem::take(&mut news) {202 watched.listed(&listings, Some(copies), Instant::now());
177 notify();203 bound = Some(files);
178 }204 }
179 let _ = match wait {205 Err(error) => {
180 Some(wait) => receiver.recv_timeout(wait).ok(),206 let error = Error::RemoteIo(error);
181 None => receiver.recv().ok(),207 let retry = now + RETRY;
182 };208 for watch in &mut watched.sections {
183 continue;209 news |= watch.fail(&error, None);
184 }210 watch.due = watch.due.max(retry);
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));
217 }211 }
212 watched.relist = watched.relist.map(|due| due.max(retry));
218 }213 }
219 continue;
220 }214 }
221 Next::List(folders) => {215 continue;
222 let Some((_, list_folder)) = &mut bound else {216 }
223 continue;217 Next::List(folders) => {
224 };218 let Some((_, list_folder)) = &mut bound else {
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());
230 continue;219 continue;
231 }220 };
232 Next::Check {221 let listings = list(list_folder, folders);
233 path,222 let Ok(mut watched) = owner.watched.lock() else {
234 replica,223 return;
235 seen,224 };
236 current,225 watched.listed(&listings, None, Instant::now());
237 image,
238 } => (path, replica, seen, current, image),
239 };
240 let Some((bind, _)) = &mut bound else {
241 continue;226 continue;
242 };227 }
243 let (queued, outcome) = step(228 Next::Check {
244 &mut bind(&path),229 path,
245 replica.as_deref(),230 replica,
246 seen.as_deref(),231 seen,
247 current,232 current,
248 image,233 image,
249 copies,234 } => (path, replica, seen, current, image),
250 );235 };
251 if outcome.as_ref().is_err_and(disconnected) {236 let Some((bind, _)) = &mut bound else {
252 bound = None;237 continue;
253 }238 };
254 let Ok(mut watched) = owner.watched.lock() else {239 let (queued, outcome) = step(
255 return;240 &mut bind(&path),
256 };241 replica.as_deref(),
257 let interval = watched.interval();242 seen.as_deref(),
258 let Watched {243 current,
259 sections, changed, ..244 image,
260 } = &mut *watched;245 copies,
261 let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else {246 );
262 continue;247 if outcome.as_ref().is_err_and(disconnected) {
263 };248 bound = None;
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 };
288 }249 }
289 if let Ok(mut watched) = owner.watched.lock() {250 let Ok(mut watched) = owner.watched.lock() else {
290 watched.watching(false);251 return;
291 if owner.discard.load(Ordering::Acquire) {252 };
292 for replica in watched253 let interval = watched.interval();
293 .sections254 let Watched {
294 .iter()255 sections, changed, ..
295 .filter_map(|watch| watch.replica.as_ref())256 } = &mut *watched;
296 {257 let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else {
297 discard(replica);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 };
298 }276 }
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);
299 }294 }
300 }295 }
301 })?;296 }
297 })?;
302 Ok(Self(shared))298 Ok(Self(shared))
303 }299 }
304300
...@@ -668,7 +664,7 @@ impl Watched {...@@ -668,7 +664,7 @@ impl Watched {
668 && watch664 && watch
669 .replica665 .replica
670 .as_ref()666 .as_ref()
671 .is_some_and(|replica| !replica.exists());667 .is_some_and(|replica| fs::metadata(replica).is_err());
672 watch.due = if read {668 watch.due = if read {
673 staggered += 1;669 staggered += 1;
674 now + STAGGER * (staggered - 1)670 now + STAGGER * (staggered - 1)
...@@ -750,7 +746,7 @@ fn discard(replica: &Path) {...@@ -750,7 +746,7 @@ fn discard(replica: &Path) {
750 for suffix in ["-wal", "-shm", ""] {746 for suffix in ["-wal", "-shm", ""] {
751 let mut file = replica.as_os_str().to_owned();747 let mut file = replica.as_os_str().to_owned();
752 file.push(suffix);748 file.push(suffix);
753 let _ = std::fs::remove_file(file);749 let _ = fs::remove_file(file);
754 }750 }
755 }751 }
756}752}
...@@ -833,14 +829,14 @@ fn step<R: Remote>(...@@ -833,14 +829,14 @@ fn step<R: Remote>(
833 let Some(replica) = replica else {829 let Some(replica) = replica else {
834 return (Some(0), Ok((stamp, moved)));830 return (Some(0), Ok((stamp, moved)));
835 };831 };
836 if !replica.exists() {832 if fs::metadata(replica).is_err() {
837 if !copies {833 if !copies {
838 return (Some(0), Ok((stamp, moved)));834 return (Some(0), Ok((stamp, moved)));
839 }835 }
840 let copied = (|| {836 let copied = (|| {
841 let image = remote.read().map_err(Error::RemoteIo)?;837 let image = remote.read().map_err(Error::RemoteIo)?;
842 if let Some(folder) = replica.parent() {838 if let Some(folder) = replica.parent() {
843 std::fs::create_dir_all(folder)?;839 fs::create_dir_all(folder)?;
844 }840 }
845 Replica::seed(replica, &image, None)?;841 Replica::seed(replica, &image, None)?;
846 Ok(Stamp::of(&image)?)842 Ok(Stamp::of(&image)?)
crates/notebook/src/bin/onestore-diagnostic.rs+4-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1use notebook::fs;
1use onestore::{2use onestore::{
2 Arena, ExGuid, Section, TextAttribute,3 Arena, ExGuid, Section, TextAttribute,
3 document::{Format, Layout},4 document::{Format, Layout},
...@@ -9,7 +10,7 @@ use onestore::{...@@ -9,7 +10,7 @@ use onestore::{
9use serde::Deserialize;10use serde::Deserialize;
10use serde_json::{Value, json};11use serde_json::{Value, json};
11use std::{12use std::{
12 env, fs,13 env,
13 io::{self, Write},14 io::{self, Write},
14};15};
1516
...@@ -96,7 +97,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>>...@@ -96,7 +97,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>>
96 return Err("Usage: onestore-diagnostic catalog ROOT | snapshot FILE NEW_SNAPSHOT | check SNAPSHOT - | commit FILE SNAPSHOT; edits arrive as JSON on stdin".into());97 return Err("Usage: onestore-diagnostic catalog ROOT | snapshot FILE NEW_SNAPSHOT | check SNAPSHOT - | commit FILE SNAPSHOT; edits arrive as JSON on stdin".into());
97 }98 }
98 if args[0] == "snapshot" {99 if args[0] == "snapshot" {
99 let bytes = onestore::read_file(&args[1])?;100 let bytes = fs::read_file(&args[1])?;
100 let mut file = fs::OpenOptions::new()101 let mut file = fs::OpenOptions::new()
101 .write(true)102 .write(true)
102 .create_new(true)103 .create_new(true)
...@@ -199,7 +200,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>>...@@ -199,7 +200,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>>
199 let Some(transaction) = transaction else {200 let Some(transaction) = transaction else {
200 return Ok(json!({"ok": true, "state": "Unchanged"}));201 return Ok(json!({"ok": true, "state": "Unchanged"}));
201 };202 };
202 Ok(match transaction.commit_file(&args[1]) {203 Ok(match fs::commit_file(&transaction, &args[1]) {
203 Ok(()) => json!({"ok": true, "state": "Committed"}),204 Ok(()) => json!({"ok": true, "state": "Committed"}),
204 Err(error) => json!({"ok": false, "state": format!("{:?}", error.state),205 Err(error) => json!({"ok": false, "state": format!("{:?}", error.state),
205 "kind": format!("{:?}", error.error.kind()), "error": error.error.to_string()}),206 "kind": format!("{:?}", error.error.kind()), "error": error.error.to_string()}),
crates/notebook/src/discover/source.rs+8-7
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1use super::{Entry, EntryKind, Listed, Source, component};1use super::{Entry, EntryKind, Listed, Source, component};
2use crate::fs;
2use std::{3use std::{
3 collections::BTreeSet,4 collections::BTreeSet,
4 io,5 io,
...@@ -14,7 +15,7 @@ fn evicted(name: &str) -> Option<&str> {...@@ -14,7 +15,7 @@ fn evicted(name: &str) -> Option<&str> {
1415
15/// Whether the system keeps the file's contents elsewhere until it is read, as macOS 14 and16/// Whether the system keeps the file's contents elsewhere until it is read, as macOS 14 and
16/// later list a file iCloud Drive evicted (`SF_DATALESS`).17/// later list a file iCloud Drive evicted (`SF_DATALESS`).
17fn dataless(metadata: &std::fs::Metadata) -> bool {18fn dataless(metadata: &fs::Metadata) -> bool {
18 const SF_DATALESS: u32 = 0x4000_0000;19 const SF_DATALESS: u32 = 0x4000_0000;
19 #[cfg(target_os = "macos")]20 #[cfg(target_os = "macos")]
20 let flags = std::os::macos::fs::MetadataExt::st_flags(metadata);21 let flags = std::os::macos::fs::MetadataExt::st_flags(metadata);
...@@ -41,8 +42,8 @@ pub struct Local {...@@ -41,8 +42,8 @@ pub struct Local {
4142
42impl Local {43impl Local {
43 pub fn open(root: impl AsRef<Path>) -> io::Result<Self> {44 pub fn open(root: impl AsRef<Path>) -> io::Result<Self> {
44 let root = root.as_ref().canonicalize()?;45 let root = fs::canonicalize(root)?;
45 if !root.is_dir() {46 if !fs::metadata(&root)?.is_dir() {
46 return Err(io::ErrorKind::NotADirectory.into());47 return Err(io::ErrorKind::NotADirectory.into());
47 }48 }
48 Ok(Self { root })49 Ok(Self { root })
...@@ -52,7 +53,7 @@ impl Local {...@@ -52,7 +53,7 @@ impl Local {
52 if !relative.is_empty() && !relative.split('/').all(component) {53 if !relative.is_empty() && !relative.split('/').all(component) {
53 return Err(io::ErrorKind::InvalidInput.into());54 return Err(io::ErrorKind::InvalidInput.into());
54 }55 }
55 let path = self.root.join(relative).canonicalize()?;56 let path = fs::canonicalize(self.root.join(relative))?;
56 if !path.starts_with(&self.root) {57 if !path.starts_with(&self.root) {
57 return Err(io::ErrorKind::PermissionDenied.into());58 return Err(io::ErrorKind::PermissionDenied.into());
58 }59 }
...@@ -63,7 +64,7 @@ impl Local {...@@ -63,7 +64,7 @@ impl Local {
63impl Source for Local {64impl Source for Local {
64 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {65 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
65 let mut entries = Vec::new();66 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)?)? {
67 let entry = entry?;68 let entry = entry?;
68 if entries.len() == limit {69 if entries.len() == limit {
69 return Err(io::ErrorKind::FileTooLarge.into());70 return Err(io::ErrorKind::FileTooLarge.into());
...@@ -108,7 +109,7 @@ impl Source for Local {...@@ -108,7 +109,7 @@ impl Source for Local {
108 }109 }
109110
110 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {111 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)
112 }113 }
113}114}
114115
...@@ -190,7 +191,7 @@ impl Source for Smb<'_> {...@@ -190,7 +191,7 @@ impl Source for Smb<'_> {
190 /// Reads the file's stamp, then its replica's base if that has the same.191 /// Reads the file's stamp, then its replica's base if that has the same.
191 fn copy(&mut self, path: &str, known: [u8; 16]) -> Option<Vec<u8>> {192 fn copy(&mut self, path: &str, known: [u8; 16]) -> Option<Vec<u8>> {
192 let replica = crate::session::replica_file(self.copies.as_ref()?, &known);193 let replica = crate::session::replica_file(self.copies.as_ref()?, &known);
193 if !replica.exists() {194 if fs::metadata(&replica).is_err() {
194 return None;195 return None;
195 }196 }
196 let stamp = self.client.stamp(&self.path(path).ok()?).ok()?;197 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 @@...@@ -3,9 +3,11 @@
3#![doc = include_str!("../README.md")]3#![doc = include_str!("../README.md")]
44
5pub mod discover;5pub mod discover;
6pub mod fs;
6#[cfg(feature = "smb")]7#[cfg(feature = "smb")]
7pub mod smb;8pub mod smb;
89
10use fs::OpenOptions;
9use onestore::{11use onestore::{
10 ExGuid,12 ExGuid,
11 op::{Edit, OpError},13 op::{Edit, OpError},
...@@ -14,7 +16,6 @@ use onestore::{...@@ -14,7 +16,6 @@ use onestore::{
14};16};
15use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};17use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
16use std::{18use std::{
17 fs::OpenOptions,
18 io,19 io,
19 path::{Path, PathBuf},20 path::{Path, PathBuf},
20 sync::{Arc, Mutex, MutexGuard, mpsc},21 sync::{Arc, Mutex, MutexGuard, mpsc},
...@@ -38,6 +39,7 @@ pub use recovery::{Recovery, RecoverySummary};...@@ -38,6 +39,7 @@ pub use recovery::{Recovery, RecoverySummary};
38pub mod sidecar;39pub mod sidecar;
39mod sync;40mod sync;
40pub use sync::{EditStatus, Remote, Synced};41pub use sync::{EditStatus, Remote, Synced};
42mod task;
41mod worker;43mod worker;
42mod working;44mod working;
43#[cfg(feature = "smb")]45#[cfg(feature = "smb")]
...@@ -137,7 +139,7 @@ impl Replica {...@@ -137,7 +139,7 @@ impl Replica {
137 source: impl FnOnce() -> Result<Vec<u8>>,139 source: impl FnOnce() -> Result<Vec<u8>>,
138 ) -> Result<Self> {140 ) -> Result<Self> {
139 let path = path.as_ref();141 let path = path.as_ref();
140 if !path.exists() {142 if fs::metadata(path).is_err() {
141 match Self::seed(path, &source()?, key) {143 match Self::seed(path, &source()?, key) {
142 Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {}144 Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {}
143 seeded => seeded?,145 seeded => seeded?,
...@@ -154,14 +156,14 @@ impl Replica {...@@ -154,14 +156,14 @@ impl Replica {
154 static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);156 static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
155 let build = BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);157 let build = BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
156 let mut building = path.as_os_str().to_owned();158 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()));
158 let building = PathBuf::from(building);160 let building = PathBuf::from(building);
159 let built = Self::build(&building, source);161 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)?));
161 for suffix in ["", "-wal", "-shm"] {163 for suffix in ["", "-wal", "-shm"] {
162 let mut file = building.as_os_str().to_owned();164 let mut file = building.as_os_str().to_owned();
163 file.push(suffix);165 file.push(suffix);
164 let _ = std::fs::remove_file(file);166 let _ = fs::remove_file(file);
165 }167 }
166 linked168 linked
167 }169 }
...@@ -463,6 +465,8 @@ fn signed(value: u64) -> Result<i64> {...@@ -463,6 +465,8 @@ fn signed(value: u64) -> Result<i64> {
463/// Opens a cache without writing to it: exclusive locking, a full sync of every commit465/// Opens a cache without writing to it: exclusive locking, a full sync of every commit
464/// (`F_FULLFSYNC` on macOS, the WAL's syncs included) and foreign keys, each queried back.466/// (`F_FULLFSYNC` on macOS, the WAL's syncs included) and foreign keys, each queried back.
465fn cache_connection(path: &Path) -> Result<Connection> {467fn cache_connection(path: &Path) -> Result<Connection> {
468 #[cfg(target_arch = "wasm32")]
469 fs::install_sqlite();
466 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;470 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
467 connection.busy_timeout(Duration::ZERO)?;471 connection.busy_timeout(Duration::ZERO)?;
468 connection.execute_batch(472 connection.execute_batch(
...@@ -512,8 +516,8 @@ fn write_ahead(connection: &Connection) -> Result<()> {...@@ -512,8 +516,8 @@ fn write_ahead(connection: &Connection) -> Result<()> {
512pub(crate) fn now() -> u64 {516pub(crate) fn now() -> u64 {
513 use std::sync::atomic::{AtomicU64, Ordering};517 use std::sync::atomic::{AtomicU64, Ordering};
514 static LAST: AtomicU64 = AtomicU64::new(0);518 static LAST: AtomicU64 = AtomicU64::new(0);
515 let unix = std::time::SystemTime::now()519 let unix = web_time::SystemTime::now()
516 .duration_since(std::time::UNIX_EPOCH)520 .duration_since(web_time::UNIX_EPOCH)
517 .unwrap_or_default();521 .unwrap_or_default();
518 let clock =522 let clock =
519 (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100);523 (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 @@...@@ -6,6 +6,7 @@
6//! A location is a string naming where a notebook or lone section is reached: the canonical6//! A location is a string naming where a notebook or lone section is reached: the canonical
7//! path of a local folder or file (`local`), or `smb` for one on a share.7//! path of a local folder or file (`local`), or `smb` for one on a share.
88
9use crate::fs;
9use onestore::{Header, Stamp};10use onestore::{Header, Stamp};
10use std::{11use std::{
11 io,12 io,
...@@ -26,11 +27,10 @@ pub fn folder(cache: &Path, location: &str) -> PathBuf {...@@ -26,11 +27,10 @@ pub fn folder(cache: &Path, location: &str) -> PathBuf {
26/// its canonical path, or its folder's with its name.27/// its canonical path, or its folder's with its name.
27pub fn local(path: &Path) -> io::Result<String> {28pub fn local(path: &Path) -> io::Result<String> {
28 let canonical =29 let canonical =
29 path.canonicalize()30 fs::canonicalize(path).or_else(|error| match (path.parent(), path.file_name()) {
30 .or_else(|error| match (path.parent(), path.file_name()) {31 (Some(parent), Some(name)) => Ok(fs::canonicalize(parent)?.join(name)),
31 (Some(parent), Some(name)) => Ok(parent.canonicalize()?.join(name)),32 _ => Err(error),
32 _ => Err(error),33 })?;
33 })?;
34 Ok(canonical.to_string_lossy().into_owned())34 Ok(canonical.to_string_lossy().into_owned())
35}35}
3636
...@@ -46,13 +46,15 @@ pub fn smb(address: &str, share: &str, root: &str) -> String {...@@ -46,13 +46,15 @@ pub fn smb(address: &str, share: &str, root: &str) -> String {
46/// must be closed.46/// must be closed.
47pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> {47pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> {
48 let old = folder(cache, from);48 let old = folder(cache, from);
49 if !old.exists() {49 if fs::metadata(&old).is_err() {
50 return Ok(());50 return Ok(());
51 }51 }
52 let new = claimed(cache, to)?;52 let new = claimed(cache, to)?;
53 for entry in std::fs::read_dir(&old)? {53 for entry in fs::read_dir(&old)? {
54 let name = entry?.file_name();54 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 {
56 adopt(&old.join(&name), &new.join(&name))?;58 adopt(&old.join(&name), &new.join(&name))?;
57 }59 }
58 }60 }
...@@ -64,9 +66,9 @@ pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> {...@@ -64,9 +66,9 @@ pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> {
64fn claimed(cache: &Path, location: &str) -> io::Result<PathBuf> {66fn claimed(cache: &Path, location: &str) -> io::Result<PathBuf> {
65 let folder = folder(cache, location);67 let folder = folder(cache, location);
66 let label = folder.join(LOCATION);68 let label = folder.join(LOCATION);
67 if !label.exists() {69 if fs::metadata(&label).is_err() {
68 std::fs::create_dir_all(&folder)?;70 fs::create_dir_all(&folder)?;
69 std::fs::write(&label, location)?;71 fs::write(&label, location)?;
70 }72 }
71 Ok(folder)73 Ok(folder)
72}74}
...@@ -93,7 +95,7 @@ pub(crate) fn claim(...@@ -93,7 +95,7 @@ pub(crate) fn claim(
93 let folder = claimed(cache, location)?;95 let folder = claimed(cache, location)?;
94 let missing: Vec<&[u8; 16]> = sections96 let missing: Vec<&[u8; 16]> = sections
95 .iter()97 .iter()
96 .filter(|identity| !crate::session::replica_file(&folder, identity).exists())98 .filter(|identity| fs::metadata(crate::session::replica_file(&folder, identity)).is_err())
97 .collect();99 .collect();
98 if missing.is_empty() {100 if missing.is_empty() {
99 return Ok(folder);101 return Ok(folder);
...@@ -107,7 +109,7 @@ pub(crate) fn claim(...@@ -107,7 +109,7 @@ pub(crate) fn claim(
107 .iter()109 .iter()
108 .map(|orphan| (crate::session::replica_file(orphan, identity), false));110 .map(|orphan| (crate::session::replica_file(orphan, identity), false));
109 for (replica, legacy) in legacy.chain(left) {111 for (replica, legacy) in legacy.chain(left) {
110 if !replica.exists() {112 if fs::metadata(&replica).is_err() {
111 continue;113 continue;
112 }114 }
113 let adoptable = match crate::closed(&replica).and_then(|held| crate::peek(&held)) {115 let adoptable = match crate::closed(&replica).and_then(|held| crate::peek(&held)) {
...@@ -132,15 +134,15 @@ pub(crate) fn claim(...@@ -132,15 +134,15 @@ pub(crate) fn claim(
132134
133/// The replica folders of local locations that no longer exist.135/// The replica folders of local locations that no longer exist.
134fn orphans(cache: &Path) -> Vec<PathBuf> {136fn 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 {
136 return Vec::new();138 return Vec::new();
137 };139 };
138 entries140 entries
139 .filter_map(|entry| {141 .filter_map(|entry| {
140 let folder = entry.ok()?.path();142 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()?;
142 let path = Path::new(&location);144 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)
144 })146 })
145 .collect()147 .collect()
146}148}
...@@ -160,7 +162,7 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> {...@@ -160,7 +162,7 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> {
160 let (mut source, mut target) = (from.as_os_str().to_owned(), to.as_os_str().to_owned());162 let (mut source, mut target) = (from.as_os_str().to_owned(), to.as_os_str().to_owned());
161 source.push(suffix);163 source.push(suffix);
162 target.push(suffix);164 target.push(suffix);
163 match std::fs::rename(&source, &target) {165 match fs::rename(&source, &target) {
164 Err(error) if !suffix.is_empty() && error.kind() == io::ErrorKind::NotFound => {}166 Err(error) if !suffix.is_empty() && error.kind() == io::ErrorKind::NotFound => {}
165 moved => moved?,167 moved => moved?,
166 }168 }
...@@ -170,12 +172,12 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> {...@@ -170,12 +172,12 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> {
170172
171/// Removes a replica folder that holds no replica.173/// Removes a replica folder that holds no replica.
172fn tidy(folder: &Path) {174fn 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| {
174 entries.all(|entry| entry.is_ok_and(|entry| entry.file_name() == LOCATION))176 entries.all(|entry| entry.is_ok_and(|entry| entry.file_name() == LOCATION))
175 });177 });
176 if empty {178 if empty {
177 let _ = std::fs::remove_file(folder.join(LOCATION));179 let _ = fs::remove_file(folder.join(LOCATION));
178 let _ = std::fs::remove_dir(folder);180 let _ = fs::remove_dir(folder);
179 }181 }
180}182}
181183
crates/notebook/src/package.rs+4-4
...@@ -7,7 +7,7 @@...@@ -7,7 +7,7 @@
7//! MSZIP, which every cabinet reader takes, and reads either.7//! MSZIP, which every cabinet reader takes, and reads either.
88
9use crate::{9use crate::{
10 Error, Result,10 Error, Result, fs,
11 session::{Notebook, lists, moved},11 session::{Notebook, lists, moved},
12};12};
13use onestore::{TocEdit, op::Edit, page::Page};13use onestore::{TocEdit, op::Edit, page::Page};
...@@ -196,13 +196,13 @@ pub fn unpack(mut files: Files, root: &Path) -> Result<()> {...@@ -196,13 +196,13 @@ pub fn unpack(mut files: Files, root: &Path) -> Result<()> {
196 transaction.apply(image)?;196 transaction.apply(image)?;
197 }197 }
198 }198 }
199 std::fs::create_dir(root)?;199 fs::create_dir(root)?;
200 for (path, bytes) in &files {200 for (path, bytes) in &files {
201 let file = root.join(path);201 let file = root.join(path);
202 if let Some(parent) = file.parent() {202 if let Some(parent) = file.parent() {
203 std::fs::create_dir_all(parent)?;203 fs::create_dir_all(parent)?;
204 }204 }
205 std::fs::File::create_new(file)?.write_all(bytes)?;205 fs::File::create_new(file)?.write_all(bytes)?;
206 }206 }
207 Ok(())207 Ok(())
208}208}
crates/notebook/src/session.rs+33-36
...@@ -3,7 +3,7 @@...@@ -3,7 +3,7 @@
33
4pub use crate::background::{Background, Known};4pub use crate::background::{Background, Known};
5use crate::{5use crate::{
6 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover,6 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs,
7};7};
8use onestore::{8use onestore::{
9 CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction,9 CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction,
...@@ -132,17 +132,17 @@ impl Storage for Directory {...@@ -132,17 +132,17 @@ impl Storage for Directory {
132 }132 }
133133
134 fn exists(&self, path: &str) -> bool {134 fn exists(&self, path: &str) -> bool {
135 self.path(path).exists()135 fs::metadata(self.path(path)).is_ok()
136 }136 }
137137
138 fn read(&self, path: &str) -> Result<Vec<u8>> {138 fn read(&self, path: &str) -> Result<Vec<u8>> {
139 Ok(onestore::read_file(self.path(path))?)139 Ok(fs::read_file(self.path(path))?)
140 }140 }
141141
142 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> {142 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> {
143 use std::io::Read;143 use std::io::Read;
144 let mut bytes = Vec::new();144 let mut bytes = Vec::new();
145 std::fs::File::open(self.path(path))?145 fs::File::open(self.path(path))?
146 .take(limit as u64 + 1)146 .take(limit as u64 + 1)
147 .read_to_end(&mut bytes)?;147 .read_to_end(&mut bytes)?;
148 if bytes.len() > limit {148 if bytes.len() > limit {
...@@ -153,15 +153,16 @@ impl Storage for Directory {...@@ -153,15 +153,16 @@ impl Storage for Directory {
153153
154 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {154 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {
155 let path = self.path(path);155 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 {
157 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());158 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
158 }159 }
159 std::fs::File::create_new(path)?.write_all(bytes)?;160 fs::File::create_new(path)?.write_all(bytes)?;
160 Ok(())161 Ok(())
161 }162 }
162163
163 fn create_directory(&self, path: &str) -> Result<()> {164 fn create_directory(&self, path: &str) -> Result<()> {
164 Ok(std::fs::create_dir(self.path(path))?)165 Ok(fs::create_dir(self.path(path))?)
165 }166 }
166167
167 /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted;168 /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted;
...@@ -207,37 +208,33 @@ impl Storage for Directory {...@@ -207,37 +208,33 @@ impl Storage for Directory {
207 }208 }
208209
209 fn rename(&self, from: &str, to: &str) -> Result<()> {210 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))?)
211 }212 }
212213
213 fn replace(&self, from: &str, to: &str) -> Result<()> {214 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))?)
215 }216 }
216217
217 fn delete(&self, path: &str) -> Result<()> {218 fn delete(&self, path: &str) -> Result<()> {
218 let path = self.path(path);219 let path = self.path(path);
219 if path.is_dir() {220 if fs::metadata(&path).is_ok_and(|metadata| metadata.is_dir()) {
220 std::fs::remove_dir(path)?;221 fs::remove_dir(path)?;
221 } else {222 } else {
222 std::fs::remove_file(path)?;223 fs::remove_file(path)?;
223 }224 }
224 Ok(())225 Ok(())
225 }226 }
226227
227 fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> {228 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)?)
229 }230 }
230231
231 fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> {232 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))?)
233 }234 }
234235
235 fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> {236 fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> {
236 Ok(onestore::supersede_file(237 Ok(fs::supersede_file(self.path(path), base, self.path(with))?)
237 self.path(path),
238 base,
239 self.path(with),
240 )?)
241 }238 }
242}239}
243240
...@@ -363,7 +360,7 @@ impl Notebook {...@@ -363,7 +360,7 @@ impl Notebook {
363 pub const NEW_COLOR: u32 = 0x00aeba91;360 pub const NEW_COLOR: u32 = 0x00aeba91;
364361
365 pub fn open(root: impl AsRef<Path>, cache: impl AsRef<Path>) -> Result<Self> {362 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)?;
367 Self::with(Box::new(Directory(root.clone())), Some(root), cache)364 Self::with(Box::new(Directory(root.clone())), Some(root), cache)
368 }365 }
369366
...@@ -376,7 +373,7 @@ impl Notebook {...@@ -376,7 +373,7 @@ impl Notebook {
376 color: u32,373 color: u32,
377 page: &PageCreation,374 page: &PageCreation,
378 ) -> Result<Self> {375 ) -> Result<Self> {
379 std::fs::create_dir(root.as_ref())?;376 fs::create_dir(root.as_ref())?;
380 let mut notebook = Self::open(root, cache)?;377 let mut notebook = Self::open(root, cache)?;
381 notebook.edit_toc("", &[onestore::TocEdit::Color(color)])?;378 notebook.edit_toc("", &[onestore::TocEdit::Color(color)])?;
382 notebook.refresh()?;379 notebook.refresh()?;
...@@ -412,13 +409,13 @@ impl Notebook {...@@ -412,13 +409,13 @@ impl Notebook {
412 ) -> Result<Self> {409 ) -> Result<Self> {
413 let cache = cache.as_ref().to_path_buf();410 let cache = cache.as_ref().to_path_buf();
414 let listings = cache.join("listings");411 let listings = cache.join("listings");
415 std::fs::create_dir_all(&listings)?;412 fs::create_dir_all(&listings)?;
416 let name: String = <sha2::Sha256 as sha2::Digest>::digest(storage.location())[..16]413 let name: String = <sha2::Sha256 as sha2::Digest>::digest(storage.location())[..16]
417 .iter()414 .iter()
418 .map(|byte| format!("{byte:02x}"))415 .map(|byte| format!("{byte:02x}"))
419 .collect();416 .collect();
420 let listing = listings.join(format!("{name}.json"));417 let listing = listings.join(format!("{name}.json"));
421 let mut read = std::fs::read(&listing)418 let mut read = fs::read(&listing)
422 .ok()419 .ok()
423 .and_then(|bytes| serde_json::from_slice(&bytes).ok())420 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
424 .unwrap_or_default();421 .unwrap_or_default();
...@@ -1271,7 +1268,7 @@ impl Notebook {...@@ -1271,7 +1268,7 @@ impl Notebook {
1271 let folders: BTreeSet<&PathBuf> = placed.iter().map(|(.., folder)| folder).collect();1268 let folders: BTreeSet<&PathBuf> = placed.iter().map(|(.., folder)| folder).collect();
1272 let mut superseded = Vec::new();1269 let mut superseded = Vec::new();
1273 for folder in folders {1270 for folder in folders {
1274 let Ok(entries) = std::fs::read_dir(folder) else {1271 let Ok(entries) = fs::read_dir(folder) else {
1275 continue;1272 continue;
1276 };1273 };
1277 for path in entries.filter_map(|entry| Some(entry.ok()?.path())) {1274 for path in entries.filter_map(|entry| Some(entry.ok()?.path())) {
...@@ -1337,8 +1334,8 @@ impl Notebook {...@@ -1337,8 +1334,8 @@ impl Notebook {
1337 let section = self.section_path(path)?;1334 let section = self.section_path(path)?;
1338 let replica = self.replica_path(path)?;1335 let replica = self.replica_path(path)?;
1339 // Held until the file is replaced, so that no session queues edits to the old file.1336 // Held until the file is replaced, so that no session queues edits to the old file.
1340 let held = replica1337 let held = fs::metadata(&replica)
1341 .exists()1338 .is_ok()
1342 .then(|| crate::closed(&replica))1339 .then(|| crate::closed(&replica))
1343 .transpose()?;1340 .transpose()?;
1344 if let Some(held) = &held1341 if let Some(held) = &held
...@@ -1525,7 +1522,7 @@ impl Notebook {...@@ -1525,7 +1522,7 @@ impl Notebook {
1525 .into());1522 .into());
1526 };1523 };
1527 // A section replaced by a link out of the notebook is not the catalog's section.1524 // 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))?;
1529 if !file.starts_with(root) {1526 if !file.starts_with(root) {
1530 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());1527 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
1531 }1528 }
...@@ -1533,14 +1530,14 @@ impl Notebook {...@@ -1533,14 +1530,14 @@ impl Notebook {
1533 // worker checks next, as a share's does.1530 // worker checks next, as a share's does.
1534 if let discover::SectionState::Readable { document, .. } = &section.state {1531 if let discover::SectionState::Readable { document, .. } = &section.state {
1535 let cache = replica_file(&replicas, document);1532 let cache = replica_file(&replicas, document);
1536 if cache.exists() {1533 if fs::metadata(&cache).is_ok() {
1537 let (replica, remote, mut connect) =1534 let (replica, remote, mut connect) =
1538 (Replica::open(&cache)?, file.clone(), connect);1535 (Replica::open(&cache)?, file.clone(), connect);
1539 return Section::start(file, replica, move || connect(&remote), notify);1536 return Section::start(file, replica, move || connect(&remote), notify);
1540 }1537 }
1541 }1538 }
1542 let replica = |identity: &[u8; 16], _: &[u8]| {1539 let replica = |identity: &[u8; 16], _: &[u8]| {
1543 std::fs::create_dir_all(&replicas)?;1540 fs::create_dir_all(&replicas)?;
1544 Ok(replica_file(&replicas, identity))1541 Ok(replica_file(&replicas, identity))
1545 };1542 };
1546 Section::open_in(file, key, replica, connect, notify)1543 Section::open_in(file, key, replica, connect, notify)
...@@ -1712,7 +1709,7 @@ fn remove_replica(replica: &Path) -> io::Result<()> {...@@ -1712,7 +1709,7 @@ fn remove_replica(replica: &Path) -> io::Result<()> {
1712 for suffix in ["", "-wal", "-shm"] {1709 for suffix in ["", "-wal", "-shm"] {
1713 let mut file = replica.as_os_str().to_owned();1710 let mut file = replica.as_os_str().to_owned();
1714 file.push(suffix);1711 file.push(suffix);
1715 match std::fs::remove_file(file) {1712 match fs::remove_file(file) {
1716 Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),1713 Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
1717 _ => {}1714 _ => {}
1718 }1715 }
...@@ -1839,7 +1836,7 @@ impl Section {...@@ -1839,7 +1836,7 @@ impl Section {
1839 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,1836 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1840 notify: impl Fn() + Send + 'static,1837 notify: impl Fn() + Send + 'static,
1841 ) -> Result<Self> {1838 ) -> Result<Self> {
1842 let file = file.as_ref().canonicalize()?;1839 let file = fs::canonicalize(file)?;
1843 let location = file.to_string_lossy().into_owned();1840 let location = file.to_string_lossy().into_owned();
1844 let replicas = |identity: &[u8; 16], source: &[u8]| {1841 let replicas = |identity: &[u8; 16], source: &[u8]| {
1845 let folder = crate::location::claim(cache.as_ref(), &location, &[*identity], |_| {1842 let folder = crate::location::claim(cache.as_ref(), &location, &[*identity], |_| {
...@@ -1875,7 +1872,7 @@ impl Section {...@@ -1875,7 +1872,7 @@ impl Section {
1875 replica: Replica,1872 replica: Replica,
1876 notify: impl Fn() + Send + 'static,1873 notify: impl Fn() + Send + 'static,
1877 ) -> Result<Self> {1874 ) -> Result<Self> {
1878 let file = std::path::absolute(file)?;1875 let file = fs::absolute(file)?;
1879 let remote = file.clone();1876 let remote = file.clone();
1880 Self::start(1877 Self::start(
1881 file,1878 file,
...@@ -2200,14 +2197,14 @@ struct FileRemote(PathBuf);...@@ -2200,14 +2197,14 @@ struct FileRemote(PathBuf);
22002197
2201impl Remote for FileRemote {2198impl Remote for FileRemote {
2202 fn read(&mut self) -> io::Result<Vec<u8>> {2199 fn read(&mut self) -> io::Result<Vec<u8>> {
2203 onestore::read_file(&self.0)2200 fs::read_file(&self.0)
2204 }2201 }
22052202
2206 /// Read without the whole-file lock, which would block OneNote's readers: a change2203 /// Read without the whole-file lock, which would block OneNote's readers: a change
2207 /// detector, never a snapshot to edit.2204 /// detector, never a snapshot to edit.
2208 fn stamp(&mut self) -> io::Result<Stamp> {2205 fn stamp(&mut self) -> io::Result<Stamp> {
2209 use std::io::Read;2206 use std::io::Read;
2210 let mut file = std::fs::File::open(&self.0)?;2207 let mut file = fs::File::open(&self.0)?;
2211 let mut header = [0; 1024];2208 let mut header = [0; 1024];
2212 file.read_exact(&mut header)?;2209 file.read_exact(&mut header)?;
2213 let length = file.metadata()?.len();2210 let length = file.metadata()?.len();
...@@ -2215,11 +2212,11 @@ impl Remote for FileRemote {...@@ -2215,11 +2212,11 @@ impl Remote for FileRemote {
2215 }2212 }
22162213
2217 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> {2214 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> {
2218 transaction.commit_file(&self.0)2215 fs::commit_file(transaction, &self.0)
2219 }2216 }
22202217
2221 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {2218 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {
2222 onestore::confirm_file(&self.0, base)2219 fs::confirm_file(&self.0, base)
2223 }2220 }
2224}2221}
22252222
crates/notebook/src/sidecar.rs+1-1
...@@ -158,7 +158,7 @@ pub(crate) fn map(...@@ -158,7 +158,7 @@ pub(crate) fn map(
158/// A name beside `path` no other writer picks.158/// A name beside `path` no other writer picks.
159fn temporary(path: &str) -> String {159fn temporary(path: &str) -> String {
160 use std::hash::{BuildHasher, RandomState};160 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()));
162 format!("{path}.{unique:016x}.tmp")162 format!("{path}.{unique:016x}.tmp")
163}163}
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 @@...@@ -1,15 +1,14 @@
1use super::*;1use super::*;
2use crate::task::{self, JoinHandle};
2use std::{3use std::{
3 collections::hash_map::RandomState,4 collections::hash_map::RandomState,
4 hash::BuildHasher,5 hash::BuildHasher,
5 sync::{6 sync::{
6 Arc, Mutex,7 Arc, Mutex,
7 atomic::{AtomicBool, AtomicU64, Ordering},8 atomic::{AtomicBool, AtomicU64, Ordering},
8 mpsc::{self, SyncSender},
9 },9 },
10 thread::{self, JoinHandle},
11 time::Instant,
12};10};
11use web_time::Instant;
1312
14pub(super) struct Signal {13pub(super) struct Signal {
15 pub(crate) stopped: AtomicBool,14 pub(crate) stopped: AtomicBool,
...@@ -22,7 +21,7 @@ pub(super) struct Signal {...@@ -22,7 +21,7 @@ pub(super) struct Signal {
22 /// waits for that instead of checking the file on its own (`Background::hold`).21 /// waits for that instead of checking the file on its own (`Background::hold`).
23 pub(crate) watched: AtomicBool,22 pub(crate) watched: AtomicBool,
24 pause: Mutex<Pause>,23 pause: Mutex<Pause>,
25 sender: SyncSender<()>,24 sender: task::Sender<()>,
26}25}
2726
28/// How local edits wait before they publish (`SyncWorker::set_pause`).27/// How local edits wait before they publish (`SyncWorker::set_pause`).
...@@ -37,8 +36,8 @@ struct Pause {...@@ -37,8 +36,8 @@ struct Pause {
37const LONGEST: Duration = Duration::from_secs(30);36const LONGEST: Duration = Duration::from_secs(30);
3837
39impl Signal {38impl Signal {
40 pub(crate) fn new() -> (Arc<Self>, mpsc::Receiver<()>) {39 pub(crate) fn new() -> (Arc<Self>, task::Receiver<()>) {
41 let (sender, receiver) = mpsc::sync_channel(1);40 let (sender, receiver) = task::channel();
42 let signal = Self {41 let signal = Self {
43 stopped: AtomicBool::new(false),42 stopped: AtomicBool::new(false),
44 offline: AtomicBool::new(false),43 offline: AtomicBool::new(false),
...@@ -123,9 +122,12 @@ impl SyncWorker {...@@ -123,9 +122,12 @@ impl SyncWorker {
123 pub fn stop(mut self) -> Result<()> {122 pub fn stop(mut self) -> Result<()> {
124 self.signal.stopped.store(true, Ordering::Release);123 self.signal.stopped.store(true, Ordering::Release);
125 self.signal.wake();124 self.signal.wake();
126 self.thread125 let thread = self.thread.take().expect("Worker owns its thread");
127 .take()126 // In the browser no step is in flight outside the worker's own callback.
128 .expect("Worker owns its thread")127 #[cfg(target_arch = "wasm32")]
128 return thread.finished().unwrap_or(Ok(()));
129 #[cfg(not(target_arch = "wasm32"))]
130 thread
129 .join()131 .join()
130 .map_err(|_| io::Error::other("Synchronization worker panicked"))?132 .map_err(|_| io::Error::other("Synchronization worker panicked"))?
131 }133 }
...@@ -176,118 +178,115 @@ impl Replica {...@@ -176,118 +178,115 @@ impl Replica {
176 let (signal, receiver) = Signal::new();178 let (signal, receiver) = Signal::new();
177 let replica = Arc::clone(self);179 let replica = Arc::clone(self);
178 let worker_signal = Arc::clone(&signal);180 let worker_signal = Arc::clone(&signal);
179 let thread = thread::Builder::new()181 let thread = task::spawn("onestore-sync", move || async move {
180 .name("onestore-sync".into())182 let mut remote: Option<R> = None;
181 .spawn(move || {183 let jitter = RandomState::new();
182 let mut remote: Option<R> = None;184 let mut contention = 0_u32;
183 let jitter = RandomState::new();185 // The first step, and the first after a failure, always runs, so `observe`
184 let mut contention = 0_u32;186 // hears that the remote is reachable and what state the queue is in.
185 // The first step, and the first after a failure, always runs, so `observe`187 let mut reported = false;
186 // hears that the remote is reachable and what state the queue is in.188 // With nothing to do: a watched worker waits for the watch, any other looks
187 let mut reported = false;189 // again after `interval`.
188 // With nothing to do: a watched worker waits for the watch, any other looks190 let rest = || {
189 // again after `interval`.191 task::wait(
190 let rest = || {192 &receiver,
191 if worker_signal.watched.load(Ordering::Acquire) {193 (!worker_signal.watched.load(Ordering::Acquire)).then_some(interval),
192 let _ = receiver.recv();194 )
193 } else {195 };
194 let _ = receiver.recv_timeout(interval);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
195 }226 }
227 None => match connect() {
228 Ok(connected) => {
229 remote = Some(connected);
230 continue;
231 }
232 Err(error) => Err(Error::RemoteIo(error)),
233 },
196 };234 };
197 while !worker_signal.stopped.load(Ordering::Acquire) {235 if result.is_ok() {
198 if worker_signal.offline.load(Ordering::Acquire)236 worker_signal.synced.store(crate::now(), Ordering::Release);
199 && !worker_signal.requested.load(Ordering::Acquire)237 }
200 {238 observe(&result);
201 remote = None;239 let idle = matches!(result, Ok(Synced { edit: None, .. }));
202 reported = false;240 match result {
203 let _ = receiver.recv();241 Ok(Synced {
242 edit: Some((_, EditStatus::Published { .. })),
243 ..
244 }) => {
245 contention = 0;
204 continue;246 continue;
205 }247 }
206 if !worker_signal.requested.load(Ordering::Acquire)248 Ok(Synced { edit: None, .. }) => contention = 0,
207 && let Some(wait) = worker_signal.paused()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 ) =>
208 {257 {
209 let _ = receiver.recv_timeout(wait);258 contention = contention.saturating_add(1);
210 continue;259 let ceiling = (50_u64 << contention.min(5)).min(1000);
211 }260 let until = Instant::now()
212 if let Ok(mut pause) = worker_signal.pause.lock() {261 + Duration::from_millis(jitter.hash_one(contention) % ceiling);
213 // The step publishes what waits; edits from now on wait anew.262 // Local wakes must not keep competing writers in the same retry phase.
214 pause.waiting = None;263 while !worker_signal.stopped.load(Ordering::Acquire) {
215 }264 let Some(remaining) = until.checked_duration_since(Instant::now())
216 let result = match remote.as_mut() {265 else {
217 Some(remote) => {266 break;
218 if reported && replica.settled(remote).unwrap_or(false) {267 };
219 worker_signal.requested.store(false, Ordering::Release);268 task::wait(&receiver, Some(remaining)).await;
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
227 }269 }
228 None => match connect() {270 continue;
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);
238 }271 }
239 observe(&result);272 Err(Error::RemoteIo(ref error))
240 let idle = matches!(result, Ok(Synced { edit: None, .. }));273 if matches!(
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!(
255 error.kind(),274 error.kind(),
256 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy275 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
257 ) =>276 ) => {}
258 {277 Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None,
259 contention = contention.saturating_add(1);278 Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {}
260 let ceiling = (50_u64 << contention.min(5)).min(1000);279 Err(error) => return Err(error),
261 let until = Instant::now()280 }
262 + Duration::from_millis(jitter.hash_one(contention) % ceiling);281 worker_signal.requested.store(false, Ordering::Release);
263 // Local wakes must not keep competing writers in the same retry phase.282 if idle {
264 while !worker_signal.stopped.load(Ordering::Acquire) {283 rest().await;
265 let Some(remaining) = until.checked_duration_since(Instant::now())284 } else if !worker_signal.stopped.load(Ordering::Acquire) {
266 else {285 task::wait(&receiver, Some(interval)).await;
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 }
288 }286 }
289 Ok(())287 }
290 })?;288 Ok(())
289 })?;
291 *owner = Arc::downgrade(&signal);290 *owner = Arc::downgrade(&signal);
292 Ok(SyncWorker {291 Ok(SyncWorker {
293 signal,292 signal,
crates/notebook/src/working.rs+132-24
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1//! The section thread: the only owner of the parsed section, which is the cached base1//! The section thread: the only owner of the parsed section, which is the cached base
2//! image with each sealed batch replayed and the open batch's edits applied. Edits apply2//! image with each sealed batch replayed and the open batch's edits applied. Edits apply
3//! here and are written in one SQLite transaction per burst; the sync thread asks it to seal3//! 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
6use crate::{Result, base, lock, merge, queue, worker::Signal};7use crate::{Result, base, lock, merge, queue, worker::Signal};
7use onestore::{8use onestore::{
...@@ -15,7 +16,6 @@ use std::{...@@ -15,7 +16,6 @@ use std::{
15 collections::{BTreeMap, BTreeSet, VecDeque},16 collections::{BTreeMap, BTreeSet, VecDeque},
16 io,17 io,
17 sync::{Arc, Mutex, Weak, mpsc},18 sync::{Arc, Mutex, Weak, mpsc},
18 thread,
19};19};
2020
21pub(crate) type Reply<T> = Box<dyn FnOnce(Result<T>) + Send>;21pub(crate) type Reply<T> = Box<dyn FnOnce(Result<T>) + Send>;
...@@ -64,8 +64,10 @@ pub(crate) enum Request {...@@ -64,8 +64,10 @@ pub(crate) enum Request {
64 reply: Reply<()>,64 reply: Reply<()>,
65 },65 },
66 /// From the thread that rebuilt the section: hand it the requests.66 /// From the thread that rebuilt the section: hand it the requests.
67 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
67 Handover(mpsc::SyncSender<Takeover>),68 Handover(mpsc::SyncSender<Takeover>),
68 /// From the thread that rebuilt nothing: carry on with the section as it is.69 /// From the thread that rebuilt nothing: carry on with the section as it is.
70 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
69 Resume,71 Resume,
70}72}
7173
...@@ -107,7 +109,8 @@ pub(crate) struct Thread {...@@ -107,7 +109,8 @@ pub(crate) struct Thread {
107 pub(crate) worker: Mutex<Weak<Signal>>,109 pub(crate) worker: Mutex<Weak<Signal>>,
108 /// Taken when the replica drops, which ends every section thread.110 /// Taken when the replica drops, which ends every section thread.
109 sender: Mutex<Option<mpsc::Sender<Request>>>,111 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<()>>>,
111 /// A password-protected section's key, under which its queue is sealed too.114 /// A password-protected section's key, under which its queue is sealed too.
112 pub(crate) key: Option<Key>,115 pub(crate) key: Option<Key>,
113}116}
...@@ -118,7 +121,10 @@ impl Thread {...@@ -118,7 +121,10 @@ impl Thread {
118 .lock()121 .lock()
119 .ok()122 .ok()
120 .and_then(|sender| sender.as_ref()?.send(request).ok())123 .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(())
122 }128 }
123129
124 /// Ends the section threads and waits for them, so the cache is released.130 /// Ends the section threads and waits for them, so the cache is released.
...@@ -126,6 +132,9 @@ impl Thread {...@@ -126,6 +132,9 @@ impl Thread {
126 if let Ok(mut sender) = self.sender.lock() {132 if let Ok(mut sender) = self.sender.lock() {
127 sender.take();133 sender.take();
128 }134 }
135 #[cfg(target_arch = "wasm32")]
136 serving::serve(self);
137 #[cfg(not(target_arch = "wasm32"))]
129 while let Some(thread) = self138 while let Some(thread) = self
130 .threads139 .threads
131 .lock()140 .lock()
...@@ -136,17 +145,81 @@ impl Thread {...@@ -136,17 +145,81 @@ impl Thread {
136 }145 }
137 }146 }
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<()> {
140 let shared = Arc::clone(self);153 let shared = Arc::clone(self);
141 let thread = thread::Builder::new()154 let thread = std::thread::Builder::new()
142 .name("onestore-section".into())155 .name("onestore-section".into())
143 .spawn(move || run(shared))?;156 .spawn(move || crate::task::complete(run(shared)))?;
144 self.threads157 self.threads
145 .lock()158 .lock()
146 .map_err(|_| io::Error::other("The section thread panicked"))?159 .map_err(|_| io::Error::other("The section thread panicked"))?
147 .push(thread);160 .push(thread);
148 Ok(())161 Ok(())
149 }162 }
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
150}223}
151224
152/// Starts the section thread once the queue opens.225/// Starts the section thread once the queue opens.
...@@ -156,6 +229,7 @@ pub(crate) fn spawn(connection: Connection, key: Option<Key>) -> Result<(Arc<Thr...@@ -156,6 +229,7 @@ pub(crate) fn spawn(connection: Connection, key: Option<Key>) -> Result<(Arc<Thr
156 connection: Mutex::new(connection),229 connection: Mutex::new(connection),
157 worker: Mutex::new(Weak::new()),230 worker: Mutex::new(Weak::new()),
158 sender: Mutex::new(Some(sender)),231 sender: Mutex::new(Some(sender)),
232 #[cfg(not(target_arch = "wasm32"))]
159 threads: Mutex::new(Vec::new()),233 threads: Mutex::new(Vec::new()),
160 key,234 key,
161 });235 });
...@@ -180,7 +254,7 @@ enum Next {...@@ -180,7 +254,7 @@ enum Next {
180 Handover(mpsc::SyncSender<Takeover>, VecDeque<Request>),254 Handover(mpsc::SyncSender<Takeover>, VecDeque<Request>),
181}255}
182256
183fn run(257async fn run(
184 shared: Arc<Thread>,258 shared: Arc<Thread>,
185 requests: mpsc::Receiver<Request>,259 requests: mpsc::Receiver<Request>,
186 mut backlog: VecDeque<Request>,260 mut backlog: VecDeque<Request>,
...@@ -202,7 +276,7 @@ fn run(...@@ -202,7 +276,7 @@ fn run(
202 if let Some(ready) = ready.take() {276 if let Some(ready) = ready.take() {
203 let _ = ready.send(Ok(working.section.root()));277 let _ = ready.send(Ok(working.section.root()));
204 }278 }
205 match working.serve(&shared, &requests, &mut backlog) {279 match working.serve(&shared, &requests, &mut backlog).await {
206 Next::Stop => return,280 Next::Stop => return,
207 Next::Reopen => {}281 Next::Reopen => {}
208 Next::Handover(to, held) => {282 Next::Handover(to, held) => {
...@@ -222,6 +296,7 @@ fn fail(error: crate::Error, backlog: VecDeque<Request>, requests: mpsc::Receive...@@ -222,6 +296,7 @@ fn fail(error: crate::Error, backlog: VecDeque<Request>, requests: mpsc::Receive
222}296}
223297
224/// What a rebuilding thread does before it rereads the section.298/// What a rebuilding thread does before it rereads the section.
299#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
225enum Job {300enum Job {
226 Rebase {301 Rebase {
227 image: Option<Vec<u8>>,302 image: Option<Vec<u8>>,
...@@ -234,6 +309,7 @@ enum Job {...@@ -234,6 +309,7 @@ enum Job {
234309
235/// Runs `job`, rereads the section and takes the requests over from the thread that310/// Runs `job`, rereads the section and takes the requests over from the thread that
236/// started it; a failed rebase changed nothing that thread serves.311/// started it; a failed rebase changed nothing that thread serves.
312#[cfg(not(target_arch = "wasm32"))]
237fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) {313fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) {
238 let answer: Box<dyn FnOnce() + Send> = match job {314 let answer: Box<dyn FnOnce() + Send> = match job {
239 Job::Reopen { reply } => Box::new(move || reply(Ok(()))),315 Job::Reopen { reply } => Box::new(move || reply(Ok(()))),
...@@ -263,15 +339,34 @@ fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) {...@@ -263,15 +339,34 @@ fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) {
263 Ok(working) => working,339 Ok(working) => working,
264 Err(error) => return fail(error, backlog, requests),340 Err(error) => return fail(error, backlog, requests),
265 };341 };
266 match working.serve(&shared, &requests, &mut backlog) {342 match crate::task::complete(working.serve(&shared, &requests, &mut backlog)) {
267 Next::Stop => {}343 Next::Stop => {}
268 Next::Reopen => run(shared, requests, backlog, None),344 Next::Reopen => crate::task::complete(run(shared, requests, backlog, None)),
269 Next::Handover(to, held) => {345 Next::Handover(to, held) => {
270 let _ = to.send((requests, held));346 let _ = to.send((requests, held));
271 }347 }
272 }348 }
273}349}
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
275/// An edit applied to the section and not yet written, with who waits for it.370/// An edit applied to the section and not yet written, with who waits for it.
276struct Accepted {371struct Accepted {
277 author: String,372 author: String,
...@@ -297,7 +392,7 @@ impl<'a> Working<'a> {...@@ -297,7 +392,7 @@ impl<'a> Working<'a> {
297 })392 })
298 }393 }
299394
300 fn serve(395 async fn serve(
301 mut self,396 mut self,
302 shared: &Arc<Thread>,397 shared: &Arc<Thread>,
303 requests: &mpsc::Receiver<Request>,398 requests: &mpsc::Receiver<Request>,
...@@ -310,9 +405,9 @@ impl<'a> Working<'a> {...@@ -310,9 +405,9 @@ impl<'a> Working<'a> {
310 loop {405 loop {
311 let first = match backlog.pop_front() {406 let first = match backlog.pop_front() {
312 Some(request) => request,407 Some(request) => request,
313 None => match requests.recv() {408 None => match receive(requests).await {
314 Ok(request) => request,409 Some(request) => request,
315 Err(_) => return Next::Stop,410 None => return Next::Stop,
316 },411 },
317 };412 };
318 let mut burst: VecDeque<Request> = VecDeque::from([first]);413 let mut burst: VecDeque<Request> = VecDeque::from([first]);
...@@ -415,10 +510,16 @@ impl<'a> Working<'a> {...@@ -415,10 +510,16 @@ impl<'a> Working<'a> {
415 }510 }
416 Request::Rebase { image, reply } => {511 Request::Rebase { image, reply } => {
417 if self.flush(shared, &mut accepted) {512 if self.flush(shared, &mut accepted) {
418 held = self513 #[cfg(target_arch = "wasm32")]
419 .rebuild(shared, Job::Rebase { image, reply })514 let reopen = inline(shared, Job::Rebase { image, reply });
420 .then(VecDeque::new);515 #[cfg(not(target_arch = "wasm32"))]
421 false516 let reopen = {
517 held = self
518 .rebuild(shared, Job::Rebase { image, reply })
519 .then(VecDeque::new);
520 false
521 };
522 reopen
422 } else {523 } else {
423 reply(Err(524 reply(Err(
424 io::Error::other("The queue could not be written").into()525 io::Error::other("The queue could not be written").into()
...@@ -428,10 +529,16 @@ impl<'a> Working<'a> {...@@ -428,10 +529,16 @@ impl<'a> Working<'a> {
428 }529 }
429 Request::Reopen { reply } => {530 Request::Reopen { reply } => {
430 self.flush(shared, &mut accepted);531 self.flush(shared, &mut accepted);
431 held = self532 #[cfg(target_arch = "wasm32")]
432 .rebuild(shared, Job::Reopen { reply })533 let reopen = inline(shared, Job::Reopen { reply });
433 .then(VecDeque::new);534 #[cfg(not(target_arch = "wasm32"))]
434 false535 let reopen = {
536 held = self
537 .rebuild(shared, Job::Reopen { reply })
538 .then(VecDeque::new);
539 false
540 };
541 reopen
435 }542 }
436 Request::Handover(_) | Request::Resume => false,543 Request::Handover(_) | Request::Resume => false,
437 };544 };
...@@ -448,6 +555,7 @@ impl<'a> Working<'a> {...@@ -448,6 +555,7 @@ impl<'a> Working<'a> {
448555
449 /// Starts a thread that runs `job` and rereads the section; false when the replica is556 /// Starts a thread that runs `job` and rereads the section; false when the replica is
450 /// stopping, which answers the job's request.557 /// stopping, which answers the job's request.
558 #[cfg(not(target_arch = "wasm32"))]
451 fn rebuild(&self, shared: &Arc<Thread>, job: Job) -> bool {559 fn rebuild(&self, shared: &Arc<Thread>, job: Job) -> bool {
452 let signal = shared560 let signal = shared
453 .sender561 .sender
...@@ -466,7 +574,7 @@ impl<'a> Working<'a> {...@@ -466,7 +574,7 @@ impl<'a> Working<'a> {
466 return false;574 return false;
467 };575 };
468 shared576 shared
469 .start(move |shared| build(shared, job, signal))577 .start(move |shared| async move { build(shared, job, signal) })
470 .is_ok()578 .is_ok()
471 }579 }
472580
crates/onestore/Cargo.toml+2
...@@ -17,6 +17,8 @@ roxmltree = "0.21.1"...@@ -17,6 +17,8 @@ roxmltree = "0.21.1"
17sha1 = { version = "0.11.0", features = ["zeroize"] }17sha1 = { version = "0.11.0", features = ["zeroize"] }
18subtle = "2.6.1"18subtle = "2.6.1"
19zeroize = "1.9.0"19zeroize = "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
21[target.'cfg(target_os = "macos")'.dependencies]23[target.'cfg(target_os = "macos")'.dependencies]
22nix = { version = "0.31.3", default-features = false, features = ["fs"] }24nix = { version = "0.31.3", default-features = false, features = ["fs"] }
crates/onestore/src/create.rs+1-1
...@@ -5,7 +5,7 @@ use crate::{...@@ -5,7 +5,7 @@ use crate::{
5 write::{append, append_list, fresh_guid, node},5 write::{append, append_list, fresh_guid, node},
6};6};
7use std::collections::BTreeMap;7use std::collections::BTreeMap;
8use std::time::{SystemTime, UNIX_EPOCH};8use web_time::{SystemTime, UNIX_EPOCH};
99
10type Result<T> = std::result::Result<T, Error>;10type Result<T> = std::result::Result<T, Error>;
1111
crates/onestore/src/lib.rs+1
...@@ -8,6 +8,7 @@ mod create;...@@ -8,6 +8,7 @@ mod create;
8pub mod document;8pub mod document;
9mod edit;9mod edit;
10mod files;10mod files;
11#[cfg(any(unix, windows))]
11mod flush;12mod flush;
12mod formatting;13mod formatting;
13mod identity;14mod identity;
crates/snowbound/Cargo.toml+22-7
...@@ -17,24 +17,39 @@ png = "0.18"...@@ -17,24 +17,39 @@ png = "0.18"
17# Encodes a video recording's pictures as JPEG, which OneNote 2010 plays.17# Encodes a video recording's pictures as JPEG, which OneNote 2010 plays.
18image = { version = "0.25", default-features = false, features = ["jpeg"] }18image = { version = "0.25", default-features = false, features = ["jpeg"] }
19onestore = { path = "../onestore" }19onestore = { path = "../onestore" }
20notebook = { path = "../notebook", features = ["smb"] }20notebook = { path = "../notebook" }
21serde = { version = "1", features = ["derive"] }21serde = { version = "1", features = ["derive"] }
22serde_json = "1"22serde_json = "1"
23pollster = "0.4"23pollster = "0.4"
24wgpu = { workspace = true, optional = true }24wgpu = { workspace = true, optional = true }
25parley.workspace = true25parley.workspace = true
26winit = "0.30.13"26winit = "0.30.13"
27arboard = { version = "3.6.1", default-features = false }
28accesskit = "0.25.0"27accesskit = "0.25.0"
29accesskit_consumer = "0.39.0"28accesskit_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"
34# Clears passwords typed into the password dialogs.29# Clears passwords typed into the password dialogs.
35zeroize = "1.9.0"30zeroize = "1.9.0"
36# Names characters in More Symbols, Insert Symbol's dialog.31# Names characters in More Symbols, Insert Symbol's dialog.
37unicode_names2 = "4.0.0"32unicode_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
39[target.'cfg(target_os = "macos")'.dependencies]54[target.'cfg(target_os = "macos")'.dependencies]
40libc = "0.2"55libc = "0.2"
...@@ -43,7 +58,7 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl...@@ -43,7 +58,7 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl
43objc2-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"] }58objc2-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"] }
44block2 = "0.5.1"59block2 = "0.5.1"
4560
46[target.'cfg(not(target_os = "macos"))'.dependencies]61[target.'cfg(not(any(target_os = "macos", target_arch = "wasm32")))'.dependencies]
47# Clipboard bitmaps; macOS reads the pasteboard's PNG and TIFF itself.62# Clipboard bitmaps; macOS reads the pasteboard's PNG and TIFF itself.
48arboard = { version = "3.6.1", default-features = false, features = ["image-data"] }63arboard = { version = "3.6.1", default-features = false, features = ["image-data"] }
4964
crates/snowbound/src/attachment.rs+5-5
...@@ -21,7 +21,7 @@ impl State {...@@ -21,7 +21,7 @@ impl State {
21 }21 }
22 let (Some(name), Ok(bytes)) = (22 let (Some(name), Ok(bytes)) = (
23 path.file_name().and_then(|name| name.to_str()),23 path.file_name().and_then(|name| name.to_str()),
24 std::fs::read(path),24 notebook::fs::read(path),
25 ) else {25 ) else {
26 platform::alert("Couldn't attach the file", "Choose a file you can open.");26 platform::alert("Couldn't attach the file", "Choose a file you can open.");
27 return Ok(());27 return Ok(());
...@@ -68,14 +68,14 @@ impl State {...@@ -68,14 +68,14 @@ impl State {
68 return Ok(None);68 return Ok(None);
69 };69 };
70 let root = std::env::temp_dir().join("Snowbound Attachments");70 let root = std::env::temp_dir().join("Snowbound Attachments");
71 std::fs::create_dir_all(&root)?;71 notebook::fs::create_dir_all(&root)?;
72 // A copy opened earlier may still be open, edited, in its application.72 // A copy opened earlier may still be open, edited, in its application.
73 let folder = (0..1000)73 let folder = (0..1000)
74 .map(|index| root.join(index.to_string()))74 .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())
76 .ok_or("No folder is free for a copy of the file")?;76 .ok_or("No folder is free for a copy of the file")?;
77 let path = folder.join(&file.filename);77 let path = folder.join(&file.filename);
78 std::fs::write(&path, bytes)?;78 notebook::fs::write(&path, bytes)?;
79 Ok(Some(path))79 Ok(Some(path))
80 }80 }
8181
...@@ -86,7 +86,7 @@ impl State {...@@ -86,7 +86,7 @@ impl State {
86 return Ok(());86 return Ok(());
87 };87 };
88 if let Some(path) = platform::pick_new("Save As", &file.filename, "Save", None) {88 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)?;
90 }90 }
91 Ok(())91 Ok(())
92 }92 }
crates/snowbound/src/commands.rs+1-1
...@@ -1562,7 +1562,7 @@ impl State {...@@ -1562,7 +1562,7 @@ impl State {
1562 else {1562 else {
1563 return Ok(());1563 return Ok(());
1564 };1564 };
1565 return self.insert_picture(std::fs::read(path)?, None);1565 return self.insert_picture(notebook::fs::read(path)?, None);
1566 }1566 }
1567 Id::Attachment => {1567 Id::Attachment => {
1568 let Some(path) = platform::pick_file("Attach File", &[]) else {1568 let Some(path) = platform::pick_file("Attach File", &[]) else {
crates/snowbound/src/guide.rs+11-11
...@@ -35,8 +35,8 @@ impl State {...@@ -35,8 +35,8 @@ impl State {
35 /// Opens the guide in the user's documents, copying it there first unless it is there.35 /// Opens the guide in the user's documents, copying it there first unless it is there.
36 pub(crate) fn open_guide(&mut self) -> Result<(), Box<dyn Error>> {36 pub(crate) fn open_guide(&mut self) -> Result<(), Box<dyn Error>> {
37 let documents = platform::documents_dir().ok_or("There is no Documents folder")?;37 let documents = platform::documents_dir().ok_or("There is no Documents folder")?;
38 let folder = std::path::absolute(documents.join(NAME))?;38 let folder = notebook::fs::absolute(documents.join(NAME))?;
39 if !folder.exists() {39 if notebook::fs::metadata(&folder).is_err() {
40 copy(&folder, &self.cache)?;40 copy(&folder, &self.cache)?;
41 }41 }
42 self.open_notebook(folder.to_string_lossy().into_owned(), None);42 self.open_notebook(folder.to_string_lossy().into_owned(), None);
...@@ -48,12 +48,12 @@ impl State {...@@ -48,12 +48,12 @@ impl State {
48/// short never passes for the guide.48/// short never passes for the guide.
49fn copy(folder: &Path, cache: &Path) -> Result<(), Box<dyn Error>> {49fn copy(folder: &Path, cache: &Path) -> Result<(), Box<dyn Error>> {
50 let partial = folder.with_file_name(format!("{NAME}.partial"));50 let partial = folder.with_file_name(format!("{NAME}.partial"));
51 if partial.exists() {51 if notebook::fs::metadata(&partial).is_ok() {
52 std::fs::remove_dir_all(&partial)?;52 notebook::fs::remove_dir_all(&partial)?;
53 }53 }
54 std::fs::create_dir_all(&partial)?;54 notebook::fs::create_dir_all(&partial)?;
55 for (name, bytes) in FILES {55 for (name, bytes) in FILES {
56 std::fs::write(partial.join(name), bytes)?;56 notebook::fs::write(partial.join(name), bytes)?;
57 }57 }
58 // The snowflake tag's art, kept in the notebook as Customize Tags keeps it.58 // The snowflake tag's art, kept in the notebook as Customize Tags keeps it.
59 Notebook::open(&partial, cache)?.map_tag_art(59 Notebook::open(&partial, cache)?.map_tag_art(
...@@ -62,7 +62,7 @@ fn copy(folder: &Path, cache: &Path) -> Result<(), Box<dyn Error>> {...@@ -62,7 +62,7 @@ fn copy(folder: &Path, cache: &Path) -> Result<(), Box<dyn Error>> {
62 include_bytes!("../assets/tags/snowflake.svg"),62 include_bytes!("../assets/tags/snowflake.svg"),
63 "svg",63 "svg",
64 )?;64 )?;
65 std::fs::rename(&partial, folder)?;65 notebook::fs::rename(&partial, folder)?;
66 Ok(())66 Ok(())
67}67}
6868
...@@ -75,7 +75,7 @@ mod tests {...@@ -75,7 +75,7 @@ mod tests {
75 #[test]75 #[test]
76 fn the_guide_copies_whole() {76 fn the_guide_copies_whole() {
77 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/guide-notebook");77 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))
79 .unwrap()79 .unwrap()
80 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())80 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
81 .filter(|name| !name.starts_with('.'))81 .filter(|name| !name.starts_with('.'))
...@@ -87,7 +87,7 @@ mod tests {...@@ -87,7 +87,7 @@ mod tests {
8787
88 let temporary =88 let temporary =
89 std::env::temp_dir().join(format!("snowbound-guide-{}", std::process::id()));89 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);
91 let folder = temporary.join(NAME);91 let folder = temporary.join(NAME);
92 copy(&folder, &temporary.join("cache")).unwrap();92 copy(&folder, &temporary.join("cache")).unwrap();
93 let notebook = Notebook::open(&folder, temporary.join("cache")).unwrap();93 let notebook = Notebook::open(&folder, temporary.join("cache")).unwrap();
...@@ -99,8 +99,8 @@ mod tests {...@@ -99,8 +99,8 @@ mod tests {
99 .collect::<Vec<_>>(),99 .collect::<Vec<_>>(),
100 [("Snowflake", 34)]100 [("Snowflake", 34)]
101 );101 );
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"));
103 assert!(committed.unwrap().contains(&art[0].art));103 assert!(committed.unwrap().contains(&art[0].art));
104 std::fs::remove_dir_all(&temporary).unwrap();104 notebook::fs::remove_dir_all(&temporary).unwrap();
105 }105 }
106}106}
crates/snowbound/src/history.rs+1-1
...@@ -226,7 +226,7 @@ impl State {...@@ -226,7 +226,7 @@ impl State {
226 };226 };
227 let library = Arc::clone(&session.library);227 let library = Arc::clone(&session.library);
228 let proxy = self.proxy.clone();228 let proxy = self.proxy.clone();
229 std::thread::spawn(move || {229 crate::spawn(move || {
230 for path in sections(&library, &folder) {230 for path in sections(&library, &folder) {
231 if path == open {231 if path == open {
232 continue;232 continue;
crates/snowbound/src/library.rs+53-35
...@@ -4,6 +4,7 @@ use canvas::{editor::NoteTag, gpu::TagArt};...@@ -4,6 +4,7 @@ use canvas::{editor::NoteTag, gpu::TagArt};
4use notebook::discover::{Folder, SectionState};4use notebook::discover::{Folder, SectionState};
5use notebook::session::{Background, Notebook, Section};5use notebook::session::{Background, Notebook, Section};
6use notebook::sidecar::themes::Themes;6use notebook::sidecar::themes::Themes;
7#[cfg(not(target_arch = "wasm32"))]
7use notebook::smb::{Client, Credentials};8use notebook::smb::{Client, Credentials};
8use onestore::protected::Key;9use onestore::protected::Key;
9use std::{10use std::{
...@@ -14,8 +15,9 @@ use std::{...@@ -14,8 +15,9 @@ use std::{
14 Arc, Mutex, OnceLock,15 Arc, Mutex, OnceLock,
15 atomic::{AtomicBool, Ordering},16 atomic::{AtomicBool, Ordering},
16 },17 },
17 time::{Duration, Instant},18 time::Duration,
18};19};
20use web_time::Instant;
1921
20/// OneNote's Work Offline, which like OneNote's holds for every notebook.22/// OneNote's Work Offline, which like OneNote's holds for every notebook.
21static OFFLINE: AtomicBool = AtomicBool::new(false);23static OFFLINE: AtomicBool = AtomicBool::new(false);
...@@ -63,7 +65,7 @@ pub struct Mount {...@@ -63,7 +65,7 @@ pub struct Mount {
63/// the system reports mount points with symlinks (`/tmp`, `/var`) resolved.65/// the system reports mount points with symlinks (`/tmp`, `/var`) resolved.
64#[cfg(unix)]66#[cfg(unix)]
65pub fn within_mount(path: &std::path::Path, point: &str) -> Option<String> {67pub 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());
67 Some(69 Some(
68 path.strip_prefix(point)70 path.strip_prefix(point)
69 .ok()?71 .ok()?
...@@ -271,6 +273,7 @@ struct Server {...@@ -271,6 +273,7 @@ struct Server {
271 login: Login,273 login: Login,
272}274}
273275
276#[cfg(not(target_arch = "wasm32"))]
274impl Server {277impl Server {
275 fn connect(&self) -> io::Result<Client> {278 fn connect(&self) -> io::Result<Client> {
276 Client::connect(279 Client::connect(
...@@ -398,8 +401,15 @@ impl Library {...@@ -398,8 +401,15 @@ impl Library {
398 }401 }
399 }402 }
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
401 /// The notebook at `location`, which `mount` names on its server, opened through the410 /// The notebook at `location`, which `mount` names on its server, opened through the
402 /// embedded client signed in as `login`.411 /// embedded client signed in as `login`.
412 #[cfg(not(target_arch = "wasm32"))]
403 pub fn on_share(413 pub fn on_share(
404 location: &str,414 location: &str,
405 mount: Mount,415 mount: Mount,
...@@ -443,8 +453,8 @@ impl Library {...@@ -443,8 +453,8 @@ impl Library {
443 let mut names = read_display_names(&file);453 let mut names = read_display_names(&file);
444 names.insert(self.location.clone(), name.to_owned());454 names.insert(self.location.clone(), name.to_owned());
445 let partial = file.with_extension("partial");455 let partial = file.with_extension("partial");
446 std::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?;456 notebook::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?;
447 std::fs::rename(partial, file)457 notebook::fs::rename(partial, file)
448 }458 }
449459
450 /// The art the notebook's tags draw with.460 /// The art the notebook's tags draw with.
...@@ -478,7 +488,7 @@ impl Library {...@@ -478,7 +488,7 @@ impl Library {
478 }488 }
479 let extension = art.rsplit('.').next().unwrap_or_default().to_owned();489 let extension = art.rsplit('.').next().unwrap_or_default().to_owned();
480 let (library, tag) = (Arc::clone(self), tag.clone());490 let (library, tag) = (Arc::clone(self), tag.clone());
481 std::thread::spawn(move || {491 crate::spawn(move || {
482 let kept = library.reopen().and_then(|notebook| {492 let kept = library.reopen().and_then(|notebook| {
483 Ok(notebook.map_tag_art(&tag.label, tag.shape, &bytes, &extension)?)493 Ok(notebook.map_tag_art(&tag.label, tag.shape, &bytes, &extension)?)
484 });494 });
...@@ -529,7 +539,7 @@ impl Library {...@@ -529,7 +539,7 @@ impl Library {
529 .unwrap_or_else(|poisoned| poisoned.into_inner()) =539 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
530 Some((Instant::now(), Arc::new(themes)));540 Some((Instant::now(), Arc::new(themes)));
531 let library = Arc::clone(self);541 let library = Arc::clone(self);
532 std::thread::spawn(move || {542 crate::spawn(move || {
533 let kept = library543 let kept = library
534 .reopen()544 .reopen()
535 .and_then(|notebook| Ok(notebook.save_themes(change)?));545 .and_then(|notebook| Ok(notebook.save_themes(change)?));
...@@ -546,9 +556,9 @@ impl Library {...@@ -546,9 +556,9 @@ impl Library {
546 return;556 return;
547 }557 }
548 let library = Arc::clone(self);558 let library = Arc::clone(self);
549 std::thread::spawn(move || {559 crate::spawn(move || {
550 let now = std::time::SystemTime::now()560 let now = web_time::SystemTime::now()
551 .duration_since(std::time::UNIX_EPOCH)561 .duration_since(web_time::UNIX_EPOCH)
552 .map_or(0, |unix| unix.as_secs())562 .map_or(0, |unix| unix.as_secs())
553 // Time32 counts from 1980.563 // Time32 counts from 1980.
554 .saturating_sub(315_532_800);564 .saturating_sub(315_532_800);
...@@ -567,9 +577,12 @@ impl Library {...@@ -567,9 +577,12 @@ impl Library {
567 /// This notebook read again, the way it was opened, for changing its structure.577 /// This notebook read again, the way it was opened, for changing its structure.
568 pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> {578 pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> {
569 Ok(match &self.server {579 Ok(match &self.server {
580 #[cfg(not(target_arch = "wasm32"))]
570 Some(server) => {581 Some(server) => {
571 Notebook::open_smb(Arc::new(server.connect()?), &server.mount.root, &self.cache)?582 Notebook::open_smb(Arc::new(server.connect()?), &server.mount.root, &self.cache)?
572 }583 }
584 #[cfg(target_arch = "wasm32")]
585 Some(_) => unreachable!("The browser opens no notebook on a server"),
573 None => Notebook::open(&self.location, &self.cache)?,586 None => Notebook::open(&self.location, &self.cache)?,
574 })587 })
575 }588 }
...@@ -665,13 +678,16 @@ impl Library {...@@ -665,13 +678,16 @@ impl Library {
665 return Ok(kept);678 return Ok(kept);
666 }679 }
667 let opened = match (&self.notebook, &self.server) {680 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"))]
668 (Ok(Some(notebook)), Some(server)) => {684 (Ok(Some(notebook)), Some(server)) => {
669 let file = match server.mount.root.as_str() {685 let file = match server.mount.root.as_str() {
670 "" => path.to_owned(),686 "" => path.to_owned(),
671 root => format!("{root}/{path}"),687 root => format!("{root}/{path}"),
672 };688 };
673 let cache = notebook.replica_path(path)?;689 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))?;
675 let replica = notebook::Replica::open_or_create(&cache, key.as_ref(), || {691 let replica = notebook::Replica::open_or_create(&cache, key.as_ref(), || {
676 Ok(server.connect()?.read_storage(&file, LIMIT)?)692 Ok(server.connect()?.read_storage(&file, LIMIT)?)
677 });693 });
...@@ -735,7 +751,7 @@ impl Library {...@@ -735,7 +751,7 @@ impl Library {
735 Err(_) => vec![section],751 Err(_) => vec![section],
736 };752 };
737 if !gone.is_empty() {753 if !gone.is_empty() {
738 std::thread::spawn(move || close(gone));754 crate::spawn(move || close(gone));
739 }755 }
740 }756 }
741757
...@@ -984,7 +1000,7 @@ impl Library {...@@ -984,7 +1000,7 @@ impl Library {
984 if let Ok(mut kept) = self.kept.lock() {1000 if let Ok(mut kept) = self.kept.lock() {
985 for path in &locked {1001 for path in &locked {
986 if let Some(section) = kept.take(path) {1002 if let Some(section) = kept.take(path) {
987 std::thread::spawn(move || close(vec![section]));1003 crate::spawn(move || close(vec![section]));
988 }1004 }
989 }1005 }
990 }1006 }
...@@ -1013,7 +1029,7 @@ fn display_names(cache: &Path) -> PathBuf {...@@ -1013,7 +1029,7 @@ fn display_names(cache: &Path) -> PathBuf {
1013}1029}
10141030
1015fn read_display_names(file: &Path) -> std::collections::BTreeMap<String, String> {1031fn read_display_names(file: &Path) -> std::collections::BTreeMap<String, String> {
1016 std::fs::read(file)1032 notebook::fs::read(file)
1017 .ok()1033 .ok()
1018 .and_then(|bytes| serde_json::from_slice(&bytes).ok())1034 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
1019 .unwrap_or_default()1035 .unwrap_or_default()
...@@ -1119,7 +1135,7 @@ fn close(sections: Vec<Section>) {...@@ -1119,7 +1135,7 @@ fn close(sections: Vec<Section>) {
1119}1135}
11201136
1121pub fn file_name(path: &Path) -> String {1137pub fn file_name(path: &Path) -> String {
1122 path.canonicalize()1138 notebook::fs::canonicalize(path)
1123 .ok()1139 .ok()
1124 .as_deref()1140 .as_deref()
1125 .and_then(Path::file_name)1141 .and_then(Path::file_name)
...@@ -1181,7 +1197,7 @@ pub enum Located {...@@ -1181,7 +1197,7 @@ pub enum Located {
11811197
1182/// Whether `folder` holds a table of contents, as a notebook's or a section group's does.1198/// Whether `folder` holds a table of contents, as a notebook's or a section group's does.
1183fn has_toc(folder: &Path) -> bool {1199fn 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| {
1185 entries.flatten().any(|entry| {1201 entries.flatten().any(|entry| {
1186 entry1202 entry
1187 .path()1203 .path()
...@@ -1199,10 +1215,12 @@ pub fn locate(path: &Path) -> Located {...@@ -1199,10 +1215,12 @@ pub fn locate(path: &Path) -> Located {
1199 .extension()1215 .extension()
1200 .map(|extension| extension.to_string_lossy().to_ascii_lowercase());1216 .map(|extension| extension.to_string_lossy().to_ascii_lowercase());
1201 match extension.as_deref() {1217 match extension.as_deref() {
1202 _ if path.is_dir() => Located::Notebook {1218 _ if notebook::fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) => {
1203 root: path.to_owned(),1219 Located::Notebook {
1204 section: None,1220 root: path.to_owned(),
1205 },1221 section: None,
1222 }
1223 }
1206 Some("onepkg") => Located::Package(path.to_owned()),1224 Some("onepkg") => Located::Package(path.to_owned()),
1207 Some("onetoc2") => match path.parent() {1225 Some("onetoc2") => match path.parent() {
1208 Some(root) => Located::Notebook {1226 Some(root) => Located::Notebook {
...@@ -1245,14 +1263,14 @@ mod tests {...@@ -1245,14 +1263,14 @@ mod tests {
1245 #[test]1263 #[test]
1246 fn kept_sections_open_at_once() {1264 fn kept_sections_open_at_once() {
1247 let root = std::env::temp_dir().join(format!("snowbound-kept-{}", std::process::id()));1265 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);
1249 let notebook = root.join("Personal");1267 let notebook = root.join("Personal");
1250 std::fs::create_dir_all(&notebook).unwrap();1268 notebook::fs::create_dir_all(&notebook).unwrap();
1251 let sample =1269 let sample =
1252 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/sample-notebook/Personal");1270 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() {
1254 let entry = entry.unwrap();1272 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();
1256 }1274 }
1257 let library = Library::notebook(notebook.to_str().unwrap(), &root.join("cache"));1275 let library = Library::notebook(notebook.to_str().unwrap(), &root.join("cache"));
1258 let [first, second] = [0, 1].map(|tab| library.tabs("")[tab].path.clone());1276 let [first, second] = [0, 1].map(|tab| library.tabs("")[tab].path.clone());
...@@ -1268,7 +1286,7 @@ mod tests {...@@ -1268,7 +1286,7 @@ mod tests {
1268 library.close_kept();1286 library.close_kept();
1269 library.open(&first, || {}).unwrap().close().unwrap();1287 library.open(&first, || {}).unwrap().close().unwrap();
1270 drop(library);1288 drop(library);
1271 std::fs::remove_dir_all(&root).unwrap();1289 notebook::fs::remove_dir_all(&root).unwrap();
1272 }1290 }
12731291
1274 #[cfg(unix)]1292 #[cfg(unix)]
...@@ -1276,13 +1294,13 @@ mod tests {...@@ -1276,13 +1294,13 @@ mod tests {
1276 fn within_mount_resolves_symlinks() {1294 fn within_mount_resolves_symlinks() {
1277 let base = std::env::temp_dir().join(format!("snowbound-within-{}", std::process::id()));1295 let base = std::env::temp_dir().join(format!("snowbound-within-{}", std::process::id()));
1278 let point = base.join("point");1296 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();
1280 std::os::unix::fs::symlink(&point, base.join("link")).unwrap();1298 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();
1282 let point = point.to_str().unwrap();1300 let point = point.to_str().unwrap();
1283 let found = within_mount(&base.join("link/notes"), point);1301 let found = within_mount(&base.join("link/notes"), point);
1284 let missing = within_mount(&base.join("link/gone"), point);1302 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();
1286 assert_eq!(found.as_deref(), Some("notes"));1304 assert_eq!(found.as_deref(), Some("notes"));
1287 assert_eq!(missing, None);1305 assert_eq!(missing, None);
1288 }1306 }
...@@ -1310,8 +1328,8 @@ mod tests {...@@ -1310,8 +1328,8 @@ mod tests {
1310 fn a_notebook_on_a_share_opens_through_the_embedded_client() {1328 fn a_notebook_on_a_share_opens_through_the_embedded_client() {
1311 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();1329 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
1312 let root = std::env::var("ONESTORE_SMB_LAB_ROOT").unwrap_or_else(|_| {1330 let root = std::env::var("ONESTORE_SMB_LAB_ROOT").unwrap_or_else(|_| {
1313 let nanos = std::time::SystemTime::now()1331 let nanos = web_time::SystemTime::now()
1314 .duration_since(std::time::UNIX_EPOCH)1332 .duration_since(web_time::UNIX_EPOCH)
1315 .unwrap()1333 .unwrap()
1316 .subsec_nanos();1334 .subsec_nanos();
1317 format!("snowbound-{}-{nanos}", std::process::id())1335 format!("snowbound-{}-{nanos}", std::process::id())
...@@ -1400,7 +1418,7 @@ mod tests {...@@ -1400,7 +1418,7 @@ mod tests {
1400 }1418 }
1401 section.close().unwrap();1419 section.close().unwrap();
1402 drop(library);1420 drop(library);
1403 let _ = std::fs::remove_dir_all(&cache);1421 let _ = notebook::fs::remove_dir_all(&cache);
1404 if std::env::var_os("ONESTORE_SMB_LAB_KEEP").is_none() {1422 if std::env::var_os("ONESTORE_SMB_LAB_KEEP").is_none() {
1405 remove_tree(&client, &root);1423 remove_tree(&client, &root);
1406 assert!(client.read_dir(&root, 1).is_err(), "the folder is gone");1424 assert!(client.read_dir(&root, 1).is_err(), "the folder is gone");
...@@ -1488,18 +1506,18 @@ mod tests {...@@ -1488,18 +1506,18 @@ mod tests {
1488 #[test]1506 #[test]
1489 fn chosen_paths_open_their_notebook_or_section() {1507 fn chosen_paths_open_their_notebook_or_section() {
1490 let root = std::env::temp_dir().join(format!("snowbound-locate-{}", std::process::id()));1508 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);
1492 let notebook = root.join("Notebook");1510 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();
1494 for file in [1512 for file in [
1495 "Open Notebook.onetoc2",1513 "Open Notebook.onetoc2",
1496 "Group/Open Notebook.onetoc2",1514 "Group/Open Notebook.onetoc2",
1497 "Group/Inner.one",1515 "Group/Inner.one",
1498 "Top.one",1516 "Top.one",
1499 ] {1517 ] {
1500 std::fs::write(notebook.join(file), b"").unwrap();1518 notebook::fs::write(notebook.join(file), b"").unwrap();
1501 }1519 }
1502 std::fs::write(root.join("Loose.one"), b"").unwrap();1520 notebook::fs::write(root.join("Loose.one"), b"").unwrap();
1503 let at = |root: &Path, section: Option<&str>| Located::Notebook {1521 let at = |root: &Path, section: Option<&str>| Located::Notebook {
1504 root: root.to_owned(),1522 root: root.to_owned(),
1505 section: section.map(str::to_owned),1523 section: section.map(str::to_owned),
...@@ -1526,6 +1544,6 @@ mod tests {...@@ -1526,6 +1544,6 @@ mod tests {
1526 Located::Package(root.join("Shared.onepkg"))1544 Located::Package(root.join("Shared.onepkg"))
1527 );1545 );
1528 assert_eq!(locate(&root.join("missing.txt")), Located::Nothing);1546 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();
1530 }1548 }
1531}1549}
crates/snowbound/src/main.rs+190-135
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1#![cfg_attr(windows, windows_subsystem = "windows")]1#![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))]
2#[cfg(target_os = "macos")]4#[cfg(target_os = "macos")]
3mod aqua;5mod aqua;
4mod art;6mod art;
...@@ -18,6 +20,7 @@ mod history;...@@ -18,6 +20,7 @@ mod history;
18#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]20#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]
19#[cfg_attr(target_os = "macos", path = "icloud_macos.rs")]21#[cfg_attr(target_os = "macos", path = "icloud_macos.rs")]
20mod icloud;22mod icloud;
23#[cfg(not(target_arch = "wasm32"))]
21#[cfg_attr(target_os = "macos", allow(dead_code))]24#[cfg_attr(target_os = "macos", allow(dead_code))]
22mod instance;25mod instance;
23mod keys;26mod keys;
...@@ -30,6 +33,7 @@ mod manage;...@@ -30,6 +33,7 @@ mod manage;
30#[cfg_attr(target_os = "linux", path = "media_linux.rs")]33#[cfg_attr(target_os = "linux", path = "media_linux.rs")]
31#[cfg_attr(target_os = "macos", path = "media_macos.rs")]34#[cfg_attr(target_os = "macos", path = "media_macos.rs")]
32#[cfg_attr(windows, path = "media_windows.rs")]35#[cfg_attr(windows, path = "media_windows.rs")]
36#[cfg_attr(target_arch = "wasm32", path = "media_web.rs")]
33mod media;37mod media;
34mod meeting;38mod meeting;
35#[cfg(target_os = "macos")]39#[cfg(target_os = "macos")]
...@@ -43,12 +47,14 @@ mod paste;...@@ -43,12 +47,14 @@ mod paste;
43#[cfg_attr(target_os = "linux", path = "linux.rs")]47#[cfg_attr(target_os = "linux", path = "linux.rs")]
44#[cfg_attr(target_os = "macos", path = "macos.rs")]48#[cfg_attr(target_os = "macos", path = "macos.rs")]
45#[cfg_attr(windows, path = "windows.rs")]49#[cfg_attr(windows, path = "windows.rs")]
50#[cfg_attr(target_arch = "wasm32", path = "web.rs")]
46mod platform;51mod platform;
47mod prefetch;52mod prefetch;
48mod print;53mod print;
49#[cfg_attr(target_os = "linux", path = "print_linux.rs")]54#[cfg_attr(target_os = "linux", path = "print_linux.rs")]
50#[cfg_attr(target_os = "macos", path = "print_macos.rs")]55#[cfg_attr(target_os = "macos", path = "print_macos.rs")]
51#[cfg_attr(windows, path = "print_windows.rs")]56#[cfg_attr(windows, path = "print_windows.rs")]
57#[cfg_attr(target_arch = "wasm32", path = "print_web.rs")]
52mod printer;58mod printer;
53mod properties;59mod properties;
54mod protection;60mod protection;
...@@ -64,6 +70,7 @@ mod sidebar;...@@ -64,6 +70,7 @@ mod sidebar;
64#[cfg_attr(target_os = "linux", path = "spell_linux.rs")]70#[cfg_attr(target_os = "linux", path = "spell_linux.rs")]
65#[cfg_attr(target_os = "macos", path = "spell_macos.rs")]71#[cfg_attr(target_os = "macos", path = "spell_macos.rs")]
66#[cfg_attr(windows, path = "spell_windows.rs")]72#[cfg_attr(windows, path = "spell_windows.rs")]
73#[cfg_attr(target_arch = "wasm32", path = "spell_web.rs")]
67mod spell;74mod spell;
68#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")]75#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")]
69#[cfg_attr(windows, path = "surface_windows.rs")]76#[cfg_attr(windows, path = "surface_windows.rs")]
...@@ -78,17 +85,17 @@ mod unpack;...@@ -78,17 +85,17 @@ mod unpack;
78mod unread;85mod unread;
79mod update;86mod update;
80mod video;87mod video;
88#[cfg_attr(target_arch = "wasm32", path = "watch_web.rs")]
81mod watch;89mod 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};
83use canvas::gpu::colorref;95use canvas::gpu::colorref;
84use canvas::gpu::page::PageScene;96use canvas::gpu::page::PageScene;
85use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility};97use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility};
86use canvas::{98use canvas::{date::DateField, document::TextDocument, editor::CanvasEditor, layout::TextEngine};
87 date::DateField,
88 document::TextDocument,
89 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline},
90 layout::TextEngine,
91};
92use draw::Renderer;99use draw::Renderer;
93use library::Library;100use library::Library;
94use onestore::ExGuid;101use onestore::ExGuid;
...@@ -96,21 +103,29 @@ use onestore::document::Format;...@@ -96,21 +103,29 @@ use onestore::document::Format;
96use onestore::page::Page;103use onestore::page::Page;
97use onestore::page::ink::ShapeKind;104use onestore::page::ink::ShapeKind;
98use onestore::page::text::Paragraph;105use onestore::page::text::Paragraph;
106#[cfg(target_arch = "wasm32")]
107use platform::{AccessAdapter, ActiveEventLoop, EventLoopProxy, Window};
99use std::{108use std::{
100 collections::{HashMap, HashSet},109 collections::{HashMap, HashSet},
101 error::Error,110 error::Error,
102 path::{Path, PathBuf},111 path::{Path, PathBuf},
103 sync::{Arc, Mutex, mpsc},112 sync::{Arc, Mutex, mpsc},
104 time::Instant,
105};113};
106use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};114use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};
115use web_time::Instant;
116#[cfg(not(target_arch = "wasm32"))]
107use winit::{117use winit::{
108 application::ApplicationHandler,118 application::ApplicationHandler,
109 dpi::{LogicalPosition, LogicalSize},119 event::{ElementState, MouseScrollDelta, WindowEvent},
110 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
111 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},120 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},
112 keyboard::{Key, ModifiersState, NamedKey},121 keyboard::ModifiersState,
113 window::{CursorIcon, Window, WindowId},122 window::{Window, WindowId},
123};
124use winit::{
125 dpi::{LogicalPosition, LogicalSize},
126 event::{Ime, MouseButton},
127 keyboard::{Key, NamedKey},
128 window::CursorIcon,
114};129};
115130
116/// Height of the toolbar's row, which is the title bar where the platform lets it: a131/// Height of the toolbar's row, which is the title bar where the platform lets it: a
...@@ -207,6 +222,7 @@ enum UserEvent {...@@ -207,6 +222,7 @@ enum UserEvent {
207 /// A picture to insert at the caret, such as a screen clipping.222 /// A picture to insert at the caret, such as a screen clipping.
208 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]223 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
209 Picture(Vec<u8>),224 Picture(Vec<u8>),
225 #[cfg(not(target_arch = "wasm32"))]
210 Accessibility(accesskit_winit::Event),226 Accessibility(accesskit_winit::Event),
211 /// The section's synchronization thread reported an event.227 /// The section's synchronization thread reported an event.
212 Sync,228 Sync,
...@@ -298,6 +314,7 @@ enum Replay {...@@ -298,6 +314,7 @@ enum Replay {
298 Quit,314 Quit,
299}315}
300316
317#[cfg(not(target_arch = "wasm32"))]
301impl From<accesskit_winit::Event> for UserEvent {318impl From<accesskit_winit::Event> for UserEvent {
302 fn from(event: accesskit_winit::Event) -> Self {319 fn from(event: accesskit_winit::Event) -> Self {
303 Self::Accessibility(event)320 Self::Accessibility(event)
...@@ -314,7 +331,7 @@ fn lap(phase: &str, start: Instant) {...@@ -314,7 +331,7 @@ fn lap(phase: &str, start: Instant) {
314331
315fn trace_input(event: &impl std::fmt::Debug) {332fn trace_input(event: &impl std::fmt::Debug) {
316 if std::env::var_os("SNOWBOUND_TRACE_INPUT").is_some() {333 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());
318 }335 }
319}336}
320337
...@@ -334,6 +351,7 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon {...@@ -334,6 +351,7 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon {
334 }351 }
335}352}
336353
354#[cfg(not(target_arch = "wasm32"))]
337struct App {355struct App {
338 proxy: EventLoopProxy<UserEvent>,356 proxy: EventLoopProxy<UserEvent>,
339 input: Option<Input>,357 input: Option<Input>,
...@@ -373,7 +391,7 @@ impl Input {...@@ -373,7 +391,7 @@ impl Input {
373 /// Lists the notebook folder `root` and shows it first, where the window shows notebooks.391 /// Lists the notebook folder `root` and shows it first, where the window shows notebooks.
374 fn show(&mut self, root: &Path) -> std::io::Result<()> {392 fn show(&mut self, root: &Path) -> std::io::Result<()> {
375 if let Input::Notebooks { locations, current } = self {393 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();
377 if !locations.contains(&location) {395 if !locations.contains(&location) {
378 locations.push(location.clone());396 locations.push(location.clone());
379 }397 }
...@@ -816,7 +834,7 @@ struct State {...@@ -816,7 +834,7 @@ struct State {
816 min_width: f32,834 min_width: f32,
817 ime_allowed: bool,835 ime_allowed: bool,
818 clipboard: Clipboard,836 clipboard: Clipboard,
819 access_adapter: accesskit_winit::Adapter,837 access_adapter: AccessAdapter,
820 accessibility: accessibility::Accessibility,838 accessibility: accessibility::Accessibility,
821 /// Assistive technology holds the page's tree, grafted into the interface's.839 /// Assistive technology holds the page's tree, grafted into the interface's.
822 page_grafted: bool,840 page_grafted: bool,
...@@ -889,7 +907,7 @@ impl State {...@@ -889,7 +907,7 @@ impl State {
889 commands::Keymap::from_saved(&stored.keys).install();907 commands::Keymap::from_saved(&stored.keys).install();
890 platform::install_menu();908 platform::install_menu();
891 let access_adapter =909 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());
893 window.set_visible(visible);911 window.set_visible(visible);
894 let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?;912 let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?;
895 let size = window.inner_size();913 let size = window.inner_size();
...@@ -897,8 +915,9 @@ impl State {...@@ -897,8 +915,9 @@ impl State {
897 let fallbacks = platform::symbol_fonts();915 let fallbacks = platform::symbol_fonts();
898 draw::fall_back_to(&mut engine.fonts.collection, &fallbacks);916 draw::fall_back_to(&mut engine.fonts.collection, &fallbacks);
899 for path in substitutes {917 for path in substitutes {
900 let target = engine918 let target = engine.register_substitute(parley::fontique::Blob::new(Arc::new(
901 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;919 notebook::fs::read(path)?,
920 )))?;
902 eprintln!("Using {} for {target}", path.display());921 eprintln!("Using {} for {target}", path.display());
903 }922 }
904 let layouts = Arc::new(Mutex::new(engine.clone()));923 let layouts = Arc::new(Mutex::new(engine.clone()));
...@@ -1028,7 +1047,6 @@ impl State {...@@ -1028,7 +1047,6 @@ impl State {
1028 .unwrap_or([ui.theme.strip; 2]);1047 .unwrap_or([ui.theme.strip; 2]);
1029 // A window shown but never focused hears no focus event; a hidden one draws as focused.1048 // A window shown but never focused hears no focus event; a hidden one draws as focused.
1030 ui.window_focused = !visible || window.has_focus();1049 ui.window_focused = !visible || window.has_focus();
1031 platform::system_interface(&mut ui);
1032 ui.fall_back_to(&fallbacks);1050 ui.fall_back_to(&fallbacks);
1033 ui.set_focus(Some(page()));1051 ui.set_focus(Some(page()));
1034 for family in FONTS {1052 for family in FONTS {
...@@ -1036,6 +1054,7 @@ impl State {...@@ -1036,6 +1054,7 @@ impl State {
1036 ui.preview_font(face.clone(), family);1054 ui.preview_font(face.clone(), family);
1037 }1055 }
1038 }1056 }
1057 platform::system_interface(&mut ui);
1039 let mut fonts: Vec<String> = engine1058 let mut fonts: Vec<String> = engine
1040 .fonts1059 .fonts
1041 .collection1060 .collection
...@@ -3089,7 +3108,7 @@ impl State {...@@ -3089,7 +3108,7 @@ impl State {
3089 let library = Arc::clone(&session.library);3108 let library = Arc::clone(&session.library);
3090 let path = session.tabs[tab].path.clone();3109 let path = session.tabs[tab].path.clone();
3091 let (notify, author) = (notify(self.proxy.clone()), self.author.clone());3110 let (notify, author) = (notify(self.proxy.clone()), self.author.clone());
3092 std::thread::spawn(move || {3111 crate::spawn(move || {
3093 let copied = library.open(&path, notify).and_then(|section| {3112 let copied = library.open(&path, notify).and_then(|section| {
3094 section.import_page(&page, &author)?;3113 section.import_page(&page, &author)?;
3095 Ok(section.close()?)3114 Ok(section.close()?)
...@@ -3161,7 +3180,7 @@ impl State {...@@ -3161,7 +3180,7 @@ impl State {
3161 let requested = Instant::now();3180 let requested = Instant::now();
3162 let scenes = Arc::clone(&self.prefetch.scenes);3181 let scenes = Arc::clone(&self.prefetch.scenes);
3163 let open = self.session.as_ref().map(Session::key);3182 let open = self.session.as_ref().map(Session::key);
3164 std::thread::spawn(move || {3183 crate::spawn(move || {
3165 let laid = read().and_then(|loaded| {3184 let laid = read().and_then(|loaded| {
3166 lap("switch read", requested);3185 lap("switch read", requested);
3167 let (shown, page) = match loaded {3186 let (shown, page) = match loaded {
...@@ -3283,7 +3302,7 @@ impl State {...@@ -3283,7 +3302,7 @@ impl State {
3283 library.keep(&previous.tabs[previous.tab].path, previous.section);3302 library.keep(&previous.tabs[previous.tab].path, previous.section);
3284 } else {3303 } else {
3285 let section = previous.section;3304 let section = previous.section;
3286 std::thread::spawn(move || {3305 crate::spawn(move || {
3287 if let Err(error) = section.close() {3306 if let Err(error) = section.close() {
3288 eprintln!("Synchronization stopped: {error}");3307 eprintln!("Synchronization stopped: {error}");
3289 }3308 }
...@@ -3914,7 +3933,7 @@ impl State {...@@ -3914,7 +3933,7 @@ impl State {
3914 }3933 }
3915 let mut out = String::new();3934 let mut out = String::new();
3916 write(&tree.state().root(), 0, &mut out);3935 write(&tree.state().root(), 0, &mut out);
3917 std::fs::write(path, out)?;3936 notebook::fs::write(path, out)?;
3918 Ok(())3937 Ok(())
3919 }3938 }
39203939
...@@ -4000,7 +4019,7 @@ impl State {...@@ -4000,7 +4019,7 @@ impl State {
4000 let pixels = self.capture()?;4019 let pixels = self.capture()?;
4001 let size = self.surface.size;4020 let size = self.surface.size;
4002 // Encoding off the frame keeps the frames at the pace they are drawn.4021 // Encoding off the frame keeps the frames at the pace they are drawn.
4003 std::thread::spawn(move || {4022 crate::spawn(move || {
4004 if let Err(error) = write_png(&path, size, &pixels) {4023 if let Err(error) = write_png(&path, size, &pixels) {
4005 eprintln!("{error}");4024 eprintln!("{error}");
4006 }4025 }
...@@ -5436,173 +5455,199 @@ fn page_text(editor: &CanvasEditor) -> String {...@@ -5436,173 +5455,199 @@ fn page_text(editor: &CanvasEditor) -> String {
5436 .join("\n\n")5455 .join("\n\n")
5437}5456}
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
5439/// FILETIME now: when an edit happened, which its modification times record.5467/// FILETIME now: when an edit happened, which its modification times record.
5440fn filetime() -> u64 {5468fn filetime() -> u64 {
5441 let unix = std::time::SystemTime::now()5469 let unix = web_time::SystemTime::now()
5442 .duration_since(std::time::UNIX_EPOCH)5470 .duration_since(web_time::UNIX_EPOCH)
5443 .unwrap_or_default();5471 .unwrap_or_default();
5444 (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100)5472 (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100)
5445}5473}
54465474
5447impl App {5475impl State {
5448 fn close(&self, event_loop: &ActiveEventLoop) {5476 /// Does what an event sent to the event loop asks of the open window.
5449 if self.state.as_ref().is_none_or(|state| {5477 fn user_event(&mut self, event: UserEvent) {
5450 let editor = &state.view.editor;5478 match event {
5451 state.session.is_some()5479 // The event loop quits; a page stays open.
5452 || editor.caret_outline().is_none_or(TextOutline::is_empty)5480 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {}
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),
5498 UserEvent::Picture(bytes) => {5481 UserEvent::Picture(bytes) => {
5499 if let Err(error) = state.insert_picture(bytes, None) {5482 if let Err(error) = self.insert_picture(bytes, None) {
5500 eprintln!("{error}");5483 eprintln!("{error}");
5501 }5484 }
5502 return state.window.request_redraw();5485 self.window.request_redraw();
5503 }5486 }
5504 UserEvent::InsertText(text) => {5487 UserEvent::InsertText(text) => {
5505 if state.ui.focused() == Some(page()) {5488 if self.ui.focused() == Some(page()) {
5506 match state.view.insert_text(text) {5489 match self.view.insert_text(text) {
5507 Ok(response) => state.respond(response),5490 Ok(response) => self.respond(response),
5508 Err(error) => eprintln!("{error}"),5491 Err(error) => eprintln!("{error}"),
5509 }5492 }
5510 state.window.request_redraw();5493 self.window.request_redraw();
5511 } else {5494 } else {
5512 state.input(ui::Event::Ime(Ime::Commit(text)));5495 self.input(ui::Event::Ime(Ime::Commit(text)));
5513 }5496 }
5514 return;
5515 }5497 }
5516 UserEvent::Then(then) => {5498 UserEvent::Then(then) => {
5517 if let Err(error) = then(state) {5499 if let Err(error) = then(self) {
5518 eprintln!("{error}");5500 eprintln!("{error}");
5519 }5501 }
5520 return state.window.request_redraw();5502 self.window.request_redraw();
5521 }5503 }
5522 UserEvent::ICloudFolder => return state.list_icloud(),5504 UserEvent::ICloudFolder => self.list_icloud(),
5523 UserEvent::ICloudAccount => {5505 UserEvent::ICloudAccount => {
5524 state.icloud_account_changed();5506 self.icloud_account_changed();
5525 return state.window.request_redraw();5507 self.window.request_redraw();
5526 }5508 }
5527 UserEvent::Open(paths) => {5509 UserEvent::Open(paths) => {
5528 for path in paths {5510 for path in paths {
5529 state.open_path(&path);5511 self.open_path(&path);
5530 }5512 }
5531 state.window.set_minimized(false);5513 self.window.set_minimized(false);
5532 state.window.focus_window();5514 self.window.focus_window();
5533 return state.window.request_redraw();5515 self.window.request_redraw();
5534 }5516 }
5535 UserEvent::Update => {5517 UserEvent::Update => {
5536 state.updated();5518 self.updated();
5537 return state.window.request_redraw();5519 self.window.request_redraw();
5538 }5520 }
5539 UserEvent::Sync => {5521 UserEvent::Sync => {
5540 if let Err(error) = state.synced() {5522 if let Err(error) = self.synced() {
5541 eprintln!("{error}");5523 eprintln!("{error}");
5542 }5524 }
5543 return;
5544 }5525 }
5545 UserEvent::Choose(choice) => return state.choose(choice),5526 UserEvent::Choose(choice) => self.choose(choice),
5546 UserEvent::Appearance => {5527 UserEvent::Appearance => {
5547 state.follow_color_scheme();5528 self.follow_color_scheme();
5548 return state.window.request_redraw();5529 self.window.request_redraw();
5549 }5530 }
5550 UserEvent::Redraw => return state.window.request_redraw(),5531 UserEvent::Redraw => self.window.request_redraw(),
5551 UserEvent::Replay(replay) => {5532 UserEvent::Replay(replay) => {
5552 match replay {5533 match replay {
5553 Replay::Input(event) => state.input(event),5534 Replay::Input(event) => self.input(event),
5554 Replay::Pinch(factor) => {5535 Replay::Pinch(factor) => {
5555 if let Err(error) = state.pinch(factor) {5536 if let Err(error) = self.pinch(factor) {
5556 eprintln!("{error}");5537 eprintln!("{error}");
5557 }5538 }
5558 }5539 }
5559 Replay::Snapshot(path) => state.snapshot = Some(path),5540 Replay::Snapshot(path) => self.snapshot = Some(path),
5560 Replay::Accessibility(path) => {5541 Replay::Accessibility(path) => {
5561 if let Err(error) = state.write_accessibility(&path) {5542 if let Err(error) = self.write_accessibility(&path) {
5562 eprintln!("{error}");5543 eprintln!("{error}");
5563 }5544 }
5564 }5545 }
5565 Replay::Tick | Replay::Quit => {}5546 Replay::Tick | Replay::Quit => {}
5566 Replay::Appearance(appearance) => {5547 Replay::Appearance(appearance) => {
5567 state.window.set_theme(Some(appearance));5548 self.window.set_theme(Some(appearance));
5568 state.set_appearance(appearance);5549 self.set_appearance(appearance);
5569 }5550 }
5570 Replay::Resize([width, height]) => {5551 Replay::Resize([width, height]) => {
5571 let _ = state5552 let _ = self
5572 .window5553 .window
5573 .request_inner_size(LogicalSize::new(width, height));5554 .request_inner_size(LogicalSize::new(width, height));
5574 }5555 }
5575 }5556 }
5576 // A covered window gets no redraws, so each step draws its own frame.5557 // 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() {
5578 eprintln!("{error}");5559 eprintln!("{error}");
5579 }5560 }
5580 return;
5581 }5561 }
5582 UserEvent::Accessibility(event) => event,5562 #[cfg(not(target_arch = "wasm32"))]
5583 };5563 UserEvent::Accessibility(event) => self.access_event(event),
5584 if event.window_id != state.window.id() {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() {
5585 return;5571 return;
5586 }5572 }
5587 let was_marked = state.view.editor.marked_range().is_some();5573 let was_marked = self.view.editor.marked_range().is_some();
5588 let result = match event.window_event {5574 let result = match event.window_event {
5589 accesskit_winit::WindowEvent::InitialTreeRequested => {5575 accesskit_winit::WindowEvent::InitialTreeRequested => {
5590 state.deactivate_accessibility();5576 self.deactivate_accessibility();
5591 state.update_accessibility(true)5577 self.update_accessibility(true)
5592 }5578 }
5593 accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request),5579 accesskit_winit::WindowEvent::ActionRequested(request) => self.access_action(request),
5594 accesskit_winit::WindowEvent::AccessibilityDeactivated => {5580 accesskit_winit::WindowEvent::AccessibilityDeactivated => {
5595 state.deactivate_accessibility();5581 self.deactivate_accessibility();
5596 Ok(())5582 Ok(())
5597 }5583 }
5598 };5584 };
5599 if was_marked && state.view.editor.marked_range().is_none() {5585 if was_marked && self.view.editor.marked_range().is_none() {
5600 platform::clear_marked_text(&state.window);5586 platform::clear_marked_text(&self.window);
5601 }5587 }
5602 if let Err(error) = result {5588 if let Err(error) = result {
5603 eprintln!("{error}");5589 eprintln!("{error}");
5604 }5590 }
5605 }5591 }
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
5607 fn resumed(&mut self, event_loop: &ActiveEventLoop) {5652 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
5608 if self.state.is_some() {5653 if self.state.is_some() {
...@@ -5810,8 +5855,8 @@ impl ApplicationHandler<UserEvent> for App {...@@ -5810,8 +5855,8 @@ impl ApplicationHandler<UserEvent> for App {
58105855
5811fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn Error>> {5856fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn Error>> {
5812 let partial = path.with_extension("partial");5857 let partial = path.with_extension("partial");
5813 std::fs::write(&partial, paste::png(size, pixels)?)?;5858 notebook::fs::write(&partial, paste::png(size, pixels)?)?;
5814 std::fs::rename(partial, path)?;5859 notebook::fs::rename(partial, path)?;
5815 Ok(())5860 Ok(())
5816}5861}
58175862
...@@ -5820,6 +5865,7 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E...@@ -5820,6 +5865,7 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E
5820/// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift]5865/// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift]
5821/// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH`,5866/// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH`,
5822/// `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`.5867/// `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`.
5868#[cfg(not(target_arch = "wasm32"))]
5823fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {5869fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
5824 let mut steps = Vec::new();5870 let mut steps = Vec::new();
5825 for line in script.lines().filter(|line| !line.trim().is_empty()) {5871 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...@@ -5926,6 +5972,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
5926 Ok(())5972 Ok(())
5927}5973}
59285974
5975#[cfg(not(target_arch = "wasm32"))]
5929fn main() -> Result<(), Box<dyn Error>> {5976fn main() -> Result<(), Box<dyn Error>> {
5930 #[cfg(target_os = "linux")]5977 #[cfg(target_os = "linux")]
5931 if std::env::args_os()5978 if std::env::args_os()
...@@ -5947,6 +5994,11 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -5947,6 +5994,11 @@ fn main() -> Result<(), Box<dyn Error>> {
5947 platform::with_pool(launch)5994 platform::with_pool(launch)
5948}5995}
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"))]
5950fn launch() -> Result<(), Box<dyn Error>> {6002fn launch() -> Result<(), Box<dyn Error>> {
5951 let mut args = std::env::args_os().skip(1);6003 let mut args = std::env::args_os().skip(1);
5952 if args.next().is_some_and(|arg| arg == update::FINISH) {6004 if args.next().is_some_and(|arg| arg == update::FINISH) {
...@@ -6014,7 +6066,7 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -6014,7 +6066,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
6014 let title = args6066 let title = args
6015 .next()6067 .next()
6016 .ok_or("Provide a page title after the section file.")?;6068 .ok_or("Provide a page title after the section file.")?;
6017 let bytes = std::fs::read(path)?;6069 let bytes = notebook::fs::read(path)?;
6018 let store = onestore::Store::parse(&bytes)?;6070 let store = onestore::Store::parse(&bytes)?;
6019 let index = onestore::RevisionIndex::parse(&store)?;6071 let index = onestore::RevisionIndex::parse(&store)?;
6020 reference = Some(Page::from_document(6072 reference = Some(Page::from_document(
...@@ -6046,7 +6098,7 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -6046,7 +6098,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
6046 }6098 }
6047 let text = positional6099 let text = positional
6048 .first()6100 .first()
6049 .map(std::fs::read_to_string)6101 .map(notebook::fs::read_to_string)
6050 .transpose()?6102 .transpose()?
6051 .unwrap_or_default();6103 .unwrap_or_default();
6052 let width = positional6104 let width = positional
...@@ -6108,7 +6160,10 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -6108,7 +6160,10 @@ fn launch() -> Result<(), Box<dyn Error>> {
6108 instance.serve(event_loop.create_proxy());6160 instance.serve(event_loop.create_proxy());
6109 }6161 }
6110 if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") {6162 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 )?;
6112 }6167 }
6113 let mut app = App {6168 let mut app = App {
6114 proxy: event_loop.create_proxy(),6169 proxy: event_loop.create_proxy(),
...@@ -6164,10 +6219,10 @@ mod tests {...@@ -6164,10 +6219,10 @@ mod tests {
6164 fn recorded_ops_reach_the_section() {6219 fn recorded_ops_reach_the_section() {
6165 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");6220 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
6166 let directory = std::env::temp_dir().join(format!("snowbound-ops-{}", std::process::id()));6221 let directory = std::env::temp_dir().join(format!("snowbound-ops-{}", std::process::id()));
6167 let _ = std::fs::remove_dir_all(&directory);6222 let _ = notebook::fs::remove_dir_all(&directory);
6168 std::fs::create_dir_all(directory.join("cache")).unwrap();6223 notebook::fs::create_dir_all(directory.join("cache")).unwrap();
6169 let file = directory.join("section.one");6224 let file = directory.join("section.one");
6170 std::fs::copy(6225 notebook::fs::copy(
6171 root.join("corpus/paragraph-edit/before/notebook/synthetic.one"),6226 root.join("corpus/paragraph-edit/before/notebook/synthetic.one"),
6172 &file,6227 &file,
6173 )6228 )
...@@ -6215,7 +6270,7 @@ mod tests {...@@ -6215,7 +6270,7 @@ mod tests {
6215 .any(|event| matches!(event, notebook::session::Event::Rejected { .. }))6270 .any(|event| matches!(event, notebook::session::Event::Rejected { .. }))
6216 );6271 );
6217 section.close().unwrap();6272 section.close().unwrap();
6218 std::fs::remove_dir_all(&directory).unwrap();6273 notebook::fs::remove_dir_all(&directory).unwrap();
6219 }6274 }
62206275
6221 use notebook::session::{Event, Section};6276 use notebook::session::{Event, Section};
...@@ -6267,10 +6322,10 @@ mod tests {...@@ -6267,10 +6322,10 @@ mod tests {
6267 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");6322 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
6268 let directory =6323 let directory =
6269 std::env::temp_dir().join(format!("snowbound-changed-{}", std::process::id()));6324 std::env::temp_dir().join(format!("snowbound-changed-{}", std::process::id()));
6270 let _ = std::fs::remove_dir_all(&directory);6325 let _ = notebook::fs::remove_dir_all(&directory);
6271 std::fs::create_dir_all(&directory).unwrap();6326 notebook::fs::create_dir_all(&directory).unwrap();
6272 let file = directory.join("section.one");6327 let file = directory.join("section.one");
6273 std::fs::copy(6328 notebook::fs::copy(
6274 root.join("corpus/paragraph-edit/before/notebook/synthetic.one"),6329 root.join("corpus/paragraph-edit/before/notebook/synthetic.one"),
6275 &file,6330 &file,
6276 )6331 )
...@@ -6345,7 +6400,7 @@ mod tests {...@@ -6345,7 +6400,7 @@ mod tests {
6345 );6400 );
6346 ours.close().unwrap();6401 ours.close().unwrap();
6347 theirs.close().unwrap();6402 theirs.close().unwrap();
6348 std::fs::remove_dir_all(&directory).unwrap();6403 notebook::fs::remove_dir_all(&directory).unwrap();
6349 }6404 }
63506405
6351 /// Times opening each section of a notebook and each of its pages, phase by phase, as a6406 /// Times opening each section of a notebook and each of its pages, phase by phase, as a
...@@ -6356,7 +6411,7 @@ mod tests {...@@ -6356,7 +6411,7 @@ mod tests {
6356 #[ignore = "measures a notebook named by the environment"]6411 #[ignore = "measures a notebook named by the environment"]
6357 fn switch_timings() {6412 fn switch_timings() {
6358 let cache = std::env::temp_dir().join(format!("snowbound-switch-{}", std::process::id()));6413 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);
6360 let open = || match std::env::var("SNOWBOUND_SWITCH_NOTEBOOK") {6415 let open = || match std::env::var("SNOWBOUND_SWITCH_NOTEBOOK") {
6361 Ok(folder) => Library::notebook(&folder, &cache),6416 Ok(folder) => Library::notebook(&folder, &cache),
6362 Err(_) => {6417 Err(_) => {
...@@ -6421,6 +6476,6 @@ mod tests {...@@ -6421,6 +6476,6 @@ mod tests {
6421 session.section.close().unwrap();6476 session.section.close().unwrap();
6422 }6477 }
6423 }6478 }
6424 let _ = std::fs::remove_dir_all(&cache);6479 let _ = notebook::fs::remove_dir_all(&cache);
6425 }6480 }
6426}6481}
crates/snowbound/src/manage.rs+24-19
...@@ -211,7 +211,7 @@ impl State {...@@ -211,7 +211,7 @@ impl State {
211 };211 };
212 let space = self.session.as_ref().ok_or("No section is open")?.space;212 let space = self.session.as_ref().ok_or("No section is open")?.space;
213 let proxy = self.proxy.clone();213 let proxy = self.proxy.clone();
214 std::thread::spawn(move || {214 crate::spawn(move || {
215 let art = template.pictures().map_err(|error| error.to_string());215 let art = template.pictures().map_err(|error| error.to_string());
216 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {216 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
217 if state217 if state
...@@ -399,9 +399,9 @@ impl State {...@@ -399,9 +399,9 @@ impl State {
399 pub(crate) fn list_icloud(&mut self) {399 pub(crate) fn list_icloud(&mut self) {
400 let proxy = self.proxy.clone();400 let proxy = self.proxy.clone();
401 // Listing a folder iCloud Drive has not brought down yet waits for it.401 // Listing a folder iCloud Drive has not brought down yet waits for it.
402 std::thread::spawn(move || {402 crate::spawn(move || {
403 let listed = crate::icloud::folder().and_then(|root| {403 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)
405 .ok()?405 .ok()?
406 .flatten()406 .flatten()
407 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))407 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
...@@ -446,7 +446,7 @@ impl State {...@@ -446,7 +446,7 @@ impl State {
446 continue;446 continue;
447 }447 }
448 let (cache, proxy) = (self.cache.clone(), self.proxy.clone());448 let (cache, proxy) = (self.cache.clone(), self.proxy.clone());
449 std::thread::spawn(move || {449 crate::spawn(move || {
450 let library = Arc::new(Library::notebook(&location, &cache));450 let library = Arc::new(Library::notebook(&location, &cache));
451 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {451 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
452 state.icloud_reading.remove(&location);452 state.icloud_reading.remove(&location);
...@@ -494,7 +494,7 @@ impl State {...@@ -494,7 +494,7 @@ impl State {
494 return;494 return;
495 }495 }
496 let proxy = self.proxy.clone();496 let proxy = self.proxy.clone();
497 std::thread::spawn(move || {497 crate::spawn(move || {
498 if missing.is_some() {498 if missing.is_some() {
499 std::thread::sleep(std::time::Duration::from_secs(2));499 std::thread::sleep(std::time::Duration::from_secs(2));
500 }500 }
...@@ -544,11 +544,16 @@ impl State {...@@ -544,11 +544,16 @@ impl State {
544 }544 }
545545
546 /// Creates a notebook in the new folder `root` and opens it.546 /// 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>> {
548 let page = self.dated_page(None)?;551 let page = self.dated_page(None)?;
549 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));552 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));
550 self.load(move || {553 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();
552 let notebook = Notebook::create(&location, &cache, Notebook::NEW_COLOR, &page)?;557 let notebook = Notebook::create(&location, &cache, Notebook::NEW_COLOR, &page)?;
553 let library = Arc::new(Library::created(&location, notebook, &cache));558 let library = Arc::new(Library::created(&location, notebook, &cache));
554 let path = library559 let path = library
...@@ -584,7 +589,7 @@ impl State {...@@ -584,7 +589,7 @@ impl State {
584 session.library.name,589 session.library.name,
585 crate::filetime()590 crate::filetime()
586 ));591 ));
587 let exported = std::fs::create_dir_all(&folder)592 let exported = notebook::fs::create_dir_all(&folder)
588 .map_err(notebook::Error::from)593 .map_err(notebook::Error::from)
589 .and_then(|()| session.section.export_recovery(&archive));594 .and_then(|()| session.section.export_recovery(&archive));
590 if let Err(error) = exported {595 if let Err(error) = exported {
...@@ -936,13 +941,13 @@ mod tests {...@@ -936,13 +941,13 @@ mod tests {
936 }941 }
937942
938 fn copy(from: &Path, to: &Path) {943 fn copy(from: &Path, to: &Path) {
939 std::fs::create_dir_all(to).unwrap();944 notebook::fs::create_dir_all(to).unwrap();
940 for entry in std::fs::read_dir(from).unwrap().flatten() {945 for entry in notebook::fs::read_dir(from).unwrap().flatten() {
941 let target = to.join(entry.file_name());946 let target = to.join(entry.file_name());
942 if entry.file_type().unwrap().is_dir() {947 if entry.file_type().unwrap().is_dir() {
943 copy(&entry.path(), &target);948 copy(&entry.path(), &target);
944 } else {949 } else {
945 std::fs::copy(entry.path(), target).unwrap();950 notebook::fs::copy(entry.path(), target).unwrap();
946 }951 }
947 }952 }
948 }953 }
...@@ -956,8 +961,8 @@ mod tests {...@@ -956,8 +961,8 @@ mod tests {
956 fn a_notebook_is_managed_as_onenote_manages_one() {961 fn a_notebook_is_managed_as_onenote_manages_one() {
957 let temporary =962 let temporary =
958 std::env::temp_dir().join(format!("snowbound-manage-{}", std::process::id()));963 std::env::temp_dir().join(format!("snowbound-manage-{}", std::process::id()));
959 let _ = std::fs::remove_dir_all(&temporary);964 let _ = notebook::fs::remove_dir_all(&temporary);
960 std::fs::create_dir_all(&temporary).unwrap();965 notebook::fs::create_dir_all(&temporary).unwrap();
961 let root = temporary.join("Managed");966 let root = temporary.join("Managed");
962 let cache = temporary.join("cache");967 let cache = temporary.join("cache");
963 let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &dated()).unwrap();968 let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &dated()).unwrap();
...@@ -1182,7 +1187,7 @@ mod tests {...@@ -1182,7 +1187,7 @@ mod tests {
1182 if let Some(directory) = std::env::var_os("SNOWBOUND_MANAGEMENT_EXPORT") {1187 if let Some(directory) = std::env::var_os("SNOWBOUND_MANAGEMENT_EXPORT") {
1183 copy(&root, Path::new(&directory));1188 copy(&root, Path::new(&directory));
1184 }1189 }
1185 std::fs::remove_dir_all(&temporary).unwrap();1190 notebook::fs::remove_dir_all(&temporary).unwrap();
1186 }1191 }
11871192
1188 /// Deleting a group's last section shows the notebook's other one; deleting that too1193 /// Deleting a group's last section shows the notebook's other one; deleting that too
...@@ -1191,8 +1196,8 @@ mod tests {...@@ -1191,8 +1196,8 @@ mod tests {
1191 fn deleting_every_section_leaves_the_notebook_showing_none() {1196 fn deleting_every_section_leaves_the_notebook_showing_none() {
1192 let temporary =1197 let temporary =
1193 std::env::temp_dir().join(format!("snowbound-empty-{}", std::process::id()));1198 std::env::temp_dir().join(format!("snowbound-empty-{}", std::process::id()));
1194 let _ = std::fs::remove_dir_all(&temporary);1199 let _ = notebook::fs::remove_dir_all(&temporary);
1195 std::fs::create_dir_all(&temporary).unwrap();1200 notebook::fs::create_dir_all(&temporary).unwrap();
1196 let root = temporary.join("Emptied");1201 let root = temporary.join("Emptied");
1197 let location = root.to_str().unwrap();1202 let location = root.to_str().unwrap();
1198 let cache = temporary.join("cache");1203 let cache = temporary.join("cache");
...@@ -1213,7 +1218,7 @@ mod tests {...@@ -1213,7 +1218,7 @@ mod tests {
1213 let library = library.with(notebook);1218 let library = library.with(notebook);
1214 assert_eq!(shown(&library, Some("New Section 1.one".into())), None);1219 assert_eq!(shown(&library, Some("New Section 1.one".into())), None);
1215 assert!(library.tabs("").is_empty());1220 assert!(library.tabs("").is_empty());
1216 std::fs::remove_dir_all(&temporary).unwrap();1221 notebook::fs::remove_dir_all(&temporary).unwrap();
1217 }1222 }
12181223
1219 #[test]1224 #[test]
...@@ -1243,7 +1248,7 @@ mod tests {...@@ -1243,7 +1248,7 @@ mod tests {
1243 fn backgrounds_go_on_and_off_pages_that_have_content() {1248 fn backgrounds_go_on_and_off_pages_that_have_content() {
1244 let temporary =1249 let temporary =
1245 std::env::temp_dir().join(format!("snowbound-background-{}", std::process::id()));1250 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);
1247 copy(1252 copy(
1248 Path::new(concat!(1253 Path::new(concat!(
1249 env!("CARGO_MANIFEST_DIR"),1254 env!("CARGO_MANIFEST_DIR"),
...@@ -1329,6 +1334,6 @@ mod tests {...@@ -1329,6 +1334,6 @@ mod tests {
1329 if let Some(directory) = std::env::var_os("SNOWBOUND_BACKGROUND_EXPORT") {1334 if let Some(directory) = std::env::var_os("SNOWBOUND_BACKGROUND_EXPORT") {
1330 copy(&temporary, Path::new(&directory));1335 copy(&temporary, Path::new(&directory));
1331 }1336 }
1332 std::fs::remove_dir_all(&temporary).unwrap();1337 notebook::fs::remove_dir_all(&temporary).unwrap();
1333 }1338 }
1334}1339}
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 {...@@ -1046,10 +1046,10 @@ mod tests {
1046 fn sections_and_the_notebook_take_onenotes_colours() {1046 fn sections_and_the_notebook_take_onenotes_colours() {
1047 let temporary =1047 let temporary =
1048 std::env::temp_dir().join(format!("snowbound-colors-{}", std::process::id()));1048 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);
1050 let root = temporary.join("Colors");1050 let root = temporary.join("Colors");
1051 let cache = temporary.join("cache");1051 let cache = temporary.join("cache");
1052 std::fs::create_dir_all(&temporary).unwrap();1052 notebook::fs::create_dir_all(&temporary).unwrap();
1053 let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap();1053 let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap();
1054 let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page()).unwrap();1054 let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page()).unwrap();
1055 let named: Vec<(Option<u32>, &str)> = (SECTION_COLORS.iter())1055 let named: Vec<(Option<u32>, &str)> = (SECTION_COLORS.iter())
...@@ -1080,14 +1080,14 @@ mod tests {...@@ -1080,14 +1080,14 @@ mod tests {
1080 assert_eq!(colors, expected);1080 assert_eq!(colors, expected);
1081 if let Some(directory) = std::env::var_os("SNOWBOUND_SECTION_COLOR_EXPORT") {1081 if let Some(directory) = std::env::var_os("SNOWBOUND_SECTION_COLOR_EXPORT") {
1082 let directory = std::path::Path::new(&directory);1082 let directory = std::path::Path::new(&directory);
1083 std::fs::create_dir_all(directory).unwrap();1083 notebook::fs::create_dir_all(directory).unwrap();
1084 for entry in std::fs::read_dir(&root).unwrap() {1084 for entry in notebook::fs::read_dir(&root).unwrap() {
1085 let entry = entry.unwrap();1085 let entry = entry.unwrap();
1086 if entry.file_type().unwrap().is_file() {1086 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();
1088 }1088 }
1089 }1089 }
1090 }1090 }
1091 std::fs::remove_dir_all(&temporary).unwrap();1091 notebook::fs::remove_dir_all(&temporary).unwrap();
1092 }1092 }
1093}1093}
crates/snowbound/src/pane.rs+1-1
...@@ -169,7 +169,7 @@ fn local_day(time: u64) -> i64 {...@@ -169,7 +169,7 @@ fn local_day(time: u64) -> i64 {
169 libc::localtime_r(&unix, &mut tm);169 libc::localtime_r(&unix, &mut tm);
170 tm.tm_gmtoff as i64170 tm.tm_gmtoff as i64
171 };171 };
172 #[cfg(windows)]172 #[cfg(any(windows, target_arch = "wasm32"))]
173 let offset = crate::platform::utc_offset(unix);173 let offset = crate::platform::utc_offset(unix);
174 (unix + offset).div_euclid(86_400)174 (unix + offset).div_euclid(86_400)
175}175}
crates/snowbound/src/paste.rs+15-9
...@@ -5,7 +5,6 @@ use canvas::editor::Piece;...@@ -5,7 +5,6 @@ use canvas::editor::Piece;
5use std::{5use std::{
6 error::Error,6 error::Error,
7 path::{Path, PathBuf},7 path::{Path, PathBuf},
8 time::Duration,
9};8};
109
11/// What Paste takes from the clipboard. Files come first, as Finder also offers their names10/// What Paste takes from the clipboard. Files come first, as Finder also offers their names
...@@ -45,7 +44,7 @@ impl State {...@@ -45,7 +44,7 @@ impl State {
45 self.respond(response);44 self.respond(response);
46 for (place, (source, css)) in places.into_iter().zip(fetched) {45 for (place, (source, css)) in places.into_iter().zip(fetched) {
47 let proxy = self.proxy.clone();46 let proxy = self.proxy.clone();
48 std::thread::spawn(move || {47 crate::spawn(move || {
49 let Some(bytes) = fetch(&source) else {48 let Some(bytes) = fetch(&source) else {
50 return;49 return;
51 };50 };
...@@ -78,7 +77,7 @@ impl State {...@@ -78,7 +77,7 @@ impl State {
78 path: &Path,77 path: &Path,
79 at: Option<[f32; 2]>,78 at: Option<[f32; 2]>,
80 ) -> Result<(), Box<dyn Error>> {79 ) -> Result<(), Box<dyn Error>> {
81 match std::fs::read(path) {80 match notebook::fs::read(path) {
82 Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => {81 Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => {
83 self.insert_picture(bytes, at)82 self.insert_picture(bytes, at)
84 }83 }
...@@ -315,15 +314,22 @@ fn load(source: &str) -> Option<Vec<u8>> {...@@ -315,15 +314,22 @@ fn load(source: &str) -> Option<Vec<u8>> {
315 Some(drive) if cfg!(windows) => drive.to_owned(),314 Some(drive) if cfg!(windows) => drive.to_owned(),
316 _ => path,315 _ => path,
317 };316 };
318 return std::fs::read(path).ok();317 return notebook::fs::read(path).ok();
319 }318 }
320 None319 None
321}320}
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
323/// A web picture's bytes, on a thread of its own.328/// A web picture's bytes, on a thread of its own.
329#[cfg(not(target_arch = "wasm32"))]
324fn fetch(source: &str) -> Option<Vec<u8>> {330fn fetch(source: &str) -> Option<Vec<u8>> {
325 let agent: ureq::Agent = ureq::Agent::config_builder()331 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)))
327 .build()333 .build()
328 .into();334 .into();
329 agent335 agent
...@@ -512,7 +518,7 @@ fn exif_resolution(tiff: &[u8]) -> Option<[f32; 2]> {...@@ -512,7 +518,7 @@ fn exif_resolution(tiff: &[u8]) -> Option<[f32; 2]> {
512518
513/// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes519/// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes
514/// 96 dpi as OneNote 2010 gives a pasted bitmap.520/// 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")))]
516pub(crate) fn bitmap(image: arboard::ImageData) -> Option<Vec<u8>> {522pub(crate) fn bitmap(image: arboard::ImageData) -> Option<Vec<u8>> {
517 png(523 png(
518 [image.width, image.height].map(|side| side as u32),524 [image.width, image.height].map(|side| side as u32),
...@@ -667,12 +673,12 @@ mod tests {...@@ -667,12 +673,12 @@ mod tests {
667 assert_eq!(load("relative/pic.png"), None);673 assert_eq!(load("relative/pic.png"), None);
668 let directory =674 let directory =
669 std::env::temp_dir().join(format!("snowbound-paste-{}", std::process::id()));675 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();
671 let file = directory.join("a picture.png");677 let file = directory.join("a picture.png");
672 std::fs::write(&file, b"bytes").unwrap();678 notebook::fs::write(&file, b"bytes").unwrap();
673 let address = format!("file://{}", file.to_str().unwrap().replace(' ', "%20"));679 let address = format!("file://{}", file.to_str().unwrap().replace(' ', "%20"));
674 assert_eq!(load(&address).unwrap(), b"bytes");680 assert_eq!(load(&address).unwrap(), b"bytes");
675 std::fs::remove_dir_all(&directory).unwrap();681 notebook::fs::remove_dir_all(&directory).unwrap();
676 }682 }
677683
678 fn close(size: [f32; 2], expected: [f32; 2]) -> bool {684 fn close(size: [f32; 2], expected: [f32; 2]) -> bool {
crates/snowbound/src/prefetch.rs+4
...@@ -109,6 +109,10 @@ impl Prefetch {...@@ -109,6 +109,10 @@ impl Prefetch {
109 let scenes = Scenes::new(Mutex::new(Recent::new(SCENES)));109 let scenes = Scenes::new(Mutex::new(Recent::new(SCENES)));
110 let (jobs, queue) = mpsc::channel();110 let (jobs, queue) = mpsc::channel();
111 let kept = Arc::clone(&scenes);111 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"))]
112 std::thread::Builder::new()116 std::thread::Builder::new()
113 .name("prefetch".into())117 .name("prefetch".into())
114 .spawn(move || {118 .spawn(move || {
crates/snowbound/src/print.rs+13-11
...@@ -343,7 +343,7 @@ impl State {...@@ -343,7 +343,7 @@ impl State {
343 footer: setup.footer,343 footer: setup.footer,
344 };344 };
345 let whole = matches!(setup.scope, Scope::Section | Scope::Notebook);345 let whole = matches!(setup.scope, Scope::Section | Scope::Notebook);
346 std::thread::spawn(move || {346 crate::spawn(move || {
347 let made = (|| -> Result<Vec<u8>, Box<dyn Error>> {347 let made = (|| -> Result<Vec<u8>, Box<dyn Error>> {
348 let mut open = Some(open);348 let mut open = Some(open);
349 let this = spaces349 let this = spaces
...@@ -380,7 +380,9 @@ impl State {...@@ -380,7 +380,9 @@ impl State {
380 Ok(canvas::print::pdf(sections, &mut engine, &layout)?)380 Ok(canvas::print::pdf(sections, &mut engine, &layout)?)
381 })();381 })();
382 let done = made.and_then(|pdf| match &destination {382 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),
384 None => Ok(Some(pdf)),386 None => Ok(Some(pdf)),
385 });387 });
386 let failed = if export {388 let failed = if export {
...@@ -468,9 +470,9 @@ pub(crate) fn spooled(pdf: &[u8], title: &str) -> Result<PathBuf, Box<dyn Error>...@@ -468,9 +470,9 @@ pub(crate) fn spooled(pdf: &[u8], title: &str) -> Result<PathBuf, Box<dyn Error>
468 let folder = platform::cache_dir()470 let folder = platform::cache_dir()
469 .unwrap_or_else(std::env::temp_dir)471 .unwrap_or_else(std::env::temp_dir)
470 .join("Printing");472 .join("Printing");
471 std::fs::create_dir_all(&folder)?;473 notebook::fs::create_dir_all(&folder)?;
472 let path = folder.join(format!("{}.pdf", file_name(title)));474 let path = folder.join(format!("{}.pdf", file_name(title)));
473 std::fs::write(&path, pdf)?;475 notebook::fs::write(&path, pdf)?;
474 Ok(path)476 Ok(path)
475}477}
476478
...@@ -502,8 +504,8 @@ mod tests {...@@ -502,8 +504,8 @@ mod tests {
502 PageCreation,504 PageCreation,
503 op::{Edit, Op, SectionOp},505 op::{Edit, Op, SectionOp},
504 };506 };
505 let _ = std::fs::remove_dir_all(temporary);507 let _ = notebook::fs::remove_dir_all(temporary);
506 std::fs::create_dir_all(temporary).unwrap();508 notebook::fs::create_dir_all(temporary).unwrap();
507 let root = temporary.join("Ordered");509 let root = temporary.join("Ordered");
508 let cache = temporary.join("cache");510 let cache = temporary.join("cache");
509 let page = |title: &str| {511 let page = |title: &str| {
...@@ -558,7 +560,7 @@ mod tests {...@@ -558,7 +560,7 @@ mod tests {
558 let tabs = notebook_tabs(&library);560 let tabs = notebook_tabs(&library);
559 library.close_kept();561 library.close_kept();
560 drop(library);562 drop(library);
561 let _ = std::fs::remove_dir_all(&temporary);563 let _ = notebook::fs::remove_dir_all(&temporary);
562 let paths: Vec<&str> = tabs.iter().map(|(path, _)| path.as_str()).collect();564 let paths: Vec<&str> = tabs.iter().map(|(path, _)| path.as_str()).collect();
563 assert_eq!(paths, ["A.one", "G/C.one", "B.one"]);565 assert_eq!(paths, ["A.one", "G/C.one", "B.one"]);
564 }566 }
...@@ -576,7 +578,7 @@ mod tests {...@@ -576,7 +578,7 @@ mod tests {
576 .collect();578 .collect();
577 section.close().unwrap();579 section.close().unwrap();
578 drop(library);580 drop(library);
579 let _ = std::fs::remove_dir_all(&temporary);581 let _ = notebook::fs::remove_dir_all(&temporary);
580 assert_eq!(pages.len(), 3);582 assert_eq!(pages.len(), 3);
581 // A page's title as typed, which its title object holds.583 // A page's title as typed, which its title object holds.
582 let typed = |page: &Page| {584 let typed = |page: &Page| {
...@@ -607,8 +609,8 @@ mod tests {...@@ -607,8 +609,8 @@ mod tests {
607 use notebook::session::Notebook;609 use notebook::session::Notebook;
608 let temporary =610 let temporary =
609 std::env::temp_dir().join(format!("snowbound-print-bin-{}", std::process::id()));611 std::env::temp_dir().join(format!("snowbound-print-bin-{}", std::process::id()));
610 let _ = std::fs::remove_dir_all(&temporary);612 let _ = notebook::fs::remove_dir_all(&temporary);
611 std::fs::create_dir_all(&temporary).unwrap();613 notebook::fs::create_dir_all(&temporary).unwrap();
612 let root = temporary.join("Printed");614 let root = temporary.join("Printed");
613 let cache = temporary.join("cache");615 let cache = temporary.join("cache");
614 let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap();616 let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap();
...@@ -622,7 +624,7 @@ mod tests {...@@ -622,7 +624,7 @@ mod tests {
622 .into_iter()624 .into_iter()
623 .map(|(path, _)| path)625 .map(|(path, _)| path)
624 .collect();626 .collect();
625 std::fs::remove_dir_all(&temporary).unwrap();627 notebook::fs::remove_dir_all(&temporary).unwrap();
626 assert_eq!(paths, ["New Section 1.one", "Group/Inner.one"]);628 assert_eq!(paths, ["New Section 1.one", "Group/Inner.one"]);
627 }629 }
628}630}
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 {...@@ -73,7 +73,7 @@ impl State {
73 _ => self.media = Media::Idle,73 _ => self.media = Media::Idle,
74 }74 }
75 let folder = std::env::temp_dir().join("Snowbound Recordings");75 let folder = std::env::temp_dir().join("Snowbound Recordings");
76 std::fs::create_dir_all(&folder)?;76 notebook::fs::create_dir_all(&folder)?;
77 let extension = if video { "avi" } else { "wav" };77 let extension = if video { "avi" } else { "wav" };
78 let path = folder.join(format!("{}.{extension}", std::process::id()));78 let path = folder.join(format!("{}.{extension}", std::process::id()));
79 let recorder = if video {79 let recorder = if video {
...@@ -131,7 +131,8 @@ impl State {...@@ -131,7 +131,8 @@ impl State {
131 let recorded = path.clone();131 let recorded = path.clone();
132 let job = std::thread::spawn(move || {132 let job = std::thread::spawn(move || {
133 finish()?;133 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())?;
135 Ok(if video {136 Ok(if video {
136 recorded137 recorded
137 } else {138 } else {
...@@ -168,7 +169,7 @@ impl State {...@@ -168,7 +169,7 @@ impl State {
168 Wave::parse(&bytes).map(|wave| wave.duration_ms())169 Wave::parse(&bytes).map(|wave| wave.duration_ms())
169 };170 };
170 let preview = platform::file_icon(&path);171 let preview = platform::file_icon(&path);
171 std::fs::remove_file(&path)?;172 notebook::fs::remove_file(&path)?;
172 let title = self.view.editor.page()?.title;173 let title = self.view.editor.page()?.title;
173 let name: String = title174 let name: String = title
174 .chars()175 .chars()
...@@ -1042,10 +1043,10 @@ pub(crate) mod tests {...@@ -1042,10 +1043,10 @@ pub(crate) mod tests {
10421043
1043 if let Some(directory) = std::env::var_os(export) {1044 if let Some(directory) = std::env::var_os(export) {
1044 let directory = PathBuf::from(directory);1045 let directory = PathBuf::from(directory);
1045 std::fs::create_dir_all(&directory).unwrap();1046 notebook::fs::create_dir_all(&directory).unwrap();
1046 std::fs::write(directory.join("Recorded.one"), &image).unwrap();1047 notebook::fs::write(directory.join("Recorded.one"), &image).unwrap();
1047 let file_id = Store::parse(&image).unwrap().header.file_id;1048 let file_id = Store::parse(&image).unwrap().header.file_id;
1048 std::fs::write(1049 notebook::fs::write(
1049 directory.join("Open Notebook.onetoc2"),1050 directory.join("Open Notebook.onetoc2"),
1050 onestore::create_table_of_contents(1051 onestore::create_table_of_contents(
1051 "Open Notebook.onetoc2",1052 "Open Notebook.onetoc2",
crates/snowbound/src/recycle.rs+7-7
...@@ -261,7 +261,7 @@ mod tests {...@@ -261,7 +261,7 @@ mod tests {
261 }261 }
262262
263 fn titles(root: &std::path::Path, path: &str) -> Vec<(String, Option<[u8; 16]>)> {263 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();
265 (pages.into_iter())265 (pages.into_iter())
266 .map(|page| (page.page.title, page.page.identity))266 .map(|page| (page.page.title, page.page.identity))
267 .collect()267 .collect()
...@@ -275,8 +275,8 @@ mod tests {...@@ -275,8 +275,8 @@ mod tests {
275 #[test]275 #[test]
276 fn pages_and_sections_leave_the_recycle_bin_as_onenote_restores_them() {276 fn pages_and_sections_leave_the_recycle_bin_as_onenote_restores_them() {
277 let folder = std::env::temp_dir().join(format!("snowbound-recycle-{}", std::process::id()));277 let folder = std::env::temp_dir().join(format!("snowbound-recycle-{}", std::process::id()));
278 let _ = std::fs::remove_dir_all(&folder);278 let _ = notebook::fs::remove_dir_all(&folder);
279 std::fs::create_dir_all(&folder).unwrap();279 notebook::fs::create_dir_all(&folder).unwrap();
280 let (root, cache) = (folder.join("Recycled"), folder.join("cache"));280 let (root, cache) = (folder.join("Recycled"), folder.join("cache"));
281 let page = |title: &str| PageCreation::new(None, Some(title), AUTHOR).unwrap();281 let page = |title: &str| PageCreation::new(None, Some(title), AUTHOR).unwrap();
282 let mut notebook =282 let mut notebook =
...@@ -376,17 +376,17 @@ mod tests {...@@ -376,17 +376,17 @@ mod tests {
376 if let Some(directory) = &export {376 if let Some(directory) = &export {
377 copy(&root, &directory.join("after"));377 copy(&root, &directory.join("after"));
378 }378 }
379 std::fs::remove_dir_all(&folder).unwrap();379 notebook::fs::remove_dir_all(&folder).unwrap();
380 }380 }
381381
382 fn copy(from: &std::path::Path, to: &std::path::Path) {382 fn copy(from: &std::path::Path, to: &std::path::Path) {
383 std::fs::create_dir_all(to).unwrap();383 notebook::fs::create_dir_all(to).unwrap();
384 for entry in std::fs::read_dir(from).unwrap().flatten() {384 for entry in notebook::fs::read_dir(from).unwrap().flatten() {
385 let target = to.join(entry.file_name());385 let target = to.join(entry.file_name());
386 if entry.file_type().unwrap().is_dir() {386 if entry.file_type().unwrap().is_dir() {
387 copy(&entry.path(), &target);387 copy(&entry.path(), &target);
388 } else {388 } else {
389 std::fs::copy(entry.path(), target).unwrap();389 notebook::fs::copy(entry.path(), target).unwrap();
390 }390 }
391 }391 }
392 }392 }
crates/snowbound/src/save_as.rs+2-2
...@@ -187,7 +187,7 @@ impl State {...@@ -187,7 +187,7 @@ impl State {
187 .into_iter())187 .into_iter())
188 .find(|tab| tab.path == section)188 .find(|tab| tab.path == section)
189 .and_then(|tab| tab.color);189 .and_then(|tab| tab.color);
190 std::thread::spawn(move || {190 crate::spawn(move || {
191 let written = (|| -> Result<(), Box<dyn Error>> {191 let written = (|| -> Result<(), Box<dyn Error>> {
192 let image = |path: &str| {192 let image = |path: &str| {
193 let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?;193 let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?;
...@@ -212,7 +212,7 @@ impl State {...@@ -212,7 +212,7 @@ impl State {
212 package::pack(package::notebook_files(&notebook, image)?)?212 package::pack(package::notebook_files(&notebook, image)?)?
213 }213 }
214 };214 };
215 std::fs::write(&path, bytes)?;215 notebook::fs::write(&path, bytes)?;
216 Ok(())216 Ok(())
217 })();217 })();
218 let written = written.map_err(|error| error.to_string());218 let written = written.map_err(|error| error.to_string());
crates/snowbound/src/search.rs+67-30
...@@ -17,9 +17,10 @@ use std::{...@@ -17,9 +17,10 @@ use std::{
17 atomic::{AtomicBool, AtomicU64, Ordering},17 atomic::{AtomicBool, AtomicU64, Ordering},
18 mpsc,18 mpsc,
19 },19 },
20 time::{Duration, Instant, SystemTime},20 time::{Duration, SystemTime},
21};21};
22use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px};22use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px};
23use web_time::Instant;
23use winit::keyboard::NamedKey;24use winit::keyboard::NamedKey;
2425
25/// Where a search looks, as OneNote's scope menu offers it.26/// Where a search looks, as OneNote's scope menu offers it.
...@@ -116,6 +117,10 @@ pub struct Search {...@@ -116,6 +117,10 @@ pub struct Search {
116 version: Arc<AtomicU64>,117 version: Arc<AtomicU64>,
117 pub(crate) busy: Arc<AtomicBool>,118 pub(crate) busy: Arc<AtomicBool>,
118 jobs: mpsc::Sender<Job>,119 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>>,
119 pub(crate) found: Vec<Found>,124 pub(crate) found: Vec<Found>,
120 found_for: Option<(Query, Scope, u64, String)>,125 found_for: Option<(Query, Scope, u64, String)>,
121 pub(crate) selected: Option<u64>,126 pub(crate) selected: Option<u64>,
...@@ -136,10 +141,18 @@ impl Search {...@@ -136,10 +141,18 @@ impl Search {
136 let index = Arc::new(Mutex::new(Index::default()));141 let index = Arc::new(Mutex::new(Index::default()));
137 let version = Arc::new(AtomicU64::new(0));142 let version = Arc::new(AtomicU64::new(0));
138 let busy = Arc::new(AtomicBool::new(false));143 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"))]
140 std::thread::Builder::new()153 std::thread::Builder::new()
141 .name("search-index".into())154 .name("search-index".into())
142 .spawn(move || run(receiver, shared, redraw))155 .spawn(move || indexing.run())
143 .expect("the index thread starts");156 .expect("the index thread starts");
144 Self {157 Self {
145 query: String::new(),158 query: String::new(),
...@@ -152,6 +165,8 @@ impl Search {...@@ -152,6 +165,8 @@ impl Search {
152 version,165 version,
153 busy,166 busy,
154 jobs,167 jobs,
168 #[cfg(target_arch = "wasm32")]
169 indexing,
155 found: Vec::new(),170 found: Vec::new(),
156 found_for: None,171 found_for: None,
157 selected: None,172 selected: None,
...@@ -166,13 +181,28 @@ impl Search {...@@ -166,13 +181,28 @@ impl Search {
166 /// Asks the index to read `spaces` of the open section again, as edits or another181 /// Asks the index to read `spaces` of the open section again, as edits or another
167 /// writer changed them, and its page list.182 /// writer changed them, and its page list.
168 pub fn changed(&self, key: String, replica: &Arc<Replica>, spaces: Vec<ExGuid>) {183 pub fn changed(&self, key: String, replica: &Arc<Replica>, spaces: Vec<ExGuid>) {
169 let _ = self.jobs.send(Job::Pages {184 self.send(Job::Pages {
170 key,185 key,
171 replica: Arc::downgrade(replica),186 replica: Arc::downgrade(replica),
172 spaces,187 spaces,
173 });188 });
174 }189 }
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
176 /// Pages of section `key` among the results, which the page list marks.206 /// Pages of section `key` among the results, which the page list marks.
177 pub fn found_in(&self, key: &str) -> HashSet<ExGuid> {207 pub fn found_in(&self, key: &str) -> HashSet<ExGuid> {
178 if self.finding || self.query.trim().is_empty() {208 if self.finding || self.query.trim().is_empty() {
...@@ -195,31 +225,45 @@ impl Search {...@@ -195,31 +225,45 @@ impl Search {
195type Stamp = Option<(u64, SystemTime)>;225type Stamp = Option<(u64, SystemTime)>;
196226
197fn stamp(file: &Path) -> Stamp {227fn stamp(file: &Path) -> Stamp {
198 let metadata = std::fs::metadata(file).ok()?;228 let metadata = notebook::fs::metadata(file).ok()?;
199 Some((metadata.len(), metadata.modified().ok()?))229 Some((metadata.len(), metadata.modified().ok()?))
200}230}
201231
202/// Now in Time32, seconds since 1980, as page modification times are kept.232/// Now in Time32, seconds since 1980, as page modification times are kept.
203pub(crate) fn now() -> u64 {233pub(crate) fn now() -> u64 {
204 SystemTime::now()234 web_time::SystemTime::now()
205 .duration_since(SystemTime::UNIX_EPOCH)235 .duration_since(web_time::UNIX_EPOCH)
206 .map_or(0, |since| since.as_secs().saturating_sub(315_532_800))236 .map_or(0, |since| since.as_secs().saturating_sub(315_532_800))
207}237}
208238
209/// The index thread: answers jobs, the newest notebooks first, then page changes, and239/// The index thread's jobs and what it keeps between them.
210/// wakes the window once the index changed.240struct Indexing {
211fn run(
212 jobs: mpsc::Receiver<Job>,241 jobs: mpsc::Receiver<Job>,
213 (index, version, busy): (Arc<Mutex<Index>>, Arc<AtomicU64>, Arc<AtomicBool>),242 shared: (Arc<Mutex<Index>>, Arc<AtomicU64>, Arc<AtomicBool>),
214 redraw: std::task::Waker,243 redraw: std::task::Waker,
215) {244 stamps: HashMap<String, Stamp>,
216 let mut stamps: HashMap<String, Stamp> = HashMap::new();245}
217 while let Ok(first) = jobs.recv() {246
218 busy.store(true, Ordering::Relaxed);247impl Indexing {
219 // Typing sends a job a keystroke; a pause gathers them into one read.248 /// The index thread: answers jobs, gathering those typing sends a keystroke apart.
220 if matches!(first, Job::Pages { .. }) {249 #[cfg(not(target_arch = "wasm32"))]
221 std::thread::sleep(SETTLE);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);
222 }258 }
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);
223 let mut notebooks = None;267 let mut notebooks = None;
224 let mut changed = HashSet::new();268 let mut changed = HashSet::new();
225 let mut pages: HashMap<String, (Weak<Replica>, HashSet<ExGuid>)> = HashMap::new();269 let mut pages: HashMap<String, (Weak<Replica>, HashSet<ExGuid>)> = HashMap::new();
...@@ -246,25 +290,18 @@ fn run(...@@ -246,25 +290,18 @@ fn run(
246 }290 }
247 let start = Instant::now();291 let start = Instant::now();
248 if let Some((libraries, open)) = notebooks {292 if let Some((libraries, open)) = notebooks {
249 sync(293 sync(index, version, stamps, &libraries, open.as_ref(), &changed);
250 &index,
251 &version,
252 &mut stamps,
253 &libraries,
254 open.as_ref(),
255 &changed,
256 );
257 lap("index notebooks", start);294 lap("index notebooks", start);
258 }295 }
259 for (key, (replica, spaces)) in pages {296 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) {
261 eprintln!("Cannot index the open section: {error}");298 eprintln!("Cannot index the open section: {error}");
262 }299 }
263 version.fetch_add(1, Ordering::Relaxed);300 version.fetch_add(1, Ordering::Relaxed);
264 }301 }
265 lap("index", start);302 lap("index", start);
266 busy.store(false, Ordering::Relaxed);303 busy.store(false, Ordering::Relaxed);
267 redraw.wake_by_ref();304 self.redraw.wake_by_ref();
268 }305 }
269}306}
270307
...@@ -329,7 +366,7 @@ fn sync(...@@ -329,7 +366,7 @@ fn sync(
329 let read = || -> Result<Vec<Entry>, Box<dyn Error>> {366 let read = || -> Result<Vec<Entry>, Box<dyn Error>> {
330 let bytes = match notebook {367 let bytes = match notebook {
331 Some(notebook) => notebook.read_section(&path)?,368 Some(notebook) => notebook.read_section(&path)?,
332 None => onestore::read_file(&file)?,369 None => notebook::fs::read_file(&file)?,
333 };370 };
334 let stored = match library.unlocked(&path) {371 let stored = match library.unlocked(&path) {
335 Some(key) => notebook::session::stored_pages_unlocked(&bytes, &key)?,372 Some(key) => notebook::session::stored_pages_unlocked(&bytes, &key)?,
...@@ -494,7 +531,7 @@ impl State {...@@ -494,7 +531,7 @@ impl State {
494 return;531 return;
495 }532 }
496 self.search.synced = identity;533 self.search.synced = identity;
497 let _ = self.search.jobs.send(Job::Notebooks {534 self.search.send(Job::Notebooks {
498 libraries: self.notebooks.clone(),535 libraries: self.notebooks.clone(),
499 open,536 open,
500 changed,537 changed,
crates/snowbound/src/server.rs+6-3
...@@ -3,9 +3,12 @@...@@ -3,9 +3,12 @@
3//! where the system's file sharing can't reach the server.3//! where the system's file sharing can't reach the server.
44
5use crate::library::{Library, Login, Mount};5use crate::library::{Library, Login, Mount};
6#[cfg(target_arch = "wasm32")]
7use crate::platform::smb::{self, Client, Credentials, DirectoryEntry, Refusal};
6use crate::{State, art, platform};8use crate::{State, art, platform};
7use accesskit::Role;9use accesskit::Role;
8use notebook::smb::{Client, Credentials, DirectoryEntry, Refusal};10#[cfg(not(target_arch = "wasm32"))]
11use notebook::smb::{self, Client, Credentials, DirectoryEntry, Refusal};
9use std::{io, path::Path, sync::mpsc, time::Duration};12use std::{io, path::Path, sync::mpsc, time::Duration};
10use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, px};13use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, px};
11use winit::keyboard::NamedKey;14use winit::keyboard::NamedKey;
...@@ -98,7 +101,7 @@ fn credentials(login: &Login) -> Credentials<'_> {...@@ -98,7 +101,7 @@ fn credentials(login: &Login) -> Credentials<'_> {
98101
99impl Reach for Embedded {102impl Reach for Embedded {
100 fn shares(&self, mount: &Mount, login: &Login) -> io::Result<Vec<String>> {103 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)
102 }105 }
103106
104 fn list(&self, mount: &Mount, login: &Login) -> io::Result<Vec<DirectoryEntry>> {107 fn list(&self, mount: &Mount, login: &Login) -> io::Result<Vec<DirectoryEntry>> {
...@@ -512,7 +515,7 @@ impl State {...@@ -512,7 +515,7 @@ impl State {
512 return;515 return;
513 };516 };
514 let (replies, redraw) = (connect.replies.0.clone(), self.redraw.clone());517 let (replies, redraw) = (connect.replies.0.clone(), self.redraw.clone());
515 std::thread::spawn(move || {518 crate::spawn(move || {
516 let _ = replies.send(request.run(&Embedded));519 let _ = replies.send(request.run(&Embedded));
517 redraw.wake();520 redraw.wake();
518 });521 });
crates/snowbound/src/settings.rs+7-7
...@@ -126,7 +126,7 @@ impl ColorScheme {...@@ -126,7 +126,7 @@ impl ColorScheme {
126impl Settings {126impl Settings {
127 /// The settings at `path`; missing or unreadable ones are the defaults, reported.127 /// The settings at `path`; missing or unreadable ones are the defaults, reported.
128 pub fn load(path: &Path) -> Self {128 pub fn load(path: &Path) -> Self {
129 match std::fs::read(path) {129 match notebook::fs::read(path) {
130 Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| {130 Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| {
131 eprintln!("Ignoring the settings in {}: {error}", path.display());131 eprintln!("Ignoring the settings in {}: {error}", path.display());
132 Self::default()132 Self::default()
...@@ -142,11 +142,11 @@ impl Settings {...@@ -142,11 +142,11 @@ impl Settings {
142 /// Replaces the file at `path` whole, so a crash leaves the old settings or the new.142 /// Replaces the file at `path` whole, so a crash leaves the old settings or the new.
143 pub fn save(&self, path: &Path) -> std::io::Result<()> {143 pub fn save(&self, path: &Path) -> std::io::Result<()> {
144 if let Some(folder) = path.parent() {144 if let Some(folder) = path.parent() {
145 std::fs::create_dir_all(folder)?;145 notebook::fs::create_dir_all(folder)?;
146 }146 }
147 let partial = path.with_extension("partial");147 let partial = path.with_extension("partial");
148 std::fs::write(&partial, serde_json::to_vec_pretty(self)?)?;148 notebook::fs::write(&partial, serde_json::to_vec_pretty(self)?)?;
149 std::fs::rename(partial, path)149 notebook::fs::rename(partial, path)
150 }150 }
151}151}
152152
...@@ -277,7 +277,7 @@ mod tests {...@@ -277,7 +277,7 @@ mod tests {
277 };277 };
278 settings.save(&path).unwrap();278 settings.save(&path).unwrap();
279 assert_eq!(Settings::load(&path), settings);279 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();
281 assert_eq!(281 assert_eq!(
282 Settings::load(&path),282 Settings::load(&path),
283 Settings {283 Settings {
...@@ -286,10 +286,10 @@ mod tests {...@@ -286,10 +286,10 @@ mod tests {
286 }286 }
287 );287 );
288 // A picked "No color" stays picked; what was never picked keeps Office's default.288 // 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();
290 let toolbar = Settings::load(&path).toolbar;290 let toolbar = Settings::load(&path).toolbar;
291 assert_eq!(toolbar.highlight, None);291 assert_eq!(toolbar.highlight, None);
292 assert_eq!(toolbar.font_color, Toolbar::default().font_color);292 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();
294 }294 }
295}295}
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 @@...@@ -1,9 +1,8 @@
1//! Frames reach the window through wgpu.1//! Frames reach the window through wgpu.
22
3use crate::{platform, trace_input};3use crate::{Window, platform, trace_input};
4use draw::Renderer;4use draw::Renderer;
5use std::{error::Error, sync::Arc};5use std::{error::Error, sync::Arc};
6use winit::window::Window;
76
8pub struct Surface {7pub struct Surface {
9 /// Device pixels frames and snapshots are drawn at; `configure` gives the window it.8 /// Device pixels frames and snapshots are drawn at; `configure` gives the window it.
...@@ -14,6 +13,9 @@ pub struct Surface {...@@ -14,6 +13,9 @@ pub struct Surface {
14 instance: wgpu::Instance,13 instance: wgpu::Instance,
15 surface: wgpu::Surface<'static>,14 surface: wgpu::Surface<'static>,
16 config: wgpu::SurfaceConfiguration,15 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,
17 /// Carries frames to the window where the system's backdrop shows through the strip.19 /// Carries frames to the window where the system's backdrop shows through the strip.
18 translucent: Option<draw::Translucent>,20 translucent: Option<draw::Translucent>,
19}21}
...@@ -36,6 +38,13 @@ impl Surface {...@@ -36,6 +38,13 @@ impl Surface {
36 window: Arc<Window>,38 window: Arc<Window>,
37 backdrop: bool,39 backdrop: bool,
38 ) -> Result<(Self, Renderer), Box<dyn Error>> {40 ) -> 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"))]
39 #[cfg_attr(not(any(windows, target_os = "linux")), expect(unused_mut))]48 #[cfg_attr(not(any(windows, target_os = "linux")), expect(unused_mut))]
40 let mut descriptor =49 let mut descriptor =
41 wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone()));50 wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone()));
...@@ -61,8 +70,9 @@ impl Surface {...@@ -61,8 +70,9 @@ impl Surface {
61 wgpu::Dx12SwapchainKind::DxgiFromVisual;70 wgpu::Dx12SwapchainKind::DxgiFromVisual;
62 }71 }
63 }72 }
73 #[cfg(not(target_arch = "wasm32"))]
64 let instance = wgpu::Instance::new(descriptor);74 let instance = wgpu::Instance::new(descriptor);
65 let surface = instance.create_surface(window.clone())?;75 let surface = instance.create_surface(target(&window))?;
66 platform::configure_presentation(&surface);76 platform::configure_presentation(&surface);
67 let adapter = instance77 let adapter = instance
68 .request_adapter(&wgpu::RequestAdapterOptions {78 .request_adapter(&wgpu::RequestAdapterOptions {
...@@ -70,7 +80,18 @@ impl Surface {...@@ -70,7 +80,18 @@ impl Surface {
70 ..Default::default()80 ..Default::default()
71 })81 })
72 .await?;82 .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?;
74 let size = window.inner_size();95 let size = window.inner_size();
75 let mut config = surface96 let mut config = surface
76 .get_default_config(&adapter, size.width, size.height)97 .get_default_config(&adapter, size.width, size.height)
...@@ -92,9 +113,16 @@ impl Surface {...@@ -92,9 +113,16 @@ impl Surface {
92 if platform::cuts_corners() && alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) {113 if platform::cuts_corners() && alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) {
93 config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied;114 config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied;
94 }115 }
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 }
95 surface.configure(&device, &config);123 surface.configure(&device, &config);
96 eprintln!("Canvas GPU: {:?}", adapter.get_info());124 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);
98 Ok((126 Ok((
99 Self {127 Self {
100 size: [config.width, config.height],128 size: [config.width, config.height],
...@@ -104,6 +132,7 @@ impl Surface {...@@ -104,6 +132,7 @@ impl Surface {
104 instance,132 instance,
105 surface,133 surface,
106 config,134 config,
135 view_format,
107 translucent,136 translucent,
108 },137 },
109 renderer,138 renderer,
...@@ -134,7 +163,7 @@ impl Surface {...@@ -134,7 +163,7 @@ impl Surface {
134 return Ok(None);163 return Ok(None);
135 }164 }
136 wgpu::CurrentSurfaceTexture::Lost => {165 wgpu::CurrentSurfaceTexture::Lost => {
137 self.surface = self.instance.create_surface(self.window.clone())?;166 self.surface = self.instance.create_surface(target(&self.window))?;
138 platform::configure_presentation(&self.surface);167 platform::configure_presentation(&self.surface);
139 self.configure(renderer);168 self.configure(renderer);
140 self.window.request_redraw();169 self.window.request_redraw();
...@@ -154,7 +183,10 @@ impl Surface {...@@ -154,7 +183,10 @@ impl Surface {
154 };183 };
155 let target = match &mut self.translucent {184 let target = match &mut self.translucent {
156 Some(translucent) => translucent.target(&self.device, self.size),185 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 }),
158 };190 };
159 let target = target_of(target);191 let target = target_of(target);
160 Ok(Some(Frame {192 Ok(Some(Frame {
...@@ -195,7 +227,7 @@ impl Surface {...@@ -195,7 +227,7 @@ impl Surface {
195 mip_level_count: 1,227 mip_level_count: 1,
196 sample_count: 1,228 sample_count: 1,
197 dimension: wgpu::TextureDimension::D2,229 dimension: wgpu::TextureDimension::D2,
198 format: self.config.format,230 format: self.view_format,
199 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,231 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
200 view_formats: &[],232 view_formats: &[],
201 });233 });
...@@ -235,7 +267,7 @@ impl Surface {...@@ -235,7 +267,7 @@ impl Surface {
235 timeout: Some(std::time::Duration::from_secs(5)),267 timeout: Some(std::time::Duration::from_secs(5)),
236 })?;268 })?;
237 let bgra = matches!(269 let bgra = matches!(
238 self.config.format,270 self.view_format,
239 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb271 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
240 );272 );
241 let mut pixels = Vec::with_capacity(size[0] as usize * size[1] as usize * 4);273 let mut pixels = Vec::with_capacity(size[0] as usize * size[1] as usize * 4);
...@@ -251,6 +283,17 @@ impl Surface {...@@ -251,6 +283,17 @@ impl Surface {
251 }283 }
252}284}
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
254/// `view` as what the renderer draws into, which is the view itself unless `draw` also297/// `view` as what the renderer draws into, which is the view itself unless `draw` also
255/// paints through OpenGL, as on Windows.298/// paints through OpenGL, as on Windows.
256#[cfg_attr(not(windows), expect(clippy::useless_conversion))]299#[cfg_attr(not(windows), expect(clippy::useless_conversion))]
crates/snowbound/src/sync.rs+4-4
...@@ -1285,7 +1285,7 @@ mod tests {...@@ -1285,7 +1285,7 @@ mod tests {
1285 .flat_map(|line| &line[..size[0] as usize * 4])1285 .flat_map(|line| &line[..size[0] as usize * 4])
1286 .copied()1286 .copied()
1287 .collect();1287 .collect();
1288 let file = std::fs::File::create(path).unwrap();1288 let file = notebook::fs::File::create(path).unwrap();
1289 let mut encoder = png::Encoder::new(file, size[0], height);1289 let mut encoder = png::Encoder::new(file, size[0], height);
1290 encoder.set_color(png::ColorType::Rgba);1290 encoder.set_color(png::ColorType::Rgba);
1291 encoder.set_depth(png::BitDepth::Eight);1291 encoder.set_depth(png::BitDepth::Eight);
...@@ -1458,7 +1458,7 @@ mod tests {...@@ -1458,7 +1458,7 @@ mod tests {
1458 fn popup_renders_each_state() {1458 fn popup_renders_each_state() {
1459 let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from);1459 let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from);
1460 let mut gpu = output.as_ref().map(|output| {1460 let mut gpu = output.as_ref().map(|output| {
1461 std::fs::create_dir_all(output).unwrap();1461 notebook::fs::create_dir_all(output).unwrap();
1462 Gpu::new()1462 Gpu::new()
1463 });1463 });
1464 let states = states();1464 let states = states();
...@@ -1479,7 +1479,7 @@ mod tests {...@@ -1479,7 +1479,7 @@ mod tests {
1479 continue;1479 continue;
1480 };1480 };
1481 let frames = output.join(format!("frames-{theme}"));1481 let frames = output.join(format!("frames-{theme}"));
1482 std::fs::create_dir_all(&frames).unwrap();1482 notebook::fs::create_dir_all(&frames).unwrap();
1483 let mut ui = ui(appearance);1483 let mut ui = ui(appearance);
1484 let mut now = Instant::now();1484 let mut now = Instant::now();
1485 settle(1485 settle(
...@@ -1511,7 +1511,7 @@ mod tests {...@@ -1511,7 +1511,7 @@ mod tests {
1511 }1511 }
1512 // Work offline on, an edit waiting, then off: the edit sends and nothing moves.1512 // Work offline on, an edit waiting, then off: the edit sends and nothing moves.
1513 let frames = output.join(format!("toggle-{theme}"));1513 let frames = output.join(format!("toggle-{theme}"));
1514 std::fs::create_dir_all(&frames).unwrap();1514 notebook::fs::create_dir_all(&frames).unwrap();
1515 let mut ui = self::ui(appearance);1515 let mut ui = self::ui(appearance);
1516 let mut now = Instant::now();1516 let mut now = Instant::now();
1517 settle(1517 settle(
crates/snowbound/src/tags.rs+11-11
...@@ -104,7 +104,7 @@ fn pictures() -> Option<std::path::PathBuf> {...@@ -104,7 +104,7 @@ fn pictures() -> Option<std::path::PathBuf> {
104pub(crate) fn art_bytes(art: &str) -> Option<Vec<u8>> {104pub(crate) fn art_bytes(art: &str) -> Option<Vec<u8>> {
105 match extra_art().iter().position(|name| name == art) {105 match extra_art().iter().position(|name| name == art) {
106 Some(extra) => Some(EXTRAS[extra].2.as_bytes().to_vec()),106 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(),
108 }108 }
109}109}
110110
...@@ -595,7 +595,7 @@ impl State {...@@ -595,7 +595,7 @@ impl State {
595 let Some(path) = platform::pick_file("Custom Image", &["png", "svg"]) else {595 let Some(path) = platform::pick_file("Custom Image", &["png", "svg"]) else {
596 return;596 return;
597 };597 };
598 let kept = std::fs::read(&path)598 let kept = notebook::fs::read(&path)
599 .ok()599 .ok()
600 .and_then(|bytes| canvas::gpu::import_tag_art(&bytes));600 .and_then(|bytes| canvas::gpu::import_tag_art(&bytes));
601 let Some((bytes, extension)) = kept else {601 let Some((bytes, extension)) = kept else {
...@@ -608,8 +608,8 @@ impl State {...@@ -608,8 +608,8 @@ impl State {
608 let written = pictures()608 let written = pictures()
609 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))609 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
610 .and_then(|folder| {610 .and_then(|folder| {
611 std::fs::create_dir_all(&folder)?;611 notebook::fs::create_dir_all(&folder)?;
612 match std::fs::File::create_new(folder.join(&art)) {612 match notebook::fs::File::create_new(folder.join(&art)) {
613 Ok(mut file) => std::io::Write::write_all(&mut file, &bytes),613 Ok(mut file) => std::io::Write::write_all(&mut file, &bytes),
614 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),614 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
615 Err(error) => Err(error),615 Err(error) => Err(error),
...@@ -1185,16 +1185,16 @@ mod tests {...@@ -1185,16 +1185,16 @@ mod tests {
1185 let written = section.image();1185 let written = section.image();
1186 let temporary =1186 let temporary =
1187 std::env::temp_dir().join(format!("snowbound-custom-art-{}", std::process::id()));1187 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);
1189 let root = temporary.join("Custom Art");1189 let root = temporary.join("Custom Art");
1190 std::fs::create_dir_all(&root).unwrap();1190 notebook::fs::create_dir_all(&root).unwrap();
1191 std::fs::write(root.join("Custom art.one"), &written).unwrap();1191 notebook::fs::write(root.join("Custom art.one"), &written).unwrap();
1192 let file_id = Store::parse(&written).unwrap().header.file_id;1192 let file_id = Store::parse(&written).unwrap().header.file_id;
1193 let toc = onestore::create_table_of_contents(1193 let toc = onestore::create_table_of_contents(
1194 "Open Notebook.onetoc2",1194 "Open Notebook.onetoc2",
1195 &[("Custom art.one", file_id)],1195 &[("Custom art.one", file_id)],
1196 );1196 );
1197 std::fs::write(root.join("Open Notebook.onetoc2"), toc.unwrap()).unwrap();1197 notebook::fs::write(root.join("Open Notebook.onetoc2"), toc.unwrap()).unwrap();
1198 let notebook = notebook::session::Notebook::open(&root, temporary.join("cache"));1198 let notebook = notebook::session::Notebook::open(&root, temporary.join("cache"));
1199 let notebook = notebook.unwrap();1199 let notebook = notebook.unwrap();
1200 for tag in &list[..3] {1200 for tag in &list[..3] {
...@@ -1242,7 +1242,7 @@ mod tests {...@@ -1242,7 +1242,7 @@ mod tests {
1242 .unwrap();1242 .unwrap();
1243 assert!(status.success());1243 assert!(status.success());
1244 }1244 }
1245 std::fs::remove_dir_all(&temporary).unwrap();1245 notebook::fs::remove_dir_all(&temporary).unwrap();
1246 }1246 }
12471247
1248 /// The gallery offers each symbol OneNote 2010's does once: every one MS-ONE lists but1248 /// The gallery offers each symbol OneNote 2010's does once: every one MS-ONE lists but
...@@ -1267,8 +1267,8 @@ mod tests {...@@ -1267,8 +1267,8 @@ mod tests {
1267 let sources = canvas::gpu::art_sources(&mapping.art, bytes).unwrap();1267 let sources = canvas::gpu::art_sources(&mapping.art, bytes).unwrap();
1268 art.map(&mapping.name, mapping.shape, &mapping.art, sources);1268 art.map(&mapping.name, mapping.shape, &mapping.art, sources);
1269 }1269 }
1270 std::fs::remove_dir_all(&cache).unwrap();1270 notebook::fs::remove_dir_all(&cache).unwrap();
1271 let bytes = std::fs::read(corpus.join("cold/notebook/Custom art.one")).unwrap();1271 let bytes = notebook::fs::read(corpus.join("cold/notebook/Custom art.one")).unwrap();
1272 let store = Store::parse(&bytes).unwrap();1272 let store = Store::parse(&bytes).unwrap();
1273 let index = RevisionIndex::parse(&store).unwrap();1273 let index = RevisionIndex::parse(&store).unwrap();
1274 let document = Document::parse(&index).unwrap();1274 let document = Document::parse(&index).unwrap();
crates/snowbound/src/themes.rs+5-5
...@@ -775,9 +775,9 @@ mod tests {...@@ -775,9 +775,9 @@ mod tests {
775 fn themed_pages_hold_their_theme_under_onenote_s_names() {775 fn themed_pages_hold_their_theme_under_onenote_s_names() {
776 let temporary =776 let temporary =
777 std::env::temp_dir().join(format!("snowbound-styles-{}", std::process::id()));777 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);
779 let root = temporary.join("Styles");779 let root = temporary.join("Styles");
780 std::fs::create_dir_all(&root).unwrap();780 notebook::fs::create_dir_all(&root).unwrap();
781 let mut engine = TextEngine::default();781 let mut engine = TextEngine::default();
782 let mut sections = Vec::new();782 let mut sections = Vec::new();
783 for theme in built_in() {783 for theme in built_in() {
...@@ -880,7 +880,7 @@ mod tests {...@@ -880,7 +880,7 @@ mod tests {
880 }880 }
881 assert_eq!(definition, other[&name], "{} to modern {name}", theme.name);881 assert_eq!(definition, other[&name], "{} to modern {name}", theme.name);
882 }882 }
883 std::fs::write(root.join(&file), &written).unwrap();883 notebook::fs::write(root.join(&file), &written).unwrap();
884 let file_id = Store::parse(&written).unwrap().header.file_id;884 let file_id = Store::parse(&written).unwrap().header.file_id;
885 sections.push((file, file_id, theme.id));885 sections.push((file, file_id, theme.id));
886 }886 }
...@@ -889,7 +889,7 @@ mod tests {...@@ -889,7 +889,7 @@ mod tests {
889 .map(|(file, id, _)| (file.as_str(), *id))889 .map(|(file, id, _)| (file.as_str(), *id))
890 .collect();890 .collect();
891 let toc = onestore::create_table_of_contents("Open Notebook.onetoc2", &listed).unwrap();891 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();
893 let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")).unwrap();893 let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")).unwrap();
894 let themes = notebook894 let themes = notebook
895 .save_themes(Themes {895 .save_themes(Themes {
...@@ -916,6 +916,6 @@ mod tests {...@@ -916,6 +916,6 @@ mod tests {
916 .unwrap();916 .unwrap();
917 assert!(status.success());917 assert!(status.success());
918 }918 }
919 std::fs::remove_dir_all(&temporary).unwrap();919 notebook::fs::remove_dir_all(&temporary).unwrap();
920 }920 }
921}921}
crates/snowbound/src/undo.rs+10-10
...@@ -1124,8 +1124,8 @@ mod tests {...@@ -1124,8 +1124,8 @@ mod tests {
1124 fn new(name: &str, titles: &[&str]) -> Self {1124 fn new(name: &str, titles: &[&str]) -> Self {
1125 let folder =1125 let folder =
1126 std::env::temp_dir().join(format!("snowbound-undo-{name}-{}", std::process::id()));1126 std::env::temp_dir().join(format!("snowbound-undo-{name}-{}", std::process::id()));
1127 let _ = std::fs::remove_dir_all(&folder);1127 let _ = notebook::fs::remove_dir_all(&folder);
1128 std::fs::create_dir_all(&folder).unwrap();1128 notebook::fs::create_dir_all(&folder).unwrap();
1129 let root = folder.join("Notebook");1129 let root = folder.join("Notebook");
1130 let mut notebook =1130 let mut notebook =
1131 Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated())1131 Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated())
...@@ -1211,7 +1211,7 @@ mod tests {...@@ -1211,7 +1211,7 @@ mod tests {
1211 let file = self1211 let file = self
1212 .folder1212 .folder
1213 .join("Notebook/OneNote_RecycleBin/OneNote_DeletedPages.one");1213 .join("Notebook/OneNote_RecycleBin/OneNote_DeletedPages.one");
1214 std::fs::read(file).map_or_else(1214 notebook::fs::read(file).map_or_else(
1215 |_| Vec::new(),1215 |_| Vec::new(),
1216 |bytes| {1216 |bytes| {
1217 notebook::session::stored_pages(&bytes)1217 notebook::session::stored_pages(&bytes)
...@@ -1235,7 +1235,7 @@ mod tests {...@@ -1235,7 +1235,7 @@ mod tests {
1235 if let Some(section) = self.section.take() {1235 if let Some(section) = self.section.take() {
1236 let _ = section.close();1236 let _ = section.close();
1237 }1237 }
1238 let _ = std::fs::remove_dir_all(&self.folder);1238 let _ = notebook::fs::remove_dir_all(&self.folder);
1239 }1239 }
1240 }1240 }
12411241
...@@ -1434,8 +1434,8 @@ mod tests {...@@ -1434,8 +1434,8 @@ mod tests {
1434 use crate::manage::Structure;1434 use crate::manage::Structure;
1435 let folder =1435 let folder =
1436 std::env::temp_dir().join(format!("snowbound-undo-sections-{}", std::process::id()));1436 std::env::temp_dir().join(format!("snowbound-undo-sections-{}", std::process::id()));
1437 let _ = std::fs::remove_dir_all(&folder);1437 let _ = notebook::fs::remove_dir_all(&folder);
1438 std::fs::create_dir_all(&folder).unwrap();1438 notebook::fs::create_dir_all(&folder).unwrap();
1439 let root = folder.join("Notebook");1439 let root = folder.join("Notebook");
1440 let mut notebook =1440 let mut notebook =
1441 Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()).unwrap();1441 Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()).unwrap();
...@@ -1495,7 +1495,7 @@ mod tests {...@@ -1495,7 +1495,7 @@ mod tests {
1495 carry(&mut notebook, structure);1495 carry(&mut notebook, structure);
1496 notebook.rename("Kitchen.one", "Pantry").unwrap();1496 notebook.rename("Kitchen.one", "Pantry").unwrap();
1497 assert!(restructuring(&library(notebook), &undo.change).is_none());1497 assert!(restructuring(&library(notebook), &undo.change).is_none());
1498 let _ = std::fs::remove_dir_all(&folder);1498 let _ = notebook::fs::remove_dir_all(&folder);
1499 }1499 }
15001500
1501 /// Every kind of step taken back and redone in one notebook, written as the app writes1501 /// Every kind of step taken back and redone in one notebook, written as the app writes
...@@ -1596,13 +1596,13 @@ mod tests {...@@ -1596,13 +1596,13 @@ mod tests {
1596 }1596 }
15971597
1598 fn copy(from: &std::path::Path, to: &std::path::Path) {1598 fn copy(from: &std::path::Path, to: &std::path::Path) {
1599 std::fs::create_dir_all(to).unwrap();1599 notebook::fs::create_dir_all(to).unwrap();
1600 for entry in std::fs::read_dir(from).unwrap().flatten() {1600 for entry in notebook::fs::read_dir(from).unwrap().flatten() {
1601 let target = to.join(entry.file_name());1601 let target = to.join(entry.file_name());
1602 if entry.file_type().unwrap().is_dir() {1602 if entry.file_type().unwrap().is_dir() {
1603 copy(&entry.path(), &target);1603 copy(&entry.path(), &target);
1604 } else {1604 } else {
1605 std::fs::copy(entry.path(), target).unwrap();1605 notebook::fs::copy(entry.path(), target).unwrap();
1606 }1606 }
1607 }1607 }
1608 }1608 }
crates/snowbound/src/unpack.rs+6-4
...@@ -189,7 +189,7 @@ impl State {...@@ -189,7 +189,7 @@ impl State {
189 if create && !valid {189 if create && !valid {
190 return platform::alert("Couldn't unpack the notebook", "Give the notebook a name.");190 return platform::alert("Couldn't unpack the notebook", "Give the notebook a name.");
191 }191 }
192 if create && root.exists() {192 if create && notebook::fs::metadata(&root).is_ok() {
193 return platform::alert(193 return platform::alert(
194 "Couldn't unpack the notebook",194 "Couldn't unpack the notebook",
195 &format!(195 &format!(
...@@ -212,11 +212,13 @@ impl State {...@@ -212,11 +212,13 @@ impl State {
212 /// `color` where one was picked, and opens it.212 /// `color` where one was picked, and opens it.
213 fn unpack(&mut self, package: PathBuf, root: PathBuf, color: Option<u32>) {213 fn unpack(&mut self, package: PathBuf, root: PathBuf, color: Option<u32>) {
214 let (cache, proxy) = (self.cache.clone(), self.proxy.clone());214 let (cache, proxy) = (self.cache.clone(), self.proxy.clone());
215 std::thread::spawn(move || {215 crate::spawn(move || {
216 let unpacked = (|| -> Result<String, Box<dyn std::error::Error>> {216 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)?;
218 notebook::package::unpack(files, &root)?;218 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();
220 if let Some(color) = color {222 if let Some(color) = color {
221 Notebook::open(&location, &cache)?.set_color(color)?;223 Notebook::open(&location, &cache)?.set_color(color)?;
222 }224 }
crates/snowbound/src/unread.rs+8-8
...@@ -51,8 +51,8 @@ fn hex(identity: [u8; 16]) -> String {...@@ -51,8 +51,8 @@ fn hex(identity: [u8; 16]) -> String {
51}51}
5252
53fn now() -> u32 {53fn now() -> u32 {
54 let unix = std::time::SystemTime::now()54 let unix = web_time::SystemTime::now()
55 .duration_since(std::time::UNIX_EPOCH)55 .duration_since(web_time::UNIX_EPOCH)
56 .map_or(0, |since| since.as_secs());56 .map_or(0, |since| since.as_secs());
57 u32::try_from(unix.saturating_sub(TIME32)).unwrap_or(u32::MAX)57 u32::try_from(unix.saturating_sub(TIME32)).unwrap_or(u32::MAX)
58}58}
...@@ -61,7 +61,7 @@ impl Reads {...@@ -61,7 +61,7 @@ impl Reads {
61 /// What the cache folder `cache` keeps.61 /// What the cache folder `cache` keeps.
62 pub fn load(cache: &Path) -> Self {62 pub fn load(cache: &Path) -> Self {
63 let file = cache.join("read.json");63 let file = cache.join("read.json");
64 let reads: Self = (std::fs::read(&file).ok())64 let reads: Self = (notebook::fs::read(&file).ok())
65 .and_then(|bytes| serde_json::from_slice(&bytes).ok())65 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
66 .unwrap_or_default();66 .unwrap_or_default();
67 Self { file, ..reads }67 Self { file, ..reads }
...@@ -71,8 +71,8 @@ impl Reads {...@@ -71,8 +71,8 @@ impl Reads {
71 let partial = self.file.with_extension("partial");71 let partial = self.file.with_extension("partial");
72 let saved = serde_json::to_vec(self)72 let saved = serde_json::to_vec(self)
73 .map_err(std::io::Error::from)73 .map_err(std::io::Error::from)
74 .and_then(|bytes| std::fs::write(&partial, bytes))74 .and_then(|bytes| notebook::fs::write(&partial, bytes))
75 .and_then(|()| std::fs::rename(&partial, &self.file));75 .and_then(|()| notebook::fs::rename(&partial, &self.file));
76 if let Err(error) = saved {76 if let Err(error) = saved {
77 eprintln!("Keeping what was read failed: {error}");77 eprintln!("Keeping what was read failed: {error}");
78 }78 }
...@@ -254,7 +254,7 @@ impl State {...@@ -254,7 +254,7 @@ impl State {
254 /// pages are read on a thread of their own, those changed since unread.254 /// pages are read on a thread of their own, those changed since unread.
255 pub(crate) fn sections_changed(&self, library: &Arc<Library>, paths: Vec<String>) {255 pub(crate) fn sections_changed(&self, library: &Arc<Library>, paths: Vec<String>) {
256 let (library, proxy) = (Arc::clone(library), self.proxy.clone());256 let (library, proxy) = (Arc::clone(library), self.proxy.clone());
257 std::thread::spawn(move || {257 crate::spawn(move || {
258 let Ok(Some(notebook)) = &library.notebook else {258 let Ok(Some(notebook)) = &library.notebook else {
259 return;259 return;
260 };260 };
...@@ -387,7 +387,7 @@ mod tests {...@@ -387,7 +387,7 @@ mod tests {
387 #[test]387 #[test]
388 fn pages_changed_elsewhere_stay_unread_until_read() {388 fn pages_changed_elsewhere_stay_unread_until_read() {
389 let folder = std::env::temp_dir().join(format!("snowbound-unread-{}", std::process::id()));389 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();
391 let (notebook, section) = ("/notebook", [3; 16]);391 let (notebook, section) = ("/notebook", [3; 16]);
392 let mut reads = Reads::load(&folder);392 let mut reads = Reads::load(&folder);
393 let known = reads.section_mut(notebook, section).read;393 let known = reads.section_mut(notebook, section).read;
...@@ -426,6 +426,6 @@ mod tests {...@@ -426,6 +426,6 @@ mod tests {
426 );426 );
427 reads.notebooks.get_mut(notebook).unwrap().hidden = true;427 reads.notebooks.get_mut(notebook).unwrap().hidden = true;
428 assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook));428 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();
430 }430 }
431}431}
crates/snowbound/src/update.rs+48-9
...@@ -2,11 +2,14 @@...@@ -2,11 +2,14 @@
2//! `build.json` the release key signs; `latest.json` names each platform's newest build. A2//! `build.json` the release key signs; `latest.json` names each platform's newest build. A
3//! thread checks on launch and daily, downloads a newer build for this platform, verifies3//! thread checks on launch and daily, downloads a newer build for this platform, verifies
4//! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits.4//! 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
7#[cfg(target_os = "linux")]9#[cfg(target_os = "linux")]
8use crate::loader::executable;10use crate::loader::executable;
9use crate::{State, UserEvent, platform};11use crate::{EventLoopProxy, State, UserEvent, platform};
12#[cfg(not(target_arch = "wasm32"))]
10use ring::signature::{ED25519, UnparsedPublicKey};13use ring::signature::{ED25519, UnparsedPublicKey};
11use serde::Deserialize;14use serde::Deserialize;
12use std::collections::HashMap;15use std::collections::HashMap;
...@@ -16,8 +19,9 @@ use std::ffi::OsString;...@@ -16,8 +19,9 @@ use std::ffi::OsString;
16use std::path::{Path, PathBuf};19use std::path::{Path, PathBuf};
17use std::process::Command;20use std::process::Command;
18use std::sync::{Arc, Mutex};21use std::sync::{Arc, Mutex};
19use std::time::{Duration, Instant};22use std::time::Duration;
20use winit::event_loop::EventLoopProxy;23#[cfg(not(target_arch = "wasm32"))]
24use std::time::Instant;
2125
22/// Where the builds are published.26/// Where the builds are published.
23const BASE: &str = "https://file.paperclover.net/shr/snowbound/";27const BASE: &str = "https://file.paperclover.net/shr/snowbound/";
...@@ -206,6 +210,16 @@ fn unhex(text: &str) -> Option<Vec<u8>> {...@@ -206,6 +210,16 @@ fn unhex(text: &str) -> Option<Vec<u8>> {
206 .flatten()210 .flatten()
207}211}
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"))]
209fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> {223fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> {
210 let signature = unhex(signature).ok_or("The signature isn’t hex")?;224 let signature = unhex(signature).ok_or("The signature isn’t hex")?;
211 UnparsedPublicKey::new(&ED25519, key)225 UnparsedPublicKey::new(&ED25519, key)
...@@ -271,9 +285,12 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri...@@ -271,9 +285,12 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri
271 archive.size285 archive.size
272 ));286 ));
273 }287 }
274 let digest = ring::digest::digest(&ring::digest::SHA256, bytes);288 #[cfg(not(target_arch = "wasm32"))]
275 if unhex(&archive.sha256).as_deref() != Some(digest.as_ref()) {289 {
276 return Err(format!("{}’s SHA-256 doesn’t match", archive.file));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 }
277 }294 }
278 verify(key, bytes, &archive.signature)295 verify(key, bytes, &archive.signature)
279}296}
...@@ -448,7 +465,13 @@ fn check(...@@ -448,7 +465,13 @@ fn check(
448 result.unwrap_or_else(Status::Failed)465 result.unwrap_or_else(Status::Failed)
449}466}
450467
468#[cfg(target_arch = "wasm32")]
469fn download(_: &str, _: u64) -> Result<Vec<u8>, String> {
470 Err(BROWSER.to_owned())
471}
472
451/// The `Fetch` the app uses.473/// The `Fetch` the app uses.
474#[cfg(not(target_arch = "wasm32"))]
452fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {475fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {
453 let agent: ureq::Agent = ureq::Agent::config_builder()476 let agent: ureq::Agent = ureq::Agent::config_builder()
454 .timeout_global(Some(Duration::from_secs(10 * 60)))477 .timeout_global(Some(Duration::from_secs(10 * 60)))
...@@ -498,6 +521,12 @@ impl Updates {...@@ -498,6 +521,12 @@ impl Updates {
498 ..Shared::default()521 ..Shared::default()
499 }));522 }));
500 let key = unhex(KEY).expect("release-key.pub holds a key in hex");523 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"))]
501 let thread = {530 let thread = {
502 let shared = Arc::clone(&shared);531 let shared = Arc::clone(&shared);
503 std::thread::Builder::new()532 std::thread::Builder::new()
...@@ -570,8 +599,13 @@ impl Updates {...@@ -570,8 +599,13 @@ impl Updates {
570599
571 /// Checks now, and shows what the check finds when it finishes.600 /// Checks now, and shows what the check finds when it finishes.
572 pub fn check_now(&self) {601 pub fn check_now(&self) {
573 self.shared.lock().unwrap().asked = true;602 #[cfg(target_arch = "wasm32")]
574 self.thread.unpark();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 }
575 }609 }
576610
577 /// The staged build to swap in, once Restart to Update has quit the app.611 /// 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 {...@@ -715,6 +749,11 @@ fn quits(pid: u32, patience: Duration) -> bool {
715 true749 true
716}750}
717751
752#[cfg(target_arch = "wasm32")]
753fn quits(_: u32, _: Duration) -> bool {
754 false
755}
756
718#[cfg(windows)]757#[cfg(windows)]
719fn quits(pid: u32, patience: Duration) -> bool {758fn quits(pid: u32, patience: Duration) -> bool {
720 use windows_sys::Win32::{759 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"] }...@@ -10,6 +10,8 @@ draw = { path = "../draw", default-features = false, features = ["render"] }
10nucleo-matcher = "0.3.1"10nucleo-matcher = "0.3.1"
11parley.workspace = true11parley.workspace = true
12winit = "0.30.13"12winit = "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
14[dev-dependencies]16[dev-dependencies]
15accesskit_consumer = "0.39.0"17accesskit_consumer = "0.39.0"
crates/ui/src/lib.rs+2-1
...@@ -29,9 +29,10 @@ use std::{...@@ -29,9 +29,10 @@ use std::{
29 hash::{DefaultHasher, Hash, Hasher},29 hash::{DefaultHasher, Hash, Hasher},
30 ops::BitOr,30 ops::BitOr,
31 rc::Rc,31 rc::Rc,
32 time::{Duration, Instant},32 time::Duration,
33};33};
34use text::{Label, Texts};34use text::{Label, Texts};
35use web_time::Instant;
35use winit::{36use winit::{
36 event::{Ime, MouseButton},37 event::{Ime, MouseButton},
37 keyboard::{Key, ModifiersState, NamedKey},38 keyboard::{Key, ModifiersState, NamedKey},
tools/TESTING.md+1
...@@ -19,6 +19,7 @@ another, and the exit status is the result....@@ -19,6 +19,7 @@ another, and the exit status is the result.
19| `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) |19| `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) |
20| `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 |20| `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 |
21| `ios` | `xcodebuild` of the simulator app, unsigned |21| `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 |
22| `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` |23| `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` |
2324
24```sh25```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...@@ -90,7 +90,7 @@ A paragraph stored as an equation draws in two dimensions from the tree `onestor
9090
91`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.91`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
95```sh95```sh
96"target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE96"target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE
tools/ci.py+20
...@@ -75,6 +75,15 @@ def lanes():...@@ -75,6 +75,15 @@ def lanes():
75 commands=[cross(linux, arch, *cross_clippy), cross(linux, arch, 'build', '-p', 'snowbound')],75 commands=[cross(linux, arch, *cross_clippy), cross(linux, arch, 'build', '-p', 'snowbound')],
76 environment={'CARGO_TARGET_DIR': str(TARGET / 'linux')},76 environment={'CARGO_TARGET_DIR': str(TARGET / 'linux')},
77 missing=None if shutil.which('zig') else 'needs zig'))77 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'))
78 result.append(dict(87 result.append(dict(
79 name='ios', minutes=30, packages=['mobile'], paths=('apps/ios/',),88 name='ios', minutes=30, packages=['mobile'], paths=('apps/ios/',),
80 # build-rust.sh builds the Rust half into target/ios.89 # build-rust.sh builds the Rust half into target/ios.
...@@ -92,6 +101,17 @@ def lanes():...@@ -92,6 +101,17 @@ def lanes():
92 return result101 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
95def jj(*args, cwd=ROOT):115def jj(*args, cwd=ROOT):
96 return subprocess.run(['jj', *args], cwd=cwd, check=True, capture_output=True, text=True).stdout116 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()