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