| author | |
| committer | |
| log | 563a6671858bb6446fcc24546f424b3e379f9063 |
| tree | 674c0b6eb27a01a9888e21b28e670d4e954cf29d |
| parent | d9fc5cc413b64a5ce5699007860952ea911fded2 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.575 files changed, 4080 insertions(+), 749 deletions(-)
Cargo.lock+27| ... | ... | @@ -664,6 +664,7 @@ dependencies = [ |
| 664 | 664 | "serde_json", |
| 665 | 665 | "sha2", |
| 666 | 666 | "skrifa", |
| 667 | "web-time", | |
| 667 | 668 | "wgpu", |
| 668 | 669 | ] |
| 669 | 670 | |
| ... | ... | @@ -1010,6 +1011,7 @@ dependencies = [ |
| 1010 | 1011 | "roxmltree", |
| 1011 | 1012 | "subsetter", |
| 1012 | 1013 | "swash", |
| 1014 | "web-time", | |
| 1013 | 1015 | "wgpu", |
| 1014 | 1016 | "windows", |
| 1015 | 1017 | ] |
| ... | ... | @@ -1316,9 +1318,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1316 | 1318 | checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" |
| 1317 | 1319 | dependencies = [ |
| 1318 | 1320 | "cfg-if", |
| 1321 | "js-sys", | |
| 1319 | 1322 | "libc", |
| 1320 | 1323 | "r-efi 6.0.0", |
| 1321 | 1324 | "rand_core 0.10.1", |
| 1325 | "wasm-bindgen", | |
| 1322 | 1326 | ] |
| 1323 | 1327 | |
| 1324 | 1328 | [[package]] |
| ... | ... | @@ -2079,9 +2083,11 @@ dependencies = [ |
| 2079 | 2083 | "cab", |
| 2080 | 2084 | "getrandom 0.4.3", |
| 2081 | 2085 | "hmac", |
| 2086 | "js-sys", | |
| 2082 | 2087 | "libc", |
| 2083 | 2088 | "nix", |
| 2084 | 2089 | "onestore", |
| 2090 | "rsqlite-vfs", | |
| 2085 | 2091 | "rusqlite", |
| 2086 | 2092 | "serde", |
| 2087 | 2093 | "serde_json", |
| ... | ... | @@ -2090,6 +2096,9 @@ dependencies = [ |
| 2090 | 2096 | "tempfile", |
| 2091 | 2097 | "thiserror 2.0.20", |
| 2092 | 2098 | "tokio", |
| 2099 | "wasm-bindgen", | |
| 2100 | "wasm-bindgen-futures", | |
| 2101 | "web-time", | |
| 2093 | 2102 | "windows-sys 0.61.2", |
| 2094 | 2103 | "zeroize", |
| 2095 | 2104 | ] |
| ... | ... | @@ -2471,6 +2480,7 @@ dependencies = [ |
| 2471 | 2480 | "serde_json", |
| 2472 | 2481 | "sha1", |
| 2473 | 2482 | "subtle", |
| 2483 | "web-time", | |
| 2474 | 2484 | "zeroize", |
| 2475 | 2485 | ] |
| 2476 | 2486 | |
| ... | ... | @@ -3500,7 +3510,9 @@ dependencies = [ |
| 3500 | 3510 | "canvas", |
| 3501 | 3511 | "draw", |
| 3502 | 3512 | "fontique", |
| 3513 | "getrandom 0.4.3", | |
| 3503 | 3514 | "image", |
| 3515 | "js-sys", | |
| 3504 | 3516 | "libc", |
| 3505 | 3517 | "notebook", |
| 3506 | 3518 | "objc2 0.5.2", |
| ... | ... | @@ -3519,9 +3531,13 @@ dependencies = [ |
| 3519 | 3531 | "ui", |
| 3520 | 3532 | "unicode_names2", |
| 3521 | 3533 | "ureq", |
| 3534 | "wasm-bindgen", | |
| 3535 | "wasm-bindgen-futures", | |
| 3522 | 3536 | "wayland-backend", |
| 3523 | 3537 | "wayland-client", |
| 3524 | 3538 | "wayland-protocols", |
| 3539 | "web-sys", | |
| 3540 | "web-time", | |
| 3525 | 3541 | "wgpu", |
| 3526 | 3542 | "windows-sys 0.61.2", |
| 3527 | 3543 | "winit", |
| ... | ... | @@ -3923,6 +3939,7 @@ dependencies = [ |
| 3923 | 3939 | "parley", |
| 3924 | 3940 | "png", |
| 3925 | 3941 | "pollster", |
| 3942 | "web-time", | |
| 3926 | 3943 | "wgpu", |
| 3927 | 3944 | "winit", |
| 3928 | 3945 | ] |
| ... | ... | @@ -4377,6 +4394,7 @@ dependencies = [ |
| 4377 | 4394 | "thiserror 2.0.20", |
| 4378 | 4395 | "wgpu-core-deps-apple", |
| 4379 | 4396 | "wgpu-core-deps-emscripten", |
| 4397 | "wgpu-core-deps-wasm", | |
| 4380 | 4398 | "wgpu-core-deps-windows-linux-android", |
| 4381 | 4399 | "wgpu-hal", |
| 4382 | 4400 | "wgpu-naga-bridge", |
| ... | ... | @@ -4401,6 +4419,15 @@ dependencies = [ |
| 4401 | 4419 | "wgpu-hal", |
| 4402 | 4420 | ] |
| 4403 | 4421 | |
| 4422 | [[package]] | |
| 4423 | name = "wgpu-core-deps-wasm" | |
| 4424 | version = "30.0.1" | |
| 4425 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4426 | checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" | |
| 4427 | dependencies = [ | |
| 4428 | "wgpu-hal", | |
| 4429 | ] | |
| 4430 | ||
| 4404 | 4431 | [[package]] |
| 4405 | 4432 | name = "wgpu-core-deps-windows-linux-android" |
| 4406 | 4433 | version = "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 | 146 | Windows has no menu bar: the toolbar and the command palette run the |
| 147 | 147 | command table, with OneNote 2010's Ctrl chords. |
| 148 | 148 | |
| 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 | 167 | ## iOS: native around the canvas |
| 150 | 168 | |
| 151 | 169 | On 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 | 19 | serde_json = "1.0.151" |
| 20 | 20 | icu_normalizer = "2.3" |
| 21 | 21 | skrifa = "0.44.0" |
| 22 | # std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. | |
| 23 | web-time = "1.1" | |
| 22 | 24 | |
| 23 | 25 | accesskit = { version = "0.25.0", optional = true } |
| 24 | 26 | color_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 @@ |
| 1 | Copyright 2012 The Caladea Project Authors (https://github.com/huertatipografica/Caladea) | |
| 2 | ||
| 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. | |
| 4 | This license is copied below, and is also available with a FAQ at: | |
| 5 | http://scripts.sil.org/OFL | |
| 6 | ||
| 7 | ||
| 8 | ----------------------------------------------------------- | |
| 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 | |
| 10 | ----------------------------------------------------------- | |
| 11 | ||
| 12 | PREAMBLE | |
| 13 | The goals of the Open Font License (OFL) are to stimulate worldwide | |
| 14 | development of collaborative font projects, to support the font creation | |
| 15 | efforts of academic and linguistic communities, and to provide a free and | |
| 16 | open framework in which fonts may be shared and improved in partnership | |
| 17 | with others. | |
| 18 | ||
| 19 | The OFL allows the licensed fonts to be used, studied, modified and | |
| 20 | redistributed freely as long as they are not sold by themselves. The | |
| 21 | fonts, including any derivative works, can be bundled, embedded, | |
| 22 | redistributed and/or sold with any software provided that any reserved | |
| 23 | names are not used by derivative works. The fonts and derivatives, | |
| 24 | however, cannot be released under any other type of license. The | |
| 25 | requirement for fonts to remain under this license does not apply | |
| 26 | to any document created using the fonts or their derivatives. | |
| 27 | ||
| 28 | DEFINITIONS | |
| 29 | "Font Software" refers to the set of files released by the Copyright | |
| 30 | Holder(s) under this license and clearly marked as such. This may | |
| 31 | include source files, build scripts and documentation. | |
| 32 | ||
| 33 | "Reserved Font Name" refers to any names specified as such after the | |
| 34 | copyright statement(s). | |
| 35 | ||
| 36 | "Original Version" refers to the collection of Font Software components as | |
| 37 | distributed by the Copyright Holder(s). | |
| 38 | ||
| 39 | "Modified Version" refers to any derivative made by adding to, deleting, | |
| 40 | or substituting -- in part or in whole -- any of the components of the | |
| 41 | Original Version, by changing formats or by porting the Font Software to a | |
| 42 | new environment. | |
| 43 | ||
| 44 | "Author" refers to any designer, engineer, programmer, technical | |
| 45 | writer or other person who contributed to the Font Software. | |
| 46 | ||
| 47 | PERMISSION & CONDITIONS | |
| 48 | Permission is hereby granted, free of charge, to any person obtaining | |
| 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, | |
| 50 | redistribute, and sell modified and unmodified copies of the Font | |
| 51 | Software, subject to the following conditions: | |
| 52 | ||
| 53 | 1) Neither the Font Software nor any of its individual components, | |
| 54 | in Original or Modified Versions, may be sold by itself. | |
| 55 | ||
| 56 | 2) Original or Modified Versions of the Font Software may be bundled, | |
| 57 | redistributed and/or sold with any software, provided that each copy | |
| 58 | contains the above copyright notice and this license. These can be | |
| 59 | included either as stand-alone text files, human-readable headers or | |
| 60 | in the appropriate machine-readable metadata fields within text or | |
| 61 | binary files as long as those fields can be easily viewed by the user. | |
| 62 | ||
| 63 | 3) No Modified Version of the Font Software may use the Reserved Font | |
| 64 | Name(s) unless explicit written permission is granted by the corresponding | |
| 65 | Copyright Holder. This restriction only applies to the primary font name as | |
| 66 | presented to the users. | |
| 67 | ||
| 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font | |
| 69 | Software shall not be used to promote, endorse or advertise any | |
| 70 | Modified Version, except to acknowledge the contribution(s) of the | |
| 71 | Copyright Holder(s) and the Author(s) or with their explicit written | |
| 72 | permission. | |
| 73 | ||
| 74 | 5) The Font Software, modified or unmodified, in part or in whole, | |
| 75 | must be distributed entirely under this license, and must not be | |
| 76 | distributed under any other license. The requirement for fonts to | |
| 77 | remain under this license does not apply to any document created | |
| 78 | using the Font Software. | |
| 79 | ||
| 80 | TERMINATION | |
| 81 | This license becomes null and void if any of the above conditions are | |
| 82 | not met. | |
| 83 | ||
| 84 | DISCLAIMER | |
| 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF | |
| 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT | |
| 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE | |
| 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | |
| 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL | |
| 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | |
| 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM | |
| 93 | OTHER 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 | 565 | |
| 566 | 566 | /// Seconds since 1980, as note tags date themselves. |
| 567 | 567 | fn time32() -> Option<u32> { |
| 568 | let now = std::time::SystemTime::now() | |
| 569 | .duration_since(std::time::UNIX_EPOCH) | |
| 568 | let now = web_time::SystemTime::now() | |
| 569 | .duration_since(web_time::UNIX_EPOCH) | |
| 570 | 570 | .ok()? |
| 571 | 571 | .as_secs(); |
| 572 | 572 | u32::try_from(now.checked_sub(315_532_800)?).ok() |
crates/canvas/src/editor/recording.rs+1-1| ... | ... | @@ -5,7 +5,7 @@ |
| 5 | 5 | |
| 6 | 6 | use super::*; |
| 7 | 7 | use onestore::page::{Attachment, MediaIndex}; |
| 8 | use std::time::Instant; | |
| 8 | use web_time::Instant; | |
| 9 | 9 | |
| 10 | 10 | /// A recording in progress. |
| 11 | 11 | pub(super) struct Live { |
crates/canvas/src/gpu/page.rs+17-2| ... | ... | @@ -16,10 +16,11 @@ use onestore::page::Page; |
| 16 | 16 | use picture::Picture; |
| 17 | 17 | use std::{ |
| 18 | 18 | fmt, |
| 19 | sync::{Arc, Mutex, OnceLock, mpsc}, | |
| 19 | sync::{Arc, Mutex}, | |
| 20 | 20 | task::Waker, |
| 21 | time::{Duration, Instant}, | |
| 21 | time::Duration, | |
| 22 | 22 | }; |
| 23 | use web_time::Instant; | |
| 23 | 24 | |
| 24 | 25 | /// Retained drawing data in the source page's coordinate system. |
| 25 | 26 | pub struct PageScene { |
| ... | ... | @@ -40,11 +41,13 @@ type Slot<T> = Arc<Mutex<Option<T>>>; |
| 40 | 41 | |
| 41 | 42 | /// Runs `work` on the scenes' one raster thread once `start` has passed, unless the slot |
| 42 | 43 | /// returned has been dropped by then; the result lands in the slot and wakes `waker`. |
| 44 | #[cfg(not(target_arch = "wasm32"))] | |
| 43 | 45 | fn queue<T: Send + 'static>( |
| 44 | 46 | start: Instant, |
| 45 | 47 | waker: &Waker, |
| 46 | 48 | work: impl FnOnce() -> T + Send + 'static, |
| 47 | 49 | ) -> Slot<T> { |
| 50 | use std::sync::{OnceLock, mpsc}; | |
| 48 | 51 | static WORKER: OnceLock<mpsc::Sender<Box<dyn FnOnce() + Send>>> = OnceLock::new(); |
| 49 | 52 | let slot = Slot::default(); |
| 50 | 53 | let requester = Arc::downgrade(&slot); |
| ... | ... | @@ -69,6 +72,17 @@ fn queue<T: Send + 'static>( |
| 69 | 72 | slot |
| 70 | 73 | } |
| 71 | 74 | |
| 75 | /// `queue` without threads, as in the browser: `work` runs now. | |
| 76 | #[cfg(target_arch = "wasm32")] | |
| 77 | fn 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 | 86 | /// Device pixels per point a raster is made at for `scale`: the next half power of two, |
| 73 | 87 | /// so zooming makes rasters again in steps. |
| 74 | 88 | fn density(scale: f32) -> f32 { |
| ... | ... | @@ -793,6 +807,7 @@ impl PageScene { |
| 793 | 807 | /// Where the page read without an editor lies, in page coordinates: the rectangle all |
| 794 | 808 | /// its content covers, and the top and bottom of each line of text and each picture, which |
| 795 | 809 | /// a sheet of paper does not cut through where it can help it. |
| 810 | #[cfg(feature = "pdf")] | |
| 796 | 811 | pub(crate) fn printed_extent(&self) -> Result<([f32; 4], Vec<[f32; 2]>), SceneError> { |
| 797 | 812 | let mut bounds = [ |
| 798 | 813 | f32::INFINITY, |
crates/canvas/src/gpu/page/background.rs+2-4| ... | ... | @@ -6,10 +6,8 @@ use crate::gpu::Paper; |
| 6 | 6 | use draw::RasterImage; |
| 7 | 7 | use resvg::{tiny_skia, usvg}; |
| 8 | 8 | use sha2::{Digest, Sha256}; |
| 9 | use std::{ | |
| 10 | task::Waker, | |
| 11 | time::{Duration, Instant}, | |
| 12 | }; | |
| 9 | use std::{task::Waker, time::Duration}; | |
| 10 | use web_time::Instant; | |
| 13 | 11 | |
| 14 | 12 | /// One of OneNote 2010's page templates: its art recreated for white paper in `#rrggbb` |
| 15 | 13 | /// 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 | 4 | use super::{SETTLE, Slot, density, queue}; |
| 5 | 5 | use draw::RasterImage; |
| 6 | 6 | use onestore::ExGuid; |
| 7 | use std::{ | |
| 8 | collections::BTreeMap, | |
| 9 | sync::Arc, | |
| 10 | task::Waker, | |
| 11 | time::{Duration, Instant}, | |
| 12 | }; | |
| 7 | use std::{collections::BTreeMap, sync::Arc, task::Waker, time::Duration}; | |
| 8 | use web_time::Instant; | |
| 13 | 9 | |
| 14 | 10 | /// Decoded bytes a scene's pictures keep at once. Pictures in view shrink to fit it, and |
| 15 | 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 | 26 | Primitive, Stroke, |
| 27 | 27 | edit::{self, Clicks, Command, Key, Modifiers, Movement, NamedKey, SelectionUnit}, |
| 28 | 28 | }; |
| 29 | use std::{ | |
| 30 | error::Error, | |
| 31 | time::{Duration, Instant}, | |
| 32 | }; | |
| 29 | use std::{error::Error, time::Duration}; | |
| 30 | use web_time::Instant; | |
| 33 | 31 | |
| 34 | 32 | const HANDLE_HEIGHT: f32 = 6.75; |
| 35 | 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 | 96 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 97 | 97 | match self { |
| 98 | 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 | 104 | _ => write!(f, "Text layout failed: {self:?}"), |
| 102 | 105 | } |
| ... | ... | @@ -110,7 +113,9 @@ pub(crate) const DEFAULT_FONT: &str = "Arial"; |
| 110 | 113 | pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0; |
| 111 | 114 | |
| 112 | 115 | /// 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"))] | |
| 114 | 119 | const BUNDLED: [(&str, &[&[u8]]); 4] = [ |
| 115 | 120 | ( |
| 116 | 121 | "Calibri", |
| ... | ... | @@ -151,6 +156,7 @@ const BUNDLED: [(&str, &[&[u8]]); 4] = [ |
| 151 | 156 | impl Default for TextEngine { |
| 152 | 157 | /// An engine that lays out each bundled family in its substitute where it is missing. |
| 153 | 158 | fn default() -> Self { |
| 159 | #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] | |
| 154 | 160 | let mut engine = Self { |
| 155 | 161 | // Clones share loaded font files, so glyphs one lays out draw from the same cache. |
| 156 | 162 | fonts: FontContext { |
| ... | ... | @@ -161,6 +167,7 @@ impl Default for TextEngine { |
| 161 | 167 | arial_substitutes: BTreeSet::new(), |
| 162 | 168 | substitutes: BTreeMap::new(), |
| 163 | 169 | }; |
| 170 | #[cfg(not(target_arch = "wasm32"))] | |
| 164 | 171 | for (family, faces) in BUNDLED { |
| 165 | 172 | if engine.fonts.collection.family_id(family).is_none() { |
| 166 | 173 | for face in faces { |
| ... | ... | @@ -187,8 +194,9 @@ impl Clone for TextEngine { |
| 187 | 194 | } |
| 188 | 195 | |
| 189 | 196 | impl TextEngine { |
| 190 | /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as | |
| 191 | /// Courier New before creating layouts, in place of the bundled substitute. | |
| 197 | /// Register Arimo as Arial, Carlito as Calibri, Caladea as Cambria, Tinos as Times New | |
| 198 | /// Roman or Cousine as Courier New before creating layouts, in place of the bundled | |
| 199 | /// substitute. | |
| 192 | 200 | pub fn register_substitute(&mut self, data: Blob<u8>) -> Result<&'static str, LayoutError> { |
| 193 | 201 | self.register(data, false) |
| 194 | 202 | } |
| ... | ... | @@ -208,6 +216,7 @@ impl TextEngine { |
| 208 | 216 | let (substitute, target) = match family.as_str() { |
| 209 | 217 | "Arimo" => ("Arimo", "Arial"), |
| 210 | 218 | "Carlito" => ("Carlito", "Calibri"), |
| 219 | "Caladea" => ("Caladea", "Cambria"), | |
| 211 | 220 | "Tinos" => ("Tinos", "Times New Roman"), |
| 212 | 221 | "Cousine" => ("Cousine", "Courier New"), |
| 213 | 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 | 16 | |
| 17 | 17 | [dependencies] |
| 18 | 18 | parley.workspace = true |
| 19 | # std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. | |
| 20 | web-time = "1.1" | |
| 19 | 21 | |
| 20 | 22 | base64 = { version = "0.22.1", optional = true } |
| 21 | 23 | bytemuck = { version = "1.25", features = ["derive"], optional = true } |
crates/draw/src/edit.rs+2-4| ... | ... | @@ -5,10 +5,8 @@ use parley::{ |
| 5 | 5 | Affinity, Brush, Layout, |
| 6 | 6 | editing::{Cursor, Selection}, |
| 7 | 7 | }; |
| 8 | use std::{ | |
| 9 | collections::BTreeSet, | |
| 10 | time::{Duration, Instant}, | |
| 11 | }; | |
| 8 | use std::{collections::BTreeSet, time::Duration}; | |
| 9 | use web_time::Instant; | |
| 12 | 10 | |
| 13 | 11 | #[derive(Clone, Debug, PartialEq)] |
| 14 | 12 | pub enum Key { |
crates/notebook/Cargo.toml+11| ... | ... | @@ -25,6 +25,17 @@ getrandom = "0.4.3" |
| 25 | 25 | hmac = "0.13.0" |
| 26 | 26 | # OneNote packages (.onepkg) are cabinet files. |
| 27 | 27 | cab = "0.6" |
| 28 | # std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. | |
| 29 | web-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] | |
| 34 | getrandom = { version = "0.4.3", features = ["wasm_js"] } | |
| 35 | js-sys = "0.3" | |
| 36 | wasm-bindgen = "0.2" | |
| 37 | wasm-bindgen-futures = "0.4" | |
| 38 | rsqlite-vfs = "0.1.1" | |
| 28 | 39 | |
| 29 | 40 | [target.'cfg(target_vendor = "apple")'.dependencies] |
| 30 | 41 | nix = { version = "0.31", default-features = false, features = ["fs"] } |
crates/notebook/src/background.rs+146-150| ... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | //! keeps every section of an open notebook: queued edits publish and other clients' changes |
| 3 | 3 | //! are noticed without the section being open. |
| 4 | 4 | |
| 5 | use crate::fs; | |
| 5 | 6 | use crate::{ |
| 6 | 7 | EditStatus, Error, Remote, Replica, Result, |
| 7 | 8 | discover::{Entry, Listed}, |
| ... | ... | @@ -17,9 +18,9 @@ use std::{ |
| 17 | 18 | Arc, Mutex, Weak, |
| 18 | 19 | atomic::{AtomicBool, Ordering}, |
| 19 | 20 | }, |
| 20 | thread, | |
| 21 | time::{Duration, Instant}, | |
| 21 | time::Duration, | |
| 22 | 22 | }; |
| 23 | use web_time::Instant; | |
| 23 | 24 | |
| 24 | 25 | /// How long a section that could not be reached waits to be tried again; OneNote 2010 |
| 25 | 26 | /// retries a failing section about every 31 seconds. |
| ... | ... | @@ -137,168 +138,163 @@ impl Background { |
| 137 | 138 | }); |
| 138 | 139 | let weak = Arc::downgrade(&shared); |
| 139 | 140 | let owner = Arc::clone(&shared); |
| 140 | thread::Builder::new() | |
| 141 | .name("onestore-background".into()) | |
| 142 | .spawn(move || { | |
| 143 | let signal = &owner.signal; | |
| 144 | let mut bound: Option<(B, L)> = None; | |
| 145 | let mut news = false; | |
| 146 | while !signal.stopped.load(Ordering::Acquire) { | |
| 147 | if signal.offline.load(Ordering::Acquire) | |
| 148 | && !signal.requested.load(Ordering::Acquire) | |
| 149 | { | |
| 150 | // Working online again lists every folder again. | |
| 151 | if let Ok(mut watched) = owner.watched.lock() { | |
| 152 | watched.watching(false); | |
| 153 | watched.lost = true; | |
| 154 | } | |
| 141 | crate::task::spawn("onestore-background", move || async move { | |
| 142 | let signal = &owner.signal; | |
| 143 | let mut bound: Option<(B, L)> = None; | |
| 144 | let mut news = false; | |
| 145 | while !signal.stopped.load(Ordering::Acquire) { | |
| 146 | if signal.offline.load(Ordering::Acquire) | |
| 147 | && !signal.requested.load(Ordering::Acquire) | |
| 148 | { | |
| 149 | // Working online again lists every folder again. | |
| 150 | if let Ok(mut watched) = owner.watched.lock() { | |
| 151 | watched.watching(false); | |
| 152 | watched.lost = true; | |
| 153 | } | |
| 154 | bound = None; | |
| 155 | crate::task::wait(&receiver, None).await; | |
| 156 | continue; | |
| 157 | } | |
| 158 | let now = Instant::now(); | |
| 159 | let next = { | |
| 160 | let Ok(mut watched) = owner.watched.lock() else { | |
| 161 | return; | |
| 162 | }; | |
| 163 | if std::mem::take(&mut watched.lost) { | |
| 155 | 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 | 179 | continue; |
| 158 | 180 | } |
| 159 | let now = Instant::now(); | |
| 160 | let next = { | |
| 181 | Next::Connect(connection) => { | |
| 182 | let reports = Reports { | |
| 183 | shared: weak.clone(), | |
| 184 | connection: connection + 1, | |
| 185 | }; | |
| 186 | let connected = connect(reports); | |
| 161 | 187 | let Ok(mut watched) = owner.watched.lock() else { |
| 162 | 188 | return; |
| 163 | 189 | }; |
| 164 | if std::mem::take(&mut watched.lost) { | |
| 165 | bound = None; | |
| 166 | watched.watching(false); | |
| 167 | for watch in &mut watched.sections { | |
| 168 | watch.due = now; | |
| 169 | } | |
| 170 | } | |
| 171 | watched.next(now, bound.is_some()) | |
| 172 | }; | |
| 173 | let (path, replica, seen, current, image) = match next { | |
| 174 | Next::Wait(wait) => { | |
| 175 | signal.requested.store(false, Ordering::Release); | |
| 176 | if std::mem::take(&mut news) { | |
| 177 | notify(); | |
| 190 | // Whatever ended before now ended with the last connection. | |
| 191 | watched.connection += 1; | |
| 192 | watched.lost = false; | |
| 193 | match connected { | |
| 194 | Ok((mut files, reported)) => { | |
| 195 | watched.watching(reported); | |
| 196 | let folders = watched.rescanning(); | |
| 197 | drop(watched); | |
| 198 | let listings = list(&mut files.1, folders); | |
| 199 | let Ok(mut watched) = owner.watched.lock() else { | |
| 200 | return; | |
| 201 | }; | |
| 202 | watched.listed(&listings, Some(copies), Instant::now()); | |
| 203 | bound = Some(files); | |
| 178 | 204 | } |
| 179 | let _ = match wait { | |
| 180 | Some(wait) => receiver.recv_timeout(wait).ok(), | |
| 181 | None => receiver.recv().ok(), | |
| 182 | }; | |
| 183 | continue; | |
| 184 | } | |
| 185 | Next::Connect(connection) => { | |
| 186 | let reports = Reports { | |
| 187 | shared: weak.clone(), | |
| 188 | connection: connection + 1, | |
| 189 | }; | |
| 190 | let connected = connect(reports); | |
| 191 | let Ok(mut watched) = owner.watched.lock() else { | |
| 192 | return; | |
| 193 | }; | |
| 194 | // Whatever ended before now ended with the last connection. | |
| 195 | watched.connection += 1; | |
| 196 | watched.lost = false; | |
| 197 | match connected { | |
| 198 | Ok((mut files, reported)) => { | |
| 199 | watched.watching(reported); | |
| 200 | let folders = watched.rescanning(); | |
| 201 | drop(watched); | |
| 202 | let listings = list(&mut files.1, folders); | |
| 203 | let Ok(mut watched) = owner.watched.lock() else { | |
| 204 | return; | |
| 205 | }; | |
| 206 | watched.listed(&listings, Some(copies), Instant::now()); | |
| 207 | bound = Some(files); | |
| 208 | } | |
| 209 | Err(error) => { | |
| 210 | let error = Error::RemoteIo(error); | |
| 211 | let retry = now + RETRY; | |
| 212 | for watch in &mut watched.sections { | |
| 213 | news |= watch.fail(&error, None); | |
| 214 | watch.due = watch.due.max(retry); | |
| 215 | } | |
| 216 | watched.relist = watched.relist.map(|due| due.max(retry)); | |
| 205 | Err(error) => { | |
| 206 | let error = Error::RemoteIo(error); | |
| 207 | let retry = now + RETRY; | |
| 208 | for watch in &mut watched.sections { | |
| 209 | news |= watch.fail(&error, None); | |
| 210 | watch.due = watch.due.max(retry); | |
| 217 | 211 | } |
| 212 | watched.relist = watched.relist.map(|due| due.max(retry)); | |
| 218 | 213 | } |
| 219 | continue; | |
| 220 | 214 | } |
| 221 | Next::List(folders) => { | |
| 222 | let Some((_, list_folder)) = &mut bound else { | |
| 223 | continue; | |
| 224 | }; | |
| 225 | let listings = list(list_folder, folders); | |
| 226 | let Ok(mut watched) = owner.watched.lock() else { | |
| 227 | return; | |
| 228 | }; | |
| 229 | watched.listed(&listings, None, Instant::now()); | |
| 215 | continue; | |
| 216 | } | |
| 217 | Next::List(folders) => { | |
| 218 | let Some((_, list_folder)) = &mut bound else { | |
| 230 | 219 | continue; |
| 231 | } | |
| 232 | Next::Check { | |
| 233 | path, | |
| 234 | replica, | |
| 235 | seen, | |
| 236 | current, | |
| 237 | image, | |
| 238 | } => (path, replica, seen, current, image), | |
| 239 | }; | |
| 240 | let Some((bind, _)) = &mut bound else { | |
| 220 | }; | |
| 221 | let listings = list(list_folder, folders); | |
| 222 | let Ok(mut watched) = owner.watched.lock() else { | |
| 223 | return; | |
| 224 | }; | |
| 225 | watched.listed(&listings, None, Instant::now()); | |
| 241 | 226 | continue; |
| 242 | }; | |
| 243 | let (queued, outcome) = step( | |
| 244 | &mut bind(&path), | |
| 245 | replica.as_deref(), | |
| 246 | seen.as_deref(), | |
| 227 | } | |
| 228 | Next::Check { | |
| 229 | path, | |
| 230 | replica, | |
| 231 | seen, | |
| 247 | 232 | current, |
| 248 | 233 | image, |
| 249 | copies, | |
| 250 | ); | |
| 251 | if outcome.as_ref().is_err_and(disconnected) { | |
| 252 | bound = None; | |
| 253 | } | |
| 254 | let Ok(mut watched) = owner.watched.lock() else { | |
| 255 | return; | |
| 256 | }; | |
| 257 | let interval = watched.interval(); | |
| 258 | let Watched { | |
| 259 | sections, changed, .. | |
| 260 | } = &mut *watched; | |
| 261 | let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else { | |
| 262 | continue; | |
| 263 | }; | |
| 264 | let now = Instant::now(); | |
| 265 | watch.current = false; | |
| 266 | news |= match outcome { | |
| 267 | Ok((stamp, moved)) => { | |
| 268 | watch.stamp = Some(stamp); | |
| 269 | watch.due = now + interval; | |
| 270 | if moved { | |
| 271 | changed.push(path); | |
| 272 | } | |
| 273 | let before = summary(&watch.status); | |
| 274 | if let Some(queued) = queued { | |
| 275 | watch.status = SyncStatus { | |
| 276 | synced: Some(crate::now()), | |
| 277 | error: None, | |
| 278 | queued, | |
| 279 | }; | |
| 280 | } | |
| 281 | moved || before != summary(&watch.status) | |
| 282 | } | |
| 283 | Err(error) => { | |
| 284 | watch.due = now + RETRY; | |
| 285 | watch.fail(&error, queued) | |
| 286 | } | |
| 287 | }; | |
| 234 | } => (path, replica, seen, current, image), | |
| 235 | }; | |
| 236 | let Some((bind, _)) = &mut bound else { | |
| 237 | continue; | |
| 238 | }; | |
| 239 | let (queued, outcome) = step( | |
| 240 | &mut bind(&path), | |
| 241 | replica.as_deref(), | |
| 242 | seen.as_deref(), | |
| 243 | current, | |
| 244 | image, | |
| 245 | copies, | |
| 246 | ); | |
| 247 | if outcome.as_ref().is_err_and(disconnected) { | |
| 248 | bound = None; | |
| 288 | 249 | } |
| 289 | if let Ok(mut watched) = owner.watched.lock() { | |
| 290 | watched.watching(false); | |
| 291 | if owner.discard.load(Ordering::Acquire) { | |
| 292 | for replica in watched | |
| 293 | .sections | |
| 294 | .iter() | |
| 295 | .filter_map(|watch| watch.replica.as_ref()) | |
| 296 | { | |
| 297 | discard(replica); | |
| 250 | let Ok(mut watched) = owner.watched.lock() else { | |
| 251 | return; | |
| 252 | }; | |
| 253 | let interval = watched.interval(); | |
| 254 | let Watched { | |
| 255 | sections, changed, .. | |
| 256 | } = &mut *watched; | |
| 257 | let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else { | |
| 258 | continue; | |
| 259 | }; | |
| 260 | let now = Instant::now(); | |
| 261 | watch.current = false; | |
| 262 | news |= match outcome { | |
| 263 | Ok((stamp, moved)) => { | |
| 264 | watch.stamp = Some(stamp); | |
| 265 | watch.due = now + interval; | |
| 266 | if moved { | |
| 267 | changed.push(path); | |
| 268 | } | |
| 269 | let before = summary(&watch.status); | |
| 270 | if let Some(queued) = queued { | |
| 271 | watch.status = SyncStatus { | |
| 272 | synced: Some(crate::now()), | |
| 273 | error: None, | |
| 274 | queued, | |
| 275 | }; | |
| 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 | 298 | Ok(Self(shared)) |
| 303 | 299 | } |
| 304 | 300 | |
| ... | ... | @@ -668,7 +664,7 @@ impl Watched { |
| 668 | 664 | && watch |
| 669 | 665 | .replica |
| 670 | 666 | .as_ref() |
| 671 | .is_some_and(|replica| !replica.exists()); | |
| 667 | .is_some_and(|replica| fs::metadata(replica).is_err()); | |
| 672 | 668 | watch.due = if read { |
| 673 | 669 | staggered += 1; |
| 674 | 670 | now + STAGGER * (staggered - 1) |
| ... | ... | @@ -750,7 +746,7 @@ fn discard(replica: &Path) { |
| 750 | 746 | for suffix in ["-wal", "-shm", ""] { |
| 751 | 747 | let mut file = replica.as_os_str().to_owned(); |
| 752 | 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 | 829 | let Some(replica) = replica else { |
| 834 | 830 | return (Some(0), Ok((stamp, moved))); |
| 835 | 831 | }; |
| 836 | if !replica.exists() { | |
| 832 | if fs::metadata(replica).is_err() { | |
| 837 | 833 | if !copies { |
| 838 | 834 | return (Some(0), Ok((stamp, moved))); |
| 839 | 835 | } |
| 840 | 836 | let copied = (|| { |
| 841 | 837 | let image = remote.read().map_err(Error::RemoteIo)?; |
| 842 | 838 | if let Some(folder) = replica.parent() { |
| 843 | std::fs::create_dir_all(folder)?; | |
| 839 | fs::create_dir_all(folder)?; | |
| 844 | 840 | } |
| 845 | 841 | Replica::seed(replica, &image, None)?; |
| 846 | 842 | Ok(Stamp::of(&image)?) |
crates/notebook/src/bin/onestore-diagnostic.rs+4-3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | use notebook::fs; | |
| 1 | 2 | use onestore::{ |
| 2 | 3 | Arena, ExGuid, Section, TextAttribute, |
| 3 | 4 | document::{Format, Layout}, |
| ... | ... | @@ -9,7 +10,7 @@ use onestore::{ |
| 9 | 10 | use serde::Deserialize; |
| 10 | 11 | use serde_json::{Value, json}; |
| 11 | 12 | use std::{ |
| 12 | env, fs, | |
| 13 | env, | |
| 13 | 14 | io::{self, Write}, |
| 14 | 15 | }; |
| 15 | 16 | |
| ... | ... | @@ -96,7 +97,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>> |
| 96 | 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 | 99 | if args[0] == "snapshot" { |
| 99 | let bytes = onestore::read_file(&args[1])?; | |
| 100 | let bytes = fs::read_file(&args[1])?; | |
| 100 | 101 | let mut file = fs::OpenOptions::new() |
| 101 | 102 | .write(true) |
| 102 | 103 | .create_new(true) |
| ... | ... | @@ -199,7 +200,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>> |
| 199 | 200 | let Some(transaction) = transaction else { |
| 200 | 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 | 204 | Ok(()) => json!({"ok": true, "state": "Committed"}), |
| 204 | 205 | Err(error) => json!({"ok": false, "state": format!("{:?}", error.state), |
| 205 | 206 | "kind": format!("{:?}", error.error.kind()), "error": error.error.to_string()}), |
crates/notebook/src/discover/source.rs+8-7| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | use super::{Entry, EntryKind, Listed, Source, component}; |
| 2 | use crate::fs; | |
| 2 | 3 | use std::{ |
| 3 | 4 | collections::BTreeSet, |
| 4 | 5 | io, |
| ... | ... | @@ -14,7 +15,7 @@ fn evicted(name: &str) -> Option<&str> { |
| 14 | 15 | |
| 15 | 16 | /// Whether the system keeps the file's contents elsewhere until it is read, as macOS 14 and |
| 16 | 17 | /// later list a file iCloud Drive evicted (`SF_DATALESS`). |
| 17 | fn dataless(metadata: &std::fs::Metadata) -> bool { | |
| 18 | fn dataless(metadata: &fs::Metadata) -> bool { | |
| 18 | 19 | const SF_DATALESS: u32 = 0x4000_0000; |
| 19 | 20 | #[cfg(target_os = "macos")] |
| 20 | 21 | let flags = std::os::macos::fs::MetadataExt::st_flags(metadata); |
| ... | ... | @@ -41,8 +42,8 @@ pub struct Local { |
| 41 | 42 | |
| 42 | 43 | impl Local { |
| 43 | 44 | pub fn open(root: impl AsRef<Path>) -> io::Result<Self> { |
| 44 | let root = root.as_ref().canonicalize()?; | |
| 45 | if !root.is_dir() { | |
| 45 | let root = fs::canonicalize(root)?; | |
| 46 | if !fs::metadata(&root)?.is_dir() { | |
| 46 | 47 | return Err(io::ErrorKind::NotADirectory.into()); |
| 47 | 48 | } |
| 48 | 49 | Ok(Self { root }) |
| ... | ... | @@ -52,7 +53,7 @@ impl Local { |
| 52 | 53 | if !relative.is_empty() && !relative.split('/').all(component) { |
| 53 | 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 | 57 | if !path.starts_with(&self.root) { |
| 57 | 58 | return Err(io::ErrorKind::PermissionDenied.into()); |
| 58 | 59 | } |
| ... | ... | @@ -63,7 +64,7 @@ impl Local { |
| 63 | 64 | impl Source for Local { |
| 64 | 65 | fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> { |
| 65 | 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 | 68 | let entry = entry?; |
| 68 | 69 | if entries.len() == limit { |
| 69 | 70 | return Err(io::ErrorKind::FileTooLarge.into()); |
| ... | ... | @@ -108,7 +109,7 @@ impl Source for Local { |
| 108 | 109 | } |
| 109 | 110 | |
| 110 | 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 | } |
| 114 | 115 | |
| ... | ... | @@ -190,7 +191,7 @@ impl Source for Smb<'_> { |
| 190 | 191 | /// Reads the file's stamp, then its replica's base if that has the same. |
| 191 | 192 | fn copy(&mut self, path: &str, known: [u8; 16]) -> Option<Vec<u8>> { |
| 192 | 193 | let replica = crate::session::replica_file(self.copies.as_ref()?, &known); |
| 193 | if !replica.exists() { | |
| 194 | if fs::metadata(&replica).is_err() { | |
| 194 | 195 | return None; |
| 195 | 196 | } |
| 196 | 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"))] | |
| 5 | pub use onestore::{confirm_file, place_file, read_file, read_file_limited, supersede_file}; | |
| 6 | #[cfg(not(target_arch = "wasm32"))] | |
| 7 | pub 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"))] | |
| 11 | pub 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")] | |
| 19 | mod web; | |
| 20 | #[cfg(target_arch = "wasm32")] | |
| 21 | pub 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 | ||
| 6 | use onestore::{CommitError, CommitIo, CommitState, Stamp, Transaction}; | |
| 7 | use 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 | ||
| 17 | mod sqlite; | |
| 18 | pub(crate) use sqlite::install as install_sqlite; | |
| 19 | ||
| 20 | /// A file's bytes and when they last changed, in milliseconds since 1970. | |
| 21 | struct 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 | ||
| 28 | type Shared = Rc<RefCell<Data>>; | |
| 29 | ||
| 30 | enum Node { | |
| 31 | Directory, | |
| 32 | File(Shared), | |
| 33 | } | |
| 34 | ||
| 35 | #[derive(Default)] | |
| 36 | struct Files { | |
| 37 | nodes: BTreeMap<PathBuf, Node>, | |
| 38 | /// Paths written, made or removed since the host last asked. | |
| 39 | changed: BTreeSet<PathBuf>, | |
| 40 | } | |
| 41 | ||
| 42 | thread_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. | |
| 50 | pub 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. | |
| 57 | pub 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. | |
| 73 | pub 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. | |
| 78 | pub 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 | ||
| 96 | fn now() -> f64 { | |
| 97 | js_sys::Date::now() | |
| 98 | } | |
| 99 | ||
| 100 | /// `path` absolute, without `.` or `..`. | |
| 101 | fn 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 | ||
| 115 | fn not_found(path: &Path) -> io::Error { | |
| 116 | io::Error::new(ErrorKind::NotFound, format!("{} not found", path.display())) | |
| 117 | } | |
| 118 | ||
| 119 | impl 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 | ||
| 162 | fn 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. | |
| 167 | fn 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)] | |
| 175 | pub struct FileType(bool); | |
| 176 | ||
| 177 | impl 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)] | |
| 192 | pub struct Metadata { | |
| 193 | directory: bool, | |
| 194 | length: u64, | |
| 195 | modified: f64, | |
| 196 | } | |
| 197 | ||
| 198 | impl 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 | ||
| 221 | fn 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 | ||
| 239 | pub 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 | ||
| 250 | pub fn symlink_metadata(path: impl AsRef<Path>) -> io::Result<Metadata> { | |
| 251 | metadata(path) | |
| 252 | } | |
| 253 | ||
| 254 | pub 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. | |
| 260 | pub 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. | |
| 271 | pub fn absolute(path: impl AsRef<Path>) -> io::Result<PathBuf> { | |
| 272 | Ok(normal(path.as_ref())) | |
| 273 | } | |
| 274 | ||
| 275 | pub 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 | ||
| 283 | pub 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 | ||
| 289 | pub 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 | ||
| 293 | pub 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 | ||
| 302 | pub 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. | |
| 310 | pub 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 | ||
| 318 | pub 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 | ||
| 331 | pub 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 | ||
| 348 | pub 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 | ||
| 359 | pub 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 | ||
| 376 | pub 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. | |
| 399 | pub 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 | ||
| 438 | pub struct DirEntry { | |
| 439 | path: PathBuf, | |
| 440 | metadata: Metadata, | |
| 441 | } | |
| 442 | ||
| 443 | impl 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 | ||
| 461 | pub struct ReadDir(std::vec::IntoIter<DirEntry>); | |
| 462 | ||
| 463 | impl 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 | ||
| 471 | pub 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)] | |
| 491 | pub struct OpenOptions { | |
| 492 | read: bool, | |
| 493 | write: bool, | |
| 494 | append: bool, | |
| 495 | truncate: bool, | |
| 496 | create: bool, | |
| 497 | create_new: bool, | |
| 498 | } | |
| 499 | ||
| 500 | impl 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. | |
| 560 | pub struct File { | |
| 561 | data: Shared, | |
| 562 | position: u64, | |
| 563 | append: bool, | |
| 564 | } | |
| 565 | ||
| 566 | impl 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 | ||
| 625 | impl 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 | ||
| 633 | impl 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 | ||
| 648 | impl 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 | ||
| 661 | impl 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. | |
| 677 | pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> { | |
| 678 | read(path) | |
| 679 | } | |
| 680 | ||
| 681 | pub 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 | ||
| 689 | fn writable(path: impl AsRef<Path>) -> io::Result<File> { | |
| 690 | OpenOptions::new().read(true).write(true).open(path) | |
| 691 | } | |
| 692 | ||
| 693 | fn not_committed(error: io::Error) -> CommitError { | |
| 694 | CommitError { | |
| 695 | state: CommitState::NotCommitted, | |
| 696 | error, | |
| 697 | } | |
| 698 | } | |
| 699 | ||
| 700 | pub fn commit_file(transaction: &Transaction, path: impl AsRef<Path>) -> Result<(), CommitError> { | |
| 701 | transaction.commit(&mut writable(path).map_err(not_committed)?) | |
| 702 | } | |
| 703 | ||
| 704 | pub fn confirm_file(path: impl AsRef<Path>, base: &Stamp) -> Result<(), CommitError> { | |
| 705 | onestore::confirm(&mut writable(path).map_err(not_committed)?, base) | |
| 706 | } | |
| 707 | ||
| 708 | pub 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. | |
| 714 | pub 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 | ||
| 5 | use super::{FILES, Node, Shared, normal, touched, with}; | |
| 6 | use rsqlite_vfs::{ | |
| 7 | OsCallback, SQLiteIoMethods, SQLiteVfs, SQLiteVfsFile, VfsError, VfsFile, VfsResult, VfsStore, | |
| 8 | ffi::{SQLITE_CANTOPEN, SQLITE_IOERR, SQLITE_IOERR_DELETE, sqlite3_vfs}, | |
| 9 | }; | |
| 10 | use std::{path::Path, rc::Rc, sync::Once, time::Duration}; | |
| 11 | ||
| 12 | /// Registers the VFS as SQLite's default, once. | |
| 13 | pub(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 | ||
| 21 | struct File(Shared); | |
| 22 | ||
| 23 | impl 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 | ||
| 60 | fn error(code: i32, error: std::io::Error) -> VfsError { | |
| 61 | VfsError::new(code, error.to_string()) | |
| 62 | } | |
| 63 | ||
| 64 | struct Store; | |
| 65 | ||
| 66 | impl 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 | ||
| 74 | impl 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 | ||
| 104 | struct Methods; | |
| 105 | ||
| 106 | impl SQLiteIoMethods for Methods { | |
| 107 | type File = File; | |
| 108 | type AppData = (); | |
| 109 | type Store = Store; | |
| 110 | ||
| 111 | const VERSION: i32 = 1; | |
| 112 | } | |
| 113 | ||
| 114 | struct Vfs; | |
| 115 | ||
| 116 | impl 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 | ||
| 129 | impl 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 | 3 | #![doc = include_str!("../README.md")] |
| 4 | 4 | |
| 5 | 5 | pub mod discover; |
| 6 | pub mod fs; | |
| 6 | 7 | #[cfg(feature = "smb")] |
| 7 | 8 | pub mod smb; |
| 8 | 9 | |
| 10 | use fs::OpenOptions; | |
| 9 | 11 | use onestore::{ |
| 10 | 12 | ExGuid, |
| 11 | 13 | op::{Edit, OpError}, |
| ... | ... | @@ -14,7 +16,6 @@ use onestore::{ |
| 14 | 16 | }; |
| 15 | 17 | use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; |
| 16 | 18 | use std::{ |
| 17 | fs::OpenOptions, | |
| 18 | 19 | io, |
| 19 | 20 | path::{Path, PathBuf}, |
| 20 | 21 | sync::{Arc, Mutex, MutexGuard, mpsc}, |
| ... | ... | @@ -38,6 +39,7 @@ pub use recovery::{Recovery, RecoverySummary}; |
| 38 | 39 | pub mod sidecar; |
| 39 | 40 | mod sync; |
| 40 | 41 | pub use sync::{EditStatus, Remote, Synced}; |
| 42 | mod task; | |
| 41 | 43 | mod worker; |
| 42 | 44 | mod working; |
| 43 | 45 | #[cfg(feature = "smb")] |
| ... | ... | @@ -137,7 +139,7 @@ impl Replica { |
| 137 | 139 | source: impl FnOnce() -> Result<Vec<u8>>, |
| 138 | 140 | ) -> Result<Self> { |
| 139 | 141 | let path = path.as_ref(); |
| 140 | if !path.exists() { | |
| 142 | if fs::metadata(path).is_err() { | |
| 141 | 143 | match Self::seed(path, &source()?, key) { |
| 142 | 144 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {} |
| 143 | 145 | seeded => seeded?, |
| ... | ... | @@ -154,14 +156,14 @@ impl Replica { |
| 154 | 156 | static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); |
| 155 | 157 | let build = BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
| 156 | 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 | 160 | let building = PathBuf::from(building); |
| 159 | 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 | 163 | for suffix in ["", "-wal", "-shm"] { |
| 162 | 164 | let mut file = building.as_os_str().to_owned(); |
| 163 | 165 | file.push(suffix); |
| 164 | let _ = std::fs::remove_file(file); | |
| 166 | let _ = fs::remove_file(file); | |
| 165 | 167 | } |
| 166 | 168 | linked |
| 167 | 169 | } |
| ... | ... | @@ -463,6 +465,8 @@ fn signed(value: u64) -> Result<i64> { |
| 463 | 465 | /// Opens a cache without writing to it: exclusive locking, a full sync of every commit |
| 464 | 466 | /// (`F_FULLFSYNC` on macOS, the WAL's syncs included) and foreign keys, each queried back. |
| 465 | 467 | fn cache_connection(path: &Path) -> Result<Connection> { |
| 468 | #[cfg(target_arch = "wasm32")] | |
| 469 | fs::install_sqlite(); | |
| 466 | 470 | let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; |
| 467 | 471 | connection.busy_timeout(Duration::ZERO)?; |
| 468 | 472 | connection.execute_batch( |
| ... | ... | @@ -512,8 +516,8 @@ fn write_ahead(connection: &Connection) -> Result<()> { |
| 512 | 516 | pub(crate) fn now() -> u64 { |
| 513 | 517 | use std::sync::atomic::{AtomicU64, Ordering}; |
| 514 | 518 | static LAST: AtomicU64 = AtomicU64::new(0); |
| 515 | let unix = std::time::SystemTime::now() | |
| 516 | .duration_since(std::time::UNIX_EPOCH) | |
| 519 | let unix = web_time::SystemTime::now() | |
| 520 | .duration_since(web_time::UNIX_EPOCH) | |
| 517 | 521 | .unwrap_or_default(); |
| 518 | 522 | let clock = |
| 519 | 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 | //! A location is a string naming where a notebook or lone section is reached: the canonical |
| 7 | 7 | //! path of a local folder or file (`local`), or `smb` for one on a share. |
| 8 | 8 | |
| 9 | use crate::fs; | |
| 9 | 10 | use onestore::{Header, Stamp}; |
| 10 | 11 | use std::{ |
| 11 | 12 | io, |
| ... | ... | @@ -26,11 +27,10 @@ pub fn folder(cache: &Path, location: &str) -> PathBuf { |
| 26 | 27 | /// its canonical path, or its folder's with its name. |
| 27 | 28 | pub fn local(path: &Path) -> io::Result<String> { |
| 28 | 29 | let canonical = |
| 29 | path.canonicalize() | |
| 30 | .or_else(|error| match (path.parent(), path.file_name()) { | |
| 31 | (Some(parent), Some(name)) => Ok(parent.canonicalize()?.join(name)), | |
| 32 | _ => Err(error), | |
| 33 | })?; | |
| 30 | fs::canonicalize(path).or_else(|error| match (path.parent(), path.file_name()) { | |
| 31 | (Some(parent), Some(name)) => Ok(fs::canonicalize(parent)?.join(name)), | |
| 32 | _ => Err(error), | |
| 33 | })?; | |
| 34 | 34 | Ok(canonical.to_string_lossy().into_owned()) |
| 35 | 35 | } |
| 36 | 36 | |
| ... | ... | @@ -46,13 +46,15 @@ pub fn smb(address: &str, share: &str, root: &str) -> String { |
| 46 | 46 | /// must be closed. |
| 47 | 47 | pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> { |
| 48 | 48 | let old = folder(cache, from); |
| 49 | if !old.exists() { | |
| 49 | if fs::metadata(&old).is_err() { | |
| 50 | 50 | return Ok(()); |
| 51 | 51 | } |
| 52 | 52 | let new = claimed(cache, to)?; |
| 53 | for entry in std::fs::read_dir(&old)? { | |
| 53 | for entry in fs::read_dir(&old)? { | |
| 54 | 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 | 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 | 66 | fn claimed(cache: &Path, location: &str) -> io::Result<PathBuf> { |
| 65 | 67 | let folder = folder(cache, location); |
| 66 | 68 | let label = folder.join(LOCATION); |
| 67 | if !label.exists() { | |
| 68 | std::fs::create_dir_all(&folder)?; | |
| 69 | std::fs::write(&label, location)?; | |
| 69 | if fs::metadata(&label).is_err() { | |
| 70 | fs::create_dir_all(&folder)?; | |
| 71 | fs::write(&label, location)?; | |
| 70 | 72 | } |
| 71 | 73 | Ok(folder) |
| 72 | 74 | } |
| ... | ... | @@ -93,7 +95,7 @@ pub(crate) fn claim( |
| 93 | 95 | let folder = claimed(cache, location)?; |
| 94 | 96 | let missing: Vec<&[u8; 16]> = sections |
| 95 | 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 | 99 | .collect(); |
| 98 | 100 | if missing.is_empty() { |
| 99 | 101 | return Ok(folder); |
| ... | ... | @@ -107,7 +109,7 @@ pub(crate) fn claim( |
| 107 | 109 | .iter() |
| 108 | 110 | .map(|orphan| (crate::session::replica_file(orphan, identity), false)); |
| 109 | 111 | for (replica, legacy) in legacy.chain(left) { |
| 110 | if !replica.exists() { | |
| 112 | if fs::metadata(&replica).is_err() { | |
| 111 | 113 | continue; |
| 112 | 114 | } |
| 113 | 115 | let adoptable = match crate::closed(&replica).and_then(|held| crate::peek(&held)) { |
| ... | ... | @@ -132,15 +134,15 @@ pub(crate) fn claim( |
| 132 | 134 | |
| 133 | 135 | /// The replica folders of local locations that no longer exist. |
| 134 | 136 | fn 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 | 138 | return Vec::new(); |
| 137 | 139 | }; |
| 138 | 140 | entries |
| 139 | 141 | .filter_map(|entry| { |
| 140 | 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 | 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 | 147 | .collect() |
| 146 | 148 | } |
| ... | ... | @@ -160,7 +162,7 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> { |
| 160 | 162 | let (mut source, mut target) = (from.as_os_str().to_owned(), to.as_os_str().to_owned()); |
| 161 | 163 | source.push(suffix); |
| 162 | 164 | target.push(suffix); |
| 163 | match std::fs::rename(&source, &target) { | |
| 165 | match fs::rename(&source, &target) { | |
| 164 | 166 | Err(error) if !suffix.is_empty() && error.kind() == io::ErrorKind::NotFound => {} |
| 165 | 167 | moved => moved?, |
| 166 | 168 | } |
| ... | ... | @@ -170,12 +172,12 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> { |
| 170 | 172 | |
| 171 | 173 | /// Removes a replica folder that holds no replica. |
| 172 | 174 | fn 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 | 176 | entries.all(|entry| entry.is_ok_and(|entry| entry.file_name() == LOCATION)) |
| 175 | 177 | }); |
| 176 | 178 | if empty { |
| 177 | let _ = std::fs::remove_file(folder.join(LOCATION)); | |
| 178 | let _ = std::fs::remove_dir(folder); | |
| 179 | let _ = fs::remove_file(folder.join(LOCATION)); | |
| 180 | let _ = fs::remove_dir(folder); | |
| 179 | 181 | } |
| 180 | 182 | } |
| 181 | 183 |
crates/notebook/src/package.rs+4-4| ... | ... | @@ -7,7 +7,7 @@ |
| 7 | 7 | //! MSZIP, which every cabinet reader takes, and reads either. |
| 8 | 8 | |
| 9 | 9 | use crate::{ |
| 10 | Error, Result, | |
| 10 | Error, Result, fs, | |
| 11 | 11 | session::{Notebook, lists, moved}, |
| 12 | 12 | }; |
| 13 | 13 | use onestore::{TocEdit, op::Edit, page::Page}; |
| ... | ... | @@ -196,13 +196,13 @@ pub fn unpack(mut files: Files, root: &Path) -> Result<()> { |
| 196 | 196 | transaction.apply(image)?; |
| 197 | 197 | } |
| 198 | 198 | } |
| 199 | std::fs::create_dir(root)?; | |
| 199 | fs::create_dir(root)?; | |
| 200 | 200 | for (path, bytes) in &files { |
| 201 | 201 | let file = root.join(path); |
| 202 | 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 | 207 | Ok(()) |
| 208 | 208 | } |
crates/notebook/src/session.rs+33-36| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | |
| 4 | 4 | pub use crate::background::{Background, Known}; |
| 5 | 5 | use crate::{ |
| 6 | EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, | |
| 6 | EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs, | |
| 7 | 7 | }; |
| 8 | 8 | use onestore::{ |
| 9 | 9 | CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction, |
| ... | ... | @@ -132,17 +132,17 @@ impl Storage for Directory { |
| 132 | 132 | } |
| 133 | 133 | |
| 134 | 134 | fn exists(&self, path: &str) -> bool { |
| 135 | self.path(path).exists() | |
| 135 | fs::metadata(self.path(path)).is_ok() | |
| 136 | 136 | } |
| 137 | 137 | |
| 138 | 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 | } |
| 141 | 141 | |
| 142 | 142 | fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> { |
| 143 | 143 | use std::io::Read; |
| 144 | 144 | let mut bytes = Vec::new(); |
| 145 | std::fs::File::open(self.path(path))? | |
| 145 | fs::File::open(self.path(path))? | |
| 146 | 146 | .take(limit as u64 + 1) |
| 147 | 147 | .read_to_end(&mut bytes)?; |
| 148 | 148 | if bytes.len() > limit { |
| ... | ... | @@ -153,15 +153,16 @@ impl Storage for Directory { |
| 153 | 153 | |
| 154 | 154 | fn create(&self, path: &str, bytes: &[u8]) -> Result<()> { |
| 155 | 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 | 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 | 161 | Ok(()) |
| 161 | 162 | } |
| 162 | 163 | |
| 163 | 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 | } |
| 166 | 167 | |
| 167 | 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 | 208 | } |
| 208 | 209 | |
| 209 | 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 | } |
| 212 | 213 | |
| 213 | 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 | } |
| 216 | 217 | |
| 217 | 218 | fn delete(&self, path: &str) -> Result<()> { |
| 218 | 219 | let path = self.path(path); |
| 219 | if path.is_dir() { | |
| 220 | std::fs::remove_dir(path)?; | |
| 220 | if fs::metadata(&path).is_ok_and(|metadata| metadata.is_dir()) { | |
| 221 | fs::remove_dir(path)?; | |
| 221 | 222 | } else { |
| 222 | std::fs::remove_file(path)?; | |
| 223 | fs::remove_file(path)?; | |
| 223 | 224 | } |
| 224 | 225 | Ok(()) |
| 225 | 226 | } |
| 226 | 227 | |
| 227 | 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 | } |
| 230 | 231 | |
| 231 | 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 | } |
| 234 | 235 | |
| 235 | 236 | fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> { |
| 236 | Ok(onestore::supersede_file( | |
| 237 | self.path(path), | |
| 238 | base, | |
| 239 | self.path(with), | |
| 240 | )?) | |
| 237 | Ok(fs::supersede_file(self.path(path), base, self.path(with))?) | |
| 241 | 238 | } |
| 242 | 239 | } |
| 243 | 240 | |
| ... | ... | @@ -363,7 +360,7 @@ impl Notebook { |
| 363 | 360 | pub const NEW_COLOR: u32 = 0x00aeba91; |
| 364 | 361 | |
| 365 | 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 | 364 | Self::with(Box::new(Directory(root.clone())), Some(root), cache) |
| 368 | 365 | } |
| 369 | 366 | |
| ... | ... | @@ -376,7 +373,7 @@ impl Notebook { |
| 376 | 373 | color: u32, |
| 377 | 374 | page: &PageCreation, |
| 378 | 375 | ) -> Result<Self> { |
| 379 | std::fs::create_dir(root.as_ref())?; | |
| 376 | fs::create_dir(root.as_ref())?; | |
| 380 | 377 | let mut notebook = Self::open(root, cache)?; |
| 381 | 378 | notebook.edit_toc("", &[onestore::TocEdit::Color(color)])?; |
| 382 | 379 | notebook.refresh()?; |
| ... | ... | @@ -412,13 +409,13 @@ impl Notebook { |
| 412 | 409 | ) -> Result<Self> { |
| 413 | 410 | let cache = cache.as_ref().to_path_buf(); |
| 414 | 411 | let listings = cache.join("listings"); |
| 415 | std::fs::create_dir_all(&listings)?; | |
| 412 | fs::create_dir_all(&listings)?; | |
| 416 | 413 | let name: String = <sha2::Sha256 as sha2::Digest>::digest(storage.location())[..16] |
| 417 | 414 | .iter() |
| 418 | 415 | .map(|byte| format!("{byte:02x}")) |
| 419 | 416 | .collect(); |
| 420 | 417 | let listing = listings.join(format!("{name}.json")); |
| 421 | let mut read = std::fs::read(&listing) | |
| 418 | let mut read = fs::read(&listing) | |
| 422 | 419 | .ok() |
| 423 | 420 | .and_then(|bytes| serde_json::from_slice(&bytes).ok()) |
| 424 | 421 | .unwrap_or_default(); |
| ... | ... | @@ -1271,7 +1268,7 @@ impl Notebook { |
| 1271 | 1268 | let folders: BTreeSet<&PathBuf> = placed.iter().map(|(.., folder)| folder).collect(); |
| 1272 | 1269 | let mut superseded = Vec::new(); |
| 1273 | 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 | 1272 | continue; |
| 1276 | 1273 | }; |
| 1277 | 1274 | for path in entries.filter_map(|entry| Some(entry.ok()?.path())) { |
| ... | ... | @@ -1337,8 +1334,8 @@ impl Notebook { |
| 1337 | 1334 | let section = self.section_path(path)?; |
| 1338 | 1335 | let replica = self.replica_path(path)?; |
| 1339 | 1336 | // Held until the file is replaced, so that no session queues edits to the old file. |
| 1340 | let held = replica | |
| 1341 | .exists() | |
| 1337 | let held = fs::metadata(&replica) | |
| 1338 | .is_ok() | |
| 1342 | 1339 | .then(|| crate::closed(&replica)) |
| 1343 | 1340 | .transpose()?; |
| 1344 | 1341 | if let Some(held) = &held |
| ... | ... | @@ -1525,7 +1522,7 @@ impl Notebook { |
| 1525 | 1522 | .into()); |
| 1526 | 1523 | }; |
| 1527 | 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 | 1526 | if !file.starts_with(root) { |
| 1530 | 1527 | return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); |
| 1531 | 1528 | } |
| ... | ... | @@ -1533,14 +1530,14 @@ impl Notebook { |
| 1533 | 1530 | // worker checks next, as a share's does. |
| 1534 | 1531 | if let discover::SectionState::Readable { document, .. } = &section.state { |
| 1535 | 1532 | let cache = replica_file(&replicas, document); |
| 1536 | if cache.exists() { | |
| 1533 | if fs::metadata(&cache).is_ok() { | |
| 1537 | 1534 | let (replica, remote, mut connect) = |
| 1538 | 1535 | (Replica::open(&cache)?, file.clone(), connect); |
| 1539 | 1536 | return Section::start(file, replica, move || connect(&remote), notify); |
| 1540 | 1537 | } |
| 1541 | 1538 | } |
| 1542 | 1539 | let replica = |identity: &[u8; 16], _: &[u8]| { |
| 1543 | std::fs::create_dir_all(&replicas)?; | |
| 1540 | fs::create_dir_all(&replicas)?; | |
| 1544 | 1541 | Ok(replica_file(&replicas, identity)) |
| 1545 | 1542 | }; |
| 1546 | 1543 | Section::open_in(file, key, replica, connect, notify) |
| ... | ... | @@ -1712,7 +1709,7 @@ fn remove_replica(replica: &Path) -> io::Result<()> { |
| 1712 | 1709 | for suffix in ["", "-wal", "-shm"] { |
| 1713 | 1710 | let mut file = replica.as_os_str().to_owned(); |
| 1714 | 1711 | file.push(suffix); |
| 1715 | match std::fs::remove_file(file) { | |
| 1712 | match fs::remove_file(file) { | |
| 1716 | 1713 | Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error), |
| 1717 | 1714 | _ => {} |
| 1718 | 1715 | } |
| ... | ... | @@ -1839,7 +1836,7 @@ impl Section { |
| 1839 | 1836 | connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static, |
| 1840 | 1837 | notify: impl Fn() + Send + 'static, |
| 1841 | 1838 | ) -> Result<Self> { |
| 1842 | let file = file.as_ref().canonicalize()?; | |
| 1839 | let file = fs::canonicalize(file)?; | |
| 1843 | 1840 | let location = file.to_string_lossy().into_owned(); |
| 1844 | 1841 | let replicas = |identity: &[u8; 16], source: &[u8]| { |
| 1845 | 1842 | let folder = crate::location::claim(cache.as_ref(), &location, &[*identity], |_| { |
| ... | ... | @@ -1875,7 +1872,7 @@ impl Section { |
| 1875 | 1872 | replica: Replica, |
| 1876 | 1873 | notify: impl Fn() + Send + 'static, |
| 1877 | 1874 | ) -> Result<Self> { |
| 1878 | let file = std::path::absolute(file)?; | |
| 1875 | let file = fs::absolute(file)?; | |
| 1879 | 1876 | let remote = file.clone(); |
| 1880 | 1877 | Self::start( |
| 1881 | 1878 | file, |
| ... | ... | @@ -2200,14 +2197,14 @@ struct FileRemote(PathBuf); |
| 2200 | 2197 | |
| 2201 | 2198 | impl Remote for FileRemote { |
| 2202 | 2199 | fn read(&mut self) -> io::Result<Vec<u8>> { |
| 2203 | onestore::read_file(&self.0) | |
| 2200 | fs::read_file(&self.0) | |
| 2204 | 2201 | } |
| 2205 | 2202 | |
| 2206 | 2203 | /// Read without the whole-file lock, which would block OneNote's readers: a change |
| 2207 | 2204 | /// detector, never a snapshot to edit. |
| 2208 | 2205 | fn stamp(&mut self) -> io::Result<Stamp> { |
| 2209 | 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 | 2208 | let mut header = [0; 1024]; |
| 2212 | 2209 | file.read_exact(&mut header)?; |
| 2213 | 2210 | let length = file.metadata()?.len(); |
| ... | ... | @@ -2215,11 +2212,11 @@ impl Remote for FileRemote { |
| 2215 | 2212 | } |
| 2216 | 2213 | |
| 2217 | 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 | } |
| 2220 | 2217 | |
| 2221 | 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 | } |
| 2225 | 2222 |
crates/notebook/src/sidecar.rs+1-1| ... | ... | @@ -158,7 +158,7 @@ pub(crate) fn map( |
| 158 | 158 | /// A name beside `path` no other writer picks. |
| 159 | 159 | fn temporary(path: &str) -> String { |
| 160 | 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 | 162 | format!("{path}.{unique:016x}.tmp") |
| 163 | 163 | } |
| 164 | 164 |
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 | ||
| 6 | use std::{io, time::Duration}; | |
| 7 | ||
| 8 | #[cfg(not(target_arch = "wasm32"))] | |
| 9 | pub(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"))] | |
| 16 | pub(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"))] | |
| 22 | pub(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"))] | |
| 31 | pub(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"))] | |
| 42 | pub(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")] | |
| 54 | pub(crate) use web::*; | |
| 55 | ||
| 56 | #[cfg(target_arch = "wasm32")] | |
| 57 | mod 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 | 1 | use super::*; |
| 2 | use crate::task::{self, JoinHandle}; | |
| 2 | 3 | use std::{ |
| 3 | 4 | collections::hash_map::RandomState, |
| 4 | 5 | hash::BuildHasher, |
| 5 | 6 | sync::{ |
| 6 | 7 | Arc, Mutex, |
| 7 | 8 | atomic::{AtomicBool, AtomicU64, Ordering}, |
| 8 | mpsc::{self, SyncSender}, | |
| 9 | 9 | }, |
| 10 | thread::{self, JoinHandle}, | |
| 11 | time::Instant, | |
| 12 | 10 | }; |
| 11 | use web_time::Instant; | |
| 13 | 12 | |
| 14 | 13 | pub(super) struct Signal { |
| 15 | 14 | pub(crate) stopped: AtomicBool, |
| ... | ... | @@ -22,7 +21,7 @@ pub(super) struct Signal { |
| 22 | 21 | /// waits for that instead of checking the file on its own (`Background::hold`). |
| 23 | 22 | pub(crate) watched: AtomicBool, |
| 24 | 23 | pause: Mutex<Pause>, |
| 25 | sender: SyncSender<()>, | |
| 24 | sender: task::Sender<()>, | |
| 26 | 25 | } |
| 27 | 26 | |
| 28 | 27 | /// How local edits wait before they publish (`SyncWorker::set_pause`). |
| ... | ... | @@ -37,8 +36,8 @@ struct Pause { |
| 37 | 36 | const LONGEST: Duration = Duration::from_secs(30); |
| 38 | 37 | |
| 39 | 38 | impl Signal { |
| 40 | pub(crate) fn new() -> (Arc<Self>, mpsc::Receiver<()>) { | |
| 41 | let (sender, receiver) = mpsc::sync_channel(1); | |
| 39 | pub(crate) fn new() -> (Arc<Self>, task::Receiver<()>) { | |
| 40 | let (sender, receiver) = task::channel(); | |
| 42 | 41 | let signal = Self { |
| 43 | 42 | stopped: AtomicBool::new(false), |
| 44 | 43 | offline: AtomicBool::new(false), |
| ... | ... | @@ -123,9 +122,12 @@ impl SyncWorker { |
| 123 | 122 | pub fn stop(mut self) -> Result<()> { |
| 124 | 123 | self.signal.stopped.store(true, Ordering::Release); |
| 125 | 124 | self.signal.wake(); |
| 126 | self.thread | |
| 127 | .take() | |
| 128 | .expect("Worker owns its thread") | |
| 125 | let thread = self.thread.take().expect("Worker owns its thread"); | |
| 126 | // In the browser no step is in flight outside the worker's own callback. | |
| 127 | #[cfg(target_arch = "wasm32")] | |
| 128 | return thread.finished().unwrap_or(Ok(())); | |
| 129 | #[cfg(not(target_arch = "wasm32"))] | |
| 130 | thread | |
| 129 | 131 | .join() |
| 130 | 132 | .map_err(|_| io::Error::other("Synchronization worker panicked"))? |
| 131 | 133 | } |
| ... | ... | @@ -176,118 +178,115 @@ impl Replica { |
| 176 | 178 | let (signal, receiver) = Signal::new(); |
| 177 | 179 | let replica = Arc::clone(self); |
| 178 | 180 | let worker_signal = Arc::clone(&signal); |
| 179 | let thread = thread::Builder::new() | |
| 180 | .name("onestore-sync".into()) | |
| 181 | .spawn(move || { | |
| 182 | let mut remote: Option<R> = None; | |
| 183 | let jitter = RandomState::new(); | |
| 184 | let mut contention = 0_u32; | |
| 185 | // The first step, and the first after a failure, always runs, so `observe` | |
| 186 | // hears that the remote is reachable and what state the queue is in. | |
| 187 | let mut reported = false; | |
| 188 | // With nothing to do: a watched worker waits for the watch, any other looks | |
| 189 | // again after `interval`. | |
| 190 | let rest = || { | |
| 191 | if worker_signal.watched.load(Ordering::Acquire) { | |
| 192 | let _ = receiver.recv(); | |
| 193 | } else { | |
| 194 | let _ = receiver.recv_timeout(interval); | |
| 181 | let thread = task::spawn("onestore-sync", move || async move { | |
| 182 | let mut remote: Option<R> = None; | |
| 183 | let jitter = RandomState::new(); | |
| 184 | let mut contention = 0_u32; | |
| 185 | // The first step, and the first after a failure, always runs, so `observe` | |
| 186 | // hears that the remote is reachable and what state the queue is in. | |
| 187 | let mut reported = false; | |
| 188 | // With nothing to do: a watched worker waits for the watch, any other looks | |
| 189 | // again after `interval`. | |
| 190 | let rest = || { | |
| 191 | task::wait( | |
| 192 | &receiver, | |
| 193 | (!worker_signal.watched.load(Ordering::Acquire)).then_some(interval), | |
| 194 | ) | |
| 195 | }; | |
| 196 | while !worker_signal.stopped.load(Ordering::Acquire) { | |
| 197 | if worker_signal.offline.load(Ordering::Acquire) | |
| 198 | && !worker_signal.requested.load(Ordering::Acquire) | |
| 199 | { | |
| 200 | remote = None; | |
| 201 | reported = false; | |
| 202 | task::wait(&receiver, None).await; | |
| 203 | continue; | |
| 204 | } | |
| 205 | if !worker_signal.requested.load(Ordering::Acquire) | |
| 206 | && let Some(wait) = worker_signal.paused() | |
| 207 | { | |
| 208 | task::wait(&receiver, Some(wait)).await; | |
| 209 | continue; | |
| 210 | } | |
| 211 | if let Ok(mut pause) = worker_signal.pause.lock() { | |
| 212 | // The step publishes what waits; edits from now on wait anew. | |
| 213 | pause.waiting = None; | |
| 214 | } | |
| 215 | let result = match remote.as_mut() { | |
| 216 | Some(remote) => { | |
| 217 | if reported && replica.settled(remote).unwrap_or(false) { | |
| 218 | worker_signal.requested.store(false, Ordering::Release); | |
| 219 | worker_signal.synced.store(crate::now(), Ordering::Release); | |
| 220 | rest().await; | |
| 221 | continue; | |
| 222 | } | |
| 223 | let result = replica.sync_once(remote); | |
| 224 | reported = result.is_ok(); | |
| 225 | result | |
| 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) { | |
| 198 | if worker_signal.offline.load(Ordering::Acquire) | |
| 199 | && !worker_signal.requested.load(Ordering::Acquire) | |
| 200 | { | |
| 201 | remote = None; | |
| 202 | reported = false; | |
| 203 | let _ = receiver.recv(); | |
| 235 | if result.is_ok() { | |
| 236 | worker_signal.synced.store(crate::now(), Ordering::Release); | |
| 237 | } | |
| 238 | observe(&result); | |
| 239 | let idle = matches!(result, Ok(Synced { edit: None, .. })); | |
| 240 | match result { | |
| 241 | Ok(Synced { | |
| 242 | edit: Some((_, EditStatus::Published { .. })), | |
| 243 | .. | |
| 244 | }) => { | |
| 245 | contention = 0; | |
| 204 | 246 | continue; |
| 205 | 247 | } |
| 206 | if !worker_signal.requested.load(Ordering::Acquire) | |
| 207 | && let Some(wait) = worker_signal.paused() | |
| 248 | Ok(Synced { edit: None, .. }) => contention = 0, | |
| 249 | Ok(_) => {} | |
| 250 | Err(Error::Remote(onestore::CommitError { | |
| 251 | state: onestore::CommitState::NotCommitted, | |
| 252 | ref error, | |
| 253 | })) if matches!( | |
| 254 | error.kind(), | |
| 255 | io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy | |
| 256 | ) => | |
| 208 | 257 | { |
| 209 | let _ = receiver.recv_timeout(wait); | |
| 210 | continue; | |
| 211 | } | |
| 212 | if let Ok(mut pause) = worker_signal.pause.lock() { | |
| 213 | // The step publishes what waits; edits from now on wait anew. | |
| 214 | pause.waiting = None; | |
| 215 | } | |
| 216 | let result = match remote.as_mut() { | |
| 217 | Some(remote) => { | |
| 218 | if reported && replica.settled(remote).unwrap_or(false) { | |
| 219 | worker_signal.requested.store(false, Ordering::Release); | |
| 220 | worker_signal.synced.store(crate::now(), Ordering::Release); | |
| 221 | rest(); | |
| 222 | continue; | |
| 223 | } | |
| 224 | let result = replica.sync_once(remote); | |
| 225 | reported = result.is_ok(); | |
| 226 | result | |
| 258 | contention = contention.saturating_add(1); | |
| 259 | let ceiling = (50_u64 << contention.min(5)).min(1000); | |
| 260 | let until = Instant::now() | |
| 261 | + Duration::from_millis(jitter.hash_one(contention) % ceiling); | |
| 262 | // Local wakes must not keep competing writers in the same retry phase. | |
| 263 | while !worker_signal.stopped.load(Ordering::Acquire) { | |
| 264 | let Some(remaining) = until.checked_duration_since(Instant::now()) | |
| 265 | else { | |
| 266 | break; | |
| 267 | }; | |
| 268 | task::wait(&receiver, Some(remaining)).await; | |
| 227 | 269 | } |
| 228 | None => match connect() { | |
| 229 | Ok(connected) => { | |
| 230 | remote = Some(connected); | |
| 231 | continue; | |
| 232 | } | |
| 233 | Err(error) => Err(Error::RemoteIo(error)), | |
| 234 | }, | |
| 235 | }; | |
| 236 | if result.is_ok() { | |
| 237 | worker_signal.synced.store(crate::now(), Ordering::Release); | |
| 270 | continue; | |
| 238 | 271 | } |
| 239 | observe(&result); | |
| 240 | let idle = matches!(result, Ok(Synced { edit: None, .. })); | |
| 241 | match result { | |
| 242 | Ok(Synced { | |
| 243 | edit: Some((_, EditStatus::Published { .. })), | |
| 244 | .. | |
| 245 | }) => { | |
| 246 | contention = 0; | |
| 247 | continue; | |
| 248 | } | |
| 249 | Ok(Synced { edit: None, .. }) => contention = 0, | |
| 250 | Ok(_) => {} | |
| 251 | Err(Error::Remote(onestore::CommitError { | |
| 252 | state: onestore::CommitState::NotCommitted, | |
| 253 | ref error, | |
| 254 | })) if matches!( | |
| 272 | Err(Error::RemoteIo(ref error)) | |
| 273 | if matches!( | |
| 255 | 274 | error.kind(), |
| 256 | 275 | io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy |
| 257 | ) => | |
| 258 | { | |
| 259 | contention = contention.saturating_add(1); | |
| 260 | let ceiling = (50_u64 << contention.min(5)).min(1000); | |
| 261 | let until = Instant::now() | |
| 262 | + Duration::from_millis(jitter.hash_one(contention) % ceiling); | |
| 263 | // Local wakes must not keep competing writers in the same retry phase. | |
| 264 | while !worker_signal.stopped.load(Ordering::Acquire) { | |
| 265 | let Some(remaining) = until.checked_duration_since(Instant::now()) | |
| 266 | else { | |
| 267 | break; | |
| 268 | }; | |
| 269 | let _ = receiver.recv_timeout(remaining); | |
| 270 | } | |
| 271 | continue; | |
| 272 | } | |
| 273 | Err(Error::RemoteIo(ref error)) | |
| 274 | if matches!( | |
| 275 | error.kind(), | |
| 276 | io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy | |
| 277 | ) => {} | |
| 278 | Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None, | |
| 279 | Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {} | |
| 280 | Err(error) => return Err(error), | |
| 281 | } | |
| 282 | worker_signal.requested.store(false, Ordering::Release); | |
| 283 | if idle { | |
| 284 | rest(); | |
| 285 | } else if !worker_signal.stopped.load(Ordering::Acquire) { | |
| 286 | let _ = receiver.recv_timeout(interval); | |
| 287 | } | |
| 276 | ) => {} | |
| 277 | Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None, | |
| 278 | Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {} | |
| 279 | Err(error) => return Err(error), | |
| 280 | } | |
| 281 | worker_signal.requested.store(false, Ordering::Release); | |
| 282 | if idle { | |
| 283 | rest().await; | |
| 284 | } else if !worker_signal.stopped.load(Ordering::Acquire) { | |
| 285 | task::wait(&receiver, Some(interval)).await; | |
| 288 | 286 | } |
| 289 | Ok(()) | |
| 290 | })?; | |
| 287 | } | |
| 288 | Ok(()) | |
| 289 | })?; | |
| 291 | 290 | *owner = Arc::downgrade(&signal); |
| 292 | 291 | Ok(SyncWorker { |
| 293 | 292 | signal, |
crates/notebook/src/working.rs+132-24| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | //! The section thread: the only owner of the parsed section, which is the cached base |
| 2 | 2 | //! image with each sealed batch replayed and the open batch's edits applied. Edits apply |
| 3 | 3 | //! 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. | |
| 5 | 6 | |
| 6 | 7 | use crate::{Result, base, lock, merge, queue, worker::Signal}; |
| 7 | 8 | use onestore::{ |
| ... | ... | @@ -15,7 +16,6 @@ use std::{ |
| 15 | 16 | collections::{BTreeMap, BTreeSet, VecDeque}, |
| 16 | 17 | io, |
| 17 | 18 | sync::{Arc, Mutex, Weak, mpsc}, |
| 18 | thread, | |
| 19 | 19 | }; |
| 20 | 20 | |
| 21 | 21 | pub(crate) type Reply<T> = Box<dyn FnOnce(Result<T>) + Send>; |
| ... | ... | @@ -64,8 +64,10 @@ pub(crate) enum Request { |
| 64 | 64 | reply: Reply<()>, |
| 65 | 65 | }, |
| 66 | 66 | /// From the thread that rebuilt the section: hand it the requests. |
| 67 | #[cfg_attr(target_arch = "wasm32", allow(dead_code))] | |
| 67 | 68 | Handover(mpsc::SyncSender<Takeover>), |
| 68 | 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 | 71 | Resume, |
| 70 | 72 | } |
| 71 | 73 | |
| ... | ... | @@ -107,7 +109,8 @@ pub(crate) struct Thread { |
| 107 | 109 | pub(crate) worker: Mutex<Weak<Signal>>, |
| 108 | 110 | /// Taken when the replica drops, which ends every section thread. |
| 109 | 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 | 114 | /// A password-protected section's key, under which its queue is sealed too. |
| 112 | 115 | pub(crate) key: Option<Key>, |
| 113 | 116 | } |
| ... | ... | @@ -118,7 +121,10 @@ impl Thread { |
| 118 | 121 | .lock() |
| 119 | 122 | .ok() |
| 120 | 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 | } |
| 123 | 129 | |
| 124 | 130 | /// Ends the section threads and waits for them, so the cache is released. |
| ... | ... | @@ -126,6 +132,9 @@ impl Thread { |
| 126 | 132 | if let Ok(mut sender) = self.sender.lock() { |
| 127 | 133 | sender.take(); |
| 128 | 134 | } |
| 135 | #[cfg(target_arch = "wasm32")] | |
| 136 | serving::serve(self); | |
| 137 | #[cfg(not(target_arch = "wasm32"))] | |
| 129 | 138 | while let Some(thread) = self |
| 130 | 139 | .threads |
| 131 | 140 | .lock() |
| ... | ... | @@ -136,17 +145,81 @@ impl Thread { |
| 136 | 145 | } |
| 137 | 146 | } |
| 138 | 147 | |
| 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 | 153 | let shared = Arc::clone(self); |
| 141 | let thread = thread::Builder::new() | |
| 154 | let thread = std::thread::Builder::new() | |
| 142 | 155 | .name("onestore-section".into()) |
| 143 | .spawn(move || run(shared))?; | |
| 156 | .spawn(move || crate::task::complete(run(shared)))?; | |
| 144 | 157 | self.threads |
| 145 | 158 | .lock() |
| 146 | 159 | .map_err(|_| io::Error::other("The section thread panicked"))? |
| 147 | 160 | .push(thread); |
| 148 | 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")] | |
| 177 | mod 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"))] | |
| 209 | async 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")] | |
| 216 | async 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 | } |
| 151 | 224 | |
| 152 | 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 | 229 | connection: Mutex::new(connection), |
| 157 | 230 | worker: Mutex::new(Weak::new()), |
| 158 | 231 | sender: Mutex::new(Some(sender)), |
| 232 | #[cfg(not(target_arch = "wasm32"))] | |
| 159 | 233 | threads: Mutex::new(Vec::new()), |
| 160 | 234 | key, |
| 161 | 235 | }); |
| ... | ... | @@ -180,7 +254,7 @@ enum Next { |
| 180 | 254 | Handover(mpsc::SyncSender<Takeover>, VecDeque<Request>), |
| 181 | 255 | } |
| 182 | 256 | |
| 183 | fn run( | |
| 257 | async fn run( | |
| 184 | 258 | shared: Arc<Thread>, |
| 185 | 259 | requests: mpsc::Receiver<Request>, |
| 186 | 260 | mut backlog: VecDeque<Request>, |
| ... | ... | @@ -202,7 +276,7 @@ fn run( |
| 202 | 276 | if let Some(ready) = ready.take() { |
| 203 | 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 | 280 | Next::Stop => return, |
| 207 | 281 | Next::Reopen => {} |
| 208 | 282 | Next::Handover(to, held) => { |
| ... | ... | @@ -222,6 +296,7 @@ fn fail(error: crate::Error, backlog: VecDeque<Request>, requests: mpsc::Receive |
| 222 | 296 | } |
| 223 | 297 | |
| 224 | 298 | /// What a rebuilding thread does before it rereads the section. |
| 299 | #[cfg_attr(target_arch = "wasm32", allow(dead_code))] | |
| 225 | 300 | enum Job { |
| 226 | 301 | Rebase { |
| 227 | 302 | image: Option<Vec<u8>>, |
| ... | ... | @@ -234,6 +309,7 @@ enum Job { |
| 234 | 309 | |
| 235 | 310 | /// Runs `job`, rereads the section and takes the requests over from the thread that |
| 236 | 311 | /// started it; a failed rebase changed nothing that thread serves. |
| 312 | #[cfg(not(target_arch = "wasm32"))] | |
| 237 | 313 | fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) { |
| 238 | 314 | let answer: Box<dyn FnOnce() + Send> = match job { |
| 239 | 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 | 339 | Ok(working) => working, |
| 264 | 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 | 343 | Next::Stop => {} |
| 268 | Next::Reopen => run(shared, requests, backlog, None), | |
| 344 | Next::Reopen => crate::task::complete(run(shared, requests, backlog, None)), | |
| 269 | 345 | Next::Handover(to, held) => { |
| 270 | 346 | let _ = to.send((requests, held)); |
| 271 | 347 | } |
| 272 | 348 | } |
| 273 | 349 | } |
| 274 | 350 | |
| 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")] | |
| 354 | fn 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 | 370 | /// An edit applied to the section and not yet written, with who waits for it. |
| 276 | 371 | struct Accepted { |
| 277 | 372 | author: String, |
| ... | ... | @@ -297,7 +392,7 @@ impl<'a> Working<'a> { |
| 297 | 392 | }) |
| 298 | 393 | } |
| 299 | 394 | |
| 300 | fn serve( | |
| 395 | async fn serve( | |
| 301 | 396 | mut self, |
| 302 | 397 | shared: &Arc<Thread>, |
| 303 | 398 | requests: &mpsc::Receiver<Request>, |
| ... | ... | @@ -310,9 +405,9 @@ impl<'a> Working<'a> { |
| 310 | 405 | loop { |
| 311 | 406 | let first = match backlog.pop_front() { |
| 312 | 407 | Some(request) => request, |
| 313 | None => match requests.recv() { | |
| 314 | Ok(request) => request, | |
| 315 | Err(_) => return Next::Stop, | |
| 408 | None => match receive(requests).await { | |
| 409 | Some(request) => request, | |
| 410 | None => return Next::Stop, | |
| 316 | 411 | }, |
| 317 | 412 | }; |
| 318 | 413 | let mut burst: VecDeque<Request> = VecDeque::from([first]); |
| ... | ... | @@ -415,10 +510,16 @@ impl<'a> Working<'a> { |
| 415 | 510 | } |
| 416 | 511 | Request::Rebase { image, reply } => { |
| 417 | 512 | if self.flush(shared, &mut accepted) { |
| 418 | held = self | |
| 419 | .rebuild(shared, Job::Rebase { image, reply }) | |
| 420 | .then(VecDeque::new); | |
| 421 | false | |
| 513 | #[cfg(target_arch = "wasm32")] | |
| 514 | let reopen = inline(shared, Job::Rebase { image, reply }); | |
| 515 | #[cfg(not(target_arch = "wasm32"))] | |
| 516 | let reopen = { | |
| 517 | held = self | |
| 518 | .rebuild(shared, Job::Rebase { image, reply }) | |
| 519 | .then(VecDeque::new); | |
| 520 | false | |
| 521 | }; | |
| 522 | reopen | |
| 422 | 523 | } else { |
| 423 | 524 | reply(Err( |
| 424 | 525 | io::Error::other("The queue could not be written").into() |
| ... | ... | @@ -428,10 +529,16 @@ impl<'a> Working<'a> { |
| 428 | 529 | } |
| 429 | 530 | Request::Reopen { reply } => { |
| 430 | 531 | self.flush(shared, &mut accepted); |
| 431 | held = self | |
| 432 | .rebuild(shared, Job::Reopen { reply }) | |
| 433 | .then(VecDeque::new); | |
| 434 | false | |
| 532 | #[cfg(target_arch = "wasm32")] | |
| 533 | let reopen = inline(shared, Job::Reopen { reply }); | |
| 534 | #[cfg(not(target_arch = "wasm32"))] | |
| 535 | let reopen = { | |
| 536 | held = self | |
| 537 | .rebuild(shared, Job::Reopen { reply }) | |
| 538 | .then(VecDeque::new); | |
| 539 | false | |
| 540 | }; | |
| 541 | reopen | |
| 435 | 542 | } |
| 436 | 543 | Request::Handover(_) | Request::Resume => false, |
| 437 | 544 | }; |
| ... | ... | @@ -448,6 +555,7 @@ impl<'a> Working<'a> { |
| 448 | 555 | |
| 449 | 556 | /// Starts a thread that runs `job` and rereads the section; false when the replica is |
| 450 | 557 | /// stopping, which answers the job's request. |
| 558 | #[cfg(not(target_arch = "wasm32"))] | |
| 451 | 559 | fn rebuild(&self, shared: &Arc<Thread>, job: Job) -> bool { |
| 452 | 560 | let signal = shared |
| 453 | 561 | .sender |
| ... | ... | @@ -466,7 +574,7 @@ impl<'a> Working<'a> { |
| 466 | 574 | return false; |
| 467 | 575 | }; |
| 468 | 576 | shared |
| 469 | .start(move |shared| build(shared, job, signal)) | |
| 577 | .start(move |shared| async move { build(shared, job, signal) }) | |
| 470 | 578 | .is_ok() |
| 471 | 579 | } |
| 472 | 580 |
crates/onestore/Cargo.toml+2| ... | ... | @@ -17,6 +17,8 @@ roxmltree = "0.21.1" |
| 17 | 17 | sha1 = { version = "0.11.0", features = ["zeroize"] } |
| 18 | 18 | subtle = "2.6.1" |
| 19 | 19 | zeroize = "1.9.0" |
| 20 | # std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. | |
| 21 | web-time = "1.1" | |
| 20 | 22 | |
| 21 | 23 | [target.'cfg(target_os = "macos")'.dependencies] |
| 22 | 24 | nix = { version = "0.31.3", default-features = false, features = ["fs"] } |
crates/onestore/src/create.rs+1-1| ... | ... | @@ -5,7 +5,7 @@ use crate::{ |
| 5 | 5 | write::{append, append_list, fresh_guid, node}, |
| 6 | 6 | }; |
| 7 | 7 | use std::collections::BTreeMap; |
| 8 | use std::time::{SystemTime, UNIX_EPOCH}; | |
| 8 | use web_time::{SystemTime, UNIX_EPOCH}; | |
| 9 | 9 | |
| 10 | 10 | type Result<T> = std::result::Result<T, Error>; |
| 11 | 11 |
crates/onestore/src/lib.rs+1| ... | ... | @@ -8,6 +8,7 @@ mod create; |
| 8 | 8 | pub mod document; |
| 9 | 9 | mod edit; |
| 10 | 10 | mod files; |
| 11 | #[cfg(any(unix, windows))] | |
| 11 | 12 | mod flush; |
| 12 | 13 | mod formatting; |
| 13 | 14 | mod identity; |
crates/snowbound/Cargo.toml+22-7| ... | ... | @@ -17,24 +17,39 @@ png = "0.18" |
| 17 | 17 | # Encodes a video recording's pictures as JPEG, which OneNote 2010 plays. |
| 18 | 18 | image = { version = "0.25", default-features = false, features = ["jpeg"] } |
| 19 | 19 | onestore = { path = "../onestore" } |
| 20 | notebook = { path = "../notebook", features = ["smb"] } | |
| 20 | notebook = { path = "../notebook" } | |
| 21 | 21 | serde = { version = "1", features = ["derive"] } |
| 22 | 22 | serde_json = "1" |
| 23 | 23 | pollster = "0.4" |
| 24 | 24 | wgpu = { workspace = true, optional = true } |
| 25 | 25 | parley.workspace = true |
| 26 | 26 | winit = "0.30.13" |
| 27 | arboard = { version = "3.6.1", default-features = false } | |
| 28 | 27 | accesskit = "0.25.0" |
| 29 | 28 | accesskit_consumer = "0.39.0" |
| 30 | accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } | |
| 31 | # Updates: fetched over HTTPS, verified with ed25519 and SHA-256. | |
| 32 | ureq = { version = "3.4", default-features = false, features = ["rustls"] } | |
| 33 | ring = "0.17" | |
| 34 | 29 | # Clears passwords typed into the password dialogs. |
| 35 | 30 | zeroize = "1.9.0" |
| 36 | 31 | # Names characters in More Symbols, Insert Symbol's dialog. |
| 37 | 32 | unicode_names2 = "4.0.0" |
| 33 | # std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. | |
| 34 | web-time = "1.1" | |
| 35 | ||
| 36 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] | |
| 37 | notebook = { path = "../notebook", features = ["smb"] } | |
| 38 | arboard = { version = "3.6.1", default-features = false } | |
| 39 | accesskit_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. | |
| 42 | ureq = { version = "3.4", default-features = false, features = ["rustls"] } | |
| 43 | ring = "0.17" | |
| 44 | ||
| 45 | # The browser: see arc/platforms.md. | |
| 46 | [target.'cfg(target_arch = "wasm32")'.dependencies] | |
| 47 | wgpu = { workspace = true, features = ["webgl"] } | |
| 48 | getrandom = { version = "0.4", features = ["wasm_js"] } | |
| 49 | wasm-bindgen = "0.2" | |
| 50 | wasm-bindgen-futures = "0.4" | |
| 51 | js-sys = "0.3" | |
| 52 | web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "MediaQueryList", "Navigator", "Window"] } | |
| 38 | 53 | |
| 39 | 54 | [target.'cfg(target_os = "macos")'.dependencies] |
| 40 | 55 | libc = "0.2" |
| ... | ... | @@ -43,7 +58,7 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl |
| 43 | 58 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray", "NSRange", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileVersion", "NSFileManager", "NSNotification", "NSOperation", "block2"] } |
| 44 | 59 | block2 = "0.5.1" |
| 45 | 60 | |
| 46 | [target.'cfg(not(target_os = "macos"))'.dependencies] | |
| 61 | [target.'cfg(not(any(target_os = "macos", target_arch = "wasm32")))'.dependencies] | |
| 47 | 62 | # Clipboard bitmaps; macOS reads the pasteboard's PNG and TIFF itself. |
| 48 | 63 | arboard = { version = "3.6.1", default-features = false, features = ["image-data"] } |
| 49 | 64 |
crates/snowbound/src/attachment.rs+5-5| ... | ... | @@ -21,7 +21,7 @@ impl State { |
| 21 | 21 | } |
| 22 | 22 | let (Some(name), Ok(bytes)) = ( |
| 23 | 23 | path.file_name().and_then(|name| name.to_str()), |
| 24 | std::fs::read(path), | |
| 24 | notebook::fs::read(path), | |
| 25 | 25 | ) else { |
| 26 | 26 | platform::alert("Couldn't attach the file", "Choose a file you can open."); |
| 27 | 27 | return Ok(()); |
| ... | ... | @@ -68,14 +68,14 @@ impl State { |
| 68 | 68 | return Ok(None); |
| 69 | 69 | }; |
| 70 | 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 | 72 | // A copy opened earlier may still be open, edited, in its application. |
| 73 | 73 | let folder = (0..1000) |
| 74 | 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 | 76 | .ok_or("No folder is free for a copy of the file")?; |
| 77 | 77 | let path = folder.join(&file.filename); |
| 78 | std::fs::write(&path, bytes)?; | |
| 78 | notebook::fs::write(&path, bytes)?; | |
| 79 | 79 | Ok(Some(path)) |
| 80 | 80 | } |
| 81 | 81 | |
| ... | ... | @@ -86,7 +86,7 @@ impl State { |
| 86 | 86 | return Ok(()); |
| 87 | 87 | }; |
| 88 | 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 | 91 | Ok(()) |
| 92 | 92 | } |
crates/snowbound/src/commands.rs+1-1| ... | ... | @@ -1562,7 +1562,7 @@ impl State { |
| 1562 | 1562 | else { |
| 1563 | 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 | 1567 | Id::Attachment => { |
| 1568 | 1568 | let Some(path) = platform::pick_file("Attach File", &[]) else { |
crates/snowbound/src/guide.rs+11-11| ... | ... | @@ -35,8 +35,8 @@ impl State { |
| 35 | 35 | /// Opens the guide in the user's documents, copying it there first unless it is there. |
| 36 | 36 | pub(crate) fn open_guide(&mut self) -> Result<(), Box<dyn Error>> { |
| 37 | 37 | let documents = platform::documents_dir().ok_or("There is no Documents folder")?; |
| 38 | let folder = std::path::absolute(documents.join(NAME))?; | |
| 39 | if !folder.exists() { | |
| 38 | let folder = notebook::fs::absolute(documents.join(NAME))?; | |
| 39 | if notebook::fs::metadata(&folder).is_err() { | |
| 40 | 40 | copy(&folder, &self.cache)?; |
| 41 | 41 | } |
| 42 | 42 | self.open_notebook(folder.to_string_lossy().into_owned(), None); |
| ... | ... | @@ -48,12 +48,12 @@ impl State { |
| 48 | 48 | /// short never passes for the guide. |
| 49 | 49 | fn copy(folder: &Path, cache: &Path) -> Result<(), Box<dyn Error>> { |
| 50 | 50 | let partial = folder.with_file_name(format!("{NAME}.partial")); |
| 51 | if partial.exists() { | |
| 52 | std::fs::remove_dir_all(&partial)?; | |
| 51 | if notebook::fs::metadata(&partial).is_ok() { | |
| 52 | notebook::fs::remove_dir_all(&partial)?; | |
| 53 | 53 | } |
| 54 | std::fs::create_dir_all(&partial)?; | |
| 54 | notebook::fs::create_dir_all(&partial)?; | |
| 55 | 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 | 58 | // The snowflake tag's art, kept in the notebook as Customize Tags keeps it. |
| 59 | 59 | Notebook::open(&partial, cache)?.map_tag_art( |
| ... | ... | @@ -62,7 +62,7 @@ fn copy(folder: &Path, cache: &Path) -> Result<(), Box<dyn Error>> { |
| 62 | 62 | include_bytes!("../assets/tags/snowflake.svg"), |
| 63 | 63 | "svg", |
| 64 | 64 | )?; |
| 65 | std::fs::rename(&partial, folder)?; | |
| 65 | notebook::fs::rename(&partial, folder)?; | |
| 66 | 66 | Ok(()) |
| 67 | 67 | } |
| 68 | 68 | |
| ... | ... | @@ -75,7 +75,7 @@ mod tests { |
| 75 | 75 | #[test] |
| 76 | 76 | fn the_guide_copies_whole() { |
| 77 | 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 | 79 | .unwrap() |
| 80 | 80 | .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) |
| 81 | 81 | .filter(|name| !name.starts_with('.')) |
| ... | ... | @@ -87,7 +87,7 @@ mod tests { |
| 87 | 87 | |
| 88 | 88 | let temporary = |
| 89 | 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 | 91 | let folder = temporary.join(NAME); |
| 92 | 92 | copy(&folder, &temporary.join("cache")).unwrap(); |
| 93 | 93 | let notebook = Notebook::open(&folder, temporary.join("cache")).unwrap(); |
| ... | ... | @@ -99,8 +99,8 @@ mod tests { |
| 99 | 99 | .collect::<Vec<_>>(), |
| 100 | 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 | 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 | 226 | }; |
| 227 | 227 | let library = Arc::clone(&session.library); |
| 228 | 228 | let proxy = self.proxy.clone(); |
| 229 | std::thread::spawn(move || { | |
| 229 | crate::spawn(move || { | |
| 230 | 230 | for path in sections(&library, &folder) { |
| 231 | 231 | if path == open { |
| 232 | 232 | continue; |
crates/snowbound/src/library.rs+53-35| ... | ... | @@ -4,6 +4,7 @@ use canvas::{editor::NoteTag, gpu::TagArt}; |
| 4 | 4 | use notebook::discover::{Folder, SectionState}; |
| 5 | 5 | use notebook::session::{Background, Notebook, Section}; |
| 6 | 6 | use notebook::sidecar::themes::Themes; |
| 7 | #[cfg(not(target_arch = "wasm32"))] | |
| 7 | 8 | use notebook::smb::{Client, Credentials}; |
| 8 | 9 | use onestore::protected::Key; |
| 9 | 10 | use std::{ |
| ... | ... | @@ -14,8 +15,9 @@ use std::{ |
| 14 | 15 | Arc, Mutex, OnceLock, |
| 15 | 16 | atomic::{AtomicBool, Ordering}, |
| 16 | 17 | }, |
| 17 | time::{Duration, Instant}, | |
| 18 | time::Duration, | |
| 18 | 19 | }; |
| 20 | use web_time::Instant; | |
| 19 | 21 | |
| 20 | 22 | /// OneNote's Work Offline, which like OneNote's holds for every notebook. |
| 21 | 23 | static OFFLINE: AtomicBool = AtomicBool::new(false); |
| ... | ... | @@ -63,7 +65,7 @@ pub struct Mount { |
| 63 | 65 | /// the system reports mount points with symlinks (`/tmp`, `/var`) resolved. |
| 64 | 66 | #[cfg(unix)] |
| 65 | 67 | pub 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 | 69 | Some( |
| 68 | 70 | path.strip_prefix(point) |
| 69 | 71 | .ok()? |
| ... | ... | @@ -271,6 +273,7 @@ struct Server { |
| 271 | 273 | login: Login, |
| 272 | 274 | } |
| 273 | 275 | |
| 276 | #[cfg(not(target_arch = "wasm32"))] | |
| 274 | 277 | impl Server { |
| 275 | 278 | fn connect(&self) -> io::Result<Client> { |
| 276 | 279 | Client::connect( |
| ... | ... | @@ -398,8 +401,15 @@ impl Library { |
| 398 | 401 | } |
| 399 | 402 | } |
| 400 | 403 | |
| 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 | 410 | /// The notebook at `location`, which `mount` names on its server, opened through the |
| 402 | 411 | /// embedded client signed in as `login`. |
| 412 | #[cfg(not(target_arch = "wasm32"))] | |
| 403 | 413 | pub fn on_share( |
| 404 | 414 | location: &str, |
| 405 | 415 | mount: Mount, |
| ... | ... | @@ -443,8 +453,8 @@ impl Library { |
| 443 | 453 | let mut names = read_display_names(&file); |
| 444 | 454 | names.insert(self.location.clone(), name.to_owned()); |
| 445 | 455 | let partial = file.with_extension("partial"); |
| 446 | std::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?; | |
| 447 | std::fs::rename(partial, file) | |
| 456 | notebook::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?; | |
| 457 | notebook::fs::rename(partial, file) | |
| 448 | 458 | } |
| 449 | 459 | |
| 450 | 460 | /// The art the notebook's tags draw with. |
| ... | ... | @@ -478,7 +488,7 @@ impl Library { |
| 478 | 488 | } |
| 479 | 489 | let extension = art.rsplit('.').next().unwrap_or_default().to_owned(); |
| 480 | 490 | let (library, tag) = (Arc::clone(self), tag.clone()); |
| 481 | std::thread::spawn(move || { | |
| 491 | crate::spawn(move || { | |
| 482 | 492 | let kept = library.reopen().and_then(|notebook| { |
| 483 | 493 | Ok(notebook.map_tag_art(&tag.label, tag.shape, &bytes, &extension)?) |
| 484 | 494 | }); |
| ... | ... | @@ -529,7 +539,7 @@ impl Library { |
| 529 | 539 | .unwrap_or_else(|poisoned| poisoned.into_inner()) = |
| 530 | 540 | Some((Instant::now(), Arc::new(themes))); |
| 531 | 541 | let library = Arc::clone(self); |
| 532 | std::thread::spawn(move || { | |
| 542 | crate::spawn(move || { | |
| 533 | 543 | let kept = library |
| 534 | 544 | .reopen() |
| 535 | 545 | .and_then(|notebook| Ok(notebook.save_themes(change)?)); |
| ... | ... | @@ -546,9 +556,9 @@ impl Library { |
| 546 | 556 | return; |
| 547 | 557 | } |
| 548 | 558 | let library = Arc::clone(self); |
| 549 | std::thread::spawn(move || { | |
| 550 | let now = std::time::SystemTime::now() | |
| 551 | .duration_since(std::time::UNIX_EPOCH) | |
| 559 | crate::spawn(move || { | |
| 560 | let now = web_time::SystemTime::now() | |
| 561 | .duration_since(web_time::UNIX_EPOCH) | |
| 552 | 562 | .map_or(0, |unix| unix.as_secs()) |
| 553 | 563 | // Time32 counts from 1980. |
| 554 | 564 | .saturating_sub(315_532_800); |
| ... | ... | @@ -567,9 +577,12 @@ impl Library { |
| 567 | 577 | /// This notebook read again, the way it was opened, for changing its structure. |
| 568 | 578 | pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> { |
| 569 | 579 | Ok(match &self.server { |
| 580 | #[cfg(not(target_arch = "wasm32"))] | |
| 570 | 581 | Some(server) => { |
| 571 | 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 | 586 | None => Notebook::open(&self.location, &self.cache)?, |
| 574 | 587 | }) |
| 575 | 588 | } |
| ... | ... | @@ -665,13 +678,16 @@ impl Library { |
| 665 | 678 | return Ok(kept); |
| 666 | 679 | } |
| 667 | 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 | 684 | (Ok(Some(notebook)), Some(server)) => { |
| 669 | 685 | let file = match server.mount.root.as_str() { |
| 670 | 686 | "" => path.to_owned(), |
| 671 | 687 | root => format!("{root}/{path}"), |
| 672 | 688 | }; |
| 673 | 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 | 691 | let replica = notebook::Replica::open_or_create(&cache, key.as_ref(), || { |
| 676 | 692 | Ok(server.connect()?.read_storage(&file, LIMIT)?) |
| 677 | 693 | }); |
| ... | ... | @@ -735,7 +751,7 @@ impl Library { |
| 735 | 751 | Err(_) => vec![section], |
| 736 | 752 | }; |
| 737 | 753 | if !gone.is_empty() { |
| 738 | std::thread::spawn(move || close(gone)); | |
| 754 | crate::spawn(move || close(gone)); | |
| 739 | 755 | } |
| 740 | 756 | } |
| 741 | 757 | |
| ... | ... | @@ -984,7 +1000,7 @@ impl Library { |
| 984 | 1000 | if let Ok(mut kept) = self.kept.lock() { |
| 985 | 1001 | for path in &locked { |
| 986 | 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 | 1029 | } |
| 1014 | 1030 | |
| 1015 | 1031 | fn read_display_names(file: &Path) -> std::collections::BTreeMap<String, String> { |
| 1016 | std::fs::read(file) | |
| 1032 | notebook::fs::read(file) | |
| 1017 | 1033 | .ok() |
| 1018 | 1034 | .and_then(|bytes| serde_json::from_slice(&bytes).ok()) |
| 1019 | 1035 | .unwrap_or_default() |
| ... | ... | @@ -1119,7 +1135,7 @@ fn close(sections: Vec<Section>) { |
| 1119 | 1135 | } |
| 1120 | 1136 | |
| 1121 | 1137 | pub fn file_name(path: &Path) -> String { |
| 1122 | path.canonicalize() | |
| 1138 | notebook::fs::canonicalize(path) | |
| 1123 | 1139 | .ok() |
| 1124 | 1140 | .as_deref() |
| 1125 | 1141 | .and_then(Path::file_name) |
| ... | ... | @@ -1181,7 +1197,7 @@ pub enum Located { |
| 1181 | 1197 | |
| 1182 | 1198 | /// Whether `folder` holds a table of contents, as a notebook's or a section group's does. |
| 1183 | 1199 | fn 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 | 1201 | entries.flatten().any(|entry| { |
| 1186 | 1202 | entry |
| 1187 | 1203 | .path() |
| ... | ... | @@ -1199,10 +1215,12 @@ pub fn locate(path: &Path) -> Located { |
| 1199 | 1215 | .extension() |
| 1200 | 1216 | .map(|extension| extension.to_string_lossy().to_ascii_lowercase()); |
| 1201 | 1217 | match extension.as_deref() { |
| 1202 | _ if path.is_dir() => Located::Notebook { | |
| 1203 | root: path.to_owned(), | |
| 1204 | section: None, | |
| 1205 | }, | |
| 1218 | _ if notebook::fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) => { | |
| 1219 | Located::Notebook { | |
| 1220 | root: path.to_owned(), | |
| 1221 | section: None, | |
| 1222 | } | |
| 1223 | } | |
| 1206 | 1224 | Some("onepkg") => Located::Package(path.to_owned()), |
| 1207 | 1225 | Some("onetoc2") => match path.parent() { |
| 1208 | 1226 | Some(root) => Located::Notebook { |
| ... | ... | @@ -1245,14 +1263,14 @@ mod tests { |
| 1245 | 1263 | #[test] |
| 1246 | 1264 | fn kept_sections_open_at_once() { |
| 1247 | 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 | 1267 | let notebook = root.join("Personal"); |
| 1250 | std::fs::create_dir_all(&notebook).unwrap(); | |
| 1268 | notebook::fs::create_dir_all(&notebook).unwrap(); | |
| 1251 | 1269 | let sample = |
| 1252 | 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 | 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 | 1275 | let library = Library::notebook(notebook.to_str().unwrap(), &root.join("cache")); |
| 1258 | 1276 | let [first, second] = [0, 1].map(|tab| library.tabs("")[tab].path.clone()); |
| ... | ... | @@ -1268,7 +1286,7 @@ mod tests { |
| 1268 | 1286 | library.close_kept(); |
| 1269 | 1287 | library.open(&first, || {}).unwrap().close().unwrap(); |
| 1270 | 1288 | drop(library); |
| 1271 | std::fs::remove_dir_all(&root).unwrap(); | |
| 1289 | notebook::fs::remove_dir_all(&root).unwrap(); | |
| 1272 | 1290 | } |
| 1273 | 1291 | |
| 1274 | 1292 | #[cfg(unix)] |
| ... | ... | @@ -1276,13 +1294,13 @@ mod tests { |
| 1276 | 1294 | fn within_mount_resolves_symlinks() { |
| 1277 | 1295 | let base = std::env::temp_dir().join(format!("snowbound-within-{}", std::process::id())); |
| 1278 | 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 | 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 | 1300 | let point = point.to_str().unwrap(); |
| 1283 | 1301 | let found = within_mount(&base.join("link/notes"), point); |
| 1284 | 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 | 1304 | assert_eq!(found.as_deref(), Some("notes")); |
| 1287 | 1305 | assert_eq!(missing, None); |
| 1288 | 1306 | } |
| ... | ... | @@ -1310,8 +1328,8 @@ mod tests { |
| 1310 | 1328 | fn a_notebook_on_a_share_opens_through_the_embedded_client() { |
| 1311 | 1329 | let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); |
| 1312 | 1330 | let root = std::env::var("ONESTORE_SMB_LAB_ROOT").unwrap_or_else(|_| { |
| 1313 | let nanos = std::time::SystemTime::now() | |
| 1314 | .duration_since(std::time::UNIX_EPOCH) | |
| 1331 | let nanos = web_time::SystemTime::now() | |
| 1332 | .duration_since(web_time::UNIX_EPOCH) | |
| 1315 | 1333 | .unwrap() |
| 1316 | 1334 | .subsec_nanos(); |
| 1317 | 1335 | format!("snowbound-{}-{nanos}", std::process::id()) |
| ... | ... | @@ -1400,7 +1418,7 @@ mod tests { |
| 1400 | 1418 | } |
| 1401 | 1419 | section.close().unwrap(); |
| 1402 | 1420 | drop(library); |
| 1403 | let _ = std::fs::remove_dir_all(&cache); | |
| 1421 | let _ = notebook::fs::remove_dir_all(&cache); | |
| 1404 | 1422 | if std::env::var_os("ONESTORE_SMB_LAB_KEEP").is_none() { |
| 1405 | 1423 | remove_tree(&client, &root); |
| 1406 | 1424 | assert!(client.read_dir(&root, 1).is_err(), "the folder is gone"); |
| ... | ... | @@ -1488,18 +1506,18 @@ mod tests { |
| 1488 | 1506 | #[test] |
| 1489 | 1507 | fn chosen_paths_open_their_notebook_or_section() { |
| 1490 | 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 | 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 | 1512 | for file in [ |
| 1495 | 1513 | "Open Notebook.onetoc2", |
| 1496 | 1514 | "Group/Open Notebook.onetoc2", |
| 1497 | 1515 | "Group/Inner.one", |
| 1498 | 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 | 1521 | let at = |root: &Path, section: Option<&str>| Located::Notebook { |
| 1504 | 1522 | root: root.to_owned(), |
| 1505 | 1523 | section: section.map(str::to_owned), |
| ... | ... | @@ -1526,6 +1544,6 @@ mod tests { |
| 1526 | 1544 | Located::Package(root.join("Shared.onepkg")) |
| 1527 | 1545 | ); |
| 1528 | 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 | 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 | 4 | #[cfg(target_os = "macos")] |
| 3 | 5 | mod aqua; |
| 4 | 6 | mod art; |
| ... | ... | @@ -18,6 +20,7 @@ mod history; |
| 18 | 20 | #[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")] |
| 19 | 21 | #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] |
| 20 | 22 | mod icloud; |
| 23 | #[cfg(not(target_arch = "wasm32"))] | |
| 21 | 24 | #[cfg_attr(target_os = "macos", allow(dead_code))] |
| 22 | 25 | mod instance; |
| 23 | 26 | mod keys; |
| ... | ... | @@ -30,6 +33,7 @@ mod manage; |
| 30 | 33 | #[cfg_attr(target_os = "linux", path = "media_linux.rs")] |
| 31 | 34 | #[cfg_attr(target_os = "macos", path = "media_macos.rs")] |
| 32 | 35 | #[cfg_attr(windows, path = "media_windows.rs")] |
| 36 | #[cfg_attr(target_arch = "wasm32", path = "media_web.rs")] | |
| 33 | 37 | mod media; |
| 34 | 38 | mod meeting; |
| 35 | 39 | #[cfg(target_os = "macos")] |
| ... | ... | @@ -43,12 +47,14 @@ mod paste; |
| 43 | 47 | #[cfg_attr(target_os = "linux", path = "linux.rs")] |
| 44 | 48 | #[cfg_attr(target_os = "macos", path = "macos.rs")] |
| 45 | 49 | #[cfg_attr(windows, path = "windows.rs")] |
| 50 | #[cfg_attr(target_arch = "wasm32", path = "web.rs")] | |
| 46 | 51 | mod platform; |
| 47 | 52 | mod prefetch; |
| 48 | 53 | mod print; |
| 49 | 54 | #[cfg_attr(target_os = "linux", path = "print_linux.rs")] |
| 50 | 55 | #[cfg_attr(target_os = "macos", path = "print_macos.rs")] |
| 51 | 56 | #[cfg_attr(windows, path = "print_windows.rs")] |
| 57 | #[cfg_attr(target_arch = "wasm32", path = "print_web.rs")] | |
| 52 | 58 | mod printer; |
| 53 | 59 | mod properties; |
| 54 | 60 | mod protection; |
| ... | ... | @@ -64,6 +70,7 @@ mod sidebar; |
| 64 | 70 | #[cfg_attr(target_os = "linux", path = "spell_linux.rs")] |
| 65 | 71 | #[cfg_attr(target_os = "macos", path = "spell_macos.rs")] |
| 66 | 72 | #[cfg_attr(windows, path = "spell_windows.rs")] |
| 73 | #[cfg_attr(target_arch = "wasm32", path = "spell_web.rs")] | |
| 67 | 74 | mod spell; |
| 68 | 75 | #[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")] |
| 69 | 76 | #[cfg_attr(windows, path = "surface_windows.rs")] |
| ... | ... | @@ -78,17 +85,17 @@ mod unpack; |
| 78 | 85 | mod unread; |
| 79 | 86 | mod update; |
| 80 | 87 | mod video; |
| 88 | #[cfg_attr(target_arch = "wasm32", path = "watch_web.rs")] | |
| 81 | 89 | mod watch; |
| 82 | 90 | |
| 91 | #[cfg(not(target_arch = "wasm32"))] | |
| 92 | use accesskit_winit::Adapter as AccessAdapter; | |
| 93 | #[cfg(not(target_arch = "wasm32"))] | |
| 94 | use canvas::editor::{DEFAULT_OUTLINE_WIDTH, TextOutline}; | |
| 83 | 95 | use canvas::gpu::colorref; |
| 84 | 96 | use canvas::gpu::page::PageScene; |
| 85 | 97 | use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility}; |
| 86 | use canvas::{ | |
| 87 | date::DateField, | |
| 88 | document::TextDocument, | |
| 89 | editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline}, | |
| 90 | layout::TextEngine, | |
| 91 | }; | |
| 98 | use canvas::{date::DateField, document::TextDocument, editor::CanvasEditor, layout::TextEngine}; | |
| 92 | 99 | use draw::Renderer; |
| 93 | 100 | use library::Library; |
| 94 | 101 | use onestore::ExGuid; |
| ... | ... | @@ -96,21 +103,29 @@ use onestore::document::Format; |
| 96 | 103 | use onestore::page::Page; |
| 97 | 104 | use onestore::page::ink::ShapeKind; |
| 98 | 105 | use onestore::page::text::Paragraph; |
| 106 | #[cfg(target_arch = "wasm32")] | |
| 107 | use platform::{AccessAdapter, ActiveEventLoop, EventLoopProxy, Window}; | |
| 99 | 108 | use std::{ |
| 100 | 109 | collections::{HashMap, HashSet}, |
| 101 | 110 | error::Error, |
| 102 | 111 | path::{Path, PathBuf}, |
| 103 | 112 | sync::{Arc, Mutex, mpsc}, |
| 104 | time::Instant, | |
| 105 | 113 | }; |
| 106 | 114 | use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px}; |
| 115 | use web_time::Instant; | |
| 116 | #[cfg(not(target_arch = "wasm32"))] | |
| 107 | 117 | use winit::{ |
| 108 | 118 | application::ApplicationHandler, |
| 109 | dpi::{LogicalPosition, LogicalSize}, | |
| 110 | event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, | |
| 119 | event::{ElementState, MouseScrollDelta, WindowEvent}, | |
| 111 | 120 | event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, |
| 112 | keyboard::{Key, ModifiersState, NamedKey}, | |
| 113 | window::{CursorIcon, Window, WindowId}, | |
| 121 | keyboard::ModifiersState, | |
| 122 | window::{Window, WindowId}, | |
| 123 | }; | |
| 124 | use winit::{ | |
| 125 | dpi::{LogicalPosition, LogicalSize}, | |
| 126 | event::{Ime, MouseButton}, | |
| 127 | keyboard::{Key, NamedKey}, | |
| 128 | window::CursorIcon, | |
| 114 | 129 | }; |
| 115 | 130 | |
| 116 | 131 | /// Height of the toolbar's row, which is the title bar where the platform lets it: a |
| ... | ... | @@ -207,6 +222,7 @@ enum UserEvent { |
| 207 | 222 | /// A picture to insert at the caret, such as a screen clipping. |
| 208 | 223 | #[cfg_attr(not(target_os = "macos"), allow(dead_code))] |
| 209 | 224 | Picture(Vec<u8>), |
| 225 | #[cfg(not(target_arch = "wasm32"))] | |
| 210 | 226 | Accessibility(accesskit_winit::Event), |
| 211 | 227 | /// The section's synchronization thread reported an event. |
| 212 | 228 | Sync, |
| ... | ... | @@ -298,6 +314,7 @@ enum Replay { |
| 298 | 314 | Quit, |
| 299 | 315 | } |
| 300 | 316 | |
| 317 | #[cfg(not(target_arch = "wasm32"))] | |
| 301 | 318 | impl From<accesskit_winit::Event> for UserEvent { |
| 302 | 319 | fn from(event: accesskit_winit::Event) -> Self { |
| 303 | 320 | Self::Accessibility(event) |
| ... | ... | @@ -314,7 +331,7 @@ fn lap(phase: &str, start: Instant) { |
| 314 | 331 | |
| 315 | 332 | fn trace_input(event: &impl std::fmt::Debug) { |
| 316 | 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 | } |
| 320 | 337 | |
| ... | ... | @@ -334,6 +351,7 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon { |
| 334 | 351 | } |
| 335 | 352 | } |
| 336 | 353 | |
| 354 | #[cfg(not(target_arch = "wasm32"))] | |
| 337 | 355 | struct App { |
| 338 | 356 | proxy: EventLoopProxy<UserEvent>, |
| 339 | 357 | input: Option<Input>, |
| ... | ... | @@ -373,7 +391,7 @@ impl Input { |
| 373 | 391 | /// Lists the notebook folder `root` and shows it first, where the window shows notebooks. |
| 374 | 392 | fn show(&mut self, root: &Path) -> std::io::Result<()> { |
| 375 | 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 | 395 | if !locations.contains(&location) { |
| 378 | 396 | locations.push(location.clone()); |
| 379 | 397 | } |
| ... | ... | @@ -816,7 +834,7 @@ struct State { |
| 816 | 834 | min_width: f32, |
| 817 | 835 | ime_allowed: bool, |
| 818 | 836 | clipboard: Clipboard, |
| 819 | access_adapter: accesskit_winit::Adapter, | |
| 837 | access_adapter: AccessAdapter, | |
| 820 | 838 | accessibility: accessibility::Accessibility, |
| 821 | 839 | /// Assistive technology holds the page's tree, grafted into the interface's. |
| 822 | 840 | page_grafted: bool, |
| ... | ... | @@ -889,7 +907,7 @@ impl State { |
| 889 | 907 | commands::Keymap::from_saved(&stored.keys).install(); |
| 890 | 908 | platform::install_menu(); |
| 891 | 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 | 911 | window.set_visible(visible); |
| 894 | 912 | let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?; |
| 895 | 913 | let size = window.inner_size(); |
| ... | ... | @@ -897,8 +915,9 @@ impl State { |
| 897 | 915 | let fallbacks = platform::symbol_fonts(); |
| 898 | 916 | draw::fall_back_to(&mut engine.fonts.collection, &fallbacks); |
| 899 | 917 | for path in substitutes { |
| 900 | let target = engine | |
| 901 | .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; | |
| 918 | let target = engine.register_substitute(parley::fontique::Blob::new(Arc::new( | |
| 919 | notebook::fs::read(path)?, | |
| 920 | )))?; | |
| 902 | 921 | eprintln!("Using {} for {target}", path.display()); |
| 903 | 922 | } |
| 904 | 923 | let layouts = Arc::new(Mutex::new(engine.clone())); |
| ... | ... | @@ -1028,7 +1047,6 @@ impl State { |
| 1028 | 1047 | .unwrap_or([ui.theme.strip; 2]); |
| 1029 | 1048 | // A window shown but never focused hears no focus event; a hidden one draws as focused. |
| 1030 | 1049 | ui.window_focused = !visible || window.has_focus(); |
| 1031 | platform::system_interface(&mut ui); | |
| 1032 | 1050 | ui.fall_back_to(&fallbacks); |
| 1033 | 1051 | ui.set_focus(Some(page())); |
| 1034 | 1052 | for family in FONTS { |
| ... | ... | @@ -1036,6 +1054,7 @@ impl State { |
| 1036 | 1054 | ui.preview_font(face.clone(), family); |
| 1037 | 1055 | } |
| 1038 | 1056 | } |
| 1057 | platform::system_interface(&mut ui); | |
| 1039 | 1058 | let mut fonts: Vec<String> = engine |
| 1040 | 1059 | .fonts |
| 1041 | 1060 | .collection |
| ... | ... | @@ -3089,7 +3108,7 @@ impl State { |
| 3089 | 3108 | let library = Arc::clone(&session.library); |
| 3090 | 3109 | let path = session.tabs[tab].path.clone(); |
| 3091 | 3110 | let (notify, author) = (notify(self.proxy.clone()), self.author.clone()); |
| 3092 | std::thread::spawn(move || { | |
| 3111 | crate::spawn(move || { | |
| 3093 | 3112 | let copied = library.open(&path, notify).and_then(|section| { |
| 3094 | 3113 | section.import_page(&page, &author)?; |
| 3095 | 3114 | Ok(section.close()?) |
| ... | ... | @@ -3161,7 +3180,7 @@ impl State { |
| 3161 | 3180 | let requested = Instant::now(); |
| 3162 | 3181 | let scenes = Arc::clone(&self.prefetch.scenes); |
| 3163 | 3182 | let open = self.session.as_ref().map(Session::key); |
| 3164 | std::thread::spawn(move || { | |
| 3183 | crate::spawn(move || { | |
| 3165 | 3184 | let laid = read().and_then(|loaded| { |
| 3166 | 3185 | lap("switch read", requested); |
| 3167 | 3186 | let (shown, page) = match loaded { |
| ... | ... | @@ -3283,7 +3302,7 @@ impl State { |
| 3283 | 3302 | library.keep(&previous.tabs[previous.tab].path, previous.section); |
| 3284 | 3303 | } else { |
| 3285 | 3304 | let section = previous.section; |
| 3286 | std::thread::spawn(move || { | |
| 3305 | crate::spawn(move || { | |
| 3287 | 3306 | if let Err(error) = section.close() { |
| 3288 | 3307 | eprintln!("Synchronization stopped: {error}"); |
| 3289 | 3308 | } |
| ... | ... | @@ -3914,7 +3933,7 @@ impl State { |
| 3914 | 3933 | } |
| 3915 | 3934 | let mut out = String::new(); |
| 3916 | 3935 | write(&tree.state().root(), 0, &mut out); |
| 3917 | std::fs::write(path, out)?; | |
| 3936 | notebook::fs::write(path, out)?; | |
| 3918 | 3937 | Ok(()) |
| 3919 | 3938 | } |
| 3920 | 3939 | |
| ... | ... | @@ -4000,7 +4019,7 @@ impl State { |
| 4000 | 4019 | let pixels = self.capture()?; |
| 4001 | 4020 | let size = self.surface.size; |
| 4002 | 4021 | // Encoding off the frame keeps the frames at the pace they are drawn. |
| 4003 | std::thread::spawn(move || { | |
| 4022 | crate::spawn(move || { | |
| 4004 | 4023 | if let Err(error) = write_png(&path, size, &pixels) { |
| 4005 | 4024 | eprintln!("{error}"); |
| 4006 | 4025 | } |
| ... | ... | @@ -5436,173 +5455,199 @@ fn page_text(editor: &CanvasEditor) -> String { |
| 5436 | 5455 | .join("\n\n") |
| 5437 | 5456 | } |
| 5438 | 5457 | |
| 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. | |
| 5460 | fn 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 | 5467 | /// FILETIME now: when an edit happened, which its modification times record. |
| 5440 | 5468 | fn filetime() -> u64 { |
| 5441 | let unix = std::time::SystemTime::now() | |
| 5442 | .duration_since(std::time::UNIX_EPOCH) | |
| 5469 | let unix = web_time::SystemTime::now() | |
| 5470 | .duration_since(web_time::UNIX_EPOCH) | |
| 5443 | 5471 | .unwrap_or_default(); |
| 5444 | 5472 | (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) |
| 5445 | 5473 | } |
| 5446 | 5474 | |
| 5447 | impl App { | |
| 5448 | fn close(&self, event_loop: &ActiveEventLoop) { | |
| 5449 | if self.state.as_ref().is_none_or(|state| { | |
| 5450 | let editor = &state.view.editor; | |
| 5451 | state.session.is_some() | |
| 5452 | || editor.caret_outline().is_none_or(TextOutline::is_empty) | |
| 5453 | && state.initial_date == editor.date().map(|date| date.timestamp()) | |
| 5454 | && state | |
| 5455 | .initial_layouts | |
| 5456 | .iter() | |
| 5457 | .map(|(id, layout)| (*id, layout)) | |
| 5458 | .eq(editor.object_layouts()) | |
| 5459 | && state | |
| 5460 | .initial | |
| 5461 | .iter() | |
| 5462 | .map(|(id, document)| (id, document)) | |
| 5463 | .eq(editor | |
| 5464 | .outlines() | |
| 5465 | .iter() | |
| 5466 | .map(|outline| (&outline.id, outline.document()))) | |
| 5467 | }) || platform::confirm( | |
| 5468 | "Discard this page?", | |
| 5469 | "This temporary page has no saved copy. Closing it will discard your edits.", | |
| 5470 | "Keep Editing", | |
| 5471 | "Discard Changes", | |
| 5472 | ) { | |
| 5473 | if let Some(state) = &self.state { | |
| 5474 | state.publish_now(QUIT_PUBLISH); | |
| 5475 | } | |
| 5476 | event_loop.exit(); | |
| 5477 | } | |
| 5478 | } | |
| 5479 | } | |
| 5480 | ||
| 5481 | /// How long quitting waits for the open section's edits to reach its file; they stay in the | |
| 5482 | /// replica and publish at the next launch otherwise. | |
| 5483 | const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3); | |
| 5484 | ||
| 5485 | impl 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), | |
| 5475 | impl State { | |
| 5476 | /// Does what an event sent to the event loop asks of the open window. | |
| 5477 | fn user_event(&mut self, event: UserEvent) { | |
| 5478 | match event { | |
| 5479 | // The event loop quits; a page stays open. | |
| 5480 | UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {} | |
| 5498 | 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 | 5483 | eprintln!("{error}"); |
| 5501 | 5484 | } |
| 5502 | return state.window.request_redraw(); | |
| 5485 | self.window.request_redraw(); | |
| 5503 | 5486 | } |
| 5504 | 5487 | UserEvent::InsertText(text) => { |
| 5505 | if state.ui.focused() == Some(page()) { | |
| 5506 | match state.view.insert_text(text) { | |
| 5507 | Ok(response) => state.respond(response), | |
| 5488 | if self.ui.focused() == Some(page()) { | |
| 5489 | match self.view.insert_text(text) { | |
| 5490 | Ok(response) => self.respond(response), | |
| 5508 | 5491 | Err(error) => eprintln!("{error}"), |
| 5509 | 5492 | } |
| 5510 | state.window.request_redraw(); | |
| 5493 | self.window.request_redraw(); | |
| 5511 | 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 | 5498 | UserEvent::Then(then) => { |
| 5517 | if let Err(error) = then(state) { | |
| 5499 | if let Err(error) = then(self) { | |
| 5518 | 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 | 5505 | UserEvent::ICloudAccount => { |
| 5524 | state.icloud_account_changed(); | |
| 5525 | return state.window.request_redraw(); | |
| 5506 | self.icloud_account_changed(); | |
| 5507 | self.window.request_redraw(); | |
| 5526 | 5508 | } |
| 5527 | 5509 | UserEvent::Open(paths) => { |
| 5528 | 5510 | for path in paths { |
| 5529 | state.open_path(&path); | |
| 5511 | self.open_path(&path); | |
| 5530 | 5512 | } |
| 5531 | state.window.set_minimized(false); | |
| 5532 | state.window.focus_window(); | |
| 5533 | return state.window.request_redraw(); | |
| 5513 | self.window.set_minimized(false); | |
| 5514 | self.window.focus_window(); | |
| 5515 | self.window.request_redraw(); | |
| 5534 | 5516 | } |
| 5535 | 5517 | UserEvent::Update => { |
| 5536 | state.updated(); | |
| 5537 | return state.window.request_redraw(); | |
| 5518 | self.updated(); | |
| 5519 | self.window.request_redraw(); | |
| 5538 | 5520 | } |
| 5539 | 5521 | UserEvent::Sync => { |
| 5540 | if let Err(error) = state.synced() { | |
| 5522 | if let Err(error) = self.synced() { | |
| 5541 | 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 | 5527 | UserEvent::Appearance => { |
| 5547 | state.follow_color_scheme(); | |
| 5548 | return state.window.request_redraw(); | |
| 5528 | self.follow_color_scheme(); | |
| 5529 | self.window.request_redraw(); | |
| 5549 | 5530 | } |
| 5550 | UserEvent::Redraw => return state.window.request_redraw(), | |
| 5531 | UserEvent::Redraw => self.window.request_redraw(), | |
| 5551 | 5532 | UserEvent::Replay(replay) => { |
| 5552 | 5533 | match replay { |
| 5553 | Replay::Input(event) => state.input(event), | |
| 5534 | Replay::Input(event) => self.input(event), | |
| 5554 | 5535 | Replay::Pinch(factor) => { |
| 5555 | if let Err(error) = state.pinch(factor) { | |
| 5536 | if let Err(error) = self.pinch(factor) { | |
| 5556 | 5537 | eprintln!("{error}"); |
| 5557 | 5538 | } |
| 5558 | 5539 | } |
| 5559 | Replay::Snapshot(path) => state.snapshot = Some(path), | |
| 5540 | Replay::Snapshot(path) => self.snapshot = Some(path), | |
| 5560 | 5541 | Replay::Accessibility(path) => { |
| 5561 | if let Err(error) = state.write_accessibility(&path) { | |
| 5542 | if let Err(error) = self.write_accessibility(&path) { | |
| 5562 | 5543 | eprintln!("{error}"); |
| 5563 | 5544 | } |
| 5564 | 5545 | } |
| 5565 | 5546 | Replay::Tick | Replay::Quit => {} |
| 5566 | 5547 | Replay::Appearance(appearance) => { |
| 5567 | state.window.set_theme(Some(appearance)); | |
| 5568 | state.set_appearance(appearance); | |
| 5548 | self.window.set_theme(Some(appearance)); | |
| 5549 | self.set_appearance(appearance); | |
| 5569 | 5550 | } |
| 5570 | 5551 | Replay::Resize([width, height]) => { |
| 5571 | let _ = state | |
| 5552 | let _ = self | |
| 5572 | 5553 | .window |
| 5573 | 5554 | .request_inner_size(LogicalSize::new(width, height)); |
| 5574 | 5555 | } |
| 5575 | 5556 | } |
| 5576 | 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 | 5559 | eprintln!("{error}"); |
| 5579 | 5560 | } |
| 5580 | return; | |
| 5581 | 5561 | } |
| 5582 | UserEvent::Accessibility(event) => event, | |
| 5583 | }; | |
| 5584 | if event.window_id != state.window.id() { | |
| 5562 | #[cfg(not(target_arch = "wasm32"))] | |
| 5563 | UserEvent::Accessibility(event) => self.access_event(event), | |
| 5564 | } | |
| 5565 | } | |
| 5566 | ||
| 5567 | /// Does what assistive technology asked of the window. | |
| 5568 | #[cfg(not(target_arch = "wasm32"))] | |
| 5569 | fn access_event(&mut self, event: accesskit_winit::Event) { | |
| 5570 | if event.window_id != self.window.id() { | |
| 5585 | 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 | 5574 | let result = match event.window_event { |
| 5589 | 5575 | accesskit_winit::WindowEvent::InitialTreeRequested => { |
| 5590 | state.deactivate_accessibility(); | |
| 5591 | state.update_accessibility(true) | |
| 5576 | self.deactivate_accessibility(); | |
| 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 | 5580 | accesskit_winit::WindowEvent::AccessibilityDeactivated => { |
| 5595 | state.deactivate_accessibility(); | |
| 5581 | self.deactivate_accessibility(); | |
| 5596 | 5582 | Ok(()) |
| 5597 | 5583 | } |
| 5598 | 5584 | }; |
| 5599 | if was_marked && state.view.editor.marked_range().is_none() { | |
| 5600 | platform::clear_marked_text(&state.window); | |
| 5585 | if was_marked && self.view.editor.marked_range().is_none() { | |
| 5586 | platform::clear_marked_text(&self.window); | |
| 5601 | 5587 | } |
| 5602 | 5588 | if let Err(error) = result { |
| 5603 | 5589 | eprintln!("{error}"); |
| 5604 | 5590 | } |
| 5605 | 5591 | } |
| 5592 | } | |
| 5593 | ||
| 5594 | #[cfg(not(target_arch = "wasm32"))] | |
| 5595 | impl 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"))] | |
| 5632 | const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3); | |
| 5633 | ||
| 5634 | #[cfg(not(target_arch = "wasm32"))] | |
| 5635 | impl 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 | } | |
| 5606 | 5651 | |
| 5607 | 5652 | fn resumed(&mut self, event_loop: &ActiveEventLoop) { |
| 5608 | 5653 | if self.state.is_some() { |
| ... | ... | @@ -5810,8 +5855,8 @@ impl ApplicationHandler<UserEvent> for App { |
| 5810 | 5855 | |
| 5811 | 5856 | fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn Error>> { |
| 5812 | 5857 | let partial = path.with_extension("partial"); |
| 5813 | std::fs::write(&partial, paste::png(size, pixels)?)?; | |
| 5814 | std::fs::rename(partial, path)?; | |
| 5858 | notebook::fs::write(&partial, paste::png(size, pixels)?)?; | |
| 5859 | notebook::fs::rename(partial, path)?; | |
| 5815 | 5860 | Ok(()) |
| 5816 | 5861 | } |
| 5817 | 5862 | |
| ... | ... | @@ -5820,6 +5865,7 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E |
| 5820 | 5865 | /// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] |
| 5821 | 5866 | /// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH`, |
| 5822 | 5867 | /// `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`. |
| 5868 | #[cfg(not(target_arch = "wasm32"))] | |
| 5823 | 5869 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { |
| 5824 | 5870 | let mut steps = Vec::new(); |
| 5825 | 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 | 5972 | Ok(()) |
| 5927 | 5973 | } |
| 5928 | 5974 | |
| 5975 | #[cfg(not(target_arch = "wasm32"))] | |
| 5929 | 5976 | fn main() -> Result<(), Box<dyn Error>> { |
| 5930 | 5977 | #[cfg(target_os = "linux")] |
| 5931 | 5978 | if std::env::args_os() |
| ... | ... | @@ -5947,6 +5994,11 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 5947 | 5994 | platform::with_pool(launch) |
| 5948 | 5995 | } |
| 5949 | 5996 | |
| 5997 | /// The page starts the window through `web::start` once it has fetched the fonts. | |
| 5998 | #[cfg(target_arch = "wasm32")] | |
| 5999 | fn main() {} | |
| 6000 | ||
| 6001 | #[cfg(not(target_arch = "wasm32"))] | |
| 5950 | 6002 | fn launch() -> Result<(), Box<dyn Error>> { |
| 5951 | 6003 | let mut args = std::env::args_os().skip(1); |
| 5952 | 6004 | if args.next().is_some_and(|arg| arg == update::FINISH) { |
| ... | ... | @@ -6014,7 +6066,7 @@ fn launch() -> Result<(), Box<dyn Error>> { |
| 6014 | 6066 | let title = args |
| 6015 | 6067 | .next() |
| 6016 | 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 | 6070 | let store = onestore::Store::parse(&bytes)?; |
| 6019 | 6071 | let index = onestore::RevisionIndex::parse(&store)?; |
| 6020 | 6072 | reference = Some(Page::from_document( |
| ... | ... | @@ -6046,7 +6098,7 @@ fn launch() -> Result<(), Box<dyn Error>> { |
| 6046 | 6098 | } |
| 6047 | 6099 | let text = positional |
| 6048 | 6100 | .first() |
| 6049 | .map(std::fs::read_to_string) | |
| 6101 | .map(notebook::fs::read_to_string) | |
| 6050 | 6102 | .transpose()? |
| 6051 | 6103 | .unwrap_or_default(); |
| 6052 | 6104 | let width = positional |
| ... | ... | @@ -6108,7 +6160,10 @@ fn launch() -> Result<(), Box<dyn Error>> { |
| 6108 | 6160 | instance.serve(event_loop.create_proxy()); |
| 6109 | 6161 | } |
| 6110 | 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 | 6168 | let mut app = App { |
| 6114 | 6169 | proxy: event_loop.create_proxy(), |
| ... | ... | @@ -6164,10 +6219,10 @@ mod tests { |
| 6164 | 6219 | fn recorded_ops_reach_the_section() { |
| 6165 | 6220 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); |
| 6166 | 6221 | let directory = std::env::temp_dir().join(format!("snowbound-ops-{}", std::process::id())); |
| 6167 | let _ = std::fs::remove_dir_all(&directory); | |
| 6168 | std::fs::create_dir_all(directory.join("cache")).unwrap(); | |
| 6222 | let _ = notebook::fs::remove_dir_all(&directory); | |
| 6223 | notebook::fs::create_dir_all(directory.join("cache")).unwrap(); | |
| 6169 | 6224 | let file = directory.join("section.one"); |
| 6170 | std::fs::copy( | |
| 6225 | notebook::fs::copy( | |
| 6171 | 6226 | root.join("corpus/paragraph-edit/before/notebook/synthetic.one"), |
| 6172 | 6227 | &file, |
| 6173 | 6228 | ) |
| ... | ... | @@ -6215,7 +6270,7 @@ mod tests { |
| 6215 | 6270 | .any(|event| matches!(event, notebook::session::Event::Rejected { .. })) |
| 6216 | 6271 | ); |
| 6217 | 6272 | section.close().unwrap(); |
| 6218 | std::fs::remove_dir_all(&directory).unwrap(); | |
| 6273 | notebook::fs::remove_dir_all(&directory).unwrap(); | |
| 6219 | 6274 | } |
| 6220 | 6275 | |
| 6221 | 6276 | use notebook::session::{Event, Section}; |
| ... | ... | @@ -6267,10 +6322,10 @@ mod tests { |
| 6267 | 6322 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); |
| 6268 | 6323 | let directory = |
| 6269 | 6324 | std::env::temp_dir().join(format!("snowbound-changed-{}", std::process::id())); |
| 6270 | let _ = std::fs::remove_dir_all(&directory); | |
| 6271 | std::fs::create_dir_all(&directory).unwrap(); | |
| 6325 | let _ = notebook::fs::remove_dir_all(&directory); | |
| 6326 | notebook::fs::create_dir_all(&directory).unwrap(); | |
| 6272 | 6327 | let file = directory.join("section.one"); |
| 6273 | std::fs::copy( | |
| 6328 | notebook::fs::copy( | |
| 6274 | 6329 | root.join("corpus/paragraph-edit/before/notebook/synthetic.one"), |
| 6275 | 6330 | &file, |
| 6276 | 6331 | ) |
| ... | ... | @@ -6345,7 +6400,7 @@ mod tests { |
| 6345 | 6400 | ); |
| 6346 | 6401 | ours.close().unwrap(); |
| 6347 | 6402 | theirs.close().unwrap(); |
| 6348 | std::fs::remove_dir_all(&directory).unwrap(); | |
| 6403 | notebook::fs::remove_dir_all(&directory).unwrap(); | |
| 6349 | 6404 | } |
| 6350 | 6405 | |
| 6351 | 6406 | /// Times opening each section of a notebook and each of its pages, phase by phase, as a |
| ... | ... | @@ -6356,7 +6411,7 @@ mod tests { |
| 6356 | 6411 | #[ignore = "measures a notebook named by the environment"] |
| 6357 | 6412 | fn switch_timings() { |
| 6358 | 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 | 6415 | let open = || match std::env::var("SNOWBOUND_SWITCH_NOTEBOOK") { |
| 6361 | 6416 | Ok(folder) => Library::notebook(&folder, &cache), |
| 6362 | 6417 | Err(_) => { |
| ... | ... | @@ -6421,6 +6476,6 @@ mod tests { |
| 6421 | 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 | 211 | }; |
| 212 | 212 | let space = self.session.as_ref().ok_or("No section is open")?.space; |
| 213 | 213 | let proxy = self.proxy.clone(); |
| 214 | std::thread::spawn(move || { | |
| 214 | crate::spawn(move || { | |
| 215 | 215 | let art = template.pictures().map_err(|error| error.to_string()); |
| 216 | 216 | let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { |
| 217 | 217 | if state |
| ... | ... | @@ -399,9 +399,9 @@ impl State { |
| 399 | 399 | pub(crate) fn list_icloud(&mut self) { |
| 400 | 400 | let proxy = self.proxy.clone(); |
| 401 | 401 | // Listing a folder iCloud Drive has not brought down yet waits for it. |
| 402 | std::thread::spawn(move || { | |
| 402 | crate::spawn(move || { | |
| 403 | 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 | 405 | .ok()? |
| 406 | 406 | .flatten() |
| 407 | 407 | .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) |
| ... | ... | @@ -446,7 +446,7 @@ impl State { |
| 446 | 446 | continue; |
| 447 | 447 | } |
| 448 | 448 | let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); |
| 449 | std::thread::spawn(move || { | |
| 449 | crate::spawn(move || { | |
| 450 | 450 | let library = Arc::new(Library::notebook(&location, &cache)); |
| 451 | 451 | let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { |
| 452 | 452 | state.icloud_reading.remove(&location); |
| ... | ... | @@ -494,7 +494,7 @@ impl State { |
| 494 | 494 | return; |
| 495 | 495 | } |
| 496 | 496 | let proxy = self.proxy.clone(); |
| 497 | std::thread::spawn(move || { | |
| 497 | crate::spawn(move || { | |
| 498 | 498 | if missing.is_some() { |
| 499 | 499 | std::thread::sleep(std::time::Duration::from_secs(2)); |
| 500 | 500 | } |
| ... | ... | @@ -544,11 +544,16 @@ impl State { |
| 544 | 544 | } |
| 545 | 545 | |
| 546 | 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 | 551 | let page = self.dated_page(None)?; |
| 549 | 552 | let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); |
| 550 | 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 | 557 | let notebook = Notebook::create(&location, &cache, Notebook::NEW_COLOR, &page)?; |
| 553 | 558 | let library = Arc::new(Library::created(&location, notebook, &cache)); |
| 554 | 559 | let path = library |
| ... | ... | @@ -584,7 +589,7 @@ impl State { |
| 584 | 589 | session.library.name, |
| 585 | 590 | crate::filetime() |
| 586 | 591 | )); |
| 587 | let exported = std::fs::create_dir_all(&folder) | |
| 592 | let exported = notebook::fs::create_dir_all(&folder) | |
| 588 | 593 | .map_err(notebook::Error::from) |
| 589 | 594 | .and_then(|()| session.section.export_recovery(&archive)); |
| 590 | 595 | if let Err(error) = exported { |
| ... | ... | @@ -936,13 +941,13 @@ mod tests { |
| 936 | 941 | } |
| 937 | 942 | |
| 938 | 943 | fn copy(from: &Path, to: &Path) { |
| 939 | std::fs::create_dir_all(to).unwrap(); | |
| 940 | for entry in std::fs::read_dir(from).unwrap().flatten() { | |
| 944 | notebook::fs::create_dir_all(to).unwrap(); | |
| 945 | for entry in notebook::fs::read_dir(from).unwrap().flatten() { | |
| 941 | 946 | let target = to.join(entry.file_name()); |
| 942 | 947 | if entry.file_type().unwrap().is_dir() { |
| 943 | 948 | copy(&entry.path(), &target); |
| 944 | 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 | 961 | fn a_notebook_is_managed_as_onenote_manages_one() { |
| 957 | 962 | let temporary = |
| 958 | 963 | std::env::temp_dir().join(format!("snowbound-manage-{}", std::process::id())); |
| 959 | let _ = std::fs::remove_dir_all(&temporary); | |
| 960 | std::fs::create_dir_all(&temporary).unwrap(); | |
| 964 | let _ = notebook::fs::remove_dir_all(&temporary); | |
| 965 | notebook::fs::create_dir_all(&temporary).unwrap(); | |
| 961 | 966 | let root = temporary.join("Managed"); |
| 962 | 967 | let cache = temporary.join("cache"); |
| 963 | 968 | let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &dated()).unwrap(); |
| ... | ... | @@ -1182,7 +1187,7 @@ mod tests { |
| 1182 | 1187 | if let Some(directory) = std::env::var_os("SNOWBOUND_MANAGEMENT_EXPORT") { |
| 1183 | 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 | } |
| 1187 | 1192 | |
| 1188 | 1193 | /// Deleting a group's last section shows the notebook's other one; deleting that too |
| ... | ... | @@ -1191,8 +1196,8 @@ mod tests { |
| 1191 | 1196 | fn deleting_every_section_leaves_the_notebook_showing_none() { |
| 1192 | 1197 | let temporary = |
| 1193 | 1198 | std::env::temp_dir().join(format!("snowbound-empty-{}", std::process::id())); |
| 1194 | let _ = std::fs::remove_dir_all(&temporary); | |
| 1195 | std::fs::create_dir_all(&temporary).unwrap(); | |
| 1199 | let _ = notebook::fs::remove_dir_all(&temporary); | |
| 1200 | notebook::fs::create_dir_all(&temporary).unwrap(); | |
| 1196 | 1201 | let root = temporary.join("Emptied"); |
| 1197 | 1202 | let location = root.to_str().unwrap(); |
| 1198 | 1203 | let cache = temporary.join("cache"); |
| ... | ... | @@ -1213,7 +1218,7 @@ mod tests { |
| 1213 | 1218 | let library = library.with(notebook); |
| 1214 | 1219 | assert_eq!(shown(&library, Some("New Section 1.one".into())), None); |
| 1215 | 1220 | assert!(library.tabs("").is_empty()); |
| 1216 | std::fs::remove_dir_all(&temporary).unwrap(); | |
| 1221 | notebook::fs::remove_dir_all(&temporary).unwrap(); | |
| 1217 | 1222 | } |
| 1218 | 1223 | |
| 1219 | 1224 | #[test] |
| ... | ... | @@ -1243,7 +1248,7 @@ mod tests { |
| 1243 | 1248 | fn backgrounds_go_on_and_off_pages_that_have_content() { |
| 1244 | 1249 | let temporary = |
| 1245 | 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 | 1252 | copy( |
| 1248 | 1253 | Path::new(concat!( |
| 1249 | 1254 | env!("CARGO_MANIFEST_DIR"), |
| ... | ... | @@ -1329,6 +1334,6 @@ mod tests { |
| 1329 | 1334 | if let Some(directory) = std::env::var_os("SNOWBOUND_BACKGROUND_EXPORT") { |
| 1330 | 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 | ||
| 4 | use std::{convert::Infallible, path::Path}; | |
| 5 | ||
| 6 | /// Why the browser does not record yet. | |
| 7 | const UNAVAILABLE: &str = "Recording isn't available in the browser yet."; | |
| 8 | ||
| 9 | pub struct Player(Infallible); | |
| 10 | ||
| 11 | impl 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 | ||
| 41 | pub struct Recorder(Infallible); | |
| 42 | ||
| 43 | impl 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 | 1046 | fn sections_and_the_notebook_take_onenotes_colours() { |
| 1047 | 1047 | let temporary = |
| 1048 | 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 | 1050 | let root = temporary.join("Colors"); |
| 1051 | 1051 | let cache = temporary.join("cache"); |
| 1052 | std::fs::create_dir_all(&temporary).unwrap(); | |
| 1052 | notebook::fs::create_dir_all(&temporary).unwrap(); | |
| 1053 | 1053 | let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap(); |
| 1054 | 1054 | let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page()).unwrap(); |
| 1055 | 1055 | let named: Vec<(Option<u32>, &str)> = (SECTION_COLORS.iter()) |
| ... | ... | @@ -1080,14 +1080,14 @@ mod tests { |
| 1080 | 1080 | assert_eq!(colors, expected); |
| 1081 | 1081 | if let Some(directory) = std::env::var_os("SNOWBOUND_SECTION_COLOR_EXPORT") { |
| 1082 | 1082 | let directory = std::path::Path::new(&directory); |
| 1083 | std::fs::create_dir_all(directory).unwrap(); | |
| 1084 | for entry in std::fs::read_dir(&root).unwrap() { | |
| 1083 | notebook::fs::create_dir_all(directory).unwrap(); | |
| 1084 | for entry in notebook::fs::read_dir(&root).unwrap() { | |
| 1085 | 1085 | let entry = entry.unwrap(); |
| 1086 | 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 | 169 | libc::localtime_r(&unix, &mut tm); |
| 170 | 170 | tm.tm_gmtoff as i64 |
| 171 | 171 | }; |
| 172 | #[cfg(windows)] | |
| 172 | #[cfg(any(windows, target_arch = "wasm32"))] | |
| 173 | 173 | let offset = crate::platform::utc_offset(unix); |
| 174 | 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 | 5 | use std::{ |
| 6 | 6 | error::Error, |
| 7 | 7 | path::{Path, PathBuf}, |
| 8 | time::Duration, | |
| 9 | 8 | }; |
| 10 | 9 | |
| 11 | 10 | /// What Paste takes from the clipboard. Files come first, as Finder also offers their names |
| ... | ... | @@ -45,7 +44,7 @@ impl State { |
| 45 | 44 | self.respond(response); |
| 46 | 45 | for (place, (source, css)) in places.into_iter().zip(fetched) { |
| 47 | 46 | let proxy = self.proxy.clone(); |
| 48 | std::thread::spawn(move || { | |
| 47 | crate::spawn(move || { | |
| 49 | 48 | let Some(bytes) = fetch(&source) else { |
| 50 | 49 | return; |
| 51 | 50 | }; |
| ... | ... | @@ -78,7 +77,7 @@ impl State { |
| 78 | 77 | path: &Path, |
| 79 | 78 | at: Option<[f32; 2]>, |
| 80 | 79 | ) -> Result<(), Box<dyn Error>> { |
| 81 | match std::fs::read(path) { | |
| 80 | match notebook::fs::read(path) { | |
| 82 | 81 | Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => { |
| 83 | 82 | self.insert_picture(bytes, at) |
| 84 | 83 | } |
| ... | ... | @@ -315,15 +314,22 @@ fn load(source: &str) -> Option<Vec<u8>> { |
| 315 | 314 | Some(drive) if cfg!(windows) => drive.to_owned(), |
| 316 | 315 | _ => path, |
| 317 | 316 | }; |
| 318 | return std::fs::read(path).ok(); | |
| 317 | return notebook::fs::read(path).ok(); | |
| 319 | 318 | } |
| 320 | 319 | None |
| 321 | 320 | } |
| 322 | 321 | |
| 322 | /// None in the browser, whose pages rarely let another site read their pictures. | |
| 323 | #[cfg(target_arch = "wasm32")] | |
| 324 | fn fetch(_: &str) -> Option<Vec<u8>> { | |
| 325 | None | |
| 326 | } | |
| 327 | ||
| 323 | 328 | /// A web picture's bytes, on a thread of its own. |
| 329 | #[cfg(not(target_arch = "wasm32"))] | |
| 324 | 330 | fn fetch(source: &str) -> Option<Vec<u8>> { |
| 325 | 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 | 333 | .build() |
| 328 | 334 | .into(); |
| 329 | 335 | agent |
| ... | ... | @@ -512,7 +518,7 @@ fn exif_resolution(tiff: &[u8]) -> Option<[f32; 2]> { |
| 512 | 518 | |
| 513 | 519 | /// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes |
| 514 | 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")))] | |
| 516 | 522 | pub(crate) fn bitmap(image: arboard::ImageData) -> Option<Vec<u8>> { |
| 517 | 523 | png( |
| 518 | 524 | [image.width, image.height].map(|side| side as u32), |
| ... | ... | @@ -667,12 +673,12 @@ mod tests { |
| 667 | 673 | assert_eq!(load("relative/pic.png"), None); |
| 668 | 674 | let directory = |
| 669 | 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 | 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 | 679 | let address = format!("file://{}", file.to_str().unwrap().replace(' ', "%20")); |
| 674 | 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 | } |
| 677 | 683 | |
| 678 | 684 | fn close(size: [f32; 2], expected: [f32; 2]) -> bool { |
crates/snowbound/src/prefetch.rs+4| ... | ... | @@ -109,6 +109,10 @@ impl Prefetch { |
| 109 | 109 | let scenes = Scenes::new(Mutex::new(Recent::new(SCENES))); |
| 110 | 110 | let (jobs, queue) = mpsc::channel(); |
| 111 | 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 | 116 | std::thread::Builder::new() |
| 113 | 117 | .name("prefetch".into()) |
| 114 | 118 | .spawn(move || { |
crates/snowbound/src/print.rs+13-11| ... | ... | @@ -343,7 +343,7 @@ impl State { |
| 343 | 343 | footer: setup.footer, |
| 344 | 344 | }; |
| 345 | 345 | let whole = matches!(setup.scope, Scope::Section | Scope::Notebook); |
| 346 | std::thread::spawn(move || { | |
| 346 | crate::spawn(move || { | |
| 347 | 347 | let made = (|| -> Result<Vec<u8>, Box<dyn Error>> { |
| 348 | 348 | let mut open = Some(open); |
| 349 | 349 | let this = spaces |
| ... | ... | @@ -380,7 +380,9 @@ impl State { |
| 380 | 380 | Ok(canvas::print::pdf(sections, &mut engine, &layout)?) |
| 381 | 381 | })(); |
| 382 | 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 | 386 | None => Ok(Some(pdf)), |
| 385 | 387 | }); |
| 386 | 388 | let failed = if export { |
| ... | ... | @@ -468,9 +470,9 @@ pub(crate) fn spooled(pdf: &[u8], title: &str) -> Result<PathBuf, Box<dyn Error> |
| 468 | 470 | let folder = platform::cache_dir() |
| 469 | 471 | .unwrap_or_else(std::env::temp_dir) |
| 470 | 472 | .join("Printing"); |
| 471 | std::fs::create_dir_all(&folder)?; | |
| 473 | notebook::fs::create_dir_all(&folder)?; | |
| 472 | 474 | let path = folder.join(format!("{}.pdf", file_name(title))); |
| 473 | std::fs::write(&path, pdf)?; | |
| 475 | notebook::fs::write(&path, pdf)?; | |
| 474 | 476 | Ok(path) |
| 475 | 477 | } |
| 476 | 478 | |
| ... | ... | @@ -502,8 +504,8 @@ mod tests { |
| 502 | 504 | PageCreation, |
| 503 | 505 | op::{Edit, Op, SectionOp}, |
| 504 | 506 | }; |
| 505 | let _ = std::fs::remove_dir_all(temporary); | |
| 506 | std::fs::create_dir_all(temporary).unwrap(); | |
| 507 | let _ = notebook::fs::remove_dir_all(temporary); | |
| 508 | notebook::fs::create_dir_all(temporary).unwrap(); | |
| 507 | 509 | let root = temporary.join("Ordered"); |
| 508 | 510 | let cache = temporary.join("cache"); |
| 509 | 511 | let page = |title: &str| { |
| ... | ... | @@ -558,7 +560,7 @@ mod tests { |
| 558 | 560 | let tabs = notebook_tabs(&library); |
| 559 | 561 | library.close_kept(); |
| 560 | 562 | drop(library); |
| 561 | let _ = std::fs::remove_dir_all(&temporary); | |
| 563 | let _ = notebook::fs::remove_dir_all(&temporary); | |
| 562 | 564 | let paths: Vec<&str> = tabs.iter().map(|(path, _)| path.as_str()).collect(); |
| 563 | 565 | assert_eq!(paths, ["A.one", "G/C.one", "B.one"]); |
| 564 | 566 | } |
| ... | ... | @@ -576,7 +578,7 @@ mod tests { |
| 576 | 578 | .collect(); |
| 577 | 579 | section.close().unwrap(); |
| 578 | 580 | drop(library); |
| 579 | let _ = std::fs::remove_dir_all(&temporary); | |
| 581 | let _ = notebook::fs::remove_dir_all(&temporary); | |
| 580 | 582 | assert_eq!(pages.len(), 3); |
| 581 | 583 | // A page's title as typed, which its title object holds. |
| 582 | 584 | let typed = |page: &Page| { |
| ... | ... | @@ -607,8 +609,8 @@ mod tests { |
| 607 | 609 | use notebook::session::Notebook; |
| 608 | 610 | let temporary = |
| 609 | 611 | std::env::temp_dir().join(format!("snowbound-print-bin-{}", std::process::id())); |
| 610 | let _ = std::fs::remove_dir_all(&temporary); | |
| 611 | std::fs::create_dir_all(&temporary).unwrap(); | |
| 612 | let _ = notebook::fs::remove_dir_all(&temporary); | |
| 613 | notebook::fs::create_dir_all(&temporary).unwrap(); | |
| 612 | 614 | let root = temporary.join("Printed"); |
| 613 | 615 | let cache = temporary.join("cache"); |
| 614 | 616 | let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap(); |
| ... | ... | @@ -622,7 +624,7 @@ mod tests { |
| 622 | 624 | .into_iter() |
| 623 | 625 | .map(|(path, _)| path) |
| 624 | 626 | .collect(); |
| 625 | std::fs::remove_dir_all(&temporary).unwrap(); | |
| 627 | notebook::fs::remove_dir_all(&temporary).unwrap(); | |
| 626 | 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 | ||
| 3 | use crate::platform::{Window, download}; | |
| 4 | use std::error::Error; | |
| 5 | ||
| 6 | /// Letter where the browser's language is American English, A4 elsewhere. | |
| 7 | pub 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 | ||
| 15 | pub 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 | 73 | _ => self.media = Media::Idle, |
| 74 | 74 | } |
| 75 | 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 | 77 | let extension = if video { "avi" } else { "wav" }; |
| 78 | 78 | let path = folder.join(format!("{}.{extension}", std::process::id())); |
| 79 | 79 | let recorder = if video { |
| ... | ... | @@ -131,7 +131,8 @@ impl State { |
| 131 | 131 | let recorded = path.clone(); |
| 132 | 132 | let job = std::thread::spawn(move || { |
| 133 | 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 | 136 | Ok(if video { |
| 136 | 137 | recorded |
| 137 | 138 | } else { |
| ... | ... | @@ -168,7 +169,7 @@ impl State { |
| 168 | 169 | Wave::parse(&bytes).map(|wave| wave.duration_ms()) |
| 169 | 170 | }; |
| 170 | 171 | let preview = platform::file_icon(&path); |
| 171 | std::fs::remove_file(&path)?; | |
| 172 | notebook::fs::remove_file(&path)?; | |
| 172 | 173 | let title = self.view.editor.page()?.title; |
| 173 | 174 | let name: String = title |
| 174 | 175 | .chars() |
| ... | ... | @@ -1042,10 +1043,10 @@ pub(crate) mod tests { |
| 1042 | 1043 | |
| 1043 | 1044 | if let Some(directory) = std::env::var_os(export) { |
| 1044 | 1045 | let directory = PathBuf::from(directory); |
| 1045 | std::fs::create_dir_all(&directory).unwrap(); | |
| 1046 | std::fs::write(directory.join("Recorded.one"), &image).unwrap(); | |
| 1046 | notebook::fs::create_dir_all(&directory).unwrap(); | |
| 1047 | notebook::fs::write(directory.join("Recorded.one"), &image).unwrap(); | |
| 1047 | 1048 | let file_id = Store::parse(&image).unwrap().header.file_id; |
| 1048 | std::fs::write( | |
| 1049 | notebook::fs::write( | |
| 1049 | 1050 | directory.join("Open Notebook.onetoc2"), |
| 1050 | 1051 | onestore::create_table_of_contents( |
| 1051 | 1052 | "Open Notebook.onetoc2", |
crates/snowbound/src/recycle.rs+7-7| ... | ... | @@ -261,7 +261,7 @@ mod tests { |
| 261 | 261 | } |
| 262 | 262 | |
| 263 | 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 | 265 | (pages.into_iter()) |
| 266 | 266 | .map(|page| (page.page.title, page.page.identity)) |
| 267 | 267 | .collect() |
| ... | ... | @@ -275,8 +275,8 @@ mod tests { |
| 275 | 275 | #[test] |
| 276 | 276 | fn pages_and_sections_leave_the_recycle_bin_as_onenote_restores_them() { |
| 277 | 277 | let folder = std::env::temp_dir().join(format!("snowbound-recycle-{}", std::process::id())); |
| 278 | let _ = std::fs::remove_dir_all(&folder); | |
| 279 | std::fs::create_dir_all(&folder).unwrap(); | |
| 278 | let _ = notebook::fs::remove_dir_all(&folder); | |
| 279 | notebook::fs::create_dir_all(&folder).unwrap(); | |
| 280 | 280 | let (root, cache) = (folder.join("Recycled"), folder.join("cache")); |
| 281 | 281 | let page = |title: &str| PageCreation::new(None, Some(title), AUTHOR).unwrap(); |
| 282 | 282 | let mut notebook = |
| ... | ... | @@ -376,17 +376,17 @@ mod tests { |
| 376 | 376 | if let Some(directory) = &export { |
| 377 | 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 | } |
| 381 | 381 | |
| 382 | 382 | fn copy(from: &std::path::Path, to: &std::path::Path) { |
| 383 | std::fs::create_dir_all(to).unwrap(); | |
| 384 | for entry in std::fs::read_dir(from).unwrap().flatten() { | |
| 383 | notebook::fs::create_dir_all(to).unwrap(); | |
| 384 | for entry in notebook::fs::read_dir(from).unwrap().flatten() { | |
| 385 | 385 | let target = to.join(entry.file_name()); |
| 386 | 386 | if entry.file_type().unwrap().is_dir() { |
| 387 | 387 | copy(&entry.path(), &target); |
| 388 | 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 | 187 | .into_iter()) |
| 188 | 188 | .find(|tab| tab.path == section) |
| 189 | 189 | .and_then(|tab| tab.color); |
| 190 | std::thread::spawn(move || { | |
| 190 | crate::spawn(move || { | |
| 191 | 191 | let written = (|| -> Result<(), Box<dyn Error>> { |
| 192 | 192 | let image = |path: &str| { |
| 193 | 193 | let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?; |
| ... | ... | @@ -212,7 +212,7 @@ impl State { |
| 212 | 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 | 216 | Ok(()) |
| 217 | 217 | })(); |
| 218 | 218 | let written = written.map_err(|error| error.to_string()); |
crates/snowbound/src/search.rs+67-30| ... | ... | @@ -17,9 +17,10 @@ use std::{ |
| 17 | 17 | atomic::{AtomicBool, AtomicU64, Ordering}, |
| 18 | 18 | mpsc, |
| 19 | 19 | }, |
| 20 | time::{Duration, Instant, SystemTime}, | |
| 20 | time::{Duration, SystemTime}, | |
| 21 | 21 | }; |
| 22 | 22 | use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px}; |
| 23 | use web_time::Instant; | |
| 23 | 24 | use winit::keyboard::NamedKey; |
| 24 | 25 | |
| 25 | 26 | /// Where a search looks, as OneNote's scope menu offers it. |
| ... | ... | @@ -116,6 +117,10 @@ pub struct Search { |
| 116 | 117 | version: Arc<AtomicU64>, |
| 117 | 118 | pub(crate) busy: Arc<AtomicBool>, |
| 118 | 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 | 124 | pub(crate) found: Vec<Found>, |
| 120 | 125 | found_for: Option<(Query, Scope, u64, String)>, |
| 121 | 126 | pub(crate) selected: Option<u64>, |
| ... | ... | @@ -136,10 +141,18 @@ impl Search { |
| 136 | 141 | let index = Arc::new(Mutex::new(Index::default())); |
| 137 | 142 | let version = Arc::new(AtomicU64::new(0)); |
| 138 | 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 | 153 | std::thread::Builder::new() |
| 141 | 154 | .name("search-index".into()) |
| 142 | .spawn(move || run(receiver, shared, redraw)) | |
| 155 | .spawn(move || indexing.run()) | |
| 143 | 156 | .expect("the index thread starts"); |
| 144 | 157 | Self { |
| 145 | 158 | query: String::new(), |
| ... | ... | @@ -152,6 +165,8 @@ impl Search { |
| 152 | 165 | version, |
| 153 | 166 | busy, |
| 154 | 167 | jobs, |
| 168 | #[cfg(target_arch = "wasm32")] | |
| 169 | indexing, | |
| 155 | 170 | found: Vec::new(), |
| 156 | 171 | found_for: None, |
| 157 | 172 | selected: None, |
| ... | ... | @@ -166,13 +181,28 @@ impl Search { |
| 166 | 181 | /// Asks the index to read `spaces` of the open section again, as edits or another |
| 167 | 182 | /// writer changed them, and its page list. |
| 168 | 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 | 185 | key, |
| 171 | 186 | replica: Arc::downgrade(replica), |
| 172 | 187 | spaces, |
| 173 | 188 | }); |
| 174 | 189 | } |
| 175 | 190 | |
| 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 | 206 | /// Pages of section `key` among the results, which the page list marks. |
| 177 | 207 | pub fn found_in(&self, key: &str) -> HashSet<ExGuid> { |
| 178 | 208 | if self.finding || self.query.trim().is_empty() { |
| ... | ... | @@ -195,31 +225,45 @@ impl Search { |
| 195 | 225 | type Stamp = Option<(u64, SystemTime)>; |
| 196 | 226 | |
| 197 | 227 | fn stamp(file: &Path) -> Stamp { |
| 198 | let metadata = std::fs::metadata(file).ok()?; | |
| 228 | let metadata = notebook::fs::metadata(file).ok()?; | |
| 199 | 229 | Some((metadata.len(), metadata.modified().ok()?)) |
| 200 | 230 | } |
| 201 | 231 | |
| 202 | 232 | /// Now in Time32, seconds since 1980, as page modification times are kept. |
| 203 | 233 | pub(crate) fn now() -> u64 { |
| 204 | SystemTime::now() | |
| 205 | .duration_since(SystemTime::UNIX_EPOCH) | |
| 234 | web_time::SystemTime::now() | |
| 235 | .duration_since(web_time::UNIX_EPOCH) | |
| 206 | 236 | .map_or(0, |since| since.as_secs().saturating_sub(315_532_800)) |
| 207 | 237 | } |
| 208 | 238 | |
| 209 | /// The index thread: answers jobs, the newest notebooks first, then page changes, and | |
| 210 | /// wakes the window once the index changed. | |
| 211 | fn run( | |
| 239 | /// The index thread's jobs and what it keeps between them. | |
| 240 | struct Indexing { | |
| 212 | 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 | 243 | redraw: std::task::Waker, |
| 215 | ) { | |
| 216 | let mut stamps: HashMap<String, Stamp> = HashMap::new(); | |
| 217 | while let Ok(first) = jobs.recv() { | |
| 218 | busy.store(true, Ordering::Relaxed); | |
| 219 | // Typing sends a job a keystroke; a pause gathers them into one read. | |
| 220 | if matches!(first, Job::Pages { .. }) { | |
| 221 | std::thread::sleep(SETTLE); | |
| 244 | stamps: HashMap<String, Stamp>, | |
| 245 | } | |
| 246 | ||
| 247 | impl Indexing { | |
| 248 | /// The index thread: answers jobs, gathering those typing sends a keystroke apart. | |
| 249 | #[cfg(not(target_arch = "wasm32"))] | |
| 250 | fn run(mut self) { | |
| 251 | while let Ok(first) = self.jobs.recv() { | |
| 252 | self.shared.2.store(true, Ordering::Relaxed); | |
| 253 | // Typing sends a job a keystroke; a pause gathers them into one read. | |
| 254 | if matches!(first, Job::Pages { .. }) { | |
| 255 | std::thread::sleep(SETTLE); | |
| 256 | } | |
| 257 | self.answer(first); | |
| 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 | 267 | let mut notebooks = None; |
| 224 | 268 | let mut changed = HashSet::new(); |
| 225 | 269 | let mut pages: HashMap<String, (Weak<Replica>, HashSet<ExGuid>)> = HashMap::new(); |
| ... | ... | @@ -246,25 +290,18 @@ fn run( |
| 246 | 290 | } |
| 247 | 291 | let start = Instant::now(); |
| 248 | 292 | if let Some((libraries, open)) = notebooks { |
| 249 | sync( | |
| 250 | &index, | |
| 251 | &version, | |
| 252 | &mut stamps, | |
| 253 | &libraries, | |
| 254 | open.as_ref(), | |
| 255 | &changed, | |
| 256 | ); | |
| 293 | sync(index, version, stamps, &libraries, open.as_ref(), &changed); | |
| 257 | 294 | lap("index notebooks", start); |
| 258 | 295 | } |
| 259 | 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 | 298 | eprintln!("Cannot index the open section: {error}"); |
| 262 | 299 | } |
| 263 | 300 | version.fetch_add(1, Ordering::Relaxed); |
| 264 | 301 | } |
| 265 | 302 | lap("index", start); |
| 266 | 303 | busy.store(false, Ordering::Relaxed); |
| 267 | redraw.wake_by_ref(); | |
| 304 | self.redraw.wake_by_ref(); | |
| 268 | 305 | } |
| 269 | 306 | } |
| 270 | 307 | |
| ... | ... | @@ -329,7 +366,7 @@ fn sync( |
| 329 | 366 | let read = || -> Result<Vec<Entry>, Box<dyn Error>> { |
| 330 | 367 | let bytes = match notebook { |
| 331 | 368 | Some(notebook) => notebook.read_section(&path)?, |
| 332 | None => onestore::read_file(&file)?, | |
| 369 | None => notebook::fs::read_file(&file)?, | |
| 333 | 370 | }; |
| 334 | 371 | let stored = match library.unlocked(&path) { |
| 335 | 372 | Some(key) => notebook::session::stored_pages_unlocked(&bytes, &key)?, |
| ... | ... | @@ -494,7 +531,7 @@ impl State { |
| 494 | 531 | return; |
| 495 | 532 | } |
| 496 | 533 | self.search.synced = identity; |
| 497 | let _ = self.search.jobs.send(Job::Notebooks { | |
| 534 | self.search.send(Job::Notebooks { | |
| 498 | 535 | libraries: self.notebooks.clone(), |
| 499 | 536 | open, |
| 500 | 537 | changed, |
crates/snowbound/src/server.rs+6-3| ... | ... | @@ -3,9 +3,12 @@ |
| 3 | 3 | //! where the system's file sharing can't reach the server. |
| 4 | 4 | |
| 5 | 5 | use crate::library::{Library, Login, Mount}; |
| 6 | #[cfg(target_arch = "wasm32")] | |
| 7 | use crate::platform::smb::{self, Client, Credentials, DirectoryEntry, Refusal}; | |
| 6 | 8 | use crate::{State, art, platform}; |
| 7 | 9 | use accesskit::Role; |
| 8 | use notebook::smb::{Client, Credentials, DirectoryEntry, Refusal}; | |
| 10 | #[cfg(not(target_arch = "wasm32"))] | |
| 11 | use notebook::smb::{self, Client, Credentials, DirectoryEntry, Refusal}; | |
| 9 | 12 | use std::{io, path::Path, sync::mpsc, time::Duration}; |
| 10 | 13 | use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, px}; |
| 11 | 14 | use winit::keyboard::NamedKey; |
| ... | ... | @@ -98,7 +101,7 @@ fn credentials(login: &Login) -> Credentials<'_> { |
| 98 | 101 | |
| 99 | 102 | impl Reach for Embedded { |
| 100 | 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 | } |
| 103 | 106 | |
| 104 | 107 | fn list(&self, mount: &Mount, login: &Login) -> io::Result<Vec<DirectoryEntry>> { |
| ... | ... | @@ -512,7 +515,7 @@ impl State { |
| 512 | 515 | return; |
| 513 | 516 | }; |
| 514 | 517 | let (replies, redraw) = (connect.replies.0.clone(), self.redraw.clone()); |
| 515 | std::thread::spawn(move || { | |
| 518 | crate::spawn(move || { | |
| 516 | 519 | let _ = replies.send(request.run(&Embedded)); |
| 517 | 520 | redraw.wake(); |
| 518 | 521 | }); |
crates/snowbound/src/settings.rs+7-7| ... | ... | @@ -126,7 +126,7 @@ impl ColorScheme { |
| 126 | 126 | impl Settings { |
| 127 | 127 | /// The settings at `path`; missing or unreadable ones are the defaults, reported. |
| 128 | 128 | pub fn load(path: &Path) -> Self { |
| 129 | match std::fs::read(path) { | |
| 129 | match notebook::fs::read(path) { | |
| 130 | 130 | Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| { |
| 131 | 131 | eprintln!("Ignoring the settings in {}: {error}", path.display()); |
| 132 | 132 | Self::default() |
| ... | ... | @@ -142,11 +142,11 @@ impl Settings { |
| 142 | 142 | /// Replaces the file at `path` whole, so a crash leaves the old settings or the new. |
| 143 | 143 | pub fn save(&self, path: &Path) -> std::io::Result<()> { |
| 144 | 144 | if let Some(folder) = path.parent() { |
| 145 | std::fs::create_dir_all(folder)?; | |
| 145 | notebook::fs::create_dir_all(folder)?; | |
| 146 | 146 | } |
| 147 | 147 | let partial = path.with_extension("partial"); |
| 148 | std::fs::write(&partial, serde_json::to_vec_pretty(self)?)?; | |
| 149 | std::fs::rename(partial, path) | |
| 148 | notebook::fs::write(&partial, serde_json::to_vec_pretty(self)?)?; | |
| 149 | notebook::fs::rename(partial, path) | |
| 150 | 150 | } |
| 151 | 151 | } |
| 152 | 152 | |
| ... | ... | @@ -277,7 +277,7 @@ mod tests { |
| 277 | 277 | }; |
| 278 | 278 | settings.save(&path).unwrap(); |
| 279 | 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 | 281 | assert_eq!( |
| 282 | 282 | Settings::load(&path), |
| 283 | 283 | Settings { |
| ... | ... | @@ -286,10 +286,10 @@ mod tests { |
| 286 | 286 | } |
| 287 | 287 | ); |
| 288 | 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 | 290 | let toolbar = Settings::load(&path).toolbar; |
| 291 | 291 | assert_eq!(toolbar.highlight, None); |
| 292 | 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 | ||
| 4 | use canvas::spelling::Dictionary; | |
| 5 | ||
| 6 | pub fn dictionary() -> Option<Box<dyn Dictionary>> { | |
| 7 | None | |
| 8 | } |
crates/snowbound/src/surface.rs+52-9| ... | ... | @@ -1,9 +1,8 @@ |
| 1 | 1 | //! Frames reach the window through wgpu. |
| 2 | 2 | |
| 3 | use crate::{platform, trace_input}; | |
| 3 | use crate::{Window, platform, trace_input}; | |
| 4 | 4 | use draw::Renderer; |
| 5 | 5 | use std::{error::Error, sync::Arc}; |
| 6 | use winit::window::Window; | |
| 7 | 6 | |
| 8 | 7 | pub struct Surface { |
| 9 | 8 | /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. |
| ... | ... | @@ -14,6 +13,9 @@ pub struct Surface { |
| 14 | 13 | instance: wgpu::Instance, |
| 15 | 14 | surface: wgpu::Surface<'static>, |
| 16 | 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 | 19 | /// Carries frames to the window where the system's backdrop shows through the strip. |
| 18 | 20 | translucent: Option<draw::Translucent>, |
| 19 | 21 | } |
| ... | ... | @@ -36,6 +38,13 @@ impl Surface { |
| 36 | 38 | window: Arc<Window>, |
| 37 | 39 | backdrop: bool, |
| 38 | 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 | 48 | #[cfg_attr(not(any(windows, target_os = "linux")), expect(unused_mut))] |
| 40 | 49 | let mut descriptor = |
| 41 | 50 | wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())); |
| ... | ... | @@ -61,8 +70,9 @@ impl Surface { |
| 61 | 70 | wgpu::Dx12SwapchainKind::DxgiFromVisual; |
| 62 | 71 | } |
| 63 | 72 | } |
| 73 | #[cfg(not(target_arch = "wasm32"))] | |
| 64 | 74 | let instance = wgpu::Instance::new(descriptor); |
| 65 | let surface = instance.create_surface(window.clone())?; | |
| 75 | let surface = instance.create_surface(target(&window))?; | |
| 66 | 76 | platform::configure_presentation(&surface); |
| 67 | 77 | let adapter = instance |
| 68 | 78 | .request_adapter(&wgpu::RequestAdapterOptions { |
| ... | ... | @@ -70,7 +80,18 @@ impl Surface { |
| 70 | 80 | ..Default::default() |
| 71 | 81 | }) |
| 72 | 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 | 95 | let size = window.inner_size(); |
| 75 | 96 | let mut config = surface |
| 76 | 97 | .get_default_config(&adapter, size.width, size.height) |
| ... | ... | @@ -92,9 +113,16 @@ impl Surface { |
| 92 | 113 | if platform::cuts_corners() && alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { |
| 93 | 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 | 123 | surface.configure(&device, &config); |
| 96 | 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 | 126 | Ok(( |
| 99 | 127 | Self { |
| 100 | 128 | size: [config.width, config.height], |
| ... | ... | @@ -104,6 +132,7 @@ impl Surface { |
| 104 | 132 | instance, |
| 105 | 133 | surface, |
| 106 | 134 | config, |
| 135 | view_format, | |
| 107 | 136 | translucent, |
| 108 | 137 | }, |
| 109 | 138 | renderer, |
| ... | ... | @@ -134,7 +163,7 @@ impl Surface { |
| 134 | 163 | return Ok(None); |
| 135 | 164 | } |
| 136 | 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 | 167 | platform::configure_presentation(&self.surface); |
| 139 | 168 | self.configure(renderer); |
| 140 | 169 | self.window.request_redraw(); |
| ... | ... | @@ -154,7 +183,10 @@ impl Surface { |
| 154 | 183 | }; |
| 155 | 184 | let target = match &mut self.translucent { |
| 156 | 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 | 191 | let target = target_of(target); |
| 160 | 192 | Ok(Some(Frame { |
| ... | ... | @@ -195,7 +227,7 @@ impl Surface { |
| 195 | 227 | mip_level_count: 1, |
| 196 | 228 | sample_count: 1, |
| 197 | 229 | dimension: wgpu::TextureDimension::D2, |
| 198 | format: self.config.format, | |
| 230 | format: self.view_format, | |
| 199 | 231 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, |
| 200 | 232 | view_formats: &[], |
| 201 | 233 | }); |
| ... | ... | @@ -235,7 +267,7 @@ impl Surface { |
| 235 | 267 | timeout: Some(std::time::Duration::from_secs(5)), |
| 236 | 268 | })?; |
| 237 | 269 | let bgra = matches!( |
| 238 | self.config.format, | |
| 270 | self.view_format, | |
| 239 | 271 | wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb |
| 240 | 272 | ); |
| 241 | 273 | let mut pixels = Vec::with_capacity(size[0] as usize * size[1] as usize * 4); |
| ... | ... | @@ -251,6 +283,17 @@ impl Surface { |
| 251 | 283 | } |
| 252 | 284 | } |
| 253 | 285 | |
| 286 | /// What the surface presents to: the window, or in the browser the page's canvas. | |
| 287 | fn 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 | 297 | /// `view` as what the renderer draws into, which is the view itself unless `draw` also |
| 255 | 298 | /// paints through OpenGL, as on Windows. |
| 256 | 299 | #[cfg_attr(not(windows), expect(clippy::useless_conversion))] |
crates/snowbound/src/sync.rs+4-4| ... | ... | @@ -1285,7 +1285,7 @@ mod tests { |
| 1285 | 1285 | .flat_map(|line| &line[..size[0] as usize * 4]) |
| 1286 | 1286 | .copied() |
| 1287 | 1287 | .collect(); |
| 1288 | let file = std::fs::File::create(path).unwrap(); | |
| 1288 | let file = notebook::fs::File::create(path).unwrap(); | |
| 1289 | 1289 | let mut encoder = png::Encoder::new(file, size[0], height); |
| 1290 | 1290 | encoder.set_color(png::ColorType::Rgba); |
| 1291 | 1291 | encoder.set_depth(png::BitDepth::Eight); |
| ... | ... | @@ -1458,7 +1458,7 @@ mod tests { |
| 1458 | 1458 | fn popup_renders_each_state() { |
| 1459 | 1459 | let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from); |
| 1460 | 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 | 1462 | Gpu::new() |
| 1463 | 1463 | }); |
| 1464 | 1464 | let states = states(); |
| ... | ... | @@ -1479,7 +1479,7 @@ mod tests { |
| 1479 | 1479 | continue; |
| 1480 | 1480 | }; |
| 1481 | 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 | 1483 | let mut ui = ui(appearance); |
| 1484 | 1484 | let mut now = Instant::now(); |
| 1485 | 1485 | settle( |
| ... | ... | @@ -1511,7 +1511,7 @@ mod tests { |
| 1511 | 1511 | } |
| 1512 | 1512 | // Work offline on, an edit waiting, then off: the edit sends and nothing moves. |
| 1513 | 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 | 1515 | let mut ui = self::ui(appearance); |
| 1516 | 1516 | let mut now = Instant::now(); |
| 1517 | 1517 | settle( |
crates/snowbound/src/tags.rs+11-11| ... | ... | @@ -104,7 +104,7 @@ fn pictures() -> Option<std::path::PathBuf> { |
| 104 | 104 | pub(crate) fn art_bytes(art: &str) -> Option<Vec<u8>> { |
| 105 | 105 | match extra_art().iter().position(|name| name == art) { |
| 106 | 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 | } |
| 110 | 110 | |
| ... | ... | @@ -595,7 +595,7 @@ impl State { |
| 595 | 595 | let Some(path) = platform::pick_file("Custom Image", &["png", "svg"]) else { |
| 596 | 596 | return; |
| 597 | 597 | }; |
| 598 | let kept = std::fs::read(&path) | |
| 598 | let kept = notebook::fs::read(&path) | |
| 599 | 599 | .ok() |
| 600 | 600 | .and_then(|bytes| canvas::gpu::import_tag_art(&bytes)); |
| 601 | 601 | let Some((bytes, extension)) = kept else { |
| ... | ... | @@ -608,8 +608,8 @@ impl State { |
| 608 | 608 | let written = pictures() |
| 609 | 609 | .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound)) |
| 610 | 610 | .and_then(|folder| { |
| 611 | std::fs::create_dir_all(&folder)?; | |
| 612 | match std::fs::File::create_new(folder.join(&art)) { | |
| 611 | notebook::fs::create_dir_all(&folder)?; | |
| 612 | match notebook::fs::File::create_new(folder.join(&art)) { | |
| 613 | 613 | Ok(mut file) => std::io::Write::write_all(&mut file, &bytes), |
| 614 | 614 | Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), |
| 615 | 615 | Err(error) => Err(error), |
| ... | ... | @@ -1185,16 +1185,16 @@ mod tests { |
| 1185 | 1185 | let written = section.image(); |
| 1186 | 1186 | let temporary = |
| 1187 | 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 | 1189 | let root = temporary.join("Custom Art"); |
| 1190 | std::fs::create_dir_all(&root).unwrap(); | |
| 1191 | std::fs::write(root.join("Custom art.one"), &written).unwrap(); | |
| 1190 | notebook::fs::create_dir_all(&root).unwrap(); | |
| 1191 | notebook::fs::write(root.join("Custom art.one"), &written).unwrap(); | |
| 1192 | 1192 | let file_id = Store::parse(&written).unwrap().header.file_id; |
| 1193 | 1193 | let toc = onestore::create_table_of_contents( |
| 1194 | 1194 | "Open Notebook.onetoc2", |
| 1195 | 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 | 1198 | let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")); |
| 1199 | 1199 | let notebook = notebook.unwrap(); |
| 1200 | 1200 | for tag in &list[..3] { |
| ... | ... | @@ -1242,7 +1242,7 @@ mod tests { |
| 1242 | 1242 | .unwrap(); |
| 1243 | 1243 | assert!(status.success()); |
| 1244 | 1244 | } |
| 1245 | std::fs::remove_dir_all(&temporary).unwrap(); | |
| 1245 | notebook::fs::remove_dir_all(&temporary).unwrap(); | |
| 1246 | 1246 | } |
| 1247 | 1247 | |
| 1248 | 1248 | /// The gallery offers each symbol OneNote 2010's does once: every one MS-ONE lists but |
| ... | ... | @@ -1267,8 +1267,8 @@ mod tests { |
| 1267 | 1267 | let sources = canvas::gpu::art_sources(&mapping.art, bytes).unwrap(); |
| 1268 | 1268 | art.map(&mapping.name, mapping.shape, &mapping.art, sources); |
| 1269 | 1269 | } |
| 1270 | std::fs::remove_dir_all(&cache).unwrap(); | |
| 1271 | let bytes = std::fs::read(corpus.join("cold/notebook/Custom art.one")).unwrap(); | |
| 1270 | notebook::fs::remove_dir_all(&cache).unwrap(); | |
| 1271 | let bytes = notebook::fs::read(corpus.join("cold/notebook/Custom art.one")).unwrap(); | |
| 1272 | 1272 | let store = Store::parse(&bytes).unwrap(); |
| 1273 | 1273 | let index = RevisionIndex::parse(&store).unwrap(); |
| 1274 | 1274 | let document = Document::parse(&index).unwrap(); |
crates/snowbound/src/themes.rs+5-5| ... | ... | @@ -775,9 +775,9 @@ mod tests { |
| 775 | 775 | fn themed_pages_hold_their_theme_under_onenote_s_names() { |
| 776 | 776 | let temporary = |
| 777 | 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 | 779 | let root = temporary.join("Styles"); |
| 780 | std::fs::create_dir_all(&root).unwrap(); | |
| 780 | notebook::fs::create_dir_all(&root).unwrap(); | |
| 781 | 781 | let mut engine = TextEngine::default(); |
| 782 | 782 | let mut sections = Vec::new(); |
| 783 | 783 | for theme in built_in() { |
| ... | ... | @@ -880,7 +880,7 @@ mod tests { |
| 880 | 880 | } |
| 881 | 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 | 884 | let file_id = Store::parse(&written).unwrap().header.file_id; |
| 885 | 885 | sections.push((file, file_id, theme.id)); |
| 886 | 886 | } |
| ... | ... | @@ -889,7 +889,7 @@ mod tests { |
| 889 | 889 | .map(|(file, id, _)| (file.as_str(), *id)) |
| 890 | 890 | .collect(); |
| 891 | 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 | 893 | let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")).unwrap(); |
| 894 | 894 | let themes = notebook |
| 895 | 895 | .save_themes(Themes { |
| ... | ... | @@ -916,6 +916,6 @@ mod tests { |
| 916 | 916 | .unwrap(); |
| 917 | 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 | 1124 | fn new(name: &str, titles: &[&str]) -> Self { |
| 1125 | 1125 | let folder = |
| 1126 | 1126 | std::env::temp_dir().join(format!("snowbound-undo-{name}-{}", std::process::id())); |
| 1127 | let _ = std::fs::remove_dir_all(&folder); | |
| 1128 | std::fs::create_dir_all(&folder).unwrap(); | |
| 1127 | let _ = notebook::fs::remove_dir_all(&folder); | |
| 1128 | notebook::fs::create_dir_all(&folder).unwrap(); | |
| 1129 | 1129 | let root = folder.join("Notebook"); |
| 1130 | 1130 | let mut notebook = |
| 1131 | 1131 | Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()) |
| ... | ... | @@ -1211,7 +1211,7 @@ mod tests { |
| 1211 | 1211 | let file = self |
| 1212 | 1212 | .folder |
| 1213 | 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 | 1215 | |_| Vec::new(), |
| 1216 | 1216 | |bytes| { |
| 1217 | 1217 | notebook::session::stored_pages(&bytes) |
| ... | ... | @@ -1235,7 +1235,7 @@ mod tests { |
| 1235 | 1235 | if let Some(section) = self.section.take() { |
| 1236 | 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 | } |
| 1241 | 1241 | |
| ... | ... | @@ -1434,8 +1434,8 @@ mod tests { |
| 1434 | 1434 | use crate::manage::Structure; |
| 1435 | 1435 | let folder = |
| 1436 | 1436 | std::env::temp_dir().join(format!("snowbound-undo-sections-{}", std::process::id())); |
| 1437 | let _ = std::fs::remove_dir_all(&folder); | |
| 1438 | std::fs::create_dir_all(&folder).unwrap(); | |
| 1437 | let _ = notebook::fs::remove_dir_all(&folder); | |
| 1438 | notebook::fs::create_dir_all(&folder).unwrap(); | |
| 1439 | 1439 | let root = folder.join("Notebook"); |
| 1440 | 1440 | let mut notebook = |
| 1441 | 1441 | Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()).unwrap(); |
| ... | ... | @@ -1495,7 +1495,7 @@ mod tests { |
| 1495 | 1495 | carry(&mut notebook, structure); |
| 1496 | 1496 | notebook.rename("Kitchen.one", "Pantry").unwrap(); |
| 1497 | 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 | } |
| 1500 | 1500 | |
| 1501 | 1501 | /// Every kind of step taken back and redone in one notebook, written as the app writes |
| ... | ... | @@ -1596,13 +1596,13 @@ mod tests { |
| 1596 | 1596 | } |
| 1597 | 1597 | |
| 1598 | 1598 | fn copy(from: &std::path::Path, to: &std::path::Path) { |
| 1599 | std::fs::create_dir_all(to).unwrap(); | |
| 1600 | for entry in std::fs::read_dir(from).unwrap().flatten() { | |
| 1599 | notebook::fs::create_dir_all(to).unwrap(); | |
| 1600 | for entry in notebook::fs::read_dir(from).unwrap().flatten() { | |
| 1601 | 1601 | let target = to.join(entry.file_name()); |
| 1602 | 1602 | if entry.file_type().unwrap().is_dir() { |
| 1603 | 1603 | copy(&entry.path(), &target); |
| 1604 | 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 | 189 | if create && !valid { |
| 190 | 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 | 193 | return platform::alert( |
| 194 | 194 | "Couldn't unpack the notebook", |
| 195 | 195 | &format!( |
| ... | ... | @@ -212,11 +212,13 @@ impl State { |
| 212 | 212 | /// `color` where one was picked, and opens it. |
| 213 | 213 | fn unpack(&mut self, package: PathBuf, root: PathBuf, color: Option<u32>) { |
| 214 | 214 | let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); |
| 215 | std::thread::spawn(move || { | |
| 215 | crate::spawn(move || { | |
| 216 | 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 | 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 | 222 | if let Some(color) = color { |
| 221 | 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 | 51 | } |
| 52 | 52 | |
| 53 | 53 | fn now() -> u32 { |
| 54 | let unix = std::time::SystemTime::now() | |
| 55 | .duration_since(std::time::UNIX_EPOCH) | |
| 54 | let unix = web_time::SystemTime::now() | |
| 55 | .duration_since(web_time::UNIX_EPOCH) | |
| 56 | 56 | .map_or(0, |since| since.as_secs()); |
| 57 | 57 | u32::try_from(unix.saturating_sub(TIME32)).unwrap_or(u32::MAX) |
| 58 | 58 | } |
| ... | ... | @@ -61,7 +61,7 @@ impl Reads { |
| 61 | 61 | /// What the cache folder `cache` keeps. |
| 62 | 62 | pub fn load(cache: &Path) -> Self { |
| 63 | 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 | 65 | .and_then(|bytes| serde_json::from_slice(&bytes).ok()) |
| 66 | 66 | .unwrap_or_default(); |
| 67 | 67 | Self { file, ..reads } |
| ... | ... | @@ -71,8 +71,8 @@ impl Reads { |
| 71 | 71 | let partial = self.file.with_extension("partial"); |
| 72 | 72 | let saved = serde_json::to_vec(self) |
| 73 | 73 | .map_err(std::io::Error::from) |
| 74 | .and_then(|bytes| std::fs::write(&partial, bytes)) | |
| 75 | .and_then(|()| std::fs::rename(&partial, &self.file)); | |
| 74 | .and_then(|bytes| notebook::fs::write(&partial, bytes)) | |
| 75 | .and_then(|()| notebook::fs::rename(&partial, &self.file)); | |
| 76 | 76 | if let Err(error) = saved { |
| 77 | 77 | eprintln!("Keeping what was read failed: {error}"); |
| 78 | 78 | } |
| ... | ... | @@ -254,7 +254,7 @@ impl State { |
| 254 | 254 | /// pages are read on a thread of their own, those changed since unread. |
| 255 | 255 | pub(crate) fn sections_changed(&self, library: &Arc<Library>, paths: Vec<String>) { |
| 256 | 256 | let (library, proxy) = (Arc::clone(library), self.proxy.clone()); |
| 257 | std::thread::spawn(move || { | |
| 257 | crate::spawn(move || { | |
| 258 | 258 | let Ok(Some(notebook)) = &library.notebook else { |
| 259 | 259 | return; |
| 260 | 260 | }; |
| ... | ... | @@ -387,7 +387,7 @@ mod tests { |
| 387 | 387 | #[test] |
| 388 | 388 | fn pages_changed_elsewhere_stay_unread_until_read() { |
| 389 | 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 | 391 | let (notebook, section) = ("/notebook", [3; 16]); |
| 392 | 392 | let mut reads = Reads::load(&folder); |
| 393 | 393 | let known = reads.section_mut(notebook, section).read; |
| ... | ... | @@ -426,6 +426,6 @@ mod tests { |
| 426 | 426 | ); |
| 427 | 427 | reads.notebooks.get_mut(notebook).unwrap().hidden = true; |
| 428 | 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 | 2 | //! `build.json` the release key signs; `latest.json` names each platform's newest build. A |
| 3 | 3 | //! thread checks on launch and daily, downloads a newer build for this platform, verifies |
| 4 | 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))] | |
| 6 | 8 | |
| 7 | 9 | #[cfg(target_os = "linux")] |
| 8 | 10 | use crate::loader::executable; |
| 9 | use crate::{State, UserEvent, platform}; | |
| 11 | use crate::{EventLoopProxy, State, UserEvent, platform}; | |
| 12 | #[cfg(not(target_arch = "wasm32"))] | |
| 10 | 13 | use ring::signature::{ED25519, UnparsedPublicKey}; |
| 11 | 14 | use serde::Deserialize; |
| 12 | 15 | use std::collections::HashMap; |
| ... | ... | @@ -16,8 +19,9 @@ use std::ffi::OsString; |
| 16 | 19 | use std::path::{Path, PathBuf}; |
| 17 | 20 | use std::process::Command; |
| 18 | 21 | use std::sync::{Arc, Mutex}; |
| 19 | use std::time::{Duration, Instant}; | |
| 20 | use winit::event_loop::EventLoopProxy; | |
| 22 | use std::time::Duration; | |
| 23 | #[cfg(not(target_arch = "wasm32"))] | |
| 24 | use std::time::Instant; | |
| 21 | 25 | |
| 22 | 26 | /// Where the builds are published. |
| 23 | 27 | const BASE: &str = "https://file.paperclover.net/shr/snowbound/"; |
| ... | ... | @@ -206,6 +210,16 @@ fn unhex(text: &str) -> Option<Vec<u8>> { |
| 206 | 210 | .flatten() |
| 207 | 211 | } |
| 208 | 212 | |
| 213 | #[cfg(target_arch = "wasm32")] | |
| 214 | fn 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")] | |
| 220 | const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens."; | |
| 221 | ||
| 222 | #[cfg(not(target_arch = "wasm32"))] | |
| 209 | 223 | fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> { |
| 210 | 224 | let signature = unhex(signature).ok_or("The signature isn’t hex")?; |
| 211 | 225 | UnparsedPublicKey::new(&ED25519, key) |
| ... | ... | @@ -271,9 +285,12 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri |
| 271 | 285 | archive.size |
| 272 | 286 | )); |
| 273 | 287 | } |
| 274 | let digest = ring::digest::digest(&ring::digest::SHA256, bytes); | |
| 275 | if unhex(&archive.sha256).as_deref() != Some(digest.as_ref()) { | |
| 276 | return Err(format!("{}’s SHA-256 doesn’t match", archive.file)); | |
| 288 | #[cfg(not(target_arch = "wasm32"))] | |
| 289 | { | |
| 290 | let digest = ring::digest::digest(&ring::digest::SHA256, bytes); | |
| 291 | if unhex(&archive.sha256).as_deref() != Some(digest.as_ref()) { | |
| 292 | return Err(format!("{}’s SHA-256 doesn’t match", archive.file)); | |
| 293 | } | |
| 277 | 294 | } |
| 278 | 295 | verify(key, bytes, &archive.signature) |
| 279 | 296 | } |
| ... | ... | @@ -448,7 +465,13 @@ fn check( |
| 448 | 465 | result.unwrap_or_else(Status::Failed) |
| 449 | 466 | } |
| 450 | 467 | |
| 468 | #[cfg(target_arch = "wasm32")] | |
| 469 | fn download(_: &str, _: u64) -> Result<Vec<u8>, String> { | |
| 470 | Err(BROWSER.to_owned()) | |
| 471 | } | |
| 472 | ||
| 451 | 473 | /// The `Fetch` the app uses. |
| 474 | #[cfg(not(target_arch = "wasm32"))] | |
| 452 | 475 | fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> { |
| 453 | 476 | let agent: ureq::Agent = ureq::Agent::config_builder() |
| 454 | 477 | .timeout_global(Some(Duration::from_secs(10 * 60))) |
| ... | ... | @@ -498,6 +521,12 @@ impl Updates { |
| 498 | 521 | ..Shared::default() |
| 499 | 522 | })); |
| 500 | 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 | 530 | let thread = { |
| 502 | 531 | let shared = Arc::clone(&shared); |
| 503 | 532 | std::thread::Builder::new() |
| ... | ... | @@ -570,8 +599,13 @@ impl Updates { |
| 570 | 599 | |
| 571 | 600 | /// Checks now, and shows what the check finds when it finishes. |
| 572 | 601 | pub fn check_now(&self) { |
| 573 | self.shared.lock().unwrap().asked = true; | |
| 574 | self.thread.unpark(); | |
| 602 | #[cfg(target_arch = "wasm32")] | |
| 603 | platform::inform("Snowbound is up to date", BROWSER); | |
| 604 | #[cfg(not(target_arch = "wasm32"))] | |
| 605 | { | |
| 606 | self.shared.lock().unwrap().asked = true; | |
| 607 | self.thread.unpark(); | |
| 608 | } | |
| 575 | 609 | } |
| 576 | 610 | |
| 577 | 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 | 749 | true |
| 716 | 750 | } |
| 717 | 751 | |
| 752 | #[cfg(target_arch = "wasm32")] | |
| 753 | fn quits(_: u32, _: Duration) -> bool { | |
| 754 | false | |
| 755 | } | |
| 756 | ||
| 718 | 757 | #[cfg(windows)] |
| 719 | 758 | fn quits(pid: u32, patience: Duration) -> bool { |
| 720 | 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 | ||
| 3 | use std::path::Path; | |
| 4 | ||
| 5 | pub struct Watch; | |
| 6 | ||
| 7 | pub fn watch(_: &Path, _: impl Fn(Vec<String>) + Send + Sync + 'static) -> Option<Watch> { | |
| 8 | None | |
| 9 | } | |
| 10 | ||
| 11 | pub 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 | ||
| 6 | use crate::{State, UserEvent, commands, page, settings}; | |
| 7 | use canvas::date::DateField; | |
| 8 | use std::{ | |
| 9 | cell::{Cell, RefCell}, | |
| 10 | collections::VecDeque, | |
| 11 | error::Error, | |
| 12 | marker::PhantomData, | |
| 13 | path::{Path, PathBuf}, | |
| 14 | time::Duration, | |
| 15 | }; | |
| 16 | use ui::Ui; | |
| 17 | use wasm_bindgen::prelude::*; | |
| 18 | use web_time::Instant; | |
| 19 | use 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")] | |
| 28 | extern "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. | |
| 68 | const NOTEBOOKS: &str = "/Notebooks"; | |
| 69 | const CACHE: &str = "/Cache"; | |
| 70 | /// The metric-compatible faces `index.html` fetched, which only this page load holds. | |
| 71 | const FONTS: &str = "/Fonts"; | |
| 72 | const SETTINGS: &str = "/Settings"; | |
| 73 | /// Files chosen to insert, open or paste, kept where the page links them from. | |
| 74 | const CHOSEN: &str = "/Chosen"; | |
| 75 | /// How long changed files wait to be written out, so a burst of edits writes once. | |
| 76 | const STORE_AFTER: Duration = Duration::from_millis(500); | |
| 77 | ||
| 78 | /// What the page's input brought, waiting for the next frame. | |
| 79 | enum 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. | |
| 89 | struct Host { | |
| 90 | size: PhysicalSize<u32>, | |
| 91 | ratio: f64, | |
| 92 | focused: bool, | |
| 93 | title: String, | |
| 94 | theme: Theme, | |
| 95 | redraw: bool, | |
| 96 | } | |
| 97 | ||
| 98 | thread_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. | |
| 127 | pub 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 | ||
| 136 | fn host<T>(act: impl FnOnce(&mut Host) -> T) -> T { | |
| 137 | HOST.with_borrow_mut(act) | |
| 138 | } | |
| 139 | ||
| 140 | fn 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. | |
| 145 | pub 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. | |
| 151 | pub struct ActiveEventLoop; | |
| 152 | ||
| 153 | impl 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. | |
| 162 | pub struct Window(()); | |
| 163 | ||
| 164 | impl 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. | |
| 243 | pub struct EventLoopProxy<T>(PhantomData<fn(T)>); | |
| 244 | ||
| 245 | impl<T> Clone for EventLoopProxy<T> { | |
| 246 | fn clone(&self) -> Self { | |
| 247 | Self(PhantomData) | |
| 248 | } | |
| 249 | } | |
| 250 | ||
| 251 | impl 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. | |
| 261 | pub struct AccessAdapter; | |
| 262 | ||
| 263 | impl 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. | |
| 277 | pub struct Clipboard; | |
| 278 | ||
| 279 | #[derive(Default)] | |
| 280 | struct Pasted { | |
| 281 | text: Option<String>, | |
| 282 | html: Option<String>, | |
| 283 | files: Vec<PathBuf>, | |
| 284 | } | |
| 285 | ||
| 286 | impl 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 | ||
| 315 | pub const LEADING: f32 = 8.0; | |
| 316 | pub const TRAILING: f32 = 8.0; | |
| 317 | ||
| 318 | pub fn window_attributes() -> WindowAttributes { | |
| 319 | WindowAttributes::default() | |
| 320 | } | |
| 321 | ||
| 322 | pub fn install_title_bar(_: &Window) {} | |
| 323 | ||
| 324 | pub fn install_text_input(_: &Window) {} | |
| 325 | ||
| 326 | pub fn install_backdrop(_: &Window) -> bool { | |
| 327 | false | |
| 328 | } | |
| 329 | ||
| 330 | pub fn install_menu() {} | |
| 331 | ||
| 332 | pub fn update_menu(_: impl FnOnce() -> Vec<commands::Status>) {} | |
| 333 | ||
| 334 | pub 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. | |
| 337 | pub fn system_titlebar(_: &Window) -> bool { | |
| 338 | true | |
| 339 | } | |
| 340 | ||
| 341 | pub fn represent(_: &Window, _: Option<&Path>) {} | |
| 342 | ||
| 343 | pub fn titlebar(_: Theme) -> Option<[[f32; 4]; 2]> { | |
| 344 | None | |
| 345 | } | |
| 346 | ||
| 347 | pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme { | |
| 348 | theme.over_backdrop() | |
| 349 | } | |
| 350 | ||
| 351 | /// The kit's own menus. | |
| 352 | pub fn menu(_: Theme) -> Option<ui::Menu> { | |
| 353 | None | |
| 354 | } | |
| 355 | ||
| 356 | pub fn follow_appearance(_: &Window, _: Theme) {} | |
| 357 | ||
| 358 | pub fn appearance(_: &Window) -> Theme { | |
| 359 | host(|host| host.theme) | |
| 360 | } | |
| 361 | ||
| 362 | pub fn corner_radius(_: &Window) -> f32 { | |
| 363 | 0.0 | |
| 364 | } | |
| 365 | ||
| 366 | pub fn cuts_corners() -> bool { | |
| 367 | false | |
| 368 | } | |
| 369 | ||
| 370 | pub fn window_controls(_: &mut Ui, _: &Window) {} | |
| 371 | ||
| 372 | pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<ResizeDirection> { | |
| 373 | None | |
| 374 | } | |
| 375 | ||
| 376 | pub fn set_min_size(_: &Window, _: [f32; 2]) {} | |
| 377 | ||
| 378 | pub fn zoom(_: &Window) {} | |
| 379 | ||
| 380 | pub fn move_cursor() -> CursorIcon { | |
| 381 | CursorIcon::Move | |
| 382 | } | |
| 383 | ||
| 384 | pub 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. | |
| 388 | pub fn system_interface(ui: &mut Ui) { | |
| 389 | ui.set_system_font("Calibri"); | |
| 390 | } | |
| 391 | ||
| 392 | pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} | |
| 393 | ||
| 394 | pub fn clear_marked_text(_: &Window) {} | |
| 395 | ||
| 396 | pub fn configure_presentation(_: &wgpu::Surface<'_>) {} | |
| 397 | ||
| 398 | pub fn commit_presentation(_: &Window) {} | |
| 399 | ||
| 400 | pub fn double_click_interval() -> Duration { | |
| 401 | Duration::from_millis(500) | |
| 402 | } | |
| 403 | ||
| 404 | /// Pages fall back through the fonts the browser module carries. | |
| 405 | pub fn symbol_fonts() -> Vec<String> { | |
| 406 | Vec::new() | |
| 407 | } | |
| 408 | ||
| 409 | pub fn file_icon(_: &Path) -> Option<Vec<u8>> { | |
| 410 | None | |
| 411 | } | |
| 412 | ||
| 413 | /// The browser has no file manager to show a file in. | |
| 414 | pub const SHOW_FILE: &str = "Download"; | |
| 415 | ||
| 416 | /// Downloads `file`, the nearest the browser comes to showing it. | |
| 417 | pub 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. | |
| 422 | pub 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. | |
| 434 | pub 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 | ||
| 441 | pub fn cache_dir() -> Option<PathBuf> { | |
| 442 | Some(CACHE.into()) | |
| 443 | } | |
| 444 | ||
| 445 | pub fn settings_dir() -> Option<PathBuf> { | |
| 446 | Some(SETTINGS.into()) | |
| 447 | } | |
| 448 | ||
| 449 | pub 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. | |
| 454 | pub fn user_name() -> String { | |
| 455 | String::new() | |
| 456 | } | |
| 457 | ||
| 458 | pub fn input_language() -> String { | |
| 459 | LANGUAGE.with_borrow(Clone::clone) | |
| 460 | } | |
| 461 | ||
| 462 | /// Milliseconds since 1970 at FILETIME `filetime`. | |
| 463 | fn 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. | |
| 468 | pub fn utc_offset(unix: i64) -> i64 { | |
| 469 | -(js_sys::Date::new(&(unix as f64 * 1e3).into()).get_timezone_offset() as i64) * 60 | |
| 470 | } | |
| 471 | ||
| 472 | pub fn short_date(filetime: u64) -> String { | |
| 473 | short_date_string(unix_ms(filetime)) | |
| 474 | } | |
| 475 | ||
| 476 | pub 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`. | |
| 485 | pub 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. | |
| 530 | pub 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. | |
| 537 | pub 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. | |
| 544 | pub 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 | ||
| 558 | pub fn confirm(message: &str, detail: &str, _: &str, _: &str) -> bool { | |
| 559 | ask_confirm(&format!("{message}\n\n{detail}")) | |
| 560 | } | |
| 561 | ||
| 562 | pub fn alert(message: &str, detail: &str) { | |
| 563 | tell(&format!("{message}\n\n{detail}")); | |
| 564 | } | |
| 565 | ||
| 566 | pub 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. | |
| 571 | pub fn smb_mount(_: &Path) -> Option<crate::library::Mount> { | |
| 572 | None | |
| 573 | } | |
| 574 | ||
| 575 | pub fn smb_login(_: &crate::library::Mount) -> Result<crate::library::Login, String> { | |
| 576 | Err("Servers aren't reachable from the browser yet.".into()) | |
| 577 | } | |
| 578 | ||
| 579 | pub fn remember_label() -> Option<&'static str> { | |
| 580 | None | |
| 581 | } | |
| 582 | ||
| 583 | pub 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. | |
| 589 | pub 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] | |
| 656 | pub 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]`. | |
| 694 | fn 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. | |
| 714 | fn 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. | |
| 737 | async 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 | ||
| 788 | fn 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] | |
| 797 | pub 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. | |
| 809 | fn 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] | |
| 826 | pub 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] | |
| 848 | pub 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] | |
| 857 | pub fn modifiers(held: u8) { | |
| 858 | follow_modifiers(held); | |
| 859 | } | |
| 860 | ||
| 861 | #[wasm_bindgen] | |
| 862 | pub 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] | |
| 871 | pub 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] | |
| 877 | pub 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] | |
| 886 | pub fn wheel(x: f32, y: f32) { | |
| 887 | queue(Input::Ui(ui::Event::Wheel([x, y]))); | |
| 888 | } | |
| 889 | ||
| 890 | #[wasm_bindgen] | |
| 891 | pub fn pinch(factor: f32, x: f32, y: f32) { | |
| 892 | queue(Input::Pinch(factor, [x, y])); | |
| 893 | } | |
| 894 | ||
| 895 | #[wasm_bindgen] | |
| 896 | pub fn resize(width: u32, height: u32, ratio: f32) { | |
| 897 | queue(Input::Resize([width, height], ratio)); | |
| 898 | } | |
| 899 | ||
| 900 | #[wasm_bindgen] | |
| 901 | pub fn focus(focused: bool) { | |
| 902 | queue(Input::Focus(focused)); | |
| 903 | } | |
| 904 | ||
| 905 | #[wasm_bindgen] | |
| 906 | pub 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] | |
| 914 | pub 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. | |
| 931 | fn 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 | ||
| 960 | fn 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] | |
| 967 | pub 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] | |
| 980 | pub 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. | |
| 999 | fn 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. | |
| 1081 | fn 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. | |
| 1088 | fn 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 | ||
| 4 | let wasm; | |
| 5 | let canvas; | |
| 6 | let input; | |
| 7 | let picker; | |
| 8 | let framePending = false; | |
| 9 | let wakeTimer; | |
| 10 | ||
| 11 | const 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. | |
| 14 | const FILES = "files"; | |
| 15 | const SECTIONS = "sections"; | |
| 16 | const MIGRATED = "/Notebooks/Web Notebook"; | |
| 17 | ||
| 18 | function 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 | ||
| 32 | function 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. */ | |
| 42 | export 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. */ | |
| 67 | export 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 | ||
| 81 | export function requestFrame() { | |
| 82 | if (!framePending) { | |
| 83 | framePending = true; | |
| 84 | requestAnimationFrame(() => { | |
| 85 | framePending = false; | |
| 86 | wasm.frame(); | |
| 87 | }); | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | export function wakeIn(ms) { | |
| 92 | clearTimeout(wakeTimer); | |
| 93 | wakeTimer = setTimeout(requestFrame, Math.max(0, ms)); | |
| 94 | } | |
| 95 | ||
| 96 | export function setCursor(name) { | |
| 97 | if (canvas.style.cursor !== name) canvas.style.cursor = name; | |
| 98 | } | |
| 99 | ||
| 100 | export 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. */ | |
| 105 | export 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 | ||
| 112 | export function writeClipboard(text) { | |
| 113 | navigator.clipboard.writeText(text).catch((error) => console.warn("Copy", error)); | |
| 114 | } | |
| 115 | ||
| 116 | export 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. */ | |
| 124 | export 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. */ | |
| 132 | export 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 | ||
| 145 | export function shortDate(ms) { | |
| 146 | return new Date(ms).toLocaleDateString(); | |
| 147 | } | |
| 148 | ||
| 149 | export function askConfirm(message) { | |
| 150 | return confirm(message); | |
| 151 | } | |
| 152 | ||
| 153 | export function askText(message, value) { | |
| 154 | return prompt(message, value) ?? undefined; | |
| 155 | } | |
| 156 | ||
| 157 | export function tell(message) { | |
| 158 | alert(message); | |
| 159 | } | |
| 160 | ||
| 161 | export function openLink(url) { | |
| 162 | open(url, "_blank", "noopener"); | |
| 163 | } | |
| 164 | ||
| 165 | function 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 | ||
| 174 | function read(list) { | |
| 175 | return Promise.all( | |
| 176 | [...list].map(async (file) => [file.name, new Uint8Array(await file.arrayBuffer())]), | |
| 177 | ); | |
| 178 | } | |
| 179 | ||
| 180 | const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i; | |
| 181 | ||
| 182 | /** Wires the page's canvas, text area and file input to `module`'s exports. */ | |
| 183 | export 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 | 10 | nucleo-matcher = "0.3.1" |
| 11 | 11 | parley.workspace = true |
| 12 | 12 | winit = "0.30.13" |
| 13 | # std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. | |
| 14 | web-time = "1.1" | |
| 13 | 15 | |
| 14 | 16 | [dev-dependencies] |
| 15 | 17 | accesskit_consumer = "0.39.0" |
crates/ui/src/lib.rs+2-1| ... | ... | @@ -29,9 +29,10 @@ use std::{ |
| 29 | 29 | hash::{DefaultHasher, Hash, Hasher}, |
| 30 | 30 | ops::BitOr, |
| 31 | 31 | rc::Rc, |
| 32 | time::{Duration, Instant}, | |
| 32 | time::Duration, | |
| 33 | 33 | }; |
| 34 | 34 | use text::{Label, Texts}; |
| 35 | use web_time::Instant; | |
| 35 | 36 | use winit::{ |
| 36 | 37 | event::{Ime, MouseButton}, |
| 37 | 38 | keyboard::{Key, ModifiersState, NamedKey}, |
tools/TESTING.md+1| ... | ... | @@ -19,6 +19,7 @@ another, and the exit status is the result. |
| 19 | 19 | | `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) | |
| 20 | 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 | 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 | 23 | | `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` | |
| 23 | 24 | |
| 24 | 25 | ```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 | 90 | |
| 91 | 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. |
| 92 | 92 | |
| 93 | The host and `layout-probe` also accept repeated `--substitute-font FONT_FILE` options for another Arimo, Carlito, Tinos or Cousine file. Each file is registered only in this process, as its family's counterpart; this explicitly replaces that family even when a system copy exists. No font files are installed. | |
| 93 | The host and `layout-probe` also accept repeated `--substitute-font FONT_FILE` options for another Arimo, Carlito, Caladea, Tinos or Cousine file. Each file is registered only in this process, as its family's counterpart; this explicitly replaces that family even when a system copy exists. No font files are installed. | |
| 94 | 94 | |
| 95 | 95 | ```sh |
| 96 | 96 | "target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE |
tools/ci.py+20| ... | ... | @@ -75,6 +75,15 @@ def lanes(): |
| 75 | 75 | commands=[cross(linux, arch, *cross_clippy), cross(linux, arch, 'build', '-p', 'snowbound')], |
| 76 | 76 | environment={'CARGO_TARGET_DIR': str(TARGET / 'linux')}, |
| 77 | 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 | 87 | result.append(dict( |
| 79 | 88 | name='ios', minutes=30, packages=['mobile'], paths=('apps/ios/',), |
| 80 | 89 | # build-rust.sh builds the Rust half into target/ios. |
| ... | ... | @@ -92,6 +101,17 @@ def lanes(): |
| 92 | 101 | return result |
| 93 | 102 | |
| 94 | 103 | |
| 104 | def 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 | ||
| 95 | 115 | def jj(*args, cwd=ROOT): |
| 96 | 116 | return subprocess.run(['jj', *args], cwd=cwd, check=True, capture_output=True, text=True).stdout |
| 97 | 117 |
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 | |
| 3 | static 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 | """ | |
| 8 | import argparse | |
| 9 | import os | |
| 10 | from pathlib import Path | |
| 11 | import shutil | |
| 12 | import subprocess | |
| 13 | import sys | |
| 14 | import tempfile | |
| 15 | ||
| 16 | ROOT = Path(__file__).resolve().parents[1] | |
| 17 | # The one target folder wasm builds share, apart from the native builds in target/. | |
| 18 | TARGET = ROOT / 'target/wasm' | |
| 19 | WEB = ROOT / 'crates/snowbound/web' | |
| 20 | FONTS = ROOT / 'crates/canvas/assets/fonts' | |
| 21 | PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web') | |
| 22 | # copyparty lists the folder itself, so the app is at its index.html. | |
| 23 | URL = '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. | |
| 25 | PROFILE = ['--config', 'profile.release.lto=true', '--config', 'profile.release.codegen-units=1', | |
| 26 | '--config', 'profile.release.opt-level="s"'] | |
| 27 | ||
| 28 | ||
| 29 | def run(command, **kwargs): | |
| 30 | print('+', ' '.join(map(str, command)), flush=True) | |
| 31 | subprocess.run(command, cwd=ROOT, check=True, **kwargs) | |
| 32 | ||
| 33 | ||
| 34 | def 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 | ||
| 48 | def 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 | ||
| 58 | def 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 | ||
| 80 | def 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 | ||
| 94 | def 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 | ||
| 108 | if __name__ == '__main__': | |
| 109 | main() |