| author | |
| committer | |
| log | df545058d6e66cccbce7f56c1814f5a922c8a6cb |
| tree | 5ced17aa0cc4a251379ec953f15c0528bcca3a65 |
| parent | 142c3a3763f8e3648bbd92a9188e7773dad65b28 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- One executable per architecture: x86_64 built with llvm-mingw against
msvcrt for Windows 7 SP1, aarch64 for Windows 11 on Arm; a small shim answers
the few Windows 8+ imports.
- draw builds wgpu and OpenGL 2.1 side by side on Windows and picks D3D12 where
it exists, OpenGL elsewhere.
- The title bar follows the system: Mica with system caption hit-testing on 11,
a drawn caption over Acrylic on 8 and 10, the system frame on 7.
- Section files open with OneNote's share modes and one-byte locks, so
Snowbound and OneNote 2010 edit one notebook side by side, locally and over
UNC shares.
- Dialogs, Credential Manager, ReadDirectoryChangesW, MCI audio, spelling,
icon and manifest; releases publish Windows builds that update themselves.
- Also: the Windows 10 and 11 lab VMs, Bonjour share names, redrawn sync icons
with a mount-fallback warning, symlinked mount detection, and fixes for
modifier timing, tied edit clocks, and folder syncing on Windows.
Assisted-by: claude-opus-5.558 files changed, 3727 insertions(+), 411 deletions(-)
Cargo.lock+34| ... | ... | @@ -1093,6 +1093,16 @@ dependencies = [ |
| 1093 | 1093 | "simd-adler32", |
| 1094 | 1094 | ] |
| 1095 | 1095 | |
| 1096 | [[package]] | |
| 1097 | name = "file-guard" | |
| 1098 | version = "0.2.0" | |
| 1099 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1100 | checksum = "21ef72acf95ec3d7dbf61275be556299490a245f017cf084bd23b4f68cf9407c" | |
| 1101 | dependencies = [ | |
| 1102 | "libc", | |
| 1103 | "winapi", | |
| 1104 | ] | |
| 1105 | ||
| 1096 | 1106 | [[package]] |
| 1097 | 1107 | name = "find-msvc-tools" |
| 1098 | 1108 | version = "0.1.12" |
| ... | ... | @@ -2401,6 +2411,7 @@ dependencies = [ |
| 2401 | 2411 | "base64 0.22.1", |
| 2402 | 2412 | "bumpalo", |
| 2403 | 2413 | "cbc", |
| 2414 | "file-guard", | |
| 2404 | 2415 | "getrandom 0.4.3", |
| 2405 | 2416 | "md5", |
| 2406 | 2417 | "nix", |
| ... | ... | @@ -3346,6 +3357,7 @@ dependencies = [ |
| 3346 | 3357 | "wayland-client", |
| 3347 | 3358 | "wayland-protocols", |
| 3348 | 3359 | "wgpu", |
| 3360 | "windows-sys 0.61.2", | |
| 3349 | 3361 | "winit", |
| 3350 | 3362 | "zbus", |
| 3351 | 3363 | ] |
| ... | ... | @@ -4265,6 +4277,22 @@ dependencies = [ |
| 4265 | 4277 | "web-sys", |
| 4266 | 4278 | ] |
| 4267 | 4279 | |
| 4280 | [[package]] | |
| 4281 | name = "winapi" | |
| 4282 | version = "0.3.9" | |
| 4283 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4284 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" | |
| 4285 | dependencies = [ | |
| 4286 | "winapi-i686-pc-windows-gnu", | |
| 4287 | "winapi-x86_64-pc-windows-gnu", | |
| 4288 | ] | |
| 4289 | ||
| 4290 | [[package]] | |
| 4291 | name = "winapi-i686-pc-windows-gnu" | |
| 4292 | version = "0.4.0" | |
| 4293 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4294 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | |
| 4295 | ||
| 4268 | 4296 | [[package]] |
| 4269 | 4297 | name = "winapi-util" |
| 4270 | 4298 | version = "0.1.11" |
| ... | ... | @@ -4274,6 +4302,12 @@ dependencies = [ |
| 4274 | 4302 | "windows-sys 0.61.2", |
| 4275 | 4303 | ] |
| 4276 | 4304 | |
| 4305 | [[package]] | |
| 4306 | name = "winapi-x86_64-pc-windows-gnu" | |
| 4307 | version = "0.4.0" | |
| 4308 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4309 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | |
| 4310 | ||
| 4277 | 4311 | [[package]] |
| 4278 | 4312 | name = "windows" |
| 4279 | 4313 | version = "0.62.2" |
arc/platforms.md+48-20| ... | ... | @@ -6,24 +6,24 @@ platform is the window, the input plumbing, and how much of the interface is |
| 6 | 6 | drawn by Snowbound versus the operating system. |
| 7 | 7 | |
| 8 | 8 | ```text |
| 9 | macOS Linux iOS | |
| 10 | shell snowbound + ui snowbound + ui UIKit (apps/ios, Swift) | |
| 11 | glue snowbound/src/macos snowbound/src/linux crates/mobile (C ABI, staticlib) | |
| 12 | page canvas ─────────────────────────────────────────────────────► | |
| 13 | paint draw (wgpu: Metal) draw (Vulkan or GL) draw (Metal, CAMetalLayer) | |
| 14 | data notebook::session + embedded SMB client ────────────────────► | |
| 15 | format onestore ───────────────────────────────────────────────────► | |
| 9 | macOS Linux Windows iOS | |
| 10 | shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios) | |
| 11 | glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI) | |
| 12 | page canvas ─────────────────────────────────────────────────────────────────────► | |
| 13 | paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12, or GL on 7) draw (Metal) | |
| 14 | data notebook::session + embedded SMB client ────────────────────────────────────► | |
| 15 | format onestore ───────────────────────────────────────────────────────────────────► | |
| 16 | 16 | ``` |
| 17 | 17 | |
| 18 | 18 | ## Desktop: `snowbound` |
| 19 | 19 | |
| 20 | 20 | The desktop app is a winit window with the `ui` kit's chrome and a `canvas` |
| 21 | 21 | page inside it. `snowbound` picks a platform module at compile time |
| 22 | (`macos.rs` or `linux.rs`, both mounted as `platform`), and everything else | |
| 23 | in the crate is shared: library and settings, the sidebar, menus, page and | |
| 24 | section management, templates, and screenshot and replay support. | |
| 25 | Accessibility goes through AccessKit's winit adapter on both: the interface's | |
| 26 | tree, with the page's grafted into it. | |
| 22 | (`macos.rs`, `linux.rs` or `windows.rs`, each mounted as `platform`), and | |
| 23 | everything else in the crate is shared: library and settings, the sidebar, | |
| 24 | menus, page and section management, templates, and screenshot and replay | |
| 25 | support. Accessibility goes through AccessKit's winit adapter on all three: | |
| 26 | the interface's tree, with the page's grafted into it. | |
| 27 | 27 | `commands.rs` is the one table of commands: each one's title, its chords on |
| 28 | 28 | macOS and elsewhere, when it is enabled or checked, and what it does. The |
| 29 | 29 | keyboard, the toolbar and the macOS menu bar all run commands from it. |
| ... | ... | @@ -94,14 +94,42 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. |
| 94 | 94 | executable to `~/.local/bin` and writes the visible entry under the same |
| 95 | 95 | name, so the menu never lists two; Uninstall in Options removes it. |
| 96 | 96 | |
| 97 | ### Windows and older macOS | |
| 98 | ||
| 99 | The readme names both as goals. Nothing platform-specific exists for them yet. | |
| 100 | Keeping `ui` and `draw` free of platform toolkits is what keeps them within | |
| 101 | reach. | |
| 102 | The chrome's fill already has its seam: `platform::install_backdrop` lays a | |
| 103 | system material under a transparent surface, as Mica or Aero glass would, | |
| 104 | and `platform::titlebar` names opaque fills where the system has those. | |
| 97 | ### Windows | |
| 98 | ||
| 99 | - One executable runs on Windows 7 SP1 through 11. It is built from macOS | |
| 100 | with llvm-mingw against `msvcrt.dll`, which every Windows has | |
| 101 | (`platform/windows/cargo.sh`): x86_64 on nightly's tier-3 | |
| 102 | `x86_64-win7-windows-gnu`, whose standard library avoids Windows 8's | |
| 103 | imports, and aarch64 for Windows 11 on Arm. The few imports of Windows 8 | |
| 104 | and later that dependencies still name are answered by | |
| 105 | `platform/windows/rt/shims.c`; everything newer is looked up at run time. | |
| 106 | - `draw` builds both backends on Windows and the surface picks one: | |
| 107 | Direct3D 12 through wgpu where Windows has it (10 and 11), otherwise | |
| 108 | OpenGL 2.1 on a WGL context, as on Windows 7 (`surface_windows.rs`). | |
| 109 | - The toolbar's row is the title bar wherever the desktop composes windows. | |
| 110 | On Windows 7 with Aero and on 11, the system's frame keeps its sides, | |
| 111 | its caption gives way to the row, its material (Aero glass, Mica) lies | |
| 112 | under the whole window, and the system hit-tests and runs its own | |
| 113 | caption buttons over the row, which brings 11's snap layouts. 7 draws | |
| 114 | them over the glass; 11 doesn't over the Direct3D surface, so the row | |
| 115 | draws them as 11 does, lit where the system reports the pointer. On 8 | |
| 116 | and 10, whose frames are opaque, the window has no system frame and the | |
| 117 | row draws and runs caption buttons as 10 does, over acrylic on 10. With | |
| 118 | Windows 7's basic or classic theme the system draws the title bar and | |
| 119 | the row lies beneath it, as on KDE. | |
| 120 | - A notebook on a share opens by its UNC path through Windows' own SMB | |
| 121 | client, which takes OneNote's opens and locks natively: `onestore` opens a | |
| 122 | section as OneNote does (a reader shares it with everyone, a writer denies | |
| 123 | other writers) and takes OneNote's coordination bytes with byte-range | |
| 124 | locks, so both apps can have a section open on one machine. | |
| 125 | ReadDirectoryChangesW reports changes below a notebook, including other | |
| 126 | clients' on a share. Open Notebook from Server… still uses the embedded | |
| 127 | client, its passwords in the Credential Manager. | |
| 128 | - The common file dialogs, task dialogs, the date and time picker, and the | |
| 129 | Spell Checking API (from Windows 8) are the system's; audio plays and | |
| 130 | records through MCI. Dates follow the user's locale as OneNote's do. | |
| 131 | Windows has no menu bar: the toolbar and the command palette run the | |
| 132 | command table, with OneNote 2010's Ctrl chords. | |
| 105 | 133 | |
| 106 | 134 | ## iOS: native around the canvas |
| 107 | 135 |
crates/canvas/examples/render_page.rs+5-3| ... | ... | @@ -85,7 +85,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 85 | 85 | &[viewport.layer(&primitives)], |
| 86 | 86 | ) |
| 87 | 87 | .map_err(|e| format!("Page comparison rendering failed: {e:?}"))?; |
| 88 | let mut encoder = renderer.device.create_command_encoder(&Default::default()); | |
| 88 | let mut encoder = renderer | |
| 89 | .device() | |
| 90 | .create_command_encoder(&Default::default()); | |
| 89 | 91 | encoder.copy_texture_to_buffer( |
| 90 | 92 | target.as_image_copy(), |
| 91 | 93 | wgpu::TexelCopyBufferInfo { |
| ... | ... | @@ -102,12 +104,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 102 | 104 | depth_or_array_layers: 1, |
| 103 | 105 | }, |
| 104 | 106 | ); |
| 105 | renderer.queue.submit([encoder.finish()]); | |
| 107 | renderer.queue().submit([encoder.finish()]); | |
| 106 | 108 | let (sender, receiver) = std::sync::mpsc::channel(); |
| 107 | 109 | buffer.map_async(wgpu::MapMode::Read, .., move |result| { |
| 108 | 110 | let _ = sender.send(result); |
| 109 | 111 | }); |
| 110 | renderer.device.poll(wgpu::PollType::Wait { | |
| 112 | renderer.device().poll(wgpu::PollType::Wait { | |
| 111 | 113 | submission_index: None, |
| 112 | 114 | timeout: Some(Duration::from_secs(10)), |
| 113 | 115 | })?; |
crates/canvas/src/gpu/mod.rs+5-3| ... | ... | @@ -598,7 +598,9 @@ mod tests { |
| 598 | 598 | &[viewport.layer(&primitives)], |
| 599 | 599 | ) |
| 600 | 600 | .unwrap(); |
| 601 | let mut encoder = renderer.device.create_command_encoder(&Default::default()); | |
| 601 | let mut encoder = renderer | |
| 602 | .device() | |
| 603 | .create_command_encoder(&Default::default()); | |
| 602 | 604 | encoder.copy_texture_to_buffer( |
| 603 | 605 | target.as_image_copy(), |
| 604 | 606 | wgpu::TexelCopyBufferInfo { |
| ... | ... | @@ -615,10 +617,10 @@ mod tests { |
| 615 | 617 | depth_or_array_layers: 1, |
| 616 | 618 | }, |
| 617 | 619 | ); |
| 618 | renderer.queue.submit([encoder.finish()]); | |
| 620 | renderer.queue().submit([encoder.finish()]); | |
| 619 | 621 | readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); |
| 620 | 622 | renderer |
| 621 | .device | |
| 623 | .device() | |
| 622 | 624 | .poll(wgpu::PollType::Wait { |
| 623 | 625 | submission_index: None, |
| 624 | 626 | timeout: Some(Duration::from_secs(5)), |
crates/canvas/src/gpu/page/background.rs+5-3| ... | ... | @@ -1006,7 +1006,9 @@ mod tests { |
| 1006 | 1006 | &[viewport.layer(&primitives)], |
| 1007 | 1007 | ) |
| 1008 | 1008 | .unwrap(); |
| 1009 | let mut encoder = renderer.device.create_command_encoder(&Default::default()); | |
| 1009 | let mut encoder = renderer | |
| 1010 | .device() | |
| 1011 | .create_command_encoder(&Default::default()); | |
| 1010 | 1012 | encoder.copy_texture_to_buffer( |
| 1011 | 1013 | target.as_image_copy(), |
| 1012 | 1014 | wgpu::TexelCopyBufferInfo { |
| ... | ... | @@ -1023,10 +1025,10 @@ mod tests { |
| 1023 | 1025 | depth_or_array_layers: 1, |
| 1024 | 1026 | }, |
| 1025 | 1027 | ); |
| 1026 | renderer.queue.submit([encoder.finish()]); | |
| 1028 | renderer.queue().submit([encoder.finish()]); | |
| 1027 | 1029 | readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); |
| 1028 | 1030 | renderer |
| 1029 | .device | |
| 1031 | .device() | |
| 1030 | 1032 | .poll(wgpu::PollType::Wait { |
| 1031 | 1033 | submission_index: None, |
| 1032 | 1034 | timeout: Some(Duration::from_secs(5)), |
crates/canvas/src/gpu/profile.rs+2-2| ... | ... | @@ -118,7 +118,7 @@ fn renderer_cost() { |
| 118 | 118 | .unwrap(); |
| 119 | 119 | let submitted = start.elapsed().as_nanos(); |
| 120 | 120 | renderer |
| 121 | .device | |
| 121 | .device() | |
| 122 | 122 | .poll(wgpu::PollType::Wait { |
| 123 | 123 | submission_index: None, |
| 124 | 124 | timeout: Some(Duration::from_secs(10)), |
| ... | ... | @@ -335,7 +335,7 @@ fn frame_cost() { |
| 335 | 335 | let drawn = start.elapsed(); |
| 336 | 336 | drop(primitives); |
| 337 | 337 | renderer |
| 338 | .device | |
| 338 | .device() | |
| 339 | 339 | .poll(wgpu::PollType::Wait { |
| 340 | 340 | submission_index: None, |
| 341 | 341 | timeout: Some(Duration::from_secs(10)), |
crates/draw/Cargo.toml+2-1| ... | ... | @@ -7,7 +7,8 @@ publish = false |
| 7 | 7 | [features] |
| 8 | 8 | default = ["wgpu"] |
| 9 | 9 | # The renderer; `edit` needs only parley. It submits through wgpu, or without it through |
| 10 | # OpenGL 2.1, for Mac OS X 10.6. | |
| 10 | # OpenGL 2.1, for Mac OS X 10.6. On Windows it has both, and its maker picks one, as | |
| 11 | # Windows 7 has no Direct3D 12. | |
| 11 | 12 | render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] |
| 12 | 13 | wgpu = ["render", "dep:wgpu"] |
| 13 | 14 |
crates/draw/src/render.rs+48-26| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | #[cfg(not(feature = "wgpu"))] | |
| 1 | #[cfg(all(feature = "wgpu", windows))] | |
| 2 | mod dual; | |
| 3 | #[cfg(any(windows, not(feature = "wgpu")))] | |
| 2 | 4 | mod gl; |
| 3 | 5 | mod icon; |
| 4 | 6 | mod text; |
| ... | ... | @@ -7,12 +9,16 @@ mod translucent; |
| 7 | 9 | #[cfg(feature = "wgpu")] |
| 8 | 10 | mod webgpu; |
| 9 | 11 | |
| 12 | #[cfg(all(feature = "wgpu", windows))] | |
| 13 | use dual as backend; | |
| 10 | 14 | #[cfg(not(feature = "wgpu"))] |
| 11 | 15 | use gl as backend; |
| 12 | #[cfg(feature = "wgpu")] | |
| 16 | #[cfg(all(feature = "wgpu", not(windows)))] | |
| 13 | 17 | use webgpu as backend; |
| 14 | 18 | |
| 15 | 19 | pub use backend::Target; |
| 20 | #[cfg(all(feature = "wgpu", windows))] | |
| 21 | pub use gl::Target as GlTarget; | |
| 16 | 22 | pub use icon::{Palette, picture_icon}; |
| 17 | 23 | pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; |
| 18 | 24 | #[cfg(feature = "wgpu")] |
| ... | ... | @@ -161,6 +167,14 @@ impl RasterImage { |
| 161 | 167 | } |
| 162 | 168 | } |
| 163 | 169 | |
| 170 | /// What a backend submits: the prepared vertices and batches, and the images they paint. | |
| 171 | struct Frame<'a> { | |
| 172 | vertices: &'a [Vertex], | |
| 173 | batches: &'a [Batch], | |
| 174 | groups: &'a [Group], | |
| 175 | images: &'a HashMap<u64, CachedImage>, | |
| 176 | } | |
| 177 | ||
| 164 | 178 | struct CachedImage { |
| 165 | 179 | texture: backend::Image, |
| 166 | 180 | bytes: u64, |
| ... | ... | @@ -483,10 +497,6 @@ pub struct Occupancy { |
| 483 | 497 | } |
| 484 | 498 | |
| 485 | 499 | pub struct Renderer { |
| 486 | #[cfg(feature = "wgpu")] | |
| 487 | pub device: wgpu::Device, | |
| 488 | #[cfg(feature = "wgpu")] | |
| 489 | pub queue: wgpu::Queue, | |
| 490 | 500 | gpu: backend::Gpu, |
| 491 | 501 | vertices: Vec<Vertex>, |
| 492 | 502 | glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>, |
| ... | ... | @@ -501,16 +511,8 @@ pub struct Renderer { |
| 501 | 511 | } |
| 502 | 512 | |
| 503 | 513 | impl Renderer { |
| 504 | fn with_gpu( | |
| 505 | #[cfg(feature = "wgpu")] device: wgpu::Device, | |
| 506 | #[cfg(feature = "wgpu")] queue: wgpu::Queue, | |
| 507 | gpu: backend::Gpu, | |
| 508 | ) -> Self { | |
| 514 | fn with_gpu(gpu: backend::Gpu) -> Self { | |
| 509 | 515 | let renderer = Self { |
| 510 | #[cfg(feature = "wgpu")] | |
| 511 | device, | |
| 512 | #[cfg(feature = "wgpu")] | |
| 513 | queue, | |
| 514 | 516 | gpu, |
| 515 | 517 | vertices: Vec::new(), |
| 516 | 518 | glyphs: HashMap::new(), |
| ... | ... | @@ -522,15 +524,24 @@ impl Renderer { |
| 522 | 524 | pen: [1, 0], |
| 523 | 525 | row_height: 1, |
| 524 | 526 | }; |
| 525 | renderer.write_atlas([0, 0], [1, 1], &[255; 4]); | |
| 527 | renderer.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); | |
| 526 | 528 | renderer |
| 527 | 529 | } |
| 528 | 530 | |
| 531 | /// The widest and tallest texture the device takes, in pixels. | |
| 532 | pub fn max_texture_dimension(&self) -> u32 { | |
| 533 | self.gpu.max_texture_dimension() | |
| 534 | } | |
| 535 | ||
| 536 | fn atlas_side(&self) -> u32 { | |
| 537 | self.gpu.atlas_side() | |
| 538 | } | |
| 539 | ||
| 529 | 540 | /// Replaces the atlas with an empty one twice as wide and tall. |
| 530 | 541 | fn grow_atlas(&mut self) { |
| 531 | self.new_atlas(self.atlas_side() * 2); | |
| 542 | self.gpu.new_atlas(self.atlas_side() * 2); | |
| 532 | 543 | self.clear_glyph_cache(); |
| 533 | self.write_atlas([0, 0], [1, 1], &[255; 4]); | |
| 544 | self.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); | |
| 534 | 545 | } |
| 535 | 546 | |
| 536 | 547 | fn glyph_limit(&self) -> usize { |
| ... | ... | @@ -666,7 +677,13 @@ impl Renderer { |
| 666 | 677 | break; |
| 667 | 678 | } |
| 668 | 679 | } |
| 669 | self.submit(target, size, clear); | |
| 680 | let frame = Frame { | |
| 681 | vertices: &self.vertices, | |
| 682 | batches: &self.batches, | |
| 683 | groups: &self.groups, | |
| 684 | images: &self.images, | |
| 685 | }; | |
| 686 | self.gpu.submit(&frame, target, size, clear); | |
| 670 | 687 | Ok(()) |
| 671 | 688 | } |
| 672 | 689 | |
| ... | ... | @@ -740,12 +757,13 @@ impl Renderer { |
| 740 | 757 | return Err(RenderError::FrameTooLarge); |
| 741 | 758 | } |
| 742 | 759 | let from = self.vertices.len() as u32; |
| 760 | let flipped = self.gpu.flipped(); | |
| 743 | 761 | let corner = |x: f32, y: f32| { |
| 744 | 762 | let [shown_x, shown_y] = motion.project([x, y], height); |
| 745 | 763 | let v = y / height; |
| 746 | 764 | Vertex { |
| 747 | 765 | position: [shown_x * 2.0 / width - 1.0, 1.0 - shown_y * 2.0 / height], |
| 748 | uv: [x / width, if backend::FLIPPED { 1.0 - v } else { v }], | |
| 766 | uv: [x / width, if flipped { 1.0 - v } else { v }], | |
| 749 | 767 | color: [motion.opacity; 4], |
| 750 | 768 | local: [0.0; 2], |
| 751 | 769 | shape: [0.0; 4], |
| ... | ... | @@ -946,7 +964,7 @@ impl Renderer { |
| 946 | 964 | self.images.insert( |
| 947 | 965 | image.id(), |
| 948 | 966 | CachedImage { |
| 949 | texture: self.upload_image(image), | |
| 967 | texture: self.gpu.upload_image(image), | |
| 950 | 968 | bytes, |
| 951 | 969 | pixels: image.pixels.downgrade(), |
| 952 | 970 | }, |
| ... | ... | @@ -1123,7 +1141,8 @@ impl Renderer { |
| 1123 | 1141 | .collect(), |
| 1124 | 1142 | _ => image.data, |
| 1125 | 1143 | }; |
| 1126 | self.write_atlas([cached.x, cached.y], [p.width, p.height], &rgba); | |
| 1144 | self.gpu | |
| 1145 | .write_atlas([cached.x, cached.y], [p.width, p.height], &rgba); | |
| 1127 | 1146 | self.pen[0] += p.width + 1; |
| 1128 | 1147 | self.row_height = self.row_height.max(p.height); |
| 1129 | 1148 | Ok(cached) |
| ... | ... | @@ -1928,7 +1947,9 @@ mod tests { |
| 1928 | 1947 | } |
| 1929 | 1948 | |
| 1930 | 1949 | fn capture(&self, renderer: &Renderer) -> Vec<u8> { |
| 1931 | let mut encoder = renderer.device.create_command_encoder(&Default::default()); | |
| 1950 | let mut encoder = renderer | |
| 1951 | .device() | |
| 1952 | .create_command_encoder(&Default::default()); | |
| 1932 | 1953 | encoder.copy_texture_to_buffer( |
| 1933 | 1954 | self.texture.as_image_copy(), |
| 1934 | 1955 | wgpu::TexelCopyBufferInfo { |
| ... | ... | @@ -1945,14 +1966,14 @@ mod tests { |
| 1945 | 1966 | depth_or_array_layers: 1, |
| 1946 | 1967 | }, |
| 1947 | 1968 | ); |
| 1948 | renderer.queue.submit([encoder.finish()]); | |
| 1969 | renderer.queue().submit([encoder.finish()]); | |
| 1949 | 1970 | let (sender, receiver) = std::sync::mpsc::channel(); |
| 1950 | 1971 | self.readback |
| 1951 | 1972 | .map_async(wgpu::MapMode::Read, .., move |result| { |
| 1952 | 1973 | sender.send(result).unwrap(); |
| 1953 | 1974 | }); |
| 1954 | 1975 | renderer |
| 1955 | .device | |
| 1976 | .device() | |
| 1956 | 1977 | .poll(wgpu::PollType::Wait { |
| 1957 | 1978 | submission_index: None, |
| 1958 | 1979 | timeout: Some(Duration::from_secs(5)), |
| ... | ... | @@ -2130,7 +2151,8 @@ mod tests { |
| 2130 | 2151 | renderer.clear_glyph_cache(); |
| 2131 | 2152 | renderer.images.clear(); |
| 2132 | 2153 | } else if pass == 3 { |
| 2133 | renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format); | |
| 2154 | renderer = | |
| 2155 | Renderer::new(renderer.device().clone(), renderer.queue().clone(), format); | |
| 2134 | 2156 | } |
| 2135 | 2157 | let clipping = [ |
| 2136 | 2158 | Primitive::Text { |
crates/draw/src/render/dual.rs created+126| ... | ... | @@ -0,0 +1,126 @@ |
| 1 | //! Both backends in one build, for Windows: wgpu where Direct3D 12 answers, and OpenGL 2.1 | |
| 2 | //! where it doesn't, as on Windows 7. Whoever makes the renderer picks one. | |
| 3 | use super::{gl, webgpu, *}; | |
| 4 | ||
| 5 | /// What a frame is drawn into, for the backend the renderer draws with. | |
| 6 | pub enum Target { | |
| 7 | Wgpu(wgpu::TextureView), | |
| 8 | Gl(gl::Target), | |
| 9 | } | |
| 10 | ||
| 11 | impl From<wgpu::TextureView> for Target { | |
| 12 | fn from(view: wgpu::TextureView) -> Self { | |
| 13 | Self::Wgpu(view) | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | impl From<gl::Target> for Target { | |
| 18 | fn from(target: gl::Target) -> Self { | |
| 19 | Self::Gl(target) | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | pub(super) enum Image { | |
| 24 | Wgpu(webgpu::Image), | |
| 25 | Gl(gl::Image), | |
| 26 | } | |
| 27 | ||
| 28 | impl AsRef<webgpu::Image> for Image { | |
| 29 | fn as_ref(&self) -> &webgpu::Image { | |
| 30 | match self { | |
| 31 | Self::Wgpu(image) => image, | |
| 32 | Self::Gl(_) => unreachable!("Images belong to the renderer's backend"), | |
| 33 | } | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | impl AsRef<gl::Image> for Image { | |
| 38 | fn as_ref(&self) -> &gl::Image { | |
| 39 | match self { | |
| 40 | Self::Gl(image) => image, | |
| 41 | Self::Wgpu(_) => unreachable!("Images belong to the renderer's backend"), | |
| 42 | } | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | pub(super) enum Gpu { | |
| 47 | Wgpu(webgpu::Gpu), | |
| 48 | Gl(gl::Gpu), | |
| 49 | } | |
| 50 | ||
| 51 | impl Renderer { | |
| 52 | pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { | |
| 53 | Self::with_gpu(Gpu::Wgpu(webgpu::Gpu::new(device, queue, format))) | |
| 54 | } | |
| 55 | ||
| 56 | /// Draws with the OpenGL context current on this thread, which must stay current | |
| 57 | /// whenever the renderer or a `Target` is used. | |
| 58 | pub fn opengl() -> Result<Self, String> { | |
| 59 | gl::Gpu::new().map(|gpu| Self::with_gpu(Gpu::Gl(gpu))) | |
| 60 | } | |
| 61 | ||
| 62 | /// Copies `target` to the OpenGL context's window, as `gl`'s renderer does. | |
| 63 | pub fn present_translucent(&self, target: &gl::Target) { | |
| 64 | if let Gpu::Gl(gpu) = &self.gpu { | |
| 65 | gpu.present_translucent(target); | |
| 66 | } | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | impl Gpu { | |
| 71 | pub(super) fn flipped(&self) -> bool { | |
| 72 | match self { | |
| 73 | Self::Wgpu(gpu) => gpu.flipped(), | |
| 74 | Self::Gl(gpu) => gpu.flipped(), | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | pub(super) fn max_texture_dimension(&self) -> u32 { | |
| 79 | match self { | |
| 80 | Self::Wgpu(gpu) => gpu.max_texture_dimension(), | |
| 81 | Self::Gl(gpu) => gpu.max_texture_dimension(), | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | pub(super) fn atlas_side(&self) -> u32 { | |
| 86 | match self { | |
| 87 | Self::Wgpu(gpu) => gpu.atlas_side(), | |
| 88 | Self::Gl(gpu) => gpu.atlas_side(), | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | pub(super) fn new_atlas(&mut self, side: u32) { | |
| 93 | match self { | |
| 94 | Self::Wgpu(gpu) => gpu.new_atlas(side), | |
| 95 | Self::Gl(gpu) => gpu.new_atlas(side), | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { | |
| 100 | match self { | |
| 101 | Self::Wgpu(gpu) => gpu.write_atlas(origin, size, rgba), | |
| 102 | Self::Gl(gpu) => gpu.write_atlas(origin, size, rgba), | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | pub(super) fn upload_image(&self, image: &RasterImage) -> Image { | |
| 107 | match self { | |
| 108 | Self::Wgpu(gpu) => Image::Wgpu(gpu.upload_image(image)), | |
| 109 | Self::Gl(gpu) => Image::Gl(gpu.upload_image(image)), | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | pub(super) fn submit( | |
| 114 | &mut self, | |
| 115 | frame: &Frame<'_>, | |
| 116 | target: &Target, | |
| 117 | size: [u32; 2], | |
| 118 | clear: [f32; 4], | |
| 119 | ) { | |
| 120 | match (self, target) { | |
| 121 | (Self::Wgpu(gpu), Target::Wgpu(view)) => gpu.submit(frame, view, size, clear), | |
| 122 | (Self::Gl(gpu), Target::Gl(target)) => gpu.submit(frame, target, size, clear), | |
| 123 | _ => unreachable!("Frames are drawn into the renderer's backend's targets"), | |
| 124 | } | |
| 125 | } | |
| 126 | } |
crates/draw/src/render/gl.rs+165-50| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | //! Submission through OpenGL 2.1 with EXT_framebuffer_object, EXT_framebuffer_blit and |
| 2 | 2 | //! the sRGB extensions, which every Mac OS X 10.6 driver has and OpenGL.framework |
| 3 | //! exports. Frames draw into an sRGB framebuffer object, so blending happens in linear | |
| 4 | //! light as it does in wgpu's sRGB targets. | |
| 3 | //! exports; on Windows, where opengl32.dll exports only OpenGL 1.1, the driver supplies | |
| 4 | //! them through `wglGetProcAddress`. Frames draw into an sRGB framebuffer object, so | |
| 5 | //! blending happens in linear light as it does in wgpu's sRGB targets. | |
| 5 | 6 | #![allow(non_snake_case)] |
| 6 | 7 | use super::*; |
| 7 | 8 | use std::{ |
| ... | ... | @@ -53,8 +54,95 @@ const COLOR_ATTACHMENT0: GLenum = 0x8CE0; |
| 53 | 54 | const FRAMEBUFFER: GLenum = 0x8D40; |
| 54 | 55 | const FRAMEBUFFER_SRGB: GLenum = 0x8DB9; |
| 55 | 56 | |
| 56 | #[cfg_attr(target_os = "macos", link(name = "OpenGL", kind = "framework"))] | |
| 57 | unsafe extern "C" { | |
| 57 | /// The entry points: linked where the system library exports them all, and on Windows | |
| 58 | /// loaded with the first context and called through `FUNCTIONS`. | |
| 59 | macro_rules! functions { | |
| 60 | ($(fn $name:ident($($arg:ident: $ty:ty),* $(,)?) $(-> $ret:ty)?;)*) => { | |
| 61 | #[cfg(not(windows))] | |
| 62 | #[cfg_attr(target_os = "macos", link(name = "OpenGL", kind = "framework"))] | |
| 63 | unsafe extern "C" { | |
| 64 | $(fn $name($($arg: $ty),*) $(-> $ret)?;)* | |
| 65 | } | |
| 66 | ||
| 67 | #[cfg(windows)] | |
| 68 | struct Functions { | |
| 69 | $($name: unsafe extern "system" fn($($ty),*) $(-> $ret)?,)* | |
| 70 | } | |
| 71 | ||
| 72 | #[cfg(windows)] | |
| 73 | static FUNCTIONS: std::sync::OnceLock<Functions> = std::sync::OnceLock::new(); | |
| 74 | ||
| 75 | /// Loads the entry points from the context current on this thread. | |
| 76 | #[cfg(windows)] | |
| 77 | fn load() -> Result<(), String> { | |
| 78 | if FUNCTIONS.get().is_none() { | |
| 79 | let functions = Functions { | |
| 80 | $($name: unsafe { | |
| 81 | std::mem::transmute::< | |
| 82 | *const c_void, | |
| 83 | unsafe extern "system" fn($($ty),*) $(-> $ret)?, | |
| 84 | >(windows::address(concat!(stringify!($name), "\0"))?) | |
| 85 | },)* | |
| 86 | }; | |
| 87 | let _ = FUNCTIONS.set(functions); | |
| 88 | } | |
| 89 | Ok(()) | |
| 90 | } | |
| 91 | ||
| 92 | $( | |
| 93 | #[cfg(windows)] | |
| 94 | #[allow(clippy::too_many_arguments)] | |
| 95 | unsafe fn $name($($arg: $ty),*) $(-> $ret)? { | |
| 96 | unsafe { (FUNCTIONS.get().expect("OpenGL is loaded").$name)($($arg),*) } | |
| 97 | } | |
| 98 | )* | |
| 99 | }; | |
| 100 | } | |
| 101 | ||
| 102 | #[cfg(not(windows))] | |
| 103 | fn load() -> Result<(), String> { | |
| 104 | Ok(()) | |
| 105 | } | |
| 106 | ||
| 107 | #[cfg(windows)] | |
| 108 | mod windows { | |
| 109 | use std::ffi::{c_char, c_void}; | |
| 110 | ||
| 111 | #[link(name = "opengl32")] | |
| 112 | unsafe extern "system" { | |
| 113 | fn wglGetProcAddress(name: *const c_char) -> *const c_void; | |
| 114 | } | |
| 115 | ||
| 116 | unsafe extern "system" { | |
| 117 | fn GetModuleHandleA(name: *const c_char) -> *mut c_void; | |
| 118 | fn GetProcAddress(module: *mut c_void, name: *const c_char) -> *const c_void; | |
| 119 | } | |
| 120 | ||
| 121 | /// OpenGL function `name`, NUL-terminated: from the driver, or opengl32.dll's own for | |
| 122 | /// OpenGL 1.1. An `EXT` name the driver lacks falls back to its core name. | |
| 123 | pub(super) fn address(name: &str) -> Result<*const c_void, String> { | |
| 124 | let find = |name: &str| unsafe { | |
| 125 | let driver = wglGetProcAddress(name.as_ptr().cast()); | |
| 126 | // Some drivers answer a missing name with a small integer rather than null. | |
| 127 | if !matches!(driver as isize, -1..=3) { | |
| 128 | return Some(driver); | |
| 129 | } | |
| 130 | let system = GetProcAddress( | |
| 131 | GetModuleHandleA(c"opengl32.dll".as_ptr()), | |
| 132 | name.as_ptr().cast(), | |
| 133 | ); | |
| 134 | (!system.is_null()).then_some(system) | |
| 135 | }; | |
| 136 | find(name) | |
| 137 | .or_else(|| { | |
| 138 | let core = name.strip_suffix("EXT\0")?; | |
| 139 | find(&format!("{core}\0")) | |
| 140 | }) | |
| 141 | .ok_or_else(|| format!("OpenGL has no {}", name.trim_end_matches('\0'))) | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | functions! { | |
| 58 | 146 | fn glEnable(cap: GLenum); |
| 59 | 147 | fn glDisable(cap: GLenum); |
| 60 | 148 | fn glGetIntegerv(name: GLenum, value: *mut GLint); |
| ... | ... | @@ -203,6 +291,12 @@ impl Drop for Image { |
| 203 | 291 | } |
| 204 | 292 | } |
| 205 | 293 | |
| 294 | impl AsRef<Image> for Image { | |
| 295 | fn as_ref(&self) -> &Image { | |
| 296 | self | |
| 297 | } | |
| 298 | } | |
| 299 | ||
| 206 | 300 | /// An offscreen frame a window shows by `present`, the context's default framebuffer |
| 207 | 301 | /// being no sRGB target. |
| 208 | 302 | pub struct Target { |
| ... | ... | @@ -290,9 +384,6 @@ impl Drop for Target { |
| 290 | 384 | } |
| 291 | 385 | } |
| 292 | 386 | |
| 293 | /// Whether an offscreen picture's rows run bottom first. | |
| 294 | pub(super) const FLIPPED: bool = true; | |
| 295 | ||
| 296 | 387 | pub(super) struct Gpu { |
| 297 | 388 | program: GLuint, |
| 298 | 389 | buffer: GLuint, |
| ... | ... | @@ -318,10 +409,24 @@ const ATTRIBUTES: [(&CStr, GLint, usize); 9] = [ |
| 318 | 409 | (c"blur", 1, offset_of!(Vertex, blur)), |
| 319 | 410 | ]; |
| 320 | 411 | |
| 412 | #[cfg(not(feature = "wgpu"))] | |
| 321 | 413 | impl Renderer { |
| 322 | 414 | /// Draws with the OpenGL context current on this thread, which must stay current |
| 323 | 415 | /// whenever the renderer or a `Target` is used. |
| 324 | pub fn new() -> Result<Self, String> { | |
| 416 | pub fn opengl() -> Result<Self, String> { | |
| 417 | Gpu::new().map(Self::with_gpu) | |
| 418 | } | |
| 419 | ||
| 420 | /// Copies `target` to the context's window, the same size, premultiplying each pixel | |
| 421 | /// again in sRGB, as a window server compositing a transparent surface needs. | |
| 422 | pub fn present_translucent(&self, target: &Target) { | |
| 423 | self.gpu.present_translucent(target); | |
| 424 | } | |
| 425 | } | |
| 426 | ||
| 427 | impl Gpu { | |
| 428 | pub(super) fn new() -> Result<Self, String> { | |
| 429 | load()?; | |
| 325 | 430 | let names: Vec<_> = ATTRIBUTES.iter().map(|(name, _, _)| *name).collect(); |
| 326 | 431 | let draw = unsafe { program(include_str!("../draw.glsl"), &names)? }; |
| 327 | 432 | let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? }; |
| ... | ... | @@ -340,7 +445,7 @@ impl Renderer { |
| 340 | 445 | ); |
| 341 | 446 | glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture); |
| 342 | 447 | } |
| 343 | Ok(Self::with_gpu(Gpu { | |
| 448 | Ok(Gpu { | |
| 344 | 449 | program: draw, |
| 345 | 450 | buffer, |
| 346 | 451 | translucent, |
| ... | ... | @@ -348,14 +453,12 @@ impl Renderer { |
| 348 | 453 | atlas: Image::new([ATLAS_SIZE; 2], NEAREST, None), |
| 349 | 454 | max_texture: max_texture as u32, |
| 350 | 455 | groups: Vec::new(), |
| 351 | })) | |
| 456 | }) | |
| 352 | 457 | } |
| 353 | 458 | |
| 354 | /// Copies `target` to the context's window, the same size, premultiplying each pixel | |
| 355 | /// again in sRGB, as a window server compositing a transparent surface needs. | |
| 356 | pub fn present_translucent(&self, target: &Target) { | |
| 459 | pub(super) fn present_translucent(&self, target: &Target) { | |
| 357 | 460 | let [width, height] = target.size().map(|side| side as f32); |
| 358 | let program = self.gpu.translucent; | |
| 461 | let program = self.translucent; | |
| 359 | 462 | unsafe { |
| 360 | 463 | glBindFramebufferEXT(FRAMEBUFFER, 0); |
| 361 | 464 | glDisable(FRAMEBUFFER_SRGB); |
| ... | ... | @@ -371,7 +474,7 @@ impl Renderer { |
| 371 | 474 | ); |
| 372 | 475 | glActiveTexture(TEXTURE0); |
| 373 | 476 | glBindTexture(TEXTURE_2D, target.texture.name); |
| 374 | glBindBuffer(ARRAY_BUFFER, self.gpu.triangle); | |
| 477 | glBindBuffer(ARRAY_BUFFER, self.triangle); | |
| 375 | 478 | for index in 1..ATTRIBUTES.len() { |
| 376 | 479 | glDisableVertexAttribArray(index as GLuint); |
| 377 | 480 | } |
| ... | ... | @@ -381,22 +484,26 @@ impl Renderer { |
| 381 | 484 | } |
| 382 | 485 | } |
| 383 | 486 | |
| 384 | /// The widest and tallest texture the driver takes, in pixels. | |
| 385 | pub fn max_texture_dimension(&self) -> u32 { | |
| 386 | self.gpu.max_texture | |
| 487 | /// Offscreen pictures' rows run bottom first. | |
| 488 | pub(super) fn flipped(&self) -> bool { | |
| 489 | true | |
| 490 | } | |
| 491 | ||
| 492 | pub(super) fn max_texture_dimension(&self) -> u32 { | |
| 493 | self.max_texture | |
| 387 | 494 | } |
| 388 | 495 | |
| 389 | 496 | pub(super) fn atlas_side(&self) -> u32 { |
| 390 | self.gpu.atlas.size[0] | |
| 497 | self.atlas.size[0] | |
| 391 | 498 | } |
| 392 | 499 | |
| 393 | 500 | pub(super) fn new_atlas(&mut self, side: u32) { |
| 394 | self.gpu.atlas = Image::new([side; 2], NEAREST, None); | |
| 501 | self.atlas = Image::new([side; 2], NEAREST, None); | |
| 395 | 502 | } |
| 396 | 503 | |
| 397 | 504 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { |
| 398 | 505 | unsafe { |
| 399 | glBindTexture(TEXTURE_2D, self.gpu.atlas.name); | |
| 506 | glBindTexture(TEXTURE_2D, self.atlas.name); | |
| 400 | 507 | glTexSubImage2D( |
| 401 | 508 | TEXTURE_2D, |
| 402 | 509 | 0, |
| ... | ... | @@ -417,23 +524,28 @@ impl Renderer { |
| 417 | 524 | |
| 418 | 525 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared |
| 419 | 526 | /// batches, each group's offscreen first. |
| 420 | pub(super) fn submit(&mut self, target: &Target, size: [u32; 2], clear: [f32; 4]) { | |
| 527 | pub(super) fn submit( | |
| 528 | &mut self, | |
| 529 | frame: &Frame<'_>, | |
| 530 | target: &Target, | |
| 531 | size: [u32; 2], | |
| 532 | clear: [f32; 4], | |
| 533 | ) { | |
| 421 | 534 | if self |
| 422 | .gpu | |
| 423 | 535 | .groups |
| 424 | 536 | .first() |
| 425 | 537 | .is_some_and(|picture| picture.size() != size) |
| 426 | 538 | { |
| 427 | self.gpu.groups.clear(); | |
| 539 | self.groups.clear(); | |
| 428 | 540 | } |
| 429 | while self.gpu.groups.len() < self.groups.len() { | |
| 541 | while self.groups.len() < frame.groups.len() { | |
| 430 | 542 | match Target::new(size) { |
| 431 | 543 | Ok(picture) => unsafe { |
| 432 | 544 | // Filtered, so a picture leaning back stays smooth. |
| 433 | 545 | glBindTexture(TEXTURE_2D, picture.texture.name); |
| 434 | 546 | glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR); |
| 435 | 547 | glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR); |
| 436 | self.gpu.groups.push(picture); | |
| 548 | self.groups.push(picture); | |
| 437 | 549 | }, |
| 438 | 550 | Err(error) => { |
| 439 | 551 | eprintln!("{error}"); |
| ... | ... | @@ -442,7 +554,7 @@ impl Renderer { |
| 442 | 554 | } |
| 443 | 555 | } |
| 444 | 556 | let [width, height] = size.map(|side| side as GLsizei); |
| 445 | let gpu = &self.gpu; | |
| 557 | let gpu = &*self; | |
| 446 | 558 | let begin = |target: &Target, clear: [f32; 4]| unsafe { |
| 447 | 559 | glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); |
| 448 | 560 | glDisable(SCISSOR_TEST); |
| ... | ... | @@ -459,7 +571,10 @@ impl Renderer { |
| 459 | 571 | gpu.atlas.name, |
| 460 | 572 | [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], |
| 461 | 573 | ), |
| 462 | Blend::Image(id) => (self.images[&id].texture.name, PREMULTIPLIED), | |
| 574 | Blend::Image(id) => { | |
| 575 | let image: &Image = frame.images[&id].texture.as_ref(); | |
| 576 | (image.name, PREMULTIPLIED) | |
| 577 | } | |
| 463 | 578 | Blend::Erase => ( |
| 464 | 579 | gpu.atlas.name, |
| 465 | 580 | [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA], |
| ... | ... | @@ -486,8 +601,8 @@ impl Renderer { |
| 486 | 601 | glBindBuffer(ARRAY_BUFFER, gpu.buffer); |
| 487 | 602 | glBufferData( |
| 488 | 603 | ARRAY_BUFFER, |
| 489 | size_of_val(self.vertices.as_slice()) as isize, | |
| 490 | self.vertices.as_ptr().cast(), | |
| 604 | size_of_val(frame.vertices) as isize, | |
| 605 | frame.vertices.as_ptr().cast(), | |
| 491 | 606 | STREAM_DRAW, |
| 492 | 607 | ); |
| 493 | 608 | for (index, (_, components, offset)) in ATTRIBUTES.iter().enumerate() { |
| ... | ... | @@ -502,14 +617,14 @@ impl Renderer { |
| 502 | 617 | ); |
| 503 | 618 | } |
| 504 | 619 | glEnable(BLEND); |
| 505 | for (group, picture) in self.groups.iter().zip(&gpu.groups) { | |
| 620 | for (group, picture) in frame.groups.iter().zip(&gpu.groups) { | |
| 506 | 621 | begin(picture, [0.0; 4]); |
| 507 | draw(&self.batches[group.batches.clone()]); | |
| 622 | draw(&frame.batches[group.batches.clone()]); | |
| 508 | 623 | } |
| 509 | 624 | begin(target, clear); |
| 510 | 625 | let mut next = 0; |
| 511 | for (group, picture) in self.groups.iter().zip(&gpu.groups) { | |
| 512 | draw(&self.batches[next..group.batches.start]); | |
| 626 | for (group, picture) in frame.groups.iter().zip(&gpu.groups) { | |
| 627 | draw(&frame.batches[next..group.batches.start]); | |
| 513 | 628 | glScissor(0, 0, width, height); |
| 514 | 629 | let [src_rgb, dst_rgb, src_alpha, dst_alpha] = PREMULTIPLIED; |
| 515 | 630 | glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); |
| ... | ... | @@ -521,7 +636,7 @@ impl Renderer { |
| 521 | 636 | ); |
| 522 | 637 | next = group.batches.end; |
| 523 | 638 | } |
| 524 | draw(&self.batches[next..]); | |
| 639 | draw(&frame.batches[next..]); | |
| 525 | 640 | glDisable(SCISSOR_TEST); |
| 526 | 641 | glDisable(BLEND); |
| 527 | 642 | glBindFramebufferEXT(FRAMEBUFFER, 0); |
| ... | ... | @@ -547,10 +662,7 @@ unsafe fn program(source: &str, attributes: &[&CStr]) -> Result<GLuint, String> |
| 547 | 662 | let mut linked = 0; |
| 548 | 663 | glGetProgramiv(program, LINK_STATUS, &mut linked); |
| 549 | 664 | if linked == 0 { |
| 550 | return Err(format!( | |
| 551 | "Linking a shader failed: {}", | |
| 552 | log(program, glGetProgramiv, glGetProgramInfoLog) | |
| 553 | )); | |
| 665 | return Err(format!("Linking a shader failed: {}", log(program, false))); | |
| 554 | 666 | } |
| 555 | 667 | Ok(program) |
| 556 | 668 | } |
| ... | ... | @@ -565,25 +677,28 @@ unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> { |
| 565 | 677 | let mut compiled = 0; |
| 566 | 678 | glGetShaderiv(shader, COMPILE_STATUS, &mut compiled); |
| 567 | 679 | if compiled == 0 { |
| 568 | return Err(format!( | |
| 569 | "Compiling a shader failed: {}", | |
| 570 | log(shader, glGetShaderiv, glGetShaderInfoLog) | |
| 571 | )); | |
| 680 | return Err(format!("Compiling a shader failed: {}", log(shader, true))); | |
| 572 | 681 | } |
| 573 | 682 | Ok(shader) |
| 574 | 683 | } |
| 575 | 684 | } |
| 576 | 685 | |
| 577 | unsafe fn log( | |
| 578 | object: GLuint, | |
| 579 | get: unsafe extern "C" fn(GLuint, GLenum, *mut GLint), | |
| 580 | read: unsafe extern "C" fn(GLuint, GLsizei, *mut GLsizei, *mut c_char), | |
| 581 | ) -> String { | |
| 686 | /// The info log of `object`, a shader or else a program. | |
| 687 | unsafe fn log(object: GLuint, shader: bool) -> String { | |
| 582 | 688 | unsafe { |
| 583 | 689 | let mut length = 0; |
| 584 | get(object, INFO_LOG_LENGTH, &mut length); | |
| 690 | if shader { | |
| 691 | glGetShaderiv(object, INFO_LOG_LENGTH, &mut length); | |
| 692 | } else { | |
| 693 | glGetProgramiv(object, INFO_LOG_LENGTH, &mut length); | |
| 694 | } | |
| 585 | 695 | let mut text = vec![0u8; length.max(1) as usize]; |
| 586 | read(object, length, ptr::null_mut(), text.as_mut_ptr().cast()); | |
| 696 | let log = text.as_mut_ptr().cast(); | |
| 697 | if shader { | |
| 698 | glGetShaderInfoLog(object, length, ptr::null_mut(), log); | |
| 699 | } else { | |
| 700 | glGetProgramInfoLog(object, length, ptr::null_mut(), log); | |
| 701 | } | |
| 587 | 702 | String::from_utf8_lossy(&text) |
| 588 | 703 | .trim_end_matches('\0') |
| 589 | 704 | .to_owned() |
crates/draw/src/render/webgpu.rs+81-45| ... | ... | @@ -2,14 +2,21 @@ |
| 2 | 2 | use super::*; |
| 3 | 3 | |
| 4 | 4 | /// What a frame is drawn into. |
| 5 | #[cfg(not(windows))] | |
| 5 | 6 | pub type Target = wgpu::TextureView; |
| 6 | 7 | |
| 7 | pub(super) type Image = wgpu::BindGroup; | |
| 8 | /// A picture's texture, as its batches bind it. | |
| 9 | pub(super) struct Image(wgpu::BindGroup); | |
| 8 | 10 | |
| 9 | /// Whether an offscreen picture's rows run bottom first. | |
| 10 | pub(super) const FLIPPED: bool = false; | |
| 11 | impl AsRef<Image> for Image { | |
| 12 | fn as_ref(&self) -> &Image { | |
| 13 | self | |
| 14 | } | |
| 15 | } | |
| 11 | 16 | |
| 12 | 17 | pub(super) struct Gpu { |
| 18 | device: wgpu::Device, | |
| 19 | queue: wgpu::Queue, | |
| 13 | 20 | pipeline: wgpu::RenderPipeline, |
| 14 | 21 | image_pipeline: wgpu::RenderPipeline, |
| 15 | 22 | erase_pipeline: wgpu::RenderPipeline, |
| ... | ... | @@ -24,8 +31,27 @@ pub(super) struct Gpu { |
| 24 | 31 | groups_size: [u32; 2], |
| 25 | 32 | } |
| 26 | 33 | |
| 34 | #[cfg(not(windows))] | |
| 27 | 35 | impl Renderer { |
| 28 | 36 | pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { |
| 37 | Self::with_gpu(Gpu::new(device, queue, format)) | |
| 38 | } | |
| 39 | ||
| 40 | pub fn device(&self) -> &wgpu::Device { | |
| 41 | &self.gpu.device | |
| 42 | } | |
| 43 | ||
| 44 | pub fn queue(&self) -> &wgpu::Queue { | |
| 45 | &self.gpu.queue | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | impl Gpu { | |
| 50 | pub(super) fn new( | |
| 51 | device: wgpu::Device, | |
| 52 | queue: wgpu::Queue, | |
| 53 | format: wgpu::TextureFormat, | |
| 54 | ) -> Self { | |
| 29 | 55 | let shader = device.create_shader_module(wgpu::include_wgsl!("../draw.wgsl")); |
| 30 | 56 | let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { |
| 31 | 57 | label: Some("Draw texture"), |
| ... | ... | @@ -126,36 +152,37 @@ impl Renderer { |
| 126 | 152 | min_filter: wgpu::FilterMode::Linear, |
| 127 | 153 | ..Default::default() |
| 128 | 154 | }); |
| 129 | Self::with_gpu( | |
| 155 | Self { | |
| 130 | 156 | device, |
| 131 | 157 | queue, |
| 132 | Gpu { | |
| 133 | pipeline, | |
| 134 | image_pipeline, | |
| 135 | erase_pipeline, | |
| 136 | multiply_pipeline, | |
| 137 | atlas, | |
| 138 | bind_group, | |
| 139 | image_sampler, | |
| 140 | vertex_buffer, | |
| 141 | format, | |
| 142 | groups: Vec::new(), | |
| 143 | groups_size: [0; 2], | |
| 144 | }, | |
| 145 | ) | |
| 158 | pipeline, | |
| 159 | image_pipeline, | |
| 160 | erase_pipeline, | |
| 161 | multiply_pipeline, | |
| 162 | atlas, | |
| 163 | bind_group, | |
| 164 | image_sampler, | |
| 165 | vertex_buffer, | |
| 166 | format, | |
| 167 | groups: Vec::new(), | |
| 168 | groups_size: [0; 2], | |
| 169 | } | |
| 170 | } | |
| 171 | ||
| 172 | pub(super) fn flipped(&self) -> bool { | |
| 173 | false | |
| 146 | 174 | } |
| 147 | 175 | |
| 148 | /// The widest and tallest texture the device takes, in pixels. | |
| 149 | pub fn max_texture_dimension(&self) -> u32 { | |
| 176 | pub(super) fn max_texture_dimension(&self) -> u32 { | |
| 150 | 177 | self.device.limits().max_texture_dimension_2d |
| 151 | 178 | } |
| 152 | 179 | |
| 153 | 180 | pub(super) fn atlas_side(&self) -> u32 { |
| 154 | self.gpu.atlas.width() | |
| 181 | self.atlas.width() | |
| 155 | 182 | } |
| 156 | 183 | |
| 157 | 184 | pub(super) fn new_atlas(&mut self, side: u32) { |
| 158 | (self.gpu.atlas, self.gpu.bind_group) = atlas(&self.device, &self.gpu.pipeline, side); | |
| 185 | (self.atlas, self.bind_group) = atlas(&self.device, &self.pipeline, side); | |
| 159 | 186 | } |
| 160 | 187 | |
| 161 | 188 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { |
| ... | ... | @@ -166,7 +193,7 @@ impl Renderer { |
| 166 | 193 | y: origin[1], |
| 167 | 194 | z: 0, |
| 168 | 195 | }, |
| 169 | ..self.gpu.atlas.as_image_copy() | |
| 196 | ..self.atlas.as_image_copy() | |
| 170 | 197 | }, |
| 171 | 198 | rgba, |
| 172 | 199 | wgpu::TexelCopyBufferLayout { |
| ... | ... | @@ -208,9 +235,9 @@ impl Renderer { |
| 208 | 235 | }, |
| 209 | 236 | size, |
| 210 | 237 | ); |
| 211 | self.device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 238 | Image(self.device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 212 | 239 | label: Some("Draw image"), |
| 213 | layout: &self.gpu.pipeline.get_bind_group_layout(0), | |
| 240 | layout: &self.pipeline.get_bind_group_layout(0), | |
| 214 | 241 | entries: &[ |
| 215 | 242 | wgpu::BindGroupEntry { |
| 216 | 243 | binding: 0, |
| ... | ... | @@ -220,21 +247,21 @@ impl Renderer { |
| 220 | 247 | }, |
| 221 | 248 | wgpu::BindGroupEntry { |
| 222 | 249 | binding: 1, |
| 223 | resource: wgpu::BindingResource::Sampler(&self.gpu.image_sampler), | |
| 250 | resource: wgpu::BindingResource::Sampler(&self.image_sampler), | |
| 224 | 251 | }, |
| 225 | 252 | ], |
| 226 | }) | |
| 253 | })) | |
| 227 | 254 | } |
| 228 | 255 | |
| 229 | 256 | /// An offscreen picture `size` large for each group, kept for later frames. |
| 230 | fn group_pictures(&mut self, size: [u32; 2]) { | |
| 231 | let gpu = &mut self.gpu; | |
| 257 | fn group_pictures(&mut self, size: [u32; 2], count: usize) { | |
| 258 | let gpu = self; | |
| 232 | 259 | if gpu.groups_size != size { |
| 233 | 260 | gpu.groups.clear(); |
| 234 | 261 | gpu.groups_size = size; |
| 235 | 262 | } |
| 236 | while gpu.groups.len() < self.groups.len() { | |
| 237 | let texture = self.device.create_texture(&wgpu::TextureDescriptor { | |
| 263 | while gpu.groups.len() < count { | |
| 264 | let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { | |
| 238 | 265 | label: Some("Draw group"), |
| 239 | 266 | size: wgpu::Extent3d { |
| 240 | 267 | width: size[0], |
| ... | ... | @@ -250,7 +277,7 @@ impl Renderer { |
| 250 | 277 | view_formats: &[], |
| 251 | 278 | }); |
| 252 | 279 | let view = texture.create_view(&Default::default()); |
| 253 | let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 280 | let binding = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 254 | 281 | label: Some("Draw group"), |
| 255 | 282 | layout: &gpu.pipeline.get_bind_group_layout(0), |
| 256 | 283 | entries: &[ |
| ... | ... | @@ -270,19 +297,28 @@ impl Renderer { |
| 270 | 297 | |
| 271 | 298 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared |
| 272 | 299 | /// batches, each group's offscreen first. |
| 273 | pub(super) fn submit(&mut self, target: &Target, size: [u32; 2], clear: [f32; 4]) { | |
| 274 | self.group_pictures(size); | |
| 275 | let gpu = &self.gpu; | |
| 276 | self.queue | |
| 277 | .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); | |
| 278 | let mut encoder = self.device.create_command_encoder(&Default::default()); | |
| 300 | pub(super) fn submit( | |
| 301 | &mut self, | |
| 302 | frame: &Frame<'_>, | |
| 303 | target: &wgpu::TextureView, | |
| 304 | size: [u32; 2], | |
| 305 | clear: [f32; 4], | |
| 306 | ) { | |
| 307 | self.group_pictures(size, frame.groups.len()); | |
| 308 | let gpu = &*self; | |
| 309 | gpu.queue | |
| 310 | .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(frame.vertices)); | |
| 311 | let mut encoder = gpu.device.create_command_encoder(&Default::default()); | |
| 279 | 312 | let draw = |pass: &mut wgpu::RenderPass, batches: &[Batch]| { |
| 280 | 313 | for batch in batches { |
| 281 | 314 | let [x, y, width, height] = batch.scissor; |
| 282 | 315 | pass.set_scissor_rect(x, y, width, height); |
| 283 | 316 | let (pipeline, binding) = match batch.blend { |
| 284 | 317 | Blend::Over => (&gpu.pipeline, &gpu.bind_group), |
| 285 | Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture), | |
| 318 | Blend::Image(id) => { | |
| 319 | let image: &Image = frame.images[&id].texture.as_ref(); | |
| 320 | (&gpu.image_pipeline, &image.0) | |
| 321 | } | |
| 286 | 322 | Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group), |
| 287 | 323 | Blend::Multiply => (&gpu.multiply_pipeline, &gpu.bind_group), |
| 288 | 324 | }; |
| ... | ... | @@ -309,23 +345,23 @@ impl Renderer { |
| 309 | 345 | pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..)); |
| 310 | 346 | pass.forget_lifetime() |
| 311 | 347 | }; |
| 312 | for (group, (view, _)) in self.groups.iter().zip(&gpu.groups) { | |
| 348 | for (group, (view, _)) in frame.groups.iter().zip(&gpu.groups) { | |
| 313 | 349 | let mut pass = begin(&mut encoder, view, [0.0; 4]); |
| 314 | draw(&mut pass, &self.batches[group.batches.clone()]); | |
| 350 | draw(&mut pass, &frame.batches[group.batches.clone()]); | |
| 315 | 351 | } |
| 316 | 352 | let mut pass = begin(&mut encoder, target, clear); |
| 317 | 353 | let mut next = 0; |
| 318 | for (group, (_, binding)) in self.groups.iter().zip(&gpu.groups) { | |
| 319 | draw(&mut pass, &self.batches[next..group.batches.start]); | |
| 354 | for (group, (_, binding)) in frame.groups.iter().zip(&gpu.groups) { | |
| 355 | draw(&mut pass, &frame.batches[next..group.batches.start]); | |
| 320 | 356 | pass.set_scissor_rect(0, 0, size[0], size[1]); |
| 321 | 357 | pass.set_pipeline(&gpu.image_pipeline); |
| 322 | 358 | pass.set_bind_group(0, binding, &[]); |
| 323 | 359 | pass.draw(group.composite.clone(), 0..1); |
| 324 | 360 | next = group.batches.end; |
| 325 | 361 | } |
| 326 | draw(&mut pass, &self.batches[next..]); | |
| 362 | draw(&mut pass, &frame.batches[next..]); | |
| 327 | 363 | drop(pass); |
| 328 | self.queue.submit([encoder.finish()]); | |
| 364 | gpu.queue.submit([encoder.finish()]); | |
| 329 | 365 | } |
| 330 | 366 | } |
| 331 | 367 |
crates/mobile/src/lib.rs+4-4| ... | ... | @@ -683,7 +683,7 @@ impl View { |
| 683 | 683 | let config = surface |
| 684 | 684 | .get_default_config(&gpu.adapter, pixels[0], pixels[1]) |
| 685 | 685 | .ok_or("No supported surface configuration")?; |
| 686 | surface.configure(&gpu.renderer.device, &config); | |
| 686 | surface.configure(gpu.renderer.device(), &config); | |
| 687 | 687 | let (page, read_only) = section.shared.page(space)?; |
| 688 | 688 | let frame = Arc::<Frame>::default(); |
| 689 | 689 | #[cfg_attr(not(target_os = "ios"), expect(unused_mut))] |
| ... | ... | @@ -712,7 +712,7 @@ impl View { |
| 712 | 712 | wgpu::CurrentSurfaceTexture::Success(frame) |
| 713 | 713 | | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, |
| 714 | 714 | wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { |
| 715 | self.surface.configure(&renderer.device, &self.config); | |
| 715 | self.surface.configure(renderer.device(), &self.config); | |
| 716 | 716 | return Ok(()); |
| 717 | 717 | } |
| 718 | 718 | _ => return Ok(()), |
| ... | ... | @@ -731,7 +731,7 @@ impl View { |
| 731 | 731 | &[canvas.page.viewport.layer(&primitives)], |
| 732 | 732 | ) |
| 733 | 733 | .map_err(|error| format!("Page drawing failed: {error:?}"))?; |
| 734 | renderer.queue.present(frame); | |
| 734 | renderer.queue().present(frame); | |
| 735 | 735 | Ok(()) |
| 736 | 736 | } |
| 737 | 737 | |
| ... | ... | @@ -842,7 +842,7 @@ pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale |
| 842 | 842 | view.config.width = pixels[0]; |
| 843 | 843 | view.config.height = pixels[1]; |
| 844 | 844 | if let Some(gpu) = &*gpu() { |
| 845 | view.surface.configure(&gpu.renderer.device, &view.config); | |
| 845 | view.surface.configure(gpu.renderer.device(), &view.config); | |
| 846 | 846 | } |
| 847 | 847 | } |
| 848 | 848 |
crates/notebook/src/lib.rs+12-2| ... | ... | @@ -457,10 +457,20 @@ fn write_ahead(connection: &Connection) -> Result<()> { |
| 457 | 457 | Ok(()) |
| 458 | 458 | } |
| 459 | 459 | |
| 460 | /// FILETIME now, as an edit's `at`. | |
| 460 | /// FILETIME now, as an edit's `at`: later than any this process took before, as Windows 7's | |
| 461 | /// clock ticks only every 15.6 ms and a later edit must still order after an earlier one. | |
| 461 | 462 | pub(crate) fn now() -> u64 { |
| 463 | use std::sync::atomic::{AtomicU64, Ordering}; | |
| 464 | static LAST: AtomicU64 = AtomicU64::new(0); | |
| 462 | 465 | let unix = std::time::SystemTime::now() |
| 463 | 466 | .duration_since(std::time::UNIX_EPOCH) |
| 464 | 467 | .unwrap_or_default(); |
| 465 | (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) | |
| 468 | let clock = | |
| 469 | (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100); | |
| 470 | let previous = LAST | |
| 471 | .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |last| { | |
| 472 | Some(clock.max(last + 1)) | |
| 473 | }) | |
| 474 | .unwrap_or_default(); | |
| 475 | clock.max(previous + 1) | |
| 466 | 476 | } |
crates/notebook/src/recovery.rs+4-2| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | use super::*; |
| 2 | 2 | use rusqlite::backup::{Backup, StepResult}; |
| 3 | use std::{collections::BTreeMap, fs::File}; | |
| 3 | use std::collections::BTreeMap; | |
| 4 | 4 | |
| 5 | 5 | const RECOVERY_ID: u32 = 0x4f4e4552; |
| 6 | 6 | |
| ... | ... | @@ -153,7 +153,9 @@ pub(crate) fn export(source: &Connection, path: &Path, replace: bool) -> Result< |
| 153 | 153 | .persist_noclobber(path) |
| 154 | 154 | .map_err(|error| error.error)?; |
| 155 | 155 | } |
| 156 | File::open(parent)?.sync_all()?; | |
| 156 | // Windows opens no folder as a file; NTFS journals the rename. | |
| 157 | #[cfg(unix)] | |
| 158 | std::fs::File::open(parent)?.sync_all()?; | |
| 157 | 159 | Ok(()) |
| 158 | 160 | } |
| 159 | 161 |
crates/onestore/Cargo.toml+4| ... | ... | @@ -24,4 +24,8 @@ zeroize = { version = "1.9.0", optional = true } |
| 24 | 24 | [target.'cfg(target_os = "macos")'.dependencies] |
| 25 | 25 | nix = { version = "0.31.3", default-features = false, features = ["fs"] } |
| 26 | 26 | |
| 27 | # OneNote's byte-range locks on a section, which std's whole-file locks would overlap. | |
| 28 | [target.'cfg(windows)'.dependencies] | |
| 29 | file-guard = "0.2.0" | |
| 30 | ||
| 27 | 31 | [dev-dependencies] |
crates/onestore/src/commit.rs+111-2| ... | ... | @@ -14,7 +14,13 @@ use std::os::windows::fs::FileExt; |
| 14 | 14 | |
| 15 | 15 | #[cfg(any(unix, windows))] |
| 16 | 16 | struct FileIo { |
| 17 | #[cfg(unix)] | |
| 17 | 18 | file: File, |
| 19 | #[cfg(windows)] | |
| 20 | file: std::sync::Arc<File>, | |
| 21 | /// OneNote's coordination bytes, unlocked when dropped. | |
| 22 | #[cfg(windows)] | |
| 23 | locks: Vec<file_guard::FileGuard<std::sync::Arc<File>>>, | |
| 18 | 24 | _process: MutexGuard<'static, ()>, |
| 19 | 25 | unlock_on_drop: bool, |
| 20 | 26 | } |
| ... | ... | @@ -43,22 +49,48 @@ impl FileIo { |
| 43 | 49 | }; |
| 44 | 50 | options.custom_flags(lock | nix::libc::O_NONBLOCK); |
| 45 | 51 | } |
| 52 | #[cfg(windows)] | |
| 53 | { | |
| 54 | use std::os::windows::fs::OpenOptionsExt; | |
| 55 | // As OneNote opens a section: a reader shares it with everyone, a writer denies | |
| 56 | // other writers (FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_SHARE_DELETE). | |
| 57 | options.share_mode(if write { 0x1 | 0x4 } else { 0x1 | 0x2 | 0x4 }); | |
| 58 | } | |
| 59 | #[cfg(windows)] | |
| 60 | let file = std::sync::Arc::new(options.open(path).map_err(|error| { | |
| 61 | // ERROR_SHARING_VIOLATION: another writer has it open. | |
| 62 | match error.raw_os_error() { | |
| 63 | Some(32) => ErrorKind::WouldBlock.into(), | |
| 64 | _ => error, | |
| 65 | } | |
| 66 | })?); | |
| 67 | #[cfg(unix)] | |
| 46 | 68 | let file = options.open(path)?; |
| 47 | #[cfg(not(target_os = "macos"))] | |
| 69 | #[cfg(all(unix, not(target_os = "macos")))] | |
| 48 | 70 | file.try_lock()?; |
| 49 | 71 | Ok(Self { |
| 72 | #[cfg(windows)] | |
| 73 | locks: onenote_locks(&file, write)?, | |
| 50 | 74 | file, |
| 51 | 75 | _process: process, |
| 52 | 76 | unlock_on_drop: true, |
| 53 | 77 | }) |
| 54 | 78 | } |
| 55 | 79 | |
| 80 | #[cfg(unix)] | |
| 56 | 81 | fn release(&mut self) -> io::Result<()> { |
| 57 | 82 | // A failed unlock may have reached the server; Drop must not repeat it. |
| 58 | 83 | self.unlock_on_drop = false; |
| 59 | 84 | self.file.unlock() |
| 60 | 85 | } |
| 61 | 86 | |
| 87 | #[cfg(windows)] | |
| 88 | fn release(&mut self) -> io::Result<()> { | |
| 89 | self.unlock_on_drop = false; | |
| 90 | self.locks.clear(); | |
| 91 | Ok(()) | |
| 92 | } | |
| 93 | ||
| 62 | 94 | fn finish(mut self, result: Result<(), CommitError>) -> Result<(), CommitError> { |
| 63 | 95 | let released = self.release(); |
| 64 | 96 | result?; |
| ... | ... | @@ -69,7 +101,7 @@ impl FileIo { |
| 69 | 101 | } |
| 70 | 102 | } |
| 71 | 103 | |
| 72 | #[cfg(any(unix, windows))] | |
| 104 | #[cfg(unix)] | |
| 73 | 105 | impl Drop for FileIo { |
| 74 | 106 | fn drop(&mut self) { |
| 75 | 107 | if self.unlock_on_drop { |
| ... | ... | @@ -78,6 +110,28 @@ impl Drop for FileIo { |
| 78 | 110 | } |
| 79 | 111 | } |
| 80 | 112 | |
| 113 | /// Windows takes OneNote 2010's own locks on the file, one byte each past any data, so | |
| 114 | /// neither app's locks bar the other's reads: the reader byte shared, and to write, the | |
| 115 | /// writer byte exclusively, as `notebook::smb` takes them on a share. | |
| 116 | #[cfg(windows)] | |
| 117 | fn onenote_locks( | |
| 118 | file: &std::sync::Arc<File>, | |
| 119 | write: bool, | |
| 120 | ) -> io::Result<Vec<file_guard::FileGuard<std::sync::Arc<File>>>> { | |
| 121 | use file_guard::Lock; | |
| 122 | let reader = file_guard::try_lock(file.clone(), Lock::Shared, 0xffff_fffb, 1)?; | |
| 123 | let mut locks = vec![reader]; | |
| 124 | if write { | |
| 125 | locks.push(file_guard::try_lock( | |
| 126 | file.clone(), | |
| 127 | Lock::Exclusive, | |
| 128 | 0xffff_fffd, | |
| 129 | 1, | |
| 130 | )?); | |
| 131 | } | |
| 132 | Ok(locks) | |
| 133 | } | |
| 134 | ||
| 81 | 135 | /// Places a file in its notebook the way OneNote does on adoption: the header's |
| 82 | 136 | /// `guidAncestor` becomes the parent table of contents' file identity and `crcName` the CRC |
| 83 | 137 | /// of `name` (a section's file name, a group's folder name). OneNote re-identifies a file |
| ... | ... | @@ -528,4 +582,59 @@ mod tests { |
| 528 | 582 | b"unfinished" |
| 529 | 583 | ); |
| 530 | 584 | } |
| 585 | ||
| 586 | /// OneNote's reads go on through Snowbound's reads and writes, and its writers wait for | |
| 587 | /// Snowbound's, as its opens and coordination bytes meet Snowbound's. | |
| 588 | #[cfg(windows)] | |
| 589 | #[test] | |
| 590 | fn windows_takes_onenote_s_opens_and_bytes() { | |
| 591 | use file_guard::Lock; | |
| 592 | use std::io::Read; | |
| 593 | use std::os::windows::fs::OpenOptionsExt; | |
| 594 | let folder = std::env::temp_dir().join(format!("onestore-locks-{}", std::process::id())); | |
| 595 | std::fs::create_dir_all(&folder).unwrap(); | |
| 596 | let path = folder.join("Locks.one"); | |
| 597 | std::fs::write(&path, b"section").unwrap(); | |
| 598 | let onenote = |write: bool| { | |
| 599 | File::options() | |
| 600 | .read(true) | |
| 601 | .write(write) | |
| 602 | .share_mode(if write { 0x5 } else { 0x7 }) | |
| 603 | .open(&path) | |
| 604 | .map(std::sync::Arc::new) | |
| 605 | }; | |
| 606 | let busy = |result: io::Result<file_guard::FileGuard<std::sync::Arc<File>>>| { | |
| 607 | result.is_err_and(|error| error.kind() == ErrorKind::WouldBlock) | |
| 608 | }; | |
| 609 | ||
| 610 | let reading = FileIo::open(&path, false).unwrap(); | |
| 611 | let writer = onenote(true).unwrap(); | |
| 612 | let reader_byte = file_guard::try_lock(writer.clone(), Lock::Shared, 0xffff_fffb, 1); | |
| 613 | assert!(reader_byte.is_ok(), "OneNote writes beside a reader"); | |
| 614 | drop((reader_byte, writer)); | |
| 615 | assert!(!busy(file_guard::try_lock( | |
| 616 | onenote(false).unwrap(), | |
| 617 | Lock::Exclusive, | |
| 618 | 0xffff_fffd, | |
| 619 | 1 | |
| 620 | ))); | |
| 621 | drop(reading); | |
| 622 | ||
| 623 | let writing = FileIo::open(&path, true).unwrap(); | |
| 624 | let mut text = String::new(); | |
| 625 | (&*onenote(false).unwrap()) | |
| 626 | .read_to_string(&mut text) | |
| 627 | .unwrap(); | |
| 628 | assert_eq!(text, "section", "OneNote reads through a commit"); | |
| 629 | assert!(onenote(true).is_err(), "a second writer can't open it"); | |
| 630 | let other = onenote(false).unwrap(); | |
| 631 | assert!(busy(file_guard::try_lock( | |
| 632 | other, | |
| 633 | Lock::Exclusive, | |
| 634 | 0xffff_fffd, | |
| 635 | 1 | |
| 636 | ))); | |
| 637 | drop(writing); | |
| 638 | std::fs::remove_dir_all(&folder).unwrap(); | |
| 639 | } | |
| 531 | 640 | } |
crates/snowbound/Cargo.toml+24| ... | ... | @@ -39,6 +39,30 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl |
| 39 | 39 | 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"] } |
| 40 | 40 | block2 = "0.5.1" |
| 41 | 41 | |
| 42 | [target.'cfg(windows)'.dependencies] | |
| 43 | windows-sys = { version = "0.61", features = [ | |
| 44 | "Win32_Foundation", | |
| 45 | "Win32_Globalization", | |
| 46 | "Win32_Graphics_Dwm", | |
| 47 | "Win32_Graphics_Gdi", | |
| 48 | "Win32_Graphics_OpenGL", | |
| 49 | "Win32_Security_Authentication_Identity", | |
| 50 | "Win32_Security_Credentials", | |
| 51 | "Win32_Storage_FileSystem", | |
| 52 | "Win32_System_Com", | |
| 53 | "Win32_System_IO", | |
| 54 | "Win32_System_LibraryLoader", | |
| 55 | "Win32_System_Registry", | |
| 56 | "Win32_System_Threading", | |
| 57 | "Win32_System_Time", | |
| 58 | "Win32_System_WindowsProgramming", | |
| 59 | "Win32_UI_Controls", | |
| 60 | "Win32_UI_Controls_Dialogs", | |
| 61 | "Win32_UI_Input_KeyboardAndMouse", | |
| 62 | "Win32_UI_Shell", | |
| 63 | "Win32_UI_WindowsAndMessaging", | |
| 64 | ] } | |
| 65 | ||
| 42 | 66 | [target.'cfg(target_os = "linux")'.dependencies] |
| 43 | 67 | accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] } |
| 44 | 68 | smithay-clipboard = "0.7.3" |
crates/snowbound/assets/windows/caption-close.svg created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <path d="M3 3L13 13M13 3L3 13" fill="none" stroke="currentColor" stroke-width="1"/> | |
| 3 | </svg> |
crates/snowbound/assets/windows/caption-maximize.svg created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <path d="M3.5 3.5H12.5V12.5H3.5Z" fill="none" stroke="currentColor" stroke-width="1"/> | |
| 3 | </svg> |
crates/snowbound/assets/windows/caption-minimize.svg created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <path d="M3 8.5H13" fill="none" stroke="currentColor" stroke-width="1"/> | |
| 3 | </svg> |
crates/snowbound/assets/windows/caption-restore.svg created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <path d="M3.5 5.5H10.5V12.5H3.5Z" fill="none" stroke="currentColor" stroke-width="1"/> | |
| 3 | <path d="M5.5 5.5V3.5H12.5V10.5H10.5" fill="none" stroke="currentColor" stroke-width="1"/> | |
| 4 | </svg> |
crates/snowbound/build.rs created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | //! On Windows, links in the executable's icon and manifest, compiled by llvm-mingw's windres, | |
| 2 | //! which `platform/windows/cargo.sh` names. | |
| 3 | ||
| 4 | fn main() { | |
| 5 | println!("cargo:rerun-if-changed=build.rs"); | |
| 6 | if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { | |
| 7 | return; | |
| 8 | } | |
| 9 | let folder = std::path::Path::new("windows"); | |
| 10 | for input in ["snowbound.rc", "snowbound.ico", "snowbound.manifest"] { | |
| 11 | println!("cargo:rerun-if-changed={}", folder.join(input).display()); | |
| 12 | } | |
| 13 | println!("cargo:rerun-if-env-changed=LLVM_MINGW"); | |
| 14 | let tools = std::env::var("LLVM_MINGW") | |
| 15 | .expect("Windows builds need llvm-mingw: build through platform/windows/cargo.sh"); | |
| 16 | let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap(); | |
| 17 | let output = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("resources.o"); | |
| 18 | let status = std::process::Command::new(format!("{tools}/bin/{arch}-w64-mingw32-windres")) | |
| 19 | .current_dir(folder) | |
| 20 | .arg("snowbound.rc") | |
| 21 | .arg(&output) | |
| 22 | .status() | |
| 23 | .expect("windres runs"); | |
| 24 | assert!(status.success(), "windres compiled the resources"); | |
| 25 | println!("cargo:rustc-link-arg-bins={}", output.display()); | |
| 26 | } |
crates/snowbound/src/icloud_linux.rs+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | //! iCloud Drive is Apple's; on Linux no folder is in it. | |
| 1 | //! iCloud Drive is Apple's; on Linux and Windows no folder is in it. | |
| 2 | 2 | |
| 3 | 3 | use notebook::session::{Background, Notebook, Section}; |
| 4 | 4 | use std::path::{Path, PathBuf}; |
crates/snowbound/src/library.rs+30| ... | ... | @@ -57,10 +57,24 @@ pub struct Mount { |
| 57 | 57 | pub root: String, |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | /// Where `path` sits below the mount point `point`, matched on its resolved path since | |
| 61 | /// the system reports mount points with symlinks (`/tmp`, `/var`) resolved. | |
| 62 | #[cfg(unix)] | |
| 63 | pub fn within_mount(path: &std::path::Path, point: &str) -> Option<String> { | |
| 64 | let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned()); | |
| 65 | Some( | |
| 66 | path.strip_prefix(point) | |
| 67 | .ok()? | |
| 68 | .to_string_lossy() | |
| 69 | .into_owned(), | |
| 70 | ) | |
| 71 | } | |
| 72 | ||
| 60 | 73 | impl Mount { |
| 61 | 74 | /// The mount of `source` (`//[domain;][user[:…]@]server/share[/folder]`), holding the |
| 62 | 75 | /// folder `within` its mount point; `options` are the mount's (`username=`, `user=`, |
| 63 | 76 | /// `domain=`), where the source names no account. |
| 77 | #[cfg_attr(windows, allow(dead_code))] | |
| 64 | 78 | pub fn parse(source: &str, within: &str, options: &str) -> Option<Self> { |
| 65 | 79 | let source = source.strip_prefix("//")?; |
| 66 | 80 | let (account, rest) = match source.rsplit_once('@') { |
| ... | ... | @@ -876,6 +890,22 @@ pub fn locate(path: &Path) -> Located { |
| 876 | 890 | mod tests { |
| 877 | 891 | use super::*; |
| 878 | 892 | |
| 893 | #[cfg(unix)] | |
| 894 | #[test] | |
| 895 | fn within_mount_resolves_symlinks() { | |
| 896 | let base = std::env::temp_dir().join(format!("snowbound-within-{}", std::process::id())); | |
| 897 | let point = base.join("point"); | |
| 898 | std::fs::create_dir_all(point.join("notes")).unwrap(); | |
| 899 | std::os::unix::fs::symlink(&point, base.join("link")).unwrap(); | |
| 900 | let point = std::fs::canonicalize(&point).unwrap(); | |
| 901 | let point = point.to_str().unwrap(); | |
| 902 | let found = within_mount(&base.join("link/notes"), point); | |
| 903 | let missing = within_mount(&base.join("link/gone"), point); | |
| 904 | std::fs::remove_dir_all(&base).unwrap(); | |
| 905 | assert_eq!(found.as_deref(), Some("notes")); | |
| 906 | assert_eq!(missing, None); | |
| 907 | } | |
| 908 | ||
| 879 | 909 | /// Removes the folder at `path` on the share and everything in it. |
| 880 | 910 | fn remove_tree(client: &Client, path: &str) { |
| 881 | 911 | for entry in client.read_dir(path, 10_000).unwrap_or_default() { |
crates/snowbound/src/linux.rs+12-9| ... | ... | @@ -762,7 +762,7 @@ pub fn cache_dir() -> Option<PathBuf> { |
| 762 | 762 | pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { |
| 763 | 763 | let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?; |
| 764 | 764 | // The mount holding the path is the one at its longest ancestor. |
| 765 | let (source, point, options) = mounts | |
| 765 | let (source, point, options, within) = mounts | |
| 766 | 766 | .lines() |
| 767 | 767 | .filter_map(|line| { |
| 768 | 768 | let mut fields = line.split_whitespace(); |
| ... | ... | @@ -772,16 +772,19 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { |
| 772 | 772 | fields.next()?, |
| 773 | 773 | fields.next()?, |
| 774 | 774 | ); |
| 775 | if !matches!(kind, "cifs" | "smb3") { | |
| 776 | return None; | |
| 777 | } | |
| 775 | 778 | let point = point.replace("\\040", " "); |
| 776 | (matches!(kind, "cifs" | "smb3") && path.starts_with(&point)) | |
| 777 | .then(|| (source.replace("\\040", " "), point, options.to_owned())) | |
| 779 | let within = crate::library::within_mount(path, &point)?; | |
| 780 | Some(( | |
| 781 | source.replace("\\040", " "), | |
| 782 | point, | |
| 783 | options.to_owned(), | |
| 784 | within, | |
| 785 | )) | |
| 778 | 786 | }) |
| 779 | .max_by_key(|(_, point, _)| point.len())?; | |
| 780 | let within = path | |
| 781 | .strip_prefix(&point) | |
| 782 | .ok()? | |
| 783 | .to_string_lossy() | |
| 784 | .into_owned(); | |
| 787 | .max_by_key(|(_, point, _, _)| point.len())?; | |
| 785 | 788 | crate::library::Mount::parse(&source, &within, &options) |
| 786 | 789 | } |
| 787 | 790 |
crates/snowbound/src/macos.rs+2-2| ... | ... | @@ -340,8 +340,8 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { |
| 340 | 340 | if text(&mount.f_fstypename) != "smbfs" { |
| 341 | 341 | return None; |
| 342 | 342 | } |
| 343 | let within = path.strip_prefix(text(&mount.f_mntonname)).ok()?; | |
| 344 | crate::library::Mount::parse(&text(&mount.f_mntfromname), &within.to_string_lossy(), "") | |
| 343 | let within = crate::library::within_mount(path, &text(&mount.f_mntonname))?; | |
| 344 | crate::library::Mount::parse(&text(&mount.f_mntfromname), &within, "") | |
| 345 | 345 | } |
| 346 | 346 | |
| 347 | 347 | #[allow(non_camel_case_types)] |
crates/snowbound/src/main.rs+8-2| ... | ... | @@ -1,16 +1,17 @@ |
| 1 | #![cfg_attr(windows, windows_subsystem = "windows")] | |
| 1 | 2 | #[cfg(target_os = "macos")] |
| 2 | 3 | mod aqua; |
| 3 | 4 | mod art; |
| 4 | 5 | mod attachment; |
| 5 | 6 | mod background; |
| 6 | 7 | mod commands; |
| 7 | #[cfg(all(test, feature = "wgpu"))] | |
| 8 | #[cfg(all(test, feature = "wgpu", not(windows)))] | |
| 8 | 9 | mod conflict_render; |
| 9 | 10 | #[cfg(target_os = "linux")] |
| 10 | 11 | #[path = "desktop_linux.rs"] |
| 11 | 12 | mod desktop; |
| 12 | 13 | mod history; |
| 13 | #[cfg_attr(target_os = "linux", path = "icloud_linux.rs")] | |
| 14 | #[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")] | |
| 14 | 15 | #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] |
| 15 | 16 | mod icloud; |
| 16 | 17 | mod library; |
| ... | ... | @@ -18,6 +19,7 @@ mod link; |
| 18 | 19 | mod manage; |
| 19 | 20 | #[cfg_attr(target_os = "linux", path = "media_linux.rs")] |
| 20 | 21 | #[cfg_attr(target_os = "macos", path = "media_macos.rs")] |
| 22 | #[cfg_attr(windows, path = "media_windows.rs")] | |
| 21 | 23 | mod media; |
| 22 | 24 | mod meeting; |
| 23 | 25 | #[cfg(target_os = "macos")] |
| ... | ... | @@ -29,6 +31,7 @@ mod palette; |
| 29 | 31 | mod pane; |
| 30 | 32 | #[cfg_attr(target_os = "linux", path = "linux.rs")] |
| 31 | 33 | #[cfg_attr(target_os = "macos", path = "macos.rs")] |
| 34 | #[cfg_attr(windows, path = "windows.rs")] | |
| 32 | 35 | mod platform; |
| 33 | 36 | mod recording; |
| 34 | 37 | mod rename; |
| ... | ... | @@ -39,8 +42,10 @@ mod settings; |
| 39 | 42 | mod sidebar; |
| 40 | 43 | #[cfg_attr(target_os = "linux", path = "spell_linux.rs")] |
| 41 | 44 | #[cfg_attr(target_os = "macos", path = "spell_macos.rs")] |
| 45 | #[cfg_attr(windows, path = "spell_windows.rs")] | |
| 42 | 46 | mod spell; |
| 43 | 47 | #[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")] |
| 48 | #[cfg_attr(windows, path = "surface_windows.rs")] | |
| 44 | 49 | mod surface; |
| 45 | 50 | mod sync; |
| 46 | 51 | mod tags; |
| ... | ... | @@ -154,6 +159,7 @@ const ROW_GAP: f32 = 3.0; |
| 154 | 159 | /// Space between the page and a page tab that isn't open. |
| 155 | 160 | const PILL_MARGIN: f32 = 3.0; |
| 156 | 161 | /// Why the platform's date dialog could not change the page's date. |
| 162 | #[cfg_attr(windows, allow(dead_code))] | |
| 157 | 163 | const DATE_UNCHOSEN: &str = "Choose another date or time."; |
| 158 | 164 | const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range."; |
| 159 | 165 |
crates/snowbound/src/media_windows.rs created+161| ... | ... | @@ -0,0 +1,161 @@ |
| 1 | //! Audio through the Media Control Interface, which every Windows since 3.1 has: DirectShow's | |
| 2 | //! `mpegvideo` device plays whatever codecs are installed (WAV, Windows Media, MP3), and | |
| 3 | //! `waveaudio` records the default microphone as PCM WAV. Video isn't recorded here. | |
| 4 | ||
| 5 | use std::{ | |
| 6 | path::Path, | |
| 7 | sync::atomic::{AtomicU32, Ordering}, | |
| 8 | }; | |
| 9 | ||
| 10 | #[link(name = "winmm")] | |
| 11 | unsafe extern "system" { | |
| 12 | fn mciSendStringW(command: *const u16, answer: *mut u16, length: u32, window: isize) -> u32; | |
| 13 | } | |
| 14 | ||
| 15 | /// Sends `command`, answering its return string; none where MCI refuses it. | |
| 16 | fn send(command: &str) -> Option<String> { | |
| 17 | let command: Vec<u16> = command.encode_utf16().chain([0]).collect(); | |
| 18 | let mut answer = [0u16; 256]; | |
| 19 | let error = unsafe { | |
| 20 | mciSendStringW( | |
| 21 | command.as_ptr(), | |
| 22 | answer.as_mut_ptr(), | |
| 23 | answer.len() as u32, | |
| 24 | 0, | |
| 25 | ) | |
| 26 | }; | |
| 27 | let end = answer.iter().position(|&unit| unit == 0).unwrap_or(0); | |
| 28 | (error == 0).then(|| String::from_utf16_lossy(&answer[..end])) | |
| 29 | } | |
| 30 | ||
| 31 | /// A device alias no other device of this process has. | |
| 32 | fn alias(kind: &str) -> String { | |
| 33 | static NEXT: AtomicU32 = AtomicU32::new(0); | |
| 34 | format!("snowbound_{kind}{}", NEXT.fetch_add(1, Ordering::Relaxed)) | |
| 35 | } | |
| 36 | ||
| 37 | /// `path` quoted for a command; none where a quote would end it early. | |
| 38 | fn quoted(path: &Path) -> Option<String> { | |
| 39 | let path = path.to_str()?; | |
| 40 | (!path.contains('"')).then(|| format!("\"{path}\"")) | |
| 41 | } | |
| 42 | ||
| 43 | /// A recording or other audio file playing, paused or ended. | |
| 44 | pub struct Player { | |
| 45 | alias: String, | |
| 46 | playing: bool, | |
| 47 | } | |
| 48 | ||
| 49 | impl Player { | |
| 50 | /// The file at `path`, ready to play, silent unless `audible`; none when no codec | |
| 51 | /// decodes it. | |
| 52 | pub fn open(path: &Path, audible: bool) -> Option<Self> { | |
| 53 | let alias = alias("player"); | |
| 54 | send(&format!( | |
| 55 | "open {} type mpegvideo alias {alias}", | |
| 56 | quoted(path)? | |
| 57 | ))?; | |
| 58 | let player = Self { | |
| 59 | alias, | |
| 60 | playing: false, | |
| 61 | }; | |
| 62 | send(&format!("set {} time format milliseconds", player.alias))?; | |
| 63 | if !audible { | |
| 64 | send(&format!("setaudio {} off", player.alias))?; | |
| 65 | } | |
| 66 | Some(player) | |
| 67 | } | |
| 68 | ||
| 69 | pub fn play(&mut self) { | |
| 70 | self.playing = send(&format!("play {}", self.alias)).is_some(); | |
| 71 | } | |
| 72 | ||
| 73 | pub fn pause(&mut self) { | |
| 74 | send(&format!("pause {}", self.alias)); | |
| 75 | self.playing = false; | |
| 76 | } | |
| 77 | ||
| 78 | pub fn playing(&self) -> bool { | |
| 79 | self.playing && send(&format!("status {} mode", self.alias)).as_deref() == Some("playing") | |
| 80 | } | |
| 81 | ||
| 82 | fn status(&self, item: &str) -> u32 { | |
| 83 | send(&format!("status {} {item}", self.alias)) | |
| 84 | .and_then(|answer| answer.trim().parse().ok()) | |
| 85 | .unwrap_or(0) | |
| 86 | } | |
| 87 | ||
| 88 | pub fn position_ms(&self) -> u32 { | |
| 89 | self.status("position") | |
| 90 | } | |
| 91 | ||
| 92 | pub fn duration_ms(&self) -> u32 { | |
| 93 | self.status("length") | |
| 94 | } | |
| 95 | ||
| 96 | pub fn seek(&mut self, ms: u32) { | |
| 97 | send(&format!("seek {} to {ms}", self.alias)); | |
| 98 | if self.playing { | |
| 99 | self.play(); | |
| 100 | } | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | impl Drop for Player { | |
| 105 | fn drop(&mut self) { | |
| 106 | send(&format!("close {}", self.alias)); | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | /// The default microphone recording to a file. | |
| 111 | pub struct Recorder { | |
| 112 | alias: String, | |
| 113 | path: String, | |
| 114 | } | |
| 115 | ||
| 116 | impl Recorder { | |
| 117 | /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. | |
| 118 | pub fn audio(path: &Path, rate: u32) -> Result<Self, String> { | |
| 119 | let path = quoted(path).ok_or("Try recording again.")?; | |
| 120 | let alias = alias("recorder"); | |
| 121 | send(&format!("open new type waveaudio alias {alias}")) | |
| 122 | .ok_or("Connect a microphone and try again.")?; | |
| 123 | let recorder = Self { alias, path }; | |
| 124 | let alias = &recorder.alias; | |
| 125 | send(&format!( | |
| 126 | "set {alias} time format milliseconds bitspersample 16 channels 1 \ | |
| 127 | samplespersec {rate} bytespersec {} alignment 2", | |
| 128 | rate * 2 | |
| 129 | )) | |
| 130 | .ok_or("Try recording again.")?; | |
| 131 | send(&format!("record {alias}")).ok_or("Connect a microphone and try again.")?; | |
| 132 | Ok(recorder) | |
| 133 | } | |
| 134 | ||
| 135 | pub fn video(_: &Path) -> Result<Self, String> { | |
| 136 | Err("Snowbound can't record video on Windows yet. Record audio instead.".into()) | |
| 137 | } | |
| 138 | ||
| 139 | /// Pauses or resumes recording. | |
| 140 | pub fn pause(&mut self, paused: bool) { | |
| 141 | let command = if paused { "pause" } else { "resume" }; | |
| 142 | send(&format!("{command} {}", self.alias)); | |
| 143 | } | |
| 144 | ||
| 145 | /// Ends the recording and writes its file, with nothing left to run. | |
| 146 | pub fn stop(self) -> Result<impl FnOnce() -> Result<(), String> + Send, String> { | |
| 147 | send(&format!("stop {}", self.alias)); | |
| 148 | let saved = send(&format!("save {} {}", self.alias, self.path)); | |
| 149 | drop(self); | |
| 150 | match saved { | |
| 151 | Some(_) => Ok(|| Ok(())), | |
| 152 | None => Err("The recording couldn't be saved. Try recording again.".into()), | |
| 153 | } | |
| 154 | } | |
| 155 | } | |
| 156 | ||
| 157 | impl Drop for Recorder { | |
| 158 | fn drop(&mut self) { | |
| 159 | send(&format!("close {}", self.alias)); | |
| 160 | } | |
| 161 | } |
crates/snowbound/src/pane.rs+5-2| ... | ... | @@ -163,12 +163,15 @@ impl TagScope { |
| 163 | 163 | fn local_day(time: u64) -> i64 { |
| 164 | 164 | let unix = time as i64 + 315_532_800; |
| 165 | 165 | // SAFETY: `localtime_r` writes only the `tm` it is given. |
| 166 | #[cfg(unix)] | |
| 166 | 167 | let offset = unsafe { |
| 167 | 168 | let mut tm: libc::tm = std::mem::zeroed(); |
| 168 | 169 | libc::localtime_r(&unix, &mut tm); |
| 169 | tm.tm_gmtoff | |
| 170 | tm.tm_gmtoff as i64 | |
| 170 | 171 | }; |
| 171 | (unix + offset as i64).div_euclid(86_400) | |
| 172 | #[cfg(windows)] | |
| 173 | let offset = crate::platform::utc_offset(unix); | |
| 174 | (unix + offset).div_euclid(86_400) | |
| 172 | 175 | } |
| 173 | 176 | |
| 174 | 177 | /// Day `day` counted from 1970 as a year, month and day. |
crates/snowbound/src/spell_windows.rs created+240| ... | ... | @@ -0,0 +1,240 @@ |
| 1 | //! Spelling through the Spell Checking API that Windows 8 added, with the dictionaries of | |
| 2 | //! the languages installed; Windows 7 has none, and words go unmarked there. The checkers | |
| 3 | //! live on a thread of their own in COM's multithreaded apartment, which every call reaches | |
| 4 | //! through a channel, whatever thread it comes from. | |
| 5 | ||
| 6 | use canvas::spelling::{Dictionary, pick}; | |
| 7 | use std::{ | |
| 8 | collections::HashMap, | |
| 9 | ffi::c_void, | |
| 10 | sync::mpsc::{Sender, channel}, | |
| 11 | }; | |
| 12 | use windows_sys::{ | |
| 13 | Win32::System::Com::{ | |
| 14 | CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree, | |
| 15 | }, | |
| 16 | core::GUID, | |
| 17 | }; | |
| 18 | ||
| 19 | /// CLSID_SpellCheckerFactory and IID_ISpellCheckerFactory. | |
| 20 | const FACTORY: GUID = GUID::from_u128(0x7ab36653_1796_484b_bdfa_e74f1db7c1dc); | |
| 21 | const IID_FACTORY: GUID = GUID::from_u128(0x8e018a9d_2415_4677_bf08_794ea61f94bb); | |
| 22 | ||
| 23 | /// A COM object: a pointer to its table of methods, which begins with IUnknown's. | |
| 24 | struct Object(*mut *const usize); | |
| 25 | ||
| 26 | impl Object { | |
| 27 | /// Method `index` of the object's table, of signature `F`. | |
| 28 | unsafe fn method<F: Copy>(&self, index: usize) -> F { | |
| 29 | unsafe { std::mem::transmute_copy(&*(*self.0).add(index)) } | |
| 30 | } | |
| 31 | ||
| 32 | fn this(&self) -> *mut c_void { | |
| 33 | self.0.cast() | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | impl Drop for Object { | |
| 38 | fn drop(&mut self) { | |
| 39 | type Release = unsafe extern "system" fn(*mut c_void) -> u32; | |
| 40 | unsafe { self.method::<Release>(2)(self.this()) }; | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | type Out = *mut *mut *const usize; | |
| 45 | ||
| 46 | /// The object a method answered through `out`, where it answered one. | |
| 47 | fn answered(result: i32, out: *mut *const usize) -> Option<Object> { | |
| 48 | (result >= 0 && !out.is_null()).then_some(Object(out)) | |
| 49 | } | |
| 50 | ||
| 51 | /// The strings an IEnumString holds, freed as they are read. | |
| 52 | fn strings(enumeration: Object) -> Vec<String> { | |
| 53 | type Next = unsafe extern "system" fn(*mut c_void, u32, *mut *mut u16, *mut u32) -> i32; | |
| 54 | let mut all = Vec::new(); | |
| 55 | loop { | |
| 56 | let mut text = std::ptr::null_mut(); | |
| 57 | let mut fetched = 0; | |
| 58 | let result = unsafe { | |
| 59 | enumeration.method::<Next>(3)(enumeration.this(), 1, &mut text, &mut fetched) | |
| 60 | }; | |
| 61 | if result != 0 || fetched == 0 || text.is_null() { | |
| 62 | return all; | |
| 63 | } | |
| 64 | let length = (0..) | |
| 65 | .take_while(|&at| unsafe { *text.add(at) } != 0) | |
| 66 | .count(); | |
| 67 | all.push(String::from_utf16_lossy(unsafe { | |
| 68 | std::slice::from_raw_parts(text, length) | |
| 69 | })); | |
| 70 | unsafe { CoTaskMemFree(text.cast()) }; | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | fn wide(text: &str) -> Vec<u16> { | |
| 75 | text.encode_utf16().chain([0]).collect() | |
| 76 | } | |
| 77 | ||
| 78 | /// The factory and the checkers made so far, owned by the spelling thread. | |
| 79 | struct Checkers { | |
| 80 | factory: Object, | |
| 81 | /// The languages the system spells, as `en-US`. | |
| 82 | languages: Vec<String>, | |
| 83 | by_language: HashMap<String, Object>, | |
| 84 | } | |
| 85 | ||
| 86 | impl Checkers { | |
| 87 | fn new() -> Option<Self> { | |
| 88 | let mut factory = std::ptr::null_mut(); | |
| 89 | let created = unsafe { | |
| 90 | CoCreateInstance( | |
| 91 | &FACTORY, | |
| 92 | std::ptr::null_mut(), | |
| 93 | CLSCTX_INPROC_SERVER, | |
| 94 | &IID_FACTORY, | |
| 95 | &mut factory, | |
| 96 | ) | |
| 97 | }; | |
| 98 | let factory = answered(created, factory.cast())?; | |
| 99 | type Languages = unsafe extern "system" fn(*mut c_void, Out) -> i32; | |
| 100 | let mut languages = std::ptr::null_mut(); | |
| 101 | let listed = unsafe { factory.method::<Languages>(3)(factory.this(), &mut languages) }; | |
| 102 | let languages = strings(answered(listed, languages)?); | |
| 103 | Some(Self { | |
| 104 | factory, | |
| 105 | languages, | |
| 106 | by_language: HashMap::new(), | |
| 107 | }) | |
| 108 | } | |
| 109 | ||
| 110 | /// The checker for `lcid`'s language, where the system spells it. | |
| 111 | fn checker(&mut self, lcid: u32) -> Option<&Object> { | |
| 112 | let language = pick(lcid, &self.languages)?.to_owned(); | |
| 113 | if !self.by_language.contains_key(&language) { | |
| 114 | type Create = unsafe extern "system" fn(*mut c_void, *const u16, Out) -> i32; | |
| 115 | let mut checker = std::ptr::null_mut(); | |
| 116 | let tag = wide(&language); | |
| 117 | let factory = &self.factory; | |
| 118 | let result = | |
| 119 | unsafe { factory.method::<Create>(5)(factory.this(), tag.as_ptr(), &mut checker) }; | |
| 120 | self.by_language | |
| 121 | .insert(language.clone(), answered(result, checker)?); | |
| 122 | } | |
| 123 | self.by_language.get(&language) | |
| 124 | } | |
| 125 | ||
| 126 | fn misspelled(&mut self, word: &str, lcid: u32) -> bool { | |
| 127 | type Check = unsafe extern "system" fn(*mut c_void, *const u16, Out) -> i32; | |
| 128 | type Next = unsafe extern "system" fn(*mut c_void, Out) -> i32; | |
| 129 | let Some(checker) = self.checker(lcid) else { | |
| 130 | return false; | |
| 131 | }; | |
| 132 | let text = wide(word); | |
| 133 | let mut errors = std::ptr::null_mut(); | |
| 134 | let checked = | |
| 135 | unsafe { checker.method::<Check>(4)(checker.this(), text.as_ptr(), &mut errors) }; | |
| 136 | let Some(errors) = answered(checked, errors) else { | |
| 137 | return false; | |
| 138 | }; | |
| 139 | let mut error = std::ptr::null_mut(); | |
| 140 | // S_OK with an error; S_FALSE once there are none. | |
| 141 | let found = unsafe { errors.method::<Next>(3)(errors.this(), &mut error) }; | |
| 142 | found == 0 && answered(found, error).is_some() | |
| 143 | } | |
| 144 | ||
| 145 | fn suggest(&mut self, word: &str, lcid: u32) -> Vec<String> { | |
| 146 | type Suggest = unsafe extern "system" fn(*mut c_void, *const u16, Out) -> i32; | |
| 147 | let Some(checker) = self.checker(lcid) else { | |
| 148 | return Vec::new(); | |
| 149 | }; | |
| 150 | let text = wide(word); | |
| 151 | let mut suggestions = std::ptr::null_mut(); | |
| 152 | let result = unsafe { | |
| 153 | checker.method::<Suggest>(5)(checker.this(), text.as_ptr(), &mut suggestions) | |
| 154 | }; | |
| 155 | answered(result, suggestions).map_or_else(Vec::new, strings) | |
| 156 | } | |
| 157 | ||
| 158 | /// Adds `word` to the user's dictionary, which every language's checker shares. | |
| 159 | fn learn(&mut self, word: &str) { | |
| 160 | type Add = unsafe extern "system" fn(*mut c_void, *const u16) -> i32; | |
| 161 | let checker = self.by_language.values().next(); | |
| 162 | if let Some(checker) = checker { | |
| 163 | let text = wide(word); | |
| 164 | unsafe { checker.method::<Add>(6)(checker.this(), text.as_ptr()) }; | |
| 165 | } | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | enum Request { | |
| 170 | Misspelled(Vec<(String, u32)>, Sender<Vec<bool>>), | |
| 171 | Suggest(String, u32, Sender<Vec<String>>), | |
| 172 | Learn(String), | |
| 173 | } | |
| 174 | ||
| 175 | struct Checker(Sender<Request>); | |
| 176 | ||
| 177 | impl Checker { | |
| 178 | fn ask<R: Default>(&self, request: impl FnOnce(Sender<R>) -> Request) -> R { | |
| 179 | let (answer, answered) = channel(); | |
| 180 | if self.0.send(request(answer)).is_err() { | |
| 181 | return R::default(); | |
| 182 | } | |
| 183 | answered.recv().unwrap_or_default() | |
| 184 | } | |
| 185 | } | |
| 186 | ||
| 187 | impl Dictionary for Checker { | |
| 188 | fn misspelled(&self, words: &[(&str, u32)]) -> Vec<bool> { | |
| 189 | let words = words | |
| 190 | .iter() | |
| 191 | .map(|(word, lcid)| ((*word).to_owned(), *lcid)) | |
| 192 | .collect(); | |
| 193 | self.ask(|answer| Request::Misspelled(words, answer)) | |
| 194 | } | |
| 195 | ||
| 196 | fn suggest(&self, word: &str, lcid: u32) -> Vec<String> { | |
| 197 | self.ask(|answer| Request::Suggest(word.to_owned(), lcid, answer)) | |
| 198 | } | |
| 199 | ||
| 200 | fn learn(&self, word: &str) { | |
| 201 | let _ = self.0.send(Request::Learn(word.to_owned())); | |
| 202 | } | |
| 203 | } | |
| 204 | ||
| 205 | /// The system's spell checker with the languages installed, from Windows 8. | |
| 206 | pub fn dictionary() -> Option<Box<dyn Dictionary>> { | |
| 207 | let (requests, received) = channel(); | |
| 208 | let (ready, started) = channel(); | |
| 209 | std::thread::Builder::new() | |
| 210 | .name("snowbound-spell".into()) | |
| 211 | .spawn(move || { | |
| 212 | unsafe { CoInitializeEx(std::ptr::null(), COINIT_MULTITHREADED as u32) }; | |
| 213 | let Some(mut checkers) = Checkers::new() else { | |
| 214 | let _ = ready.send(false); | |
| 215 | return; | |
| 216 | }; | |
| 217 | let _ = ready.send(true); | |
| 218 | for request in received { | |
| 219 | match request { | |
| 220 | Request::Misspelled(words, answer) => { | |
| 221 | let _ = answer.send( | |
| 222 | words | |
| 223 | .iter() | |
| 224 | .map(|(word, lcid)| checkers.misspelled(word, *lcid)) | |
| 225 | .collect(), | |
| 226 | ); | |
| 227 | } | |
| 228 | Request::Suggest(word, lcid, answer) => { | |
| 229 | let _ = answer.send(checkers.suggest(&word, lcid)); | |
| 230 | } | |
| 231 | Request::Learn(word) => checkers.learn(&word), | |
| 232 | } | |
| 233 | } | |
| 234 | }) | |
| 235 | .ok()?; | |
| 236 | started | |
| 237 | .recv() | |
| 238 | .ok()? | |
| 239 | .then(|| Box::new(Checker(requests)) as Box<dyn Dictionary>) | |
| 240 | } |
crates/snowbound/src/surface.rs+56-34| ... | ... | @@ -9,6 +9,8 @@ pub struct Surface { |
| 9 | 9 | /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. |
| 10 | 10 | pub size: [u32; 2], |
| 11 | 11 | window: Arc<Window>, |
| 12 | device: wgpu::Device, | |
| 13 | queue: wgpu::Queue, | |
| 12 | 14 | instance: wgpu::Instance, |
| 13 | 15 | surface: wgpu::Surface<'static>, |
| 14 | 16 | config: wgpu::SurfaceConfiguration, |
| ... | ... | @@ -17,14 +19,14 @@ pub struct Surface { |
| 17 | 19 | } |
| 18 | 20 | |
| 19 | 21 | pub struct Frame { |
| 20 | pub target: wgpu::TextureView, | |
| 21 | texture: wgpu::SurfaceTexture, | |
| 22 | reconfigure: bool, | |
| 22 | pub target: draw::Target, | |
| 23 | pub(super) texture: wgpu::SurfaceTexture, | |
| 24 | pub(super) reconfigure: bool, | |
| 23 | 25 | } |
| 24 | 26 | |
| 25 | 27 | pub struct Offscreen { |
| 26 | pub target: wgpu::TextureView, | |
| 27 | texture: wgpu::Texture, | |
| 28 | pub target: draw::Target, | |
| 29 | pub(super) texture: wgpu::Texture, | |
| 28 | 30 | } |
| 29 | 31 | |
| 30 | 32 | impl Surface { |
| ... | ... | @@ -34,9 +36,20 @@ impl Surface { |
| 34 | 36 | window: Arc<Window>, |
| 35 | 37 | backdrop: bool, |
| 36 | 38 | ) -> Result<(Self, Renderer), Box<dyn Error>> { |
| 37 | let instance = wgpu::Instance::new( | |
| 38 | wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())), | |
| 39 | ); | |
| 39 | #[cfg_attr(not(windows), expect(unused_mut))] | |
| 40 | let mut descriptor = | |
| 41 | wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())); | |
| 42 | // Direct3D 12, which Windows 10 and 11 always have, and through DirectComposition | |
| 43 | // where the backdrop shows through; elsewhere `surface_windows` draws with OpenGL. | |
| 44 | #[cfg(windows)] | |
| 45 | { | |
| 46 | descriptor.backends = wgpu::Backends::DX12; | |
| 47 | if backdrop { | |
| 48 | descriptor.backend_options.dx12.presentation_system = | |
| 49 | wgpu::Dx12SwapchainKind::DxgiFromVisual; | |
| 50 | } | |
| 51 | } | |
| 52 | let instance = wgpu::Instance::new(descriptor); | |
| 40 | 53 | let surface = instance.create_surface(window.clone())?; |
| 41 | 54 | platform::configure_presentation(&surface); |
| 42 | 55 | let adapter = instance |
| ... | ... | @@ -50,27 +63,32 @@ impl Surface { |
| 50 | 63 | let mut config = surface |
| 51 | 64 | .get_default_config(&adapter, size.width, size.height) |
| 52 | 65 | .ok_or("No supported canvas surface")?; |
| 66 | let alpha = surface.get_capabilities(&adapter).alpha_modes; | |
| 53 | 67 | let translucent = backdrop.then(|| { |
| 54 | // What wgpu calls post-multiplied is Core Animation's non-opaque layer, which | |
| 55 | // composites colour premultiplied in sRGB, as `Translucent` leaves it. | |
| 56 | config.alpha_mode = wgpu::CompositeAlphaMode::PostMultiplied; | |
| 68 | // DirectComposition's premultiplied swap chains and Core Animation's non-opaque | |
| 69 | // layers both composite colour premultiplied in sRGB, as `Translucent` leaves | |
| 70 | // it; wgpu calls the second, the only one Metal offers, post-multiplied. | |
| 71 | config.alpha_mode = if alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { | |
| 72 | wgpu::CompositeAlphaMode::PreMultiplied | |
| 73 | } else { | |
| 74 | wgpu::CompositeAlphaMode::PostMultiplied | |
| 75 | }; | |
| 57 | 76 | let window_format = config.format.remove_srgb_suffix(); |
| 58 | 77 | config.view_formats.push(window_format); |
| 59 | 78 | draw::Translucent::new(&device, config.format, window_format) |
| 60 | 79 | }); |
| 61 | if platform::cuts_corners() { | |
| 62 | let alpha = &surface.get_capabilities(&adapter).alpha_modes; | |
| 63 | if alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { | |
| 64 | config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied; | |
| 65 | } | |
| 80 | if platform::cuts_corners() && alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { | |
| 81 | config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied; | |
| 66 | 82 | } |
| 67 | 83 | surface.configure(&device, &config); |
| 68 | 84 | eprintln!("Canvas GPU: {:?}", adapter.get_info()); |
| 69 | let renderer = Renderer::new(device, queue, config.format); | |
| 85 | let renderer = Renderer::new(device.clone(), queue.clone(), config.format); | |
| 70 | 86 | Ok(( |
| 71 | 87 | Self { |
| 72 | 88 | size: [config.width, config.height], |
| 73 | 89 | window, |
| 90 | device, | |
| 91 | queue, | |
| 74 | 92 | instance, |
| 75 | 93 | surface, |
| 76 | 94 | config, |
| ... | ... | @@ -85,9 +103,9 @@ impl Surface { |
| 85 | 103 | self.translucent.is_some() |
| 86 | 104 | } |
| 87 | 105 | |
| 88 | pub fn configure(&mut self, renderer: &Renderer) { | |
| 106 | pub fn configure(&mut self, _: &Renderer) { | |
| 89 | 107 | [self.config.width, self.config.height] = self.size; |
| 90 | self.surface.configure(&renderer.device, &self.config); | |
| 108 | self.surface.configure(&self.device, &self.config); | |
| 91 | 109 | } |
| 92 | 110 | |
| 93 | 111 | /// The window's next frame, or none to skip this one. |
| ... | ... | @@ -123,9 +141,10 @@ impl Surface { |
| 123 | 141 | } |
| 124 | 142 | }; |
| 125 | 143 | let target = match &mut self.translucent { |
| 126 | Some(translucent) => translucent.target(&renderer.device, self.size), | |
| 144 | Some(translucent) => translucent.target(&self.device, self.size), | |
| 127 | 145 | None => texture.texture.create_view(&Default::default()), |
| 128 | 146 | }; |
| 147 | let target = target_of(target); | |
| 129 | 148 | Ok(Some(Frame { |
| 130 | 149 | target, |
| 131 | 150 | texture, |
| ... | ... | @@ -142,10 +161,10 @@ impl Surface { |
| 142 | 161 | format: Some(self.config.format.remove_srgb_suffix()), |
| 143 | 162 | ..Default::default() |
| 144 | 163 | }); |
| 145 | translucent.present(&renderer.device, &renderer.queue, &window); | |
| 164 | translucent.present(&self.device, &self.queue, &window); | |
| 146 | 165 | } |
| 147 | 166 | self.window.pre_present_notify(); |
| 148 | renderer.queue.present(frame.texture); | |
| 167 | self.queue.present(frame.texture); | |
| 149 | 168 | platform::commit_presentation(&self.window); |
| 150 | 169 | if frame.reconfigure { |
| 151 | 170 | self.configure(renderer); |
| ... | ... | @@ -153,8 +172,8 @@ impl Surface { |
| 153 | 172 | } |
| 154 | 173 | |
| 155 | 174 | /// A target the size of the window's frames that `read` reads back. |
| 156 | pub fn offscreen(&self, renderer: &Renderer) -> Result<Offscreen, Box<dyn Error>> { | |
| 157 | let texture = renderer.device.create_texture(&wgpu::TextureDescriptor { | |
| 175 | pub fn offscreen(&self, _: &Renderer) -> Result<Offscreen, Box<dyn Error>> { | |
| 176 | let texture = self.device.create_texture(&wgpu::TextureDescriptor { | |
| 158 | 177 | label: Some("Snapshot"), |
| 159 | 178 | size: wgpu::Extent3d { |
| 160 | 179 | width: self.size[0], |
| ... | ... | @@ -169,26 +188,22 @@ impl Surface { |
| 169 | 188 | view_formats: &[], |
| 170 | 189 | }); |
| 171 | 190 | Ok(Offscreen { |
| 172 | target: texture.create_view(&Default::default()), | |
| 191 | target: target_of(texture.create_view(&Default::default())), | |
| 173 | 192 | texture, |
| 174 | 193 | }) |
| 175 | 194 | } |
| 176 | 195 | |
| 177 | 196 | /// The offscreen target's sRGB RGBA rows, top first. |
| 178 | pub fn read( | |
| 179 | &self, | |
| 180 | renderer: &Renderer, | |
| 181 | offscreen: Offscreen, | |
| 182 | ) -> Result<Vec<u8>, Box<dyn Error>> { | |
| 197 | pub fn read(&self, _: &Renderer, offscreen: Offscreen) -> Result<Vec<u8>, Box<dyn Error>> { | |
| 183 | 198 | let size = self.size; |
| 184 | 199 | let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); |
| 185 | let buffer = renderer.device.create_buffer(&wgpu::BufferDescriptor { | |
| 200 | let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { | |
| 186 | 201 | label: Some("Snapshot readback"), |
| 187 | 202 | size: u64::from(row) * u64::from(size[1]), |
| 188 | 203 | usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, |
| 189 | 204 | mapped_at_creation: false, |
| 190 | 205 | }); |
| 191 | let mut encoder = renderer.device.create_command_encoder(&Default::default()); | |
| 206 | let mut encoder = self.device.create_command_encoder(&Default::default()); | |
| 192 | 207 | encoder.copy_texture_to_buffer( |
| 193 | 208 | offscreen.texture.as_image_copy(), |
| 194 | 209 | wgpu::TexelCopyBufferInfo { |
| ... | ... | @@ -201,9 +216,9 @@ impl Surface { |
| 201 | 216 | }, |
| 202 | 217 | offscreen.texture.size(), |
| 203 | 218 | ); |
| 204 | renderer.queue.submit([encoder.finish()]); | |
| 219 | self.queue.submit([encoder.finish()]); | |
| 205 | 220 | buffer.map_async(wgpu::MapMode::Read, .., |_| {}); |
| 206 | renderer.device.poll(wgpu::PollType::Wait { | |
| 221 | self.device.poll(wgpu::PollType::Wait { | |
| 207 | 222 | submission_index: None, |
| 208 | 223 | timeout: Some(std::time::Duration::from_secs(5)), |
| 209 | 224 | })?; |
| ... | ... | @@ -223,3 +238,10 @@ impl Surface { |
| 223 | 238 | Ok(pixels) |
| 224 | 239 | } |
| 225 | 240 | } |
| 241 | ||
| 242 | /// `view` as what the renderer draws into, which is the view itself unless `draw` also | |
| 243 | /// paints through OpenGL, as on Windows. | |
| 244 | #[cfg_attr(not(windows), expect(clippy::useless_conversion))] | |
| 245 | fn target_of(view: wgpu::TextureView) -> draw::Target { | |
| 246 | view.into() | |
| 247 | } |
crates/snowbound/src/surface_gl.rs+1-1| ... | ... | @@ -69,7 +69,7 @@ impl Surface { |
| 69 | 69 | } |
| 70 | 70 | context |
| 71 | 71 | }; |
| 72 | let renderer = Renderer::new()?; | |
| 72 | let renderer = Renderer::opengl()?; | |
| 73 | 73 | eprintln!("Canvas GPU: OpenGL"); |
| 74 | 74 | let size = window.inner_size(); |
| 75 | 75 | Ok(( |
crates/snowbound/src/surface_windows.rs created+228| ... | ... | @@ -0,0 +1,228 @@ |
| 1 | //! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), and | |
| 2 | //! otherwise, as on Windows 7, through OpenGL 2.1 on a WGL context: frames draw into an sRGB | |
| 3 | //! target, copied to the window's back buffer and swapped. | |
| 4 | ||
| 5 | #[path = "surface.rs"] | |
| 6 | mod webgpu; | |
| 7 | ||
| 8 | use draw::{GlTarget, Renderer, Target}; | |
| 9 | use std::{error::Error, sync::Arc}; | |
| 10 | use windows_sys::Win32::{ | |
| 11 | Foundation::HWND, | |
| 12 | Graphics::{ | |
| 13 | Gdi::{GetDC, HDC}, | |
| 14 | OpenGL as gl, | |
| 15 | }, | |
| 16 | }; | |
| 17 | use winit::{ | |
| 18 | raw_window_handle::{HasWindowHandle, RawWindowHandle}, | |
| 19 | window::Window, | |
| 20 | }; | |
| 21 | ||
| 22 | pub struct Surface { | |
| 23 | /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. | |
| 24 | pub size: [u32; 2], | |
| 25 | backend: Backend, | |
| 26 | } | |
| 27 | ||
| 28 | enum Backend { | |
| 29 | Wgpu(Box<webgpu::Surface>), | |
| 30 | Gl { | |
| 31 | /// The window's device context, which the context draws to. | |
| 32 | device: HDC, | |
| 33 | /// Frames leave the desktop's glass showing through their transparent pixels. | |
| 34 | translucent: bool, | |
| 35 | /// The frame target, kept between frames. | |
| 36 | target: Option<GlTarget>, | |
| 37 | }, | |
| 38 | } | |
| 39 | ||
| 40 | pub struct Frame { | |
| 41 | pub target: Target, | |
| 42 | /// The swap chain's texture, and whether it asks for reconfiguring, where Direct3D | |
| 43 | /// draws. | |
| 44 | texture: Option<(wgpu::SurfaceTexture, bool)>, | |
| 45 | } | |
| 46 | ||
| 47 | pub struct Offscreen { | |
| 48 | pub target: Target, | |
| 49 | texture: Option<wgpu::Texture>, | |
| 50 | } | |
| 51 | ||
| 52 | impl Surface { | |
| 53 | /// With `backdrop`, frames leave the system's material showing through their | |
| 54 | /// transparent pixels. | |
| 55 | pub async fn new( | |
| 56 | window: Arc<Window>, | |
| 57 | backdrop: bool, | |
| 58 | ) -> Result<(Self, Renderer), Box<dyn Error>> { | |
| 59 | let forced = std::env::var("SNOWBOUND_RENDERER").ok(); | |
| 60 | if forced.as_deref() != Some("gl") { | |
| 61 | match webgpu::Surface::new(window.clone(), backdrop).await { | |
| 62 | Ok((surface, renderer)) => { | |
| 63 | let backend = Backend::Wgpu(Box::new(surface)); | |
| 64 | let size = window.inner_size(); | |
| 65 | let size = [size.width, size.height]; | |
| 66 | return Ok((Self { size, backend }, renderer)); | |
| 67 | } | |
| 68 | Err(error) if forced.is_some() => return Err(error), | |
| 69 | Err(error) => eprintln!("No Direct3D 12 ({error}); drawing with OpenGL"), | |
| 70 | } | |
| 71 | } | |
| 72 | let RawWindowHandle::Win32(handle) = window.window_handle()?.as_raw() else { | |
| 73 | unreachable!() | |
| 74 | }; | |
| 75 | let device = unsafe { GetDC(handle.hwnd.get() as HWND) }; | |
| 76 | let alpha = if backdrop { 8 } else { 0 }; | |
| 77 | let format = gl::PIXELFORMATDESCRIPTOR { | |
| 78 | nSize: size_of::<gl::PIXELFORMATDESCRIPTOR>() as u16, | |
| 79 | nVersion: 1, | |
| 80 | // PFD_SUPPORT_COMPOSITION: the desktop composes the window's alpha. | |
| 81 | dwFlags: gl::PFD_DRAW_TO_WINDOW | |
| 82 | | gl::PFD_SUPPORT_OPENGL | |
| 83 | | gl::PFD_DOUBLEBUFFER | |
| 84 | | 0x8000, | |
| 85 | iPixelType: gl::PFD_TYPE_RGBA, | |
| 86 | cColorBits: 32, | |
| 87 | cAlphaBits: alpha, | |
| 88 | iLayerType: gl::PFD_MAIN_PLANE as u8, | |
| 89 | ..unsafe { std::mem::zeroed() } | |
| 90 | }; | |
| 91 | unsafe { | |
| 92 | let chosen = gl::ChoosePixelFormat(device, &format); | |
| 93 | if chosen == 0 || gl::SetPixelFormat(device, chosen, &format) == 0 { | |
| 94 | return Err("No OpenGL pixel format".into()); | |
| 95 | } | |
| 96 | let context = gl::wglCreateContext(device); | |
| 97 | if context.is_null() || gl::wglMakeCurrent(device, context) == 0 { | |
| 98 | return Err("No OpenGL context".into()); | |
| 99 | } | |
| 100 | // WGL_EXT_swap_control: present on the display's refresh. | |
| 101 | type SwapInterval = unsafe extern "system" fn(i32) -> i32; | |
| 102 | if let Some(interval) = gl::wglGetProcAddress(c"wglSwapIntervalEXT".as_ptr().cast()) { | |
| 103 | std::mem::transmute::<unsafe extern "system" fn() -> isize, SwapInterval>(interval)( | |
| 104 | 1, | |
| 105 | ); | |
| 106 | } | |
| 107 | } | |
| 108 | let renderer = Renderer::opengl()?; | |
| 109 | eprintln!("Canvas GPU: OpenGL"); | |
| 110 | let size = window.inner_size(); | |
| 111 | Ok(( | |
| 112 | Self { | |
| 113 | size: [size.width, size.height], | |
| 114 | backend: Backend::Gl { | |
| 115 | device, | |
| 116 | translucent: backdrop, | |
| 117 | target: None, | |
| 118 | }, | |
| 119 | }, | |
| 120 | renderer, | |
| 121 | )) | |
| 122 | } | |
| 123 | ||
| 124 | /// Whether frames leave the system's backdrop showing. | |
| 125 | pub fn translucent(&self) -> bool { | |
| 126 | match &self.backend { | |
| 127 | Backend::Wgpu(surface) => surface.translucent(), | |
| 128 | Backend::Gl { translucent, .. } => *translucent, | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | pub fn configure(&mut self, renderer: &Renderer) { | |
| 133 | if let Backend::Wgpu(surface) = &mut self.backend { | |
| 134 | surface.size = self.size; | |
| 135 | surface.configure(renderer); | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | /// The window's next frame, or none to skip this one. | |
| 140 | pub fn frame(&mut self, renderer: &Renderer) -> Result<Option<Frame>, Box<dyn Error>> { | |
| 141 | if self.size.contains(&0) { | |
| 142 | return Ok(None); | |
| 143 | } | |
| 144 | match &mut self.backend { | |
| 145 | Backend::Wgpu(surface) => { | |
| 146 | surface.size = self.size; | |
| 147 | Ok(surface.frame(renderer)?.map(|frame| Frame { | |
| 148 | target: frame.target, | |
| 149 | texture: Some((frame.texture, frame.reconfigure)), | |
| 150 | })) | |
| 151 | } | |
| 152 | Backend::Gl { target, .. } => { | |
| 153 | let target = match target.take() { | |
| 154 | Some(target) if target.size() == self.size => target, | |
| 155 | _ => GlTarget::new(self.size)?, | |
| 156 | }; | |
| 157 | Ok(Some(Frame { | |
| 158 | target: target.into(), | |
| 159 | texture: None, | |
| 160 | })) | |
| 161 | } | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | pub fn present(&mut self, renderer: &Renderer, frame: Frame) { | |
| 166 | match (&mut self.backend, frame.target, frame.texture) { | |
| 167 | (Backend::Wgpu(surface), target, Some((texture, reconfigure))) => { | |
| 168 | let frame = webgpu::Frame { | |
| 169 | target, | |
| 170 | texture, | |
| 171 | reconfigure, | |
| 172 | }; | |
| 173 | surface.present(renderer, frame); | |
| 174 | } | |
| 175 | ( | |
| 176 | Backend::Gl { | |
| 177 | device, | |
| 178 | translucent, | |
| 179 | target: kept, | |
| 180 | }, | |
| 181 | Target::Gl(target), | |
| 182 | None, | |
| 183 | ) => { | |
| 184 | if *translucent { | |
| 185 | renderer.present_translucent(&target); | |
| 186 | } else { | |
| 187 | target.present(); | |
| 188 | } | |
| 189 | unsafe { gl::SwapBuffers(*device) }; | |
| 190 | *kept = Some(target); | |
| 191 | } | |
| 192 | _ => unreachable!("Frames come from the surface's own backend"), | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | /// A target the size of the window's frames that `read` reads back. | |
| 197 | pub fn offscreen(&mut self, renderer: &Renderer) -> Result<Offscreen, Box<dyn Error>> { | |
| 198 | match &mut self.backend { | |
| 199 | Backend::Wgpu(surface) => { | |
| 200 | surface.size = self.size; | |
| 201 | let offscreen = surface.offscreen(renderer)?; | |
| 202 | Ok(Offscreen { | |
| 203 | target: offscreen.target, | |
| 204 | texture: Some(offscreen.texture), | |
| 205 | }) | |
| 206 | } | |
| 207 | Backend::Gl { .. } => Ok(Offscreen { | |
| 208 | target: GlTarget::new(self.size)?.into(), | |
| 209 | texture: None, | |
| 210 | }), | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | /// The offscreen target's sRGB RGBA rows, top first. | |
| 215 | pub fn read( | |
| 216 | &self, | |
| 217 | renderer: &Renderer, | |
| 218 | offscreen: Offscreen, | |
| 219 | ) -> Result<Vec<u8>, Box<dyn Error>> { | |
| 220 | match (&self.backend, offscreen.target, offscreen.texture) { | |
| 221 | (Backend::Wgpu(surface), target, Some(texture)) => { | |
| 222 | surface.read(renderer, webgpu::Offscreen { target, texture }) | |
| 223 | } | |
| 224 | (Backend::Gl { .. }, Target::Gl(target), None) => Ok(target.read_pixels()), | |
| 225 | _ => unreachable!("Snapshots come from the surface's own backend"), | |
| 226 | } | |
| 227 | } | |
| 228 | } |
crates/snowbound/src/update.rs+52-20| ... | ... | @@ -207,6 +207,8 @@ fn staging(install: &Path) -> Option<PathBuf> { |
| 207 | 207 | fn staged(folder: &Path) -> PathBuf { |
| 208 | 208 | folder.join(if cfg!(target_os = "macos") { |
| 209 | 209 | "Snowbound.app" |
| 210 | } else if cfg!(windows) { | |
| 211 | "snowbound.exe" | |
| 210 | 212 | } else { |
| 211 | 213 | "snowbound" |
| 212 | 214 | }) |
| ... | ... | @@ -236,6 +238,7 @@ fn stage(bytes: &[u8], folder: &Path, version: &Version) -> std::io::Result<Path |
| 236 | 238 | } |
| 237 | 239 | } else { |
| 238 | 240 | std::fs::write(&item, bytes)?; |
| 241 | #[cfg(unix)] | |
| 239 | 242 | std::fs::set_permissions(&item, std::os::unix::fs::PermissionsExt::from_mode(0o755))?; |
| 240 | 243 | } |
| 241 | 244 | std::fs::write(folder.join("version"), version.name())?; |
| ... | ... | @@ -246,17 +249,23 @@ fn stage(bytes: &[u8], folder: &Path, version: &Version) -> std::io::Result<Path |
| 246 | 249 | /// place, then removes the staging folder. |
| 247 | 250 | fn apply(staged: &Path, install: &Path) -> std::io::Result<()> { |
| 248 | 251 | let folder = staging(install).ok_or_else(|| std::io::Error::other("No install name"))?; |
| 252 | let old = folder.with_extension("old"); | |
| 249 | 253 | if install.is_dir() { |
| 250 | let old = folder.with_extension("old"); | |
| 251 | 254 | let _ = std::fs::remove_dir_all(&old); |
| 252 | std::fs::rename(install, &old)?; | |
| 253 | if let Err(error) = std::fs::rename(staged, install) { | |
| 254 | std::fs::rename(&old, install)?; | |
| 255 | return Err(error); | |
| 256 | } | |
| 255 | } else { | |
| 256 | let _ = std::fs::remove_file(&old); | |
| 257 | } | |
| 258 | // Windows renames a running executable, as the finisher is, but doesn't replace it. | |
| 259 | std::fs::rename(install, &old)?; | |
| 260 | if let Err(error) = std::fs::rename(staged, install) { | |
| 261 | std::fs::rename(&old, install)?; | |
| 262 | return Err(error); | |
| 263 | } | |
| 264 | if install.is_dir() { | |
| 257 | 265 | let _ = std::fs::remove_dir_all(&old); |
| 258 | 266 | } else { |
| 259 | std::fs::rename(staged, install)?; | |
| 267 | // Where it is still running, the next update removes it. | |
| 268 | let _ = std::fs::remove_file(&old); | |
| 260 | 269 | } |
| 261 | 270 | let _ = std::fs::remove_dir_all(folder); |
| 262 | 271 | Ok(()) |
| ... | ... | @@ -536,16 +545,11 @@ pub fn relaunch(staged: &Path) -> std::io::Result<()> { |
| 536 | 545 | /// for the app to quit, swaps the update in and opens the app, updated or not. |
| 537 | 546 | pub fn finish(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn std::error::Error>> { |
| 538 | 547 | let usage = "Usage: snowbound --finish-update PID STAGED INSTALL [ARGUMENT]..."; |
| 539 | let pid: libc::pid_t = args.next().ok_or(usage)?.to_str().ok_or(usage)?.parse()?; | |
| 548 | let pid: u32 = args.next().ok_or(usage)?.to_str().ok_or(usage)?.parse()?; | |
| 540 | 549 | let staged = PathBuf::from(args.next().ok_or(usage)?); |
| 541 | 550 | let install = PathBuf::from(args.next().ok_or(usage)?); |
| 542 | let deadline = Instant::now() + Duration::from_secs(60); | |
| 543 | // Signal 0 only asks whether the process is still there. | |
| 544 | while unsafe { libc::kill(pid, 0) } == 0 { | |
| 545 | if Instant::now() > deadline { | |
| 546 | return Err("Snowbound didn’t quit, so the update wasn’t installed.".into()); | |
| 547 | } | |
| 548 | std::thread::sleep(Duration::from_millis(100)); | |
| 551 | if !quits(pid, Duration::from_secs(60)) { | |
| 552 | return Err("Snowbound didn’t quit, so the update wasn’t installed.".into()); | |
| 549 | 553 | } |
| 550 | 554 | let applied = apply(&staged, &install); |
| 551 | 555 | let mut open = if cfg!(target_os = "macos") { |
| ... | ... | @@ -565,6 +569,38 @@ pub fn finish(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn st |
| 565 | 569 | Ok(applied?) |
| 566 | 570 | } |
| 567 | 571 | |
| 572 | /// Whether process `pid` is gone within `patience`. | |
| 573 | #[cfg(unix)] | |
| 574 | fn quits(pid: u32, patience: Duration) -> bool { | |
| 575 | let deadline = Instant::now() + patience; | |
| 576 | // Signal 0 only asks whether the process is still there. | |
| 577 | while unsafe { libc::kill(pid as libc::pid_t, 0) } == 0 { | |
| 578 | if Instant::now() > deadline { | |
| 579 | return false; | |
| 580 | } | |
| 581 | std::thread::sleep(Duration::from_millis(100)); | |
| 582 | } | |
| 583 | true | |
| 584 | } | |
| 585 | ||
| 586 | #[cfg(windows)] | |
| 587 | fn quits(pid: u32, patience: Duration) -> bool { | |
| 588 | use windows_sys::Win32::{ | |
| 589 | Foundation::{CloseHandle, WAIT_TIMEOUT}, | |
| 590 | System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject}, | |
| 591 | }; | |
| 592 | unsafe { | |
| 593 | let process = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); | |
| 594 | // A process that can't be opened has gone. | |
| 595 | if process.is_null() { | |
| 596 | return true; | |
| 597 | } | |
| 598 | let waited = WaitForSingleObject(process, patience.as_millis() as u32); | |
| 599 | CloseHandle(process); | |
| 600 | waited != WAIT_TIMEOUT | |
| 601 | } | |
| 602 | } | |
| 603 | ||
| 568 | 604 | #[cfg(test)] |
| 569 | 605 | mod tests { |
| 570 | 606 | use super::*; |
| ... | ... | @@ -892,11 +928,7 @@ mod tests { |
| 892 | 928 | #[ignore = "reads the published builds"] |
| 893 | 929 | fn the_published_build_installs() { |
| 894 | 930 | let folder = scratch("published"); |
| 895 | let install = folder.join(if cfg!(target_os = "macos") { | |
| 896 | "Snowbound.app" | |
| 897 | } else { | |
| 898 | "snowbound" | |
| 899 | }); | |
| 931 | let install = staged(&folder); | |
| 900 | 932 | let old = version("2000-01-01-r1"); |
| 901 | 933 | let status = check( |
| 902 | 934 | &download, |
crates/snowbound/src/watch.rs+216-15| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | //! Changes below a notebook folder, as the system reports them: FSEvents on macOS, inotify on |
| 2 | //! Linux. A notebook on a share learns of its changes from the server instead | |
| 3 | //! (`notebook::session::Background::smb`). | |
| 2 | //! Linux, ReadDirectoryChangesW on Windows. A notebook on a share learns of its changes from | |
| 3 | //! the server instead (`notebook::session::Background::smb`), except on Windows, whose own | |
| 4 | //! SMB client relays the server's change notifications. | |
| 4 | 5 | |
| 5 | 6 | use std::path::{Path, PathBuf}; |
| 6 | 7 | |
| ... | ... | @@ -22,19 +23,7 @@ pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static) |
| 22 | 23 | let folders = if local(&volume) { |
| 23 | 24 | vec![root.clone()] |
| 24 | 25 | } else if smbfs(&volume) { |
| 25 | // smbfs reports another client's change only in a folder watched for itself, and | |
| 26 | // names just that folder. | |
| 27 | let mut folders = vec![root.clone()]; | |
| 28 | let mut at = 0; | |
| 29 | while let Some(folder) = folders.get(at).cloned() { | |
| 30 | at += 1; | |
| 31 | for entry in std::fs::read_dir(folder).into_iter().flatten().flatten() { | |
| 32 | if entry.file_type().is_ok_and(|kind| kind.is_dir()) { | |
| 33 | folders.push(entry.path()); | |
| 34 | } | |
| 35 | } | |
| 36 | } | |
| 37 | folders | |
| 26 | share_folders(&root) | |
| 38 | 27 | } else { |
| 39 | 28 | return None; |
| 40 | 29 | }; |
| ... | ... | @@ -72,6 +61,30 @@ pub fn on_this_computer(path: &Path) -> bool { |
| 72 | 61 | statfs(path).is_none_or(|volume| local(&volume)) |
| 73 | 62 | } |
| 74 | 63 | |
| 64 | /// smbfs reports another client's change only in a folder watched for itself, and names | |
| 65 | /// just that folder. | |
| 66 | #[cfg(unix)] | |
| 67 | fn share_folders(root: &Path) -> Vec<PathBuf> { | |
| 68 | let mut folders = vec![root.to_owned()]; | |
| 69 | let mut at = 0; | |
| 70 | while let Some(folder) = folders.get(at).cloned() { | |
| 71 | at += 1; | |
| 72 | for entry in std::fs::read_dir(folder).into_iter().flatten().flatten() { | |
| 73 | if entry.file_type().is_ok_and(|kind| kind.is_dir()) { | |
| 74 | folders.push(entry.path()); | |
| 75 | } | |
| 76 | } | |
| 77 | } | |
| 78 | folders | |
| 79 | } | |
| 80 | ||
| 81 | /// Windows' SMB client watches a whole tree for the server to report on. | |
| 82 | #[cfg(windows)] | |
| 83 | fn share_folders(root: &Path) -> Vec<PathBuf> { | |
| 84 | vec![root.to_owned()] | |
| 85 | } | |
| 86 | ||
| 87 | #[cfg(unix)] | |
| 75 | 88 | fn statfs(path: &Path) -> Option<libc::statfs> { |
| 76 | 89 | use std::os::unix::ffi::OsStrExt; |
| 77 | 90 | let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?; |
| ... | ... | @@ -112,6 +125,30 @@ fn smbfs(_: &libc::statfs) -> bool { |
| 112 | 125 | false |
| 113 | 126 | } |
| 114 | 127 | |
| 128 | /// The type of the drive holding `path`, as `GetDriveTypeW` names it. | |
| 129 | #[cfg(windows)] | |
| 130 | fn statfs(path: &Path) -> Option<u32> { | |
| 131 | use std::os::windows::ffi::OsStrExt; | |
| 132 | use windows_sys::Win32::Storage::FileSystem::{GetDriveTypeW, GetVolumePathNameW}; | |
| 133 | let path: Vec<u16> = path.as_os_str().encode_wide().chain([0]).collect(); | |
| 134 | let mut root = [0u16; 1024]; | |
| 135 | unsafe { | |
| 136 | (GetVolumePathNameW(path.as_ptr(), root.as_mut_ptr(), root.len() as u32) != 0) | |
| 137 | .then(|| GetDriveTypeW(root.as_ptr())) | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | #[cfg(windows)] | |
| 142 | fn local(drive: &u32) -> bool { | |
| 143 | *drive != windows_sys::Win32::System::WindowsProgramming::DRIVE_REMOTE | |
| 144 | } | |
| 145 | ||
| 146 | /// A network drive or UNC path, which Windows reaches over SMB. | |
| 147 | #[cfg(windows)] | |
| 148 | fn smbfs(drive: &u32) -> bool { | |
| 149 | !local(drive) | |
| 150 | } | |
| 151 | ||
| 115 | 152 | type Changed = Box<dyn Fn(Vec<std::path::PathBuf>) + Send + Sync>; |
| 116 | 153 | |
| 117 | 154 | #[cfg(target_os = "macos")] |
| ... | ... | @@ -467,6 +504,170 @@ mod platform { |
| 467 | 504 | } |
| 468 | 505 | } |
| 469 | 506 | |
| 507 | #[cfg(windows)] | |
| 508 | mod platform { | |
| 509 | use super::Changed; | |
| 510 | use std::{io, os::windows::ffi::OsStrExt, path::PathBuf, thread::JoinHandle}; | |
| 511 | use windows_sys::Win32::{ | |
| 512 | Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0}, | |
| 513 | Storage::FileSystem::{ | |
| 514 | CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, FILE_LIST_DIRECTORY, | |
| 515 | FILE_NOTIFY_CHANGE_ATTRIBUTES, FILE_NOTIFY_CHANGE_DIR_NAME, | |
| 516 | FILE_NOTIFY_CHANGE_FILE_NAME, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_CHANGE_SIZE, | |
| 517 | FILE_NOTIFY_INFORMATION, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, | |
| 518 | OPEN_EXISTING, ReadDirectoryChangesW, | |
| 519 | }, | |
| 520 | System::{ | |
| 521 | IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED}, | |
| 522 | Threading::{CreateEventW, INFINITE, SetEvent, WaitForMultipleObjects}, | |
| 523 | }, | |
| 524 | }; | |
| 525 | ||
| 526 | /// A thread waiting on the folder's changes, or on being stopped. | |
| 527 | pub struct Stream { | |
| 528 | stop: HANDLE, | |
| 529 | thread: Option<JoinHandle<()>>, | |
| 530 | } | |
| 531 | ||
| 532 | // The handle is only signalled and closed, each safe from any thread. | |
| 533 | unsafe impl Send for Stream {} | |
| 534 | unsafe impl Sync for Stream {} | |
| 535 | ||
| 536 | /// Owns a handle for a thread that moves it. | |
| 537 | struct Owned(HANDLE); | |
| 538 | unsafe impl Send for Owned {} | |
| 539 | ||
| 540 | impl Stream { | |
| 541 | /// Watches the tree at `folders[0]`, which Windows reports on as a whole. | |
| 542 | pub fn start(folders: &[PathBuf], changed: Changed) -> io::Result<Self> { | |
| 543 | let root = folders[0].clone(); | |
| 544 | let name: Vec<u16> = root.as_os_str().encode_wide().chain([0]).collect(); | |
| 545 | let folder = unsafe { | |
| 546 | CreateFileW( | |
| 547 | name.as_ptr(), | |
| 548 | FILE_LIST_DIRECTORY, | |
| 549 | FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, | |
| 550 | std::ptr::null(), | |
| 551 | OPEN_EXISTING, | |
| 552 | FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, | |
| 553 | std::ptr::null_mut(), | |
| 554 | ) | |
| 555 | }; | |
| 556 | if folder == INVALID_HANDLE_VALUE { | |
| 557 | return Err(io::Error::last_os_error()); | |
| 558 | } | |
| 559 | let [stop, done] = | |
| 560 | [(); 2].map(|_| unsafe { CreateEventW(std::ptr::null(), 1, 0, std::ptr::null()) }); | |
| 561 | if stop.is_null() || done.is_null() { | |
| 562 | let error = io::Error::last_os_error(); | |
| 563 | unsafe { CloseHandle(folder) }; | |
| 564 | return Err(error); | |
| 565 | } | |
| 566 | let handles = [Owned(folder), Owned(stop), Owned(done)]; | |
| 567 | let thread = std::thread::Builder::new() | |
| 568 | .name("snowbound-watch".into()) | |
| 569 | .spawn(move || { | |
| 570 | let [folder, stop, done] = handles.map(|handle| handle.0); | |
| 571 | watch(folder, stop, done, &root, &changed); | |
| 572 | unsafe { | |
| 573 | CloseHandle(folder); | |
| 574 | CloseHandle(done); | |
| 575 | } | |
| 576 | })?; | |
| 577 | Ok(Self { | |
| 578 | stop, | |
| 579 | thread: Some(thread), | |
| 580 | }) | |
| 581 | } | |
| 582 | } | |
| 583 | ||
| 584 | /// Reports the changes below `root`, whose folder is open as `folder`, until `stop`. | |
| 585 | fn watch( | |
| 586 | folder: HANDLE, | |
| 587 | stop: HANDLE, | |
| 588 | done: HANDLE, | |
| 589 | root: &std::path::Path, | |
| 590 | changed: &Changed, | |
| 591 | ) { | |
| 592 | // 64 KiB, the most a change notification carries over SMB; DWORD-aligned. | |
| 593 | let mut buffer = vec![0u32; 16 * 1024]; | |
| 594 | let filter = FILE_NOTIFY_CHANGE_FILE_NAME | |
| 595 | | FILE_NOTIFY_CHANGE_DIR_NAME | |
| 596 | | FILE_NOTIFY_CHANGE_ATTRIBUTES | |
| 597 | | FILE_NOTIFY_CHANGE_SIZE | |
| 598 | | FILE_NOTIFY_CHANGE_LAST_WRITE; | |
| 599 | loop { | |
| 600 | let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; | |
| 601 | overlapped.hEvent = done; | |
| 602 | let asked = unsafe { | |
| 603 | ReadDirectoryChangesW( | |
| 604 | folder, | |
| 605 | buffer.as_mut_ptr().cast(), | |
| 606 | (buffer.len() * 4) as u32, | |
| 607 | 1, | |
| 608 | filter, | |
| 609 | std::ptr::null_mut(), | |
| 610 | &mut overlapped, | |
| 611 | None, | |
| 612 | ) | |
| 613 | }; | |
| 614 | if asked == 0 { | |
| 615 | return; | |
| 616 | } | |
| 617 | let woken = unsafe { WaitForMultipleObjects(2, [done, stop].as_ptr(), 0, INFINITE) }; | |
| 618 | let mut read = 0; | |
| 619 | let finished = woken == WAIT_OBJECT_0 | |
| 620 | && unsafe { GetOverlappedResult(folder, &overlapped, &mut read, 0) } != 0; | |
| 621 | if !finished { | |
| 622 | // The request must end before its buffer does. | |
| 623 | unsafe { | |
| 624 | CancelIoEx(folder, &overlapped); | |
| 625 | GetOverlappedResult(folder, &overlapped, &mut read, 1); | |
| 626 | } | |
| 627 | return; | |
| 628 | } | |
| 629 | // Too many changes for the buffer: something below the root changed. | |
| 630 | if read == 0 { | |
| 631 | changed(vec![root.to_owned()]); | |
| 632 | continue; | |
| 633 | } | |
| 634 | let bytes: &[u8] = | |
| 635 | unsafe { std::slice::from_raw_parts(buffer.as_ptr().cast(), read as usize) }; | |
| 636 | let mut paths = Vec::new(); | |
| 637 | let mut at = 0; | |
| 638 | loop { | |
| 639 | let entry: FILE_NOTIFY_INFORMATION = | |
| 640 | unsafe { std::ptr::read_unaligned(bytes[at..].as_ptr().cast()) }; | |
| 641 | let start = at + std::mem::offset_of!(FILE_NOTIFY_INFORMATION, FileName); | |
| 642 | let end = (start + entry.FileNameLength as usize).min(bytes.len()); | |
| 643 | let name: Vec<u16> = bytes[start..end] | |
| 644 | .chunks_exact(2) | |
| 645 | .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) | |
| 646 | .collect(); | |
| 647 | paths.push(root.join(String::from_utf16_lossy(&name))); | |
| 648 | if entry.NextEntryOffset == 0 { | |
| 649 | break; | |
| 650 | } | |
| 651 | at += entry.NextEntryOffset as usize; | |
| 652 | if at >= bytes.len() { | |
| 653 | break; | |
| 654 | } | |
| 655 | } | |
| 656 | changed(paths); | |
| 657 | } | |
| 658 | } | |
| 659 | ||
| 660 | impl Drop for Stream { | |
| 661 | fn drop(&mut self) { | |
| 662 | unsafe { SetEvent(self.stop) }; | |
| 663 | if let Some(thread) = self.thread.take() { | |
| 664 | let _ = thread.join(); | |
| 665 | } | |
| 666 | unsafe { CloseHandle(self.stop) }; | |
| 667 | } | |
| 668 | } | |
| 669 | } | |
| 670 | ||
| 470 | 671 | #[cfg(test)] |
| 471 | 672 | mod tests { |
| 472 | 673 | use super::*; |
crates/snowbound/src/windows.rs created+1428| ... | ... | @@ -0,0 +1,1428 @@ |
| 1 | //! Windows 7 to 11. Where the desktop composes windows, the toolbar's row is the title bar: | |
| 2 | //! over Aero glass on 7 and Mica on 11, beside the system's own caption buttons, and on 8 | |
| 3 | //! and 10 with caption buttons drawn as theirs, over 10's acrylic. Dialogs are the | |
| 4 | //! system's, passwords live in the Credential Manager, and dates follow the user's locale. | |
| 5 | //! Anything newer than Windows 7 is looked up at run time, so one executable starts on all | |
| 6 | //! of them. | |
| 7 | ||
| 8 | use canvas::date::DateField; | |
| 9 | use std::{ | |
| 10 | ffi::c_void, | |
| 11 | path::PathBuf, | |
| 12 | sync::{ | |
| 13 | OnceLock, | |
| 14 | atomic::{AtomicBool, AtomicIsize, AtomicU32, Ordering}, | |
| 15 | }, | |
| 16 | time::Duration, | |
| 17 | }; | |
| 18 | use ui::{Flags, Spec, Ui, px}; | |
| 19 | use windows_sys::Win32::{ | |
| 20 | Foundation::{FILETIME, HWND, LPARAM, LRESULT, POINT, RECT, SYSTEMTIME, WPARAM}, | |
| 21 | Graphics::{Dwm, Gdi}, | |
| 22 | System::LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW}, | |
| 23 | UI::{Controls::MARGINS, WindowsAndMessaging as wm}, | |
| 24 | }; | |
| 25 | use windows_sys::core::BOOL; | |
| 26 | use winit::{ | |
| 27 | error::EventLoopError, | |
| 28 | event_loop::{EventLoop, EventLoopProxy}, | |
| 29 | platform::windows::{IconExtWindows, WindowAttributesExtWindows}, | |
| 30 | raw_window_handle::{HasWindowHandle, RawWindowHandle}, | |
| 31 | window::{Icon, Theme, Window, WindowAttributes}, | |
| 32 | }; | |
| 33 | ||
| 34 | /// The title bar's leading margin. | |
| 35 | pub const LEADING: f32 = 8.0; | |
| 36 | /// The margin past the caption buttons, which the row's last gap already makes; the | |
| 37 | /// buttons slide over it to the window's corner. | |
| 38 | pub const TRAILING: f32 = GAP; | |
| 39 | const GAP: f32 = 6.0; | |
| 40 | /// A drawn caption button's width, as Windows 10 draws its own. | |
| 41 | const CAPTION_BUTTON: f32 = 46.0; | |
| 42 | /// How far inside the window's edges a press resizes it, at 96 dpi. | |
| 43 | const EDGE: f32 = 8.0; | |
| 44 | ||
| 45 | static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); | |
| 46 | /// The window, which dialogs belong to. | |
| 47 | static WINDOW: AtomicIsize = AtomicIsize::new(0); | |
| 48 | /// The window procedure the frame's own passes the rest to. | |
| 49 | static PREVIOUS: AtomicIsize = AtomicIsize::new(0); | |
| 50 | /// Whether the desktop composes windows, which Windows 7's basic and classic themes don't. | |
| 51 | static COMPOSED: AtomicBool = AtomicBool::new(false); | |
| 52 | /// The window's scale factor, as `f32` bits, for hit-testing outside a frame. | |
| 53 | static SCALE: AtomicU32 = AtomicU32::new(0x3f80_0000); | |
| 54 | /// The caption button under the pointer, by its hit-test code: as the system reports it | |
| 55 | /// over Mica, where the system runs them, and as the row saw it for the close button, | |
| 56 | /// whose glyph turns white on red. | |
| 57 | static CAPTION_HOVERED: AtomicU32 = AtomicU32::new(0); | |
| 58 | ||
| 59 | /// The Windows release: its major and minor version and its build. | |
| 60 | fn version() -> (u32, u32, u32) { | |
| 61 | static VERSION: OnceLock<(u32, u32, u32)> = OnceLock::new(); | |
| 62 | *VERSION.get_or_init(|| { | |
| 63 | // GetVersionEx answers as the manifest allows; ntdll's RtlGetVersion never lies. | |
| 64 | #[repr(C)] | |
| 65 | struct Info { | |
| 66 | size: u32, | |
| 67 | major: u32, | |
| 68 | minor: u32, | |
| 69 | build: u32, | |
| 70 | platform: u32, | |
| 71 | service_pack: [u16; 128], | |
| 72 | } | |
| 73 | let mut info = Info { | |
| 74 | size: size_of::<Info>() as u32, | |
| 75 | major: 6, | |
| 76 | minor: 1, | |
| 77 | build: 7601, | |
| 78 | platform: 0, | |
| 79 | service_pack: [0; 128], | |
| 80 | }; | |
| 81 | if let Some(get) = | |
| 82 | function::<unsafe extern "system" fn(*mut Info) -> i32>("ntdll.dll", c"RtlGetVersion") | |
| 83 | { | |
| 84 | unsafe { get(&mut info) }; | |
| 85 | } | |
| 86 | (info.major, info.minor, info.build) | |
| 87 | }) | |
| 88 | } | |
| 89 | ||
| 90 | /// Windows 11, whose windows have rounded corners and Mica. | |
| 91 | fn eleven() -> bool { | |
| 92 | version().0 >= 10 && version().2 >= 22000 | |
| 93 | } | |
| 94 | ||
| 95 | /// Who draws the caption buttons. | |
| 96 | #[derive(Clone, Copy, PartialEq)] | |
| 97 | enum Caption { | |
| 98 | /// The system, in its own title bar: Windows 7 while the desktop doesn't compose. | |
| 99 | System, | |
| 100 | /// The system, over the glass or Mica the row shows through: Windows 7 composing, 11. | |
| 101 | Glass, | |
| 102 | /// The row, as the system draws them: Windows 8 and 10, whose frames are opaque. | |
| 103 | Drawn, | |
| 104 | } | |
| 105 | ||
| 106 | fn caption() -> Caption { | |
| 107 | if eleven() || (version() < (6, 2, 0) && COMPOSED.load(Ordering::Relaxed)) { | |
| 108 | Caption::Glass | |
| 109 | } else if version() < (6, 2, 0) { | |
| 110 | Caption::System | |
| 111 | } else { | |
| 112 | Caption::Drawn | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | /// Function `name` of `library`, of signature `F`, where this Windows has it. | |
| 117 | fn function<F: Copy>(library: &str, name: &std::ffi::CStr) -> Option<F> { | |
| 118 | let library = wide(library); | |
| 119 | unsafe { | |
| 120 | let mut module = GetModuleHandleW(library.as_ptr()); | |
| 121 | if module.is_null() { | |
| 122 | module = LoadLibraryW(library.as_ptr()); | |
| 123 | } | |
| 124 | let address = GetProcAddress(module, name.as_ptr().cast())?; | |
| 125 | Some(std::mem::transmute_copy(&address)) | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | /// `text` as a NUL-terminated UTF-16 string. | |
| 130 | fn wide(text: impl AsRef<std::ffi::OsStr>) -> Vec<u16> { | |
| 131 | use std::os::windows::ffi::OsStrExt; | |
| 132 | text.as_ref().encode_wide().chain([0]).collect() | |
| 133 | } | |
| 134 | ||
| 135 | /// The UTF-16 string at `text` up to its first NUL. | |
| 136 | fn narrow(text: &[u16]) -> String { | |
| 137 | let end = text | |
| 138 | .iter() | |
| 139 | .position(|&unit| unit == 0) | |
| 140 | .unwrap_or(text.len()); | |
| 141 | String::from_utf16_lossy(&text[..end]) | |
| 142 | } | |
| 143 | ||
| 144 | fn hwnd(window: &Window) -> HWND { | |
| 145 | match window.window_handle().expect("Live window").as_raw() { | |
| 146 | RawWindowHandle::Win32(handle) => handle.hwnd.get() as HWND, | |
| 147 | _ => unreachable!(), | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | /// The window dialogs belong to, once there is one. | |
| 152 | fn owner() -> HWND { | |
| 153 | WINDOW.load(Ordering::Relaxed) as HWND | |
| 154 | } | |
| 155 | ||
| 156 | fn composed() -> bool { | |
| 157 | let mut on: BOOL = 0; | |
| 158 | unsafe { Dwm::DwmIsCompositionEnabled(&mut on) >= 0 && on != 0 } | |
| 159 | } | |
| 160 | ||
| 161 | pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> { | |
| 162 | let event_loop = EventLoop::with_user_event().build()?; | |
| 163 | QUIT.set(event_loop.create_proxy()) | |
| 164 | .expect("Only one application event loop is created"); | |
| 165 | COMPOSED.store(composed(), Ordering::Relaxed); | |
| 166 | Ok(event_loop) | |
| 167 | } | |
| 168 | ||
| 169 | /// The executable's icon, and where the row draws the caption buttons, no system frame: | |
| 170 | /// the desktop draws the window's shadow. | |
| 171 | pub fn window_attributes() -> WindowAttributes { | |
| 172 | let icon = |size| Icon::from_resource(1, Some(winit::dpi::PhysicalSize::new(size, size))); | |
| 173 | Window::default_attributes() | |
| 174 | .with_window_icon(icon(16).ok()) | |
| 175 | .with_taskbar_icon(icon(32).ok()) | |
| 176 | .with_decorations(caption() != Caption::Drawn) | |
| 177 | .with_undecorated_shadow(true) | |
| 178 | } | |
| 179 | ||
| 180 | /// Takes the window's frame messages ahead of winit's: over glass or Mica, the caption | |
| 181 | /// leaves the non-client area for the row, beside the system's caption buttons; with drawn | |
| 182 | /// buttons, the window's edges resize it. | |
| 183 | pub fn install_title_bar(window: &Window) { | |
| 184 | let hwnd = hwnd(window); | |
| 185 | WINDOW.store(hwnd as isize, Ordering::Relaxed); | |
| 186 | SCALE.store((window.scale_factor() as f32).to_bits(), Ordering::Relaxed); | |
| 187 | let procedure = frame_procedure as *const () as isize; | |
| 188 | PREVIOUS.store( | |
| 189 | unsafe { wm::SetWindowLongPtrW(hwnd, wm::GWLP_WNDPROC, procedure) }, | |
| 190 | Ordering::Relaxed, | |
| 191 | ); | |
| 192 | reframe(hwnd); | |
| 193 | } | |
| 194 | ||
| 195 | /// Has the system measure the window's frame again. | |
| 196 | fn reframe(hwnd: HWND) { | |
| 197 | let flags = wm::SWP_FRAMECHANGED | wm::SWP_NOMOVE | wm::SWP_NOSIZE | wm::SWP_NOZORDER; | |
| 198 | unsafe { wm::SetWindowPos(hwnd, std::ptr::null_mut(), 0, 0, 0, 0, flags) }; | |
| 199 | } | |
| 200 | ||
| 201 | fn set_attribute<T>(hwnd: HWND, attribute: u32, value: &T) -> bool { | |
| 202 | unsafe { | |
| 203 | Dwm::DwmSetWindowAttribute( | |
| 204 | hwnd, | |
| 205 | attribute, | |
| 206 | (value as *const T).cast(), | |
| 207 | size_of::<T>() as u32, | |
| 208 | ) >= 0 | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | /// The system's frame under the whole window, which the row and the rest of the chrome | |
| 213 | /// show through. | |
| 214 | fn extend_frame(hwnd: HWND) { | |
| 215 | let sheet = MARGINS { | |
| 216 | cxLeftWidth: -1, | |
| 217 | cxRightWidth: -1, | |
| 218 | cyTopHeight: -1, | |
| 219 | cyBottomHeight: -1, | |
| 220 | }; | |
| 221 | unsafe { Dwm::DwmExtendFrameIntoClientArea(hwnd, &sheet) }; | |
| 222 | } | |
| 223 | ||
| 224 | unsafe extern "system" fn frame_procedure( | |
| 225 | hwnd: HWND, | |
| 226 | message: u32, | |
| 227 | wparam: WPARAM, | |
| 228 | lparam: LPARAM, | |
| 229 | ) -> LRESULT { | |
| 230 | let previous = |message, wparam, lparam| unsafe { | |
| 231 | let procedure: wm::WNDPROC = std::mem::transmute(PREVIOUS.load(Ordering::Relaxed)); | |
| 232 | wm::CallWindowProcW(procedure, hwnd, message, wparam, lparam) | |
| 233 | }; | |
| 234 | match message { | |
| 235 | // Accent colours and transparency effects reach the window only as settings. | |
| 236 | wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => { | |
| 237 | if let Some(proxy) = QUIT.get() { | |
| 238 | let _ = proxy.send_event(crate::UserEvent::Appearance); | |
| 239 | } | |
| 240 | } | |
| 241 | wm::WM_DWMCOMPOSITIONCHANGED => { | |
| 242 | COMPOSED.store(composed(), Ordering::Relaxed); | |
| 243 | reframe(hwnd); | |
| 244 | } | |
| 245 | _ => {} | |
| 246 | } | |
| 247 | let result = match caption() { | |
| 248 | Caption::System => None, | |
| 249 | Caption::Glass => glass_frame(hwnd, message, wparam, lparam, &previous), | |
| 250 | Caption::Drawn => drawn_frame(hwnd, message, wparam, lparam, &previous), | |
| 251 | }; | |
| 252 | result.unwrap_or_else(|| previous(message, wparam, lparam)) | |
| 253 | } | |
| 254 | ||
| 255 | type Procedure<'a> = &'a dyn Fn(u32, WPARAM, LPARAM) -> LRESULT; | |
| 256 | ||
| 257 | /// The frame over glass or Mica: the system's frame keeps its sides and bottom, the caption | |
| 258 | /// goes, and the system draws and hit-tests its caption buttons over the row. | |
| 259 | fn glass_frame( | |
| 260 | hwnd: HWND, | |
| 261 | message: u32, | |
| 262 | wparam: WPARAM, | |
| 263 | lparam: LPARAM, | |
| 264 | previous: Procedure<'_>, | |
| 265 | ) -> Option<LRESULT> { | |
| 266 | if eleven() { | |
| 267 | light_caption(hwnd, message, wparam); | |
| 268 | } | |
| 269 | let mut result = 0; | |
| 270 | if unsafe { Dwm::DwmDefWindowProc(hwnd, message, wparam, lparam, &mut result) } != 0 { | |
| 271 | return Some(result); | |
| 272 | } | |
| 273 | let maximized = unsafe { wm::IsZoomed(hwnd) } != 0; | |
| 274 | match message { | |
| 275 | wm::WM_NCCALCSIZE if wparam != 0 => { | |
| 276 | let params = unsafe { &mut *(lparam as *mut wm::NCCALCSIZE_PARAMS) }; | |
| 277 | let top = params.rgrc[0].top; | |
| 278 | let result = previous(message, wparam, lparam); | |
| 279 | // A maximized window's frame lies past the screen's edge. | |
| 280 | params.rgrc[0].top = top + if maximized { frame_size() } else { 0 }; | |
| 281 | Some(result) | |
| 282 | } | |
| 283 | wm::WM_NCHITTEST => { | |
| 284 | let hit = previous(message, wparam, lparam); | |
| 285 | let top = client_point(hwnd, lparam).is_some_and(|point| point.y < frame_size()); | |
| 286 | Some(if hit == wm::HTCLIENT as LRESULT && top && !maximized { | |
| 287 | wm::HTTOP as LRESULT | |
| 288 | } else { | |
| 289 | hit | |
| 290 | }) | |
| 291 | } | |
| 292 | wm::WM_ACTIVATE | wm::WM_DWMCOMPOSITIONCHANGED => { | |
| 293 | extend_frame(hwnd); | |
| 294 | None | |
| 295 | } | |
| 296 | _ => None, | |
| 297 | } | |
| 298 | } | |
| 299 | ||
| 300 | /// Follows the pointer over the system's caption buttons, which the row draws on Windows 11. | |
| 301 | fn light_caption(hwnd: HWND, message: u32, wparam: WPARAM) { | |
| 302 | use windows_sys::Win32::UI::Input::KeyboardAndMouse as input; | |
| 303 | let over = match message { | |
| 304 | wm::WM_NCMOUSEMOVE => match wparam as u32 { | |
| 305 | hit @ (wm::HTMINBUTTON | wm::HTMAXBUTTON | wm::HTCLOSE) => hit, | |
| 306 | _ => 0, | |
| 307 | }, | |
| 308 | wm::WM_NCMOUSELEAVE => 0, | |
| 309 | _ => return, | |
| 310 | }; | |
| 311 | if CAPTION_HOVERED.swap(over, Ordering::Relaxed) == over { | |
| 312 | return; | |
| 313 | } | |
| 314 | if over != 0 { | |
| 315 | let mut track = input::TRACKMOUSEEVENT { | |
| 316 | cbSize: size_of::<input::TRACKMOUSEEVENT>() as u32, | |
| 317 | dwFlags: input::TME_LEAVE | input::TME_NONCLIENT, | |
| 318 | hwndTrack: hwnd, | |
| 319 | dwHoverTime: 0, | |
| 320 | }; | |
| 321 | unsafe { input::TrackMouseEvent(&mut track) }; | |
| 322 | } | |
| 323 | unsafe { Gdi::InvalidateRect(hwnd, std::ptr::null(), 0) }; | |
| 324 | } | |
| 325 | ||
| 326 | /// The system frame's thickness at the top, where the caption went. | |
| 327 | fn frame_size() -> i32 { | |
| 328 | unsafe { wm::GetSystemMetrics(wm::SM_CYFRAME) + wm::GetSystemMetrics(wm::SM_CXPADDEDBORDER) } | |
| 329 | } | |
| 330 | ||
| 331 | /// The screen point in a hit-test's `lparam` in the window's client coordinates. | |
| 332 | fn client_point(hwnd: HWND, lparam: LPARAM) -> Option<POINT> { | |
| 333 | let mut point = POINT { | |
| 334 | x: (lparam & 0xffff) as i16 as i32, | |
| 335 | y: ((lparam >> 16) & 0xffff) as i16 as i32, | |
| 336 | }; | |
| 337 | (unsafe { Gdi::ScreenToClient(hwnd, &mut point) } != 0).then_some(point) | |
| 338 | } | |
| 339 | ||
| 340 | /// The frame with drawn caption buttons, where winit has made the whole window its client | |
| 341 | /// area: its edges resize it. | |
| 342 | fn drawn_frame( | |
| 343 | hwnd: HWND, | |
| 344 | message: u32, | |
| 345 | wparam: WPARAM, | |
| 346 | lparam: LPARAM, | |
| 347 | previous: Procedure<'_>, | |
| 348 | ) -> Option<LRESULT> { | |
| 349 | match message { | |
| 350 | // -1 keeps the system from painting a caption the window no longer has. | |
| 351 | wm::WM_NCACTIVATE => Some(previous(message, wparam, -1)), | |
| 352 | wm::WM_NCHITTEST => { | |
| 353 | let point = client_point(hwnd, lparam)?; | |
| 354 | let style = unsafe { wm::GetWindowLongW(hwnd, wm::GWL_STYLE) } as u32; | |
| 355 | // Maximized or full screen, the window has no edges. | |
| 356 | if unsafe { wm::IsZoomed(hwnd) } != 0 || style & wm::WS_CAPTION != wm::WS_CAPTION { | |
| 357 | return None; | |
| 358 | } | |
| 359 | let mut client = RECT::default(); | |
| 360 | unsafe { wm::GetClientRect(hwnd, &mut client) }; | |
| 361 | let edge = (EDGE * f32::from_bits(SCALE.load(Ordering::Relaxed))).round() as i32; | |
| 362 | let hit = match [ | |
| 363 | point.y < edge, | |
| 364 | point.y >= client.bottom - edge, | |
| 365 | point.x < edge, | |
| 366 | point.x >= client.right - edge, | |
| 367 | ] { | |
| 368 | [true, _, true, _] => wm::HTTOPLEFT, | |
| 369 | [true, _, _, true] => wm::HTTOPRIGHT, | |
| 370 | [_, true, true, _] => wm::HTBOTTOMLEFT, | |
| 371 | [_, true, _, true] => wm::HTBOTTOMRIGHT, | |
| 372 | [true, ..] => wm::HTTOP, | |
| 373 | [_, true, ..] => wm::HTBOTTOM, | |
| 374 | [.., true, _] => wm::HTLEFT, | |
| 375 | [.., true] => wm::HTRIGHT, | |
| 376 | _ => return None, | |
| 377 | }; | |
| 378 | Some(hit as LRESULT) | |
| 379 | } | |
| 380 | // Windows 10's acrylic lags a window being dragged or resized; plain blur doesn't. | |
| 381 | wm::WM_ENTERSIZEMOVE if ACRYLIC.load(Ordering::Relaxed) => { | |
| 382 | accent(hwnd, Accent::Blur); | |
| 383 | None | |
| 384 | } | |
| 385 | wm::WM_EXITSIZEMOVE if ACRYLIC.load(Ordering::Relaxed) => { | |
| 386 | accent(hwnd, Accent::Acrylic(ACRYLIC_TINT.load(Ordering::Relaxed))); | |
| 387 | None | |
| 388 | } | |
| 389 | _ => None, | |
| 390 | } | |
| 391 | } | |
| 392 | ||
| 393 | /// Where the row is not the title bar: Windows 7 with its basic or classic theme, whose | |
| 394 | /// desktop draws no glass and whose title bars the system draws. | |
| 395 | pub fn system_titlebar(_: &Window) -> bool { | |
| 396 | caption() == Caption::System | |
| 397 | } | |
| 398 | ||
| 399 | /// Windows 11 rounds a window's corners unless it is maximized; the desktop clips them. | |
| 400 | pub fn corner_radius(window: &Window) -> f32 { | |
| 401 | if eleven() && !window.is_maximized() && window.fullscreen().is_none() { | |
| 402 | 8.0 | |
| 403 | } else { | |
| 404 | 0.0 | |
| 405 | } | |
| 406 | } | |
| 407 | ||
| 408 | pub fn cuts_corners() -> bool { | |
| 409 | false | |
| 410 | } | |
| 411 | ||
| 412 | /// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur, | |
| 413 | /// and the tint it shows it with. | |
| 414 | static ACRYLIC: AtomicBool = AtomicBool::new(false); | |
| 415 | static ACRYLIC_TINT: AtomicU32 = AtomicU32::new(0); | |
| 416 | ||
| 417 | enum Accent { | |
| 418 | Blur, | |
| 419 | /// Acrylic tinted with an `0xAABBGGRR` colour. | |
| 420 | Acrylic(u32), | |
| 421 | } | |
| 422 | ||
| 423 | /// Windows 10's accent under the window, through the undocumented | |
| 424 | /// SetWindowCompositionAttribute that its own shell uses. | |
| 425 | fn accent(hwnd: HWND, accent: Accent) -> bool { | |
| 426 | #[repr(C)] | |
| 427 | struct Policy { | |
| 428 | state: u32, | |
| 429 | flags: u32, | |
| 430 | tint: u32, | |
| 431 | animation: u32, | |
| 432 | } | |
| 433 | #[repr(C)] | |
| 434 | struct Data { | |
| 435 | attribute: u32, | |
| 436 | data: *mut c_void, | |
| 437 | size: usize, | |
| 438 | } | |
| 439 | type Set = unsafe extern "system" fn(HWND, *mut Data) -> BOOL; | |
| 440 | let Some(set) = function::<Set>("user32.dll", c"SetWindowCompositionAttribute") else { | |
| 441 | return false; | |
| 442 | }; | |
| 443 | // ACCENT_ENABLE_BLURBEHIND and ACCENT_ENABLE_ACRYLICBLURBEHIND; flag 2 takes the tint. | |
| 444 | let mut policy = match accent { | |
| 445 | Accent::Blur => Policy { | |
| 446 | state: 3, | |
| 447 | flags: 0, | |
| 448 | tint: 0, | |
| 449 | animation: 0, | |
| 450 | }, | |
| 451 | Accent::Acrylic(tint) => Policy { | |
| 452 | state: 4, | |
| 453 | flags: 2, | |
| 454 | tint, | |
| 455 | animation: 0, | |
| 456 | }, | |
| 457 | }; | |
| 458 | // WCA_ACCENT_POLICY. | |
| 459 | let mut data = Data { | |
| 460 | attribute: 19, | |
| 461 | data: (&mut policy as *mut Policy).cast(), | |
| 462 | size: size_of::<Policy>(), | |
| 463 | }; | |
| 464 | unsafe { set(hwnd, &mut data) != 0 } | |
| 465 | } | |
| 466 | ||
| 467 | /// Whether Personalization's transparency effects are on, as acrylic and Mica need. | |
| 468 | fn transparency() -> bool { | |
| 469 | registry_dword( | |
| 470 | r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", | |
| 471 | "EnableTransparency", | |
| 472 | ) != Some(0) | |
| 473 | } | |
| 474 | ||
| 475 | /// Lays the system's material under the window, which shows through the app's transparent | |
| 476 | /// pixels: Aero glass on Windows 7 while the desktop composes, acrylic on Windows 10 (blur | |
| 477 | /// before its April 2018 update) and Mica on 11. Windows 8 has none. | |
| 478 | pub fn install_backdrop(window: &Window) -> bool { | |
| 479 | let hwnd = hwnd(window); | |
| 480 | let (major, _, build) = version(); | |
| 481 | match caption() { | |
| 482 | Caption::System => false, | |
| 483 | Caption::Glass if !eleven() => { | |
| 484 | extend_frame(hwnd); | |
| 485 | // Blurring behind the whole window has the desktop take its pixels' alpha. | |
| 486 | let blur = Dwm::DWM_BLURBEHIND { | |
| 487 | dwFlags: Dwm::DWM_BB_ENABLE, | |
| 488 | fEnable: 1, | |
| 489 | hRgnBlur: std::ptr::null_mut(), | |
| 490 | fTransitionOnMaximized: 0, | |
| 491 | }; | |
| 492 | unsafe { Dwm::DwmEnableBlurBehindWindow(hwnd, &blur) }; | |
| 493 | true | |
| 494 | } | |
| 495 | _ if major < 10 || !transparency() => false, | |
| 496 | Caption::Glass => { | |
| 497 | extend_frame(hwnd); | |
| 498 | if build >= 22621 { | |
| 499 | // DWMWA_SYSTEMBACKDROP_TYPE, DWMSBT_MAINWINDOW. | |
| 500 | set_attribute(hwnd, 38, &2u32) | |
| 501 | } else { | |
| 502 | // Windows 11's first release names Mica by an undocumented attribute. | |
| 503 | set_attribute(hwnd, 1029, &1u32) | |
| 504 | } | |
| 505 | } | |
| 506 | Caption::Drawn if build >= 17134 => { | |
| 507 | // The shell's own acrylic tints: Windows 10's light and dark flyouts. | |
| 508 | let dark = appearance(window) == Theme::Dark; | |
| 509 | let tint = if dark { 0xcc20_2020 } else { 0xccf3_f3f3 }; | |
| 510 | ACRYLIC_TINT.store(tint, Ordering::Relaxed); | |
| 511 | let shown = accent(hwnd, Accent::Acrylic(tint)); | |
| 512 | ACRYLIC.store(shown, Ordering::Relaxed); | |
| 513 | shown | |
| 514 | } | |
| 515 | Caption::Drawn => accent(hwnd, Accent::Blur), | |
| 516 | } | |
| 517 | } | |
| 518 | ||
| 519 | /// The title bar's fills, focused and not, where no material lies under it: Windows 10 | |
| 520 | /// and 11's, or the accent colour where Personalization shows it on title bars. | |
| 521 | pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { | |
| 522 | if version().0 < 10 { | |
| 523 | return None; | |
| 524 | } | |
| 525 | let dwm = r"Software\Microsoft\Windows\DWM"; | |
| 526 | if registry_dword(dwm, "ColorPrevalence") == Some(1) | |
| 527 | && let Some(color) = registry_dword(dwm, "AccentColor") | |
| 528 | { | |
| 529 | let [red, green, blue, _] = color.to_le_bytes(); | |
| 530 | let inactive = if appearance == Theme::Dark { | |
| 531 | 0x2b | |
| 532 | } else { | |
| 533 | 0xff | |
| 534 | }; | |
| 535 | return Some([ | |
| 536 | draw::srgb(red, green, blue), | |
| 537 | draw::srgb(inactive, inactive, inactive), | |
| 538 | ]); | |
| 539 | } | |
| 540 | Some(match appearance { | |
| 541 | Theme::Dark => [draw::srgb(0x20, 0x20, 0x20), draw::srgb(0x2b, 0x2b, 0x2b)], | |
| 542 | Theme::Light => [draw::srgb(0xff, 0xff, 0xff); 2], | |
| 543 | }) | |
| 544 | } | |
| 545 | ||
| 546 | /// None: the kit's own menus. | |
| 547 | pub fn menu(_: Theme) -> Option<ui::Menu> { | |
| 548 | None | |
| 549 | } | |
| 550 | ||
| 551 | const MINIMIZE: &[&str] = &[include_str!("../assets/windows/caption-minimize.svg")]; | |
| 552 | const MAXIMIZE: &[&str] = &[include_str!("../assets/windows/caption-maximize.svg")]; | |
| 553 | const RESTORE: &[&str] = &[include_str!("../assets/windows/caption-restore.svg")]; | |
| 554 | const CLOSE: &[&str] = &[include_str!("../assets/windows/caption-close.svg")]; | |
| 555 | ||
| 556 | /// The caption buttons at the row's trailing end, flush with the window's corner. Over glass | |
| 557 | /// and Mica the system hit-tests and runs its own, which brings Windows 11's snap layouts: | |
| 558 | /// 7 draws them over the glass and the row leaves them room, and 11 doesn't draw them over | |
| 559 | /// the DirectComposition surface, so the row draws them as 11 does, lit as the system | |
| 560 | /// reports the pointer. On 8 and 10 the row draws and runs them as 10 does. | |
| 561 | pub fn window_controls(ui: &mut Ui, window: &Window) { | |
| 562 | let scale = window.scale_factor() as f32; | |
| 563 | SCALE.store(scale.to_bits(), Ordering::Relaxed); | |
| 564 | let system = caption() == Caption::Glass; | |
| 565 | let mut bounds = RECT::default(); | |
| 566 | // DWMWA_CAPTION_BUTTON_BOUNDS. | |
| 567 | let found = system | |
| 568 | && unsafe { | |
| 569 | Dwm::DwmGetWindowAttribute( | |
| 570 | hwnd(window), | |
| 571 | 5, | |
| 572 | (&mut bounds as *mut RECT).cast(), | |
| 573 | size_of::<RECT>() as u32, | |
| 574 | ) | |
| 575 | } >= 0; | |
| 576 | let size = if found { | |
| 577 | [bounds.right - bounds.left, bounds.bottom - bounds.top].map(|side| side as f32 / scale) | |
| 578 | } else { | |
| 579 | [3.0 * CAPTION_BUTTON, crate::TITLE] | |
| 580 | }; | |
| 581 | let offset = [GAP, -(crate::TITLE - ui::shell::TOOL) / 2.0]; | |
| 582 | if system && !eleven() { | |
| 583 | let size = [px(size[0]), px(1.0)]; | |
| 584 | ui.leaf( | |
| 585 | "caption", | |
| 586 | Spec { | |
| 587 | size, | |
| 588 | offset: [GAP, 0.0], | |
| 589 | ..Spec::default() | |
| 590 | }, | |
| 591 | ); | |
| 592 | return; | |
| 593 | } | |
| 594 | ui.open( | |
| 595 | "caption", | |
| 596 | Spec { | |
| 597 | size: [px(size[0]), px(size[1])], | |
| 598 | offset, | |
| 599 | ..Spec::default() | |
| 600 | }, | |
| 601 | ); | |
| 602 | let text = ui.theme.text; | |
| 603 | // Windows 11's subtle fill and red, and Windows 10's grey and red. | |
| 604 | let (hover, red) = if eleven() { | |
| 605 | ( | |
| 606 | [text[0], text[1], text[2], 0.06], | |
| 607 | draw::srgb(0xc4, 0x2b, 0x1c), | |
| 608 | ) | |
| 609 | } else { | |
| 610 | ( | |
| 611 | [text[0], text[1], text[2], 0.1], | |
| 612 | draw::srgb(0xe8, 0x11, 0x23), | |
| 613 | ) | |
| 614 | }; | |
| 615 | let lit = CAPTION_HOVERED.load(Ordering::Relaxed); | |
| 616 | let maximized = window.is_maximized(); | |
| 617 | let buttons = [ | |
| 618 | ("minimize", "Minimize", MINIMIZE, wm::HTMINBUTTON), | |
| 619 | ( | |
| 620 | "maximize", | |
| 621 | if maximized { "Restore" } else { "Maximize" }, | |
| 622 | if maximized { RESTORE } else { MAXIMIZE }, | |
| 623 | wm::HTMAXBUTTON, | |
| 624 | ), | |
| 625 | ("close", "Close", CLOSE, wm::HTCLOSE), | |
| 626 | ]; | |
| 627 | for (id, name, icon, hit) in buttons { | |
| 628 | let fill = if hit == wm::HTCLOSE { red } else { hover }; | |
| 629 | let spec = Spec { | |
| 630 | size: [px(size[0] / 3.0), px(size[1])], | |
| 631 | icon: Some(icon), | |
| 632 | color: Some(if hit == wm::HTCLOSE && lit == hit { | |
| 633 | [1.0; 4] | |
| 634 | } else { | |
| 635 | text | |
| 636 | }), | |
| 637 | center: true, | |
| 638 | ..Spec::default() | |
| 639 | }; | |
| 640 | if system { | |
| 641 | let fill = Some(if lit == hit { fill } else { [0.0; 4] }); | |
| 642 | ui.leaf(id, Spec { fill, ..spec }); | |
| 643 | continue; | |
| 644 | } | |
| 645 | let response = ui.leaf( | |
| 646 | id, | |
| 647 | Spec { | |
| 648 | flags: Flags::CLICKABLE, | |
| 649 | hover_fill: Some(fill), | |
| 650 | role: Some(accesskit::Role::Button), | |
| 651 | ..spec | |
| 652 | }, | |
| 653 | ); | |
| 654 | let over = if response.hovered { hit } else { 0 }; | |
| 655 | if hit == wm::HTCLOSE && CAPTION_HOVERED.swap(over, Ordering::Relaxed) != over { | |
| 656 | window.request_redraw(); | |
| 657 | } | |
| 658 | if let Some(node) = ui.access(ui.id(id)) { | |
| 659 | node.set_label(name); | |
| 660 | } | |
| 661 | if response.clicked { | |
| 662 | match id { | |
| 663 | "minimize" => window.set_minimized(true), | |
| 664 | "maximize" => zoom(window), | |
| 665 | _ => { | |
| 666 | if let Some(proxy) = QUIT.get() { | |
| 667 | let _ = proxy.send_event(crate::UserEvent::Quit); | |
| 668 | } | |
| 669 | } | |
| 670 | } | |
| 671 | } | |
| 672 | } | |
| 673 | ui.close(); | |
| 674 | } | |
| 675 | ||
| 676 | /// The frame's edges take resizing presses. | |
| 677 | pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::ResizeDirection> { | |
| 678 | None | |
| 679 | } | |
| 680 | ||
| 681 | pub fn zoom(window: &Window) { | |
| 682 | window.set_maximized(!window.is_maximized()); | |
| 683 | } | |
| 684 | ||
| 685 | /// Winit delivers typed text with its key events on Windows. | |
| 686 | pub fn install_text_input(_: &Window) {} | |
| 687 | ||
| 688 | pub fn move_cursor() -> winit::window::CursorIcon { | |
| 689 | winit::window::CursorIcon::Move | |
| 690 | } | |
| 691 | ||
| 692 | /// No system border lies under the chrome. | |
| 693 | pub fn cover_border_line(_: &mut Ui, _: f32) {} | |
| 694 | ||
| 695 | pub fn with_pool<R>(run: impl FnOnce() -> R) -> R { | |
| 696 | run() | |
| 697 | } | |
| 698 | ||
| 699 | /// The interface keeps its own font, which fontique finds as Segoe UI. | |
| 700 | pub fn system_interface(_: &mut Ui) {} | |
| 701 | ||
| 702 | /// Resizing takes the window's edges. | |
| 703 | pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} | |
| 704 | ||
| 705 | /// Options opens from the sidebar's footer and Ctrl+Comma; OneNote has a ribbon, not a | |
| 706 | /// menu bar, and the toolbar and the command palette stand in for it. | |
| 707 | pub fn install_menu() {} | |
| 708 | ||
| 709 | pub fn update_menu(_: impl FnOnce() -> Vec<crate::commands::Status>) {} | |
| 710 | ||
| 711 | pub fn update_tag_menu(_: &[canvas::editor::NoteTag]) {} | |
| 712 | ||
| 713 | pub fn clear_marked_text(_: &Window) {} | |
| 714 | ||
| 715 | #[cfg(feature = "wgpu")] | |
| 716 | pub fn configure_presentation(_: &wgpu::Surface<'_>) {} | |
| 717 | ||
| 718 | #[cfg(feature = "wgpu")] | |
| 719 | pub fn commit_presentation(_: &Window) {} | |
| 720 | ||
| 721 | /// winit reports no pen pressure here, so every stroke keeps its pen's width. | |
| 722 | pub fn pen_pressure() -> Option<f32> { | |
| 723 | None | |
| 724 | } | |
| 725 | ||
| 726 | pub fn double_click_interval() -> Duration { | |
| 727 | let millis = unsafe { windows_sys::Win32::UI::Input::KeyboardAndMouse::GetDoubleClickTime() }; | |
| 728 | Duration::from_millis(u64::from(millis)) | |
| 729 | } | |
| 730 | ||
| 731 | /// The apps' colour mode from Personalization, which Windows 10 added; light before it. | |
| 732 | pub fn appearance(window: &Window) -> Theme { | |
| 733 | window.theme().unwrap_or(Theme::Light) | |
| 734 | } | |
| 735 | ||
| 736 | pub struct Clipboard(arboard::Clipboard); | |
| 737 | ||
| 738 | impl Clipboard { | |
| 739 | pub fn new(_: &Window) -> Result<Self, arboard::Error> { | |
| 740 | arboard::Clipboard::new().map(Self) | |
| 741 | } | |
| 742 | ||
| 743 | pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> { | |
| 744 | self.0.set_text(text) | |
| 745 | } | |
| 746 | ||
| 747 | pub fn get_text(&mut self) -> Result<String, arboard::Error> { | |
| 748 | self.0.get_text() | |
| 749 | } | |
| 750 | } | |
| 751 | ||
| 752 | /// The DWORD `value` under `key` in the user's registry hive. | |
| 753 | fn registry_dword(key: &str, value: &str) -> Option<u32> { | |
| 754 | use windows_sys::Win32::System::Registry; | |
| 755 | let mut data = 0u32; | |
| 756 | let mut size = size_of::<u32>() as u32; | |
| 757 | let result = unsafe { | |
| 758 | Registry::RegGetValueW( | |
| 759 | Registry::HKEY_CURRENT_USER, | |
| 760 | wide(key).as_ptr(), | |
| 761 | wide(value).as_ptr(), | |
| 762 | Registry::RRF_RT_REG_DWORD, | |
| 763 | std::ptr::null_mut(), | |
| 764 | (&mut data as *mut u32).cast(), | |
| 765 | &mut size, | |
| 766 | ) | |
| 767 | }; | |
| 768 | (result == 0).then_some(data) | |
| 769 | } | |
| 770 | ||
| 771 | /// Windows names no document of a window's. | |
| 772 | pub fn represent(_: &Window, _: Option<&std::path::Path>) {} | |
| 773 | ||
| 774 | pub const SHOW_FILE: &str = "Show in Explorer"; | |
| 775 | ||
| 776 | /// Opens an Explorer window with `file` selected. | |
| 777 | pub fn show_file(file: &std::path::Path) { | |
| 778 | use std::os::windows::process::CommandExt; | |
| 779 | let mut argument = std::ffi::OsString::from("/select,\""); | |
| 780 | argument.push(file); | |
| 781 | argument.push("\""); | |
| 782 | if let Err(error) = std::process::Command::new("explorer.exe") | |
| 783 | .raw_arg(argument) | |
| 784 | .spawn() | |
| 785 | { | |
| 786 | eprintln!("Cannot show {}: {error}", file.display()); | |
| 787 | } | |
| 788 | } | |
| 789 | ||
| 790 | /// Opens `target`, a folder, a file or a link's URL, with the shell's handler for it. | |
| 791 | pub fn reveal(target: impl AsRef<std::ffi::OsStr>) { | |
| 792 | let target = target.as_ref(); | |
| 793 | let opened = unsafe { | |
| 794 | windows_sys::Win32::UI::Shell::ShellExecuteW( | |
| 795 | owner(), | |
| 796 | wide("open").as_ptr(), | |
| 797 | wide(target).as_ptr(), | |
| 798 | std::ptr::null(), | |
| 799 | std::ptr::null(), | |
| 800 | wm::SW_SHOWNORMAL, | |
| 801 | ) | |
| 802 | }; | |
| 803 | // ShellExecute's success is a pseudo-handle above 32. | |
| 804 | if opened as isize <= 32 { | |
| 805 | eprintln!("Cannot open {}", target.display()); | |
| 806 | } | |
| 807 | } | |
| 808 | ||
| 809 | /// Opens a copy of an attachment with the application registered for it. | |
| 810 | pub fn open_file(path: &std::path::Path) { | |
| 811 | reveal(path); | |
| 812 | } | |
| 813 | ||
| 814 | /// Opens Character Map, whose picks the user copies in. | |
| 815 | pub fn character_palette() { | |
| 816 | if let Err(error) = std::process::Command::new("charmap.exe").spawn() { | |
| 817 | eprintln!("Cannot open Character Map: {error}"); | |
| 818 | } | |
| 819 | } | |
| 820 | ||
| 821 | /// No shell icon lookup yet; the page draws a blank page for the file. | |
| 822 | pub fn file_icon(_: &std::path::Path) -> Option<Vec<u8>> { | |
| 823 | None | |
| 824 | } | |
| 825 | ||
| 826 | /// winit reports drops without a position; the file goes to the caret. | |
| 827 | pub fn drop_point(_: &Window) -> Option<[f32; 2]> { | |
| 828 | None | |
| 829 | } | |
| 830 | ||
| 831 | pub fn cache_dir() -> Option<PathBuf> { | |
| 832 | Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Snowbound")) | |
| 833 | } | |
| 834 | ||
| 835 | pub fn settings_dir() -> Option<PathBuf> { | |
| 836 | Some(PathBuf::from(std::env::var_os("APPDATA")?).join("Snowbound")) | |
| 837 | } | |
| 838 | ||
| 839 | /// None: Windows reaches a share by its UNC path with the share modes and byte-range | |
| 840 | /// locks OneNote takes, so a notebook there opens as a folder, as OneNote opens it. | |
| 841 | /// Snowbound's own client serves Open Notebook from Server…. | |
| 842 | pub fn smb_mount(_: &std::path::Path) -> Option<crate::library::Mount> { | |
| 843 | None | |
| 844 | } | |
| 845 | ||
| 846 | /// The Credential Manager's name for `mount`'s server, as Snowbound keeps it. | |
| 847 | fn credential_target(mount: &crate::library::Mount) -> Vec<u16> { | |
| 848 | wide(format!("Snowbound/smb/{}", mount.host())) | |
| 849 | } | |
| 850 | ||
| 851 | /// The password the Credential Manager keeps for `mount`'s server, or what the user types | |
| 852 | /// into the system's sign-in dialog when it keeps none. | |
| 853 | pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> { | |
| 854 | use windows_sys::Win32::Security::Credentials as cred; | |
| 855 | let target = credential_target(mount); | |
| 856 | let mut found: *mut cred::CREDENTIALW = std::ptr::null_mut(); | |
| 857 | if unsafe { cred::CredReadW(target.as_ptr(), cred::CRED_TYPE_GENERIC, 0, &mut found) } != 0 { | |
| 858 | let login = unsafe { | |
| 859 | let found = &*found; | |
| 860 | let secret = std::slice::from_raw_parts( | |
| 861 | found.CredentialBlob.cast::<u16>(), | |
| 862 | found.CredentialBlobSize as usize / 2, | |
| 863 | ); | |
| 864 | let user = if found.UserName.is_null() { | |
| 865 | String::new() | |
| 866 | } else { | |
| 867 | let length = (0..).take_while(|&at| *found.UserName.add(at) != 0).count(); | |
| 868 | String::from_utf16_lossy(std::slice::from_raw_parts(found.UserName, length)) | |
| 869 | }; | |
| 870 | (user, String::from_utf16_lossy(secret)) | |
| 871 | }; | |
| 872 | unsafe { cred::CredFree(found.cast()) }; | |
| 873 | let (user, password) = login; | |
| 874 | let user = mount | |
| 875 | .user | |
| 876 | .clone() | |
| 877 | .filter(|user| !user.is_empty()) | |
| 878 | .unwrap_or(user); | |
| 879 | return Ok(crate::library::Login { | |
| 880 | user, | |
| 881 | password, | |
| 882 | domain: mount.domain.clone(), | |
| 883 | }); | |
| 884 | } | |
| 885 | let caption = wide("Snowbound"); | |
| 886 | let message = wide(format!("Sign in to {}", mount.server)); | |
| 887 | let info = cred::CREDUI_INFOW { | |
| 888 | cbSize: size_of::<cred::CREDUI_INFOW>() as u32, | |
| 889 | hwndParent: owner(), | |
| 890 | pszMessageText: message.as_ptr(), | |
| 891 | pszCaptionText: caption.as_ptr(), | |
| 892 | hbmBanner: std::ptr::null_mut(), | |
| 893 | }; | |
| 894 | let mut user = [0u16; cred::CREDUI_MAX_USERNAME_LENGTH as usize + 1]; | |
| 895 | for (slot, unit) in user | |
| 896 | .iter_mut() | |
| 897 | .zip(mount.user.clone().unwrap_or_default().encode_utf16()) | |
| 898 | { | |
| 899 | *slot = unit; | |
| 900 | } | |
| 901 | let mut password = [0u16; 257]; | |
| 902 | let mut save: BOOL = 0; | |
| 903 | let server = wide(&mount.server); | |
| 904 | let result = unsafe { | |
| 905 | cred::CredUIPromptForCredentialsW( | |
| 906 | &info, | |
| 907 | server.as_ptr(), | |
| 908 | std::ptr::null_mut(), | |
| 909 | 0, | |
| 910 | user.as_mut_ptr(), | |
| 911 | user.len() as u32, | |
| 912 | password.as_mut_ptr(), | |
| 913 | password.len() as u32, | |
| 914 | &mut save, | |
| 915 | cred::CREDUI_FLAGS_GENERIC_CREDENTIALS | |
| 916 | | cred::CREDUI_FLAGS_DO_NOT_PERSIST | |
| 917 | | cred::CREDUI_FLAGS_ALWAYS_SHOW_UI, | |
| 918 | ) | |
| 919 | }; | |
| 920 | let login = crate::library::Login { | |
| 921 | user: narrow(&user), | |
| 922 | password: narrow(&password), | |
| 923 | domain: mount.domain.clone(), | |
| 924 | }; | |
| 925 | password.fill(0); | |
| 926 | match result { | |
| 927 | 0 => Ok(login), | |
| 928 | _ => Err("Signing in was canceled".to_owned()), | |
| 929 | } | |
| 930 | } | |
| 931 | ||
| 932 | /// What the sign-in offers for keeping a password, which the Credential Manager keeps. | |
| 933 | pub fn remember_label() -> Option<&'static str> { | |
| 934 | Some("Remember my credentials") | |
| 935 | } | |
| 936 | ||
| 937 | /// Keeps `login`'s password for `mount`'s server in the Credential Manager, where | |
| 938 | /// `smb_login` looks for it. | |
| 939 | pub fn save_login( | |
| 940 | mount: &crate::library::Mount, | |
| 941 | login: &crate::library::Login, | |
| 942 | ) -> Result<(), String> { | |
| 943 | use windows_sys::Win32::Security::Credentials as cred; | |
| 944 | let target = credential_target(mount); | |
| 945 | let mut user = wide(&login.user); | |
| 946 | let secret: Vec<u16> = login.password.encode_utf16().collect(); | |
| 947 | let credential = cred::CREDENTIALW { | |
| 948 | Flags: 0, | |
| 949 | Type: cred::CRED_TYPE_GENERIC, | |
| 950 | TargetName: target.as_ptr().cast_mut(), | |
| 951 | Comment: std::ptr::null_mut(), | |
| 952 | LastWritten: FILETIME::default(), | |
| 953 | CredentialBlobSize: (secret.len() * 2) as u32, | |
| 954 | CredentialBlob: secret.as_ptr().cast::<u8>().cast_mut(), | |
| 955 | Persist: cred::CRED_PERSIST_LOCAL_MACHINE, | |
| 956 | AttributeCount: 0, | |
| 957 | Attributes: std::ptr::null_mut(), | |
| 958 | TargetAlias: std::ptr::null_mut(), | |
| 959 | UserName: user.as_mut_ptr(), | |
| 960 | }; | |
| 961 | if unsafe { cred::CredWriteW(&credential, 0) } != 0 { | |
| 962 | Ok(()) | |
| 963 | } else { | |
| 964 | Err(std::io::Error::last_os_error().to_string()) | |
| 965 | } | |
| 966 | } | |
| 967 | ||
| 968 | /// The account's display name, as Office takes its user name, or its login where the | |
| 969 | /// directory has none. | |
| 970 | pub fn user_name() -> String { | |
| 971 | use windows_sys::Win32::Security::Authentication::Identity::{GetUserNameExW, NameDisplay}; | |
| 972 | let mut name = [0u16; 256]; | |
| 973 | let mut length = name.len() as u32; | |
| 974 | if unsafe { GetUserNameExW(NameDisplay, name.as_mut_ptr(), &mut length) } { | |
| 975 | let name = narrow(&name); | |
| 976 | if !name.trim().is_empty() { | |
| 977 | return name; | |
| 978 | } | |
| 979 | } | |
| 980 | std::env::var("USERNAME").unwrap_or_default() | |
| 981 | } | |
| 982 | ||
| 983 | /// The keyboard layout's language, as OneNote tags pasted text. | |
| 984 | pub fn input_language() -> String { | |
| 985 | use windows_sys::Win32::{ | |
| 986 | Globalization::LCIDToLocaleName, UI::Input::KeyboardAndMouse::GetKeyboardLayout, | |
| 987 | }; | |
| 988 | let layout = unsafe { GetKeyboardLayout(0) } as usize; | |
| 989 | let language = (layout & 0xffff) as u32; | |
| 990 | let mut name = [0u16; 85]; | |
| 991 | let length = unsafe { LCIDToLocaleName(language, name.as_mut_ptr(), name.len() as i32, 0) }; | |
| 992 | if length > 0 { | |
| 993 | narrow(&name) | |
| 994 | } else { | |
| 995 | String::new() | |
| 996 | } | |
| 997 | } | |
| 998 | ||
| 999 | /// FILETIME as local time. | |
| 1000 | fn local_time(filetime: u64) -> SYSTEMTIME { | |
| 1001 | use windows_sys::Win32::System::Time::{FileTimeToSystemTime, SystemTimeToTzSpecificLocalTime}; | |
| 1002 | let time = FILETIME { | |
| 1003 | dwLowDateTime: filetime as u32, | |
| 1004 | dwHighDateTime: (filetime >> 32) as u32, | |
| 1005 | }; | |
| 1006 | let [mut universal, mut local] = [SYSTEMTIME::default(); 2]; | |
| 1007 | unsafe { | |
| 1008 | FileTimeToSystemTime(&time, &mut universal); | |
| 1009 | SystemTimeToTzSpecificLocalTime(std::ptr::null(), &universal, &mut local); | |
| 1010 | } | |
| 1011 | local | |
| 1012 | } | |
| 1013 | ||
| 1014 | /// Local time as FILETIME. | |
| 1015 | fn filetime(local: &SYSTEMTIME) -> Option<u64> { | |
| 1016 | use windows_sys::Win32::System::Time::{SystemTimeToFileTime, TzSpecificLocalTimeToSystemTime}; | |
| 1017 | let mut universal = SYSTEMTIME::default(); | |
| 1018 | let mut time = FILETIME::default(); | |
| 1019 | unsafe { | |
| 1020 | (TzSpecificLocalTimeToSystemTime(std::ptr::null(), local, &mut universal) != 0 | |
| 1021 | && SystemTimeToFileTime(&universal, &mut time) != 0) | |
| 1022 | .then(|| u64::from(time.dwHighDateTime) << 32 | u64::from(time.dwLowDateTime)) | |
| 1023 | } | |
| 1024 | } | |
| 1025 | ||
| 1026 | /// `time` in the user's `flags` date format. | |
| 1027 | fn date_format(time: &SYSTEMTIME, flags: u32) -> String { | |
| 1028 | use windows_sys::Win32::Globalization::{GetDateFormatW, LOCALE_USER_DEFAULT}; | |
| 1029 | let mut text = [0u16; 128]; | |
| 1030 | let length = unsafe { | |
| 1031 | GetDateFormatW( | |
| 1032 | LOCALE_USER_DEFAULT, | |
| 1033 | flags, | |
| 1034 | time, | |
| 1035 | std::ptr::null(), | |
| 1036 | text.as_mut_ptr(), | |
| 1037 | text.len() as i32, | |
| 1038 | ) | |
| 1039 | }; | |
| 1040 | narrow(&text[..length.max(0) as usize]) | |
| 1041 | } | |
| 1042 | ||
| 1043 | /// `time` in the user's time format without seconds. | |
| 1044 | fn time_format(time: &SYSTEMTIME) -> String { | |
| 1045 | use windows_sys::Win32::Globalization::{GetTimeFormatW, LOCALE_USER_DEFAULT, TIME_NOSECONDS}; | |
| 1046 | let mut text = [0u16; 64]; | |
| 1047 | let length = unsafe { | |
| 1048 | GetTimeFormatW( | |
| 1049 | LOCALE_USER_DEFAULT, | |
| 1050 | TIME_NOSECONDS, | |
| 1051 | time, | |
| 1052 | std::ptr::null(), | |
| 1053 | text.as_mut_ptr(), | |
| 1054 | text.len() as i32, | |
| 1055 | ) | |
| 1056 | }; | |
| 1057 | narrow(&text[..length.max(0) as usize]) | |
| 1058 | } | |
| 1059 | ||
| 1060 | /// How far local time is ahead of UTC at Unix time `unix`, in seconds. | |
| 1061 | pub fn utc_offset(unix: i64) -> i64 { | |
| 1062 | let ticks = (unix + 11_644_473_600).max(0) as u64 * 10_000_000; | |
| 1063 | filetime(&local_time(ticks)).map_or(0, |local| (ticks as i64 - local as i64) / -10_000_000) | |
| 1064 | } | |
| 1065 | ||
| 1066 | /// FILETIME as the locale's short date, as OneNote labels a conflict page. | |
| 1067 | pub fn short_date(filetime: u64) -> String { | |
| 1068 | date_format( | |
| 1069 | &local_time(filetime), | |
| 1070 | windows_sys::Win32::Globalization::DATE_SHORTDATE, | |
| 1071 | ) | |
| 1072 | } | |
| 1073 | ||
| 1074 | fn date_labels(time: &SYSTEMTIME) -> [String; 2] { | |
| 1075 | [ | |
| 1076 | date_format(time, windows_sys::Win32::Globalization::DATE_LONGDATE), | |
| 1077 | time_format(time), | |
| 1078 | ] | |
| 1079 | } | |
| 1080 | ||
| 1081 | /// FILETIME as a new page's title shows it: the long date and the short time, which is | |
| 1082 | /// how OneNote 2010 writes them from the same settings. | |
| 1083 | pub fn date_text(filetime: u64) -> [String; 2] { | |
| 1084 | date_labels(&local_time(filetime)) | |
| 1085 | } | |
| 1086 | ||
| 1087 | /// Asks for the page's date or time with the system's date and time picker. | |
| 1088 | pub fn edit_date( | |
| 1089 | timestamp: u64, | |
| 1090 | field: DateField, | |
| 1091 | title: &str, | |
| 1092 | ) -> Result<Option<(u64, [String; 2])>, &'static str> { | |
| 1093 | let before = local_time(timestamp); | |
| 1094 | let Some(chosen) = picker::pick(&before, field, title) else { | |
| 1095 | return Ok(None); | |
| 1096 | }; | |
| 1097 | let mut time = before; | |
| 1098 | match field { | |
| 1099 | DateField::Date => { | |
| 1100 | (time.wYear, time.wMonth, time.wDay) = (chosen.wYear, chosen.wMonth, chosen.wDay); | |
| 1101 | } | |
| 1102 | DateField::Time => { | |
| 1103 | (time.wHour, time.wMinute) = (chosen.wHour, chosen.wMinute); | |
| 1104 | } | |
| 1105 | } | |
| 1106 | let updated = filetime(&time).ok_or(crate::DATE_OUT_OF_RANGE)?; | |
| 1107 | // FILETIME's sub-second ticks stay as they were. | |
| 1108 | let updated = updated / 10_000_000 * 10_000_000 + timestamp % 10_000_000; | |
| 1109 | Ok(Some((updated, date_labels(&local_time(updated))))) | |
| 1110 | } | |
| 1111 | ||
| 1112 | /// Asks for a notebook's table of contents or a section file, titled `title`. | |
| 1113 | pub fn pick_notebook(title: &str) -> Option<PathBuf> { | |
| 1114 | pick( | |
| 1115 | title, | |
| 1116 | &[("OneNote notebooks and sections", "*.onetoc2;*.one")], | |
| 1117 | None, | |
| 1118 | false, | |
| 1119 | ) | |
| 1120 | } | |
| 1121 | ||
| 1122 | /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. | |
| 1123 | pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { | |
| 1124 | let patterns = types | |
| 1125 | .iter() | |
| 1126 | .map(|kind| format!("*.{kind}")) | |
| 1127 | .collect::<Vec<_>>() | |
| 1128 | .join(";"); | |
| 1129 | let filters = if types.is_empty() { | |
| 1130 | vec![("All files", "*.*")] | |
| 1131 | } else { | |
| 1132 | vec![("Supported files", patterns.as_str()), ("All files", "*.*")] | |
| 1133 | }; | |
| 1134 | pick(title, &filters, None, false) | |
| 1135 | } | |
| 1136 | ||
| 1137 | /// Asks where to put something named `name` by default; the dialog names its own button. | |
| 1138 | pub fn pick_new( | |
| 1139 | title: &str, | |
| 1140 | name: &str, | |
| 1141 | _action: &str, | |
| 1142 | folder: Option<&std::path::Path>, | |
| 1143 | ) -> Option<PathBuf> { | |
| 1144 | pick(title, &[("All files", "*.*")], Some((name, folder)), true) | |
| 1145 | } | |
| 1146 | ||
| 1147 | /// The common file dialog: an open dialog over `filters` (name and `;`-separated | |
| 1148 | /// patterns), or with `new`, a save dialog suggesting a name in a folder. | |
| 1149 | fn pick( | |
| 1150 | title: &str, | |
| 1151 | filters: &[(&str, &str)], | |
| 1152 | new: Option<(&str, Option<&std::path::Path>)>, | |
| 1153 | save: bool, | |
| 1154 | ) -> Option<PathBuf> { | |
| 1155 | use windows_sys::Win32::UI::Controls::Dialogs as dialogs; | |
| 1156 | let mut filter: Vec<u16> = Vec::new(); | |
| 1157 | for (name, patterns) in filters { | |
| 1158 | filter.extend(name.encode_utf16().chain([0])); | |
| 1159 | filter.extend(patterns.encode_utf16().chain([0])); | |
| 1160 | } | |
| 1161 | filter.push(0); | |
| 1162 | let mut file = vec![0u16; 32 * 1024]; | |
| 1163 | if let Some((name, _)) = new { | |
| 1164 | for (slot, unit) in file.iter_mut().zip(name.encode_utf16()) { | |
| 1165 | *slot = unit; | |
| 1166 | } | |
| 1167 | } | |
| 1168 | let folder = new.and_then(|(_, folder)| folder).map(wide); | |
| 1169 | let title = wide(title); | |
| 1170 | let mut dialog = dialogs::OPENFILENAMEW { | |
| 1171 | lStructSize: size_of::<dialogs::OPENFILENAMEW>() as u32, | |
| 1172 | hwndOwner: owner(), | |
| 1173 | lpstrFilter: filter.as_ptr(), | |
| 1174 | lpstrFile: file.as_mut_ptr(), | |
| 1175 | nMaxFile: file.len() as u32, | |
| 1176 | lpstrTitle: title.as_ptr(), | |
| 1177 | lpstrInitialDir: folder | |
| 1178 | .as_ref() | |
| 1179 | .map_or(std::ptr::null(), |folder| folder.as_ptr()), | |
| 1180 | Flags: dialogs::OFN_EXPLORER | |
| 1181 | | dialogs::OFN_NOCHANGEDIR | |
| 1182 | | if save { | |
| 1183 | dialogs::OFN_OVERWRITEPROMPT | |
| 1184 | } else { | |
| 1185 | dialogs::OFN_FILEMUSTEXIST | |
| 1186 | }, | |
| 1187 | ..unsafe { std::mem::zeroed() } | |
| 1188 | }; | |
| 1189 | let chosen = unsafe { | |
| 1190 | if save { | |
| 1191 | dialogs::GetSaveFileNameW(&mut dialog) | |
| 1192 | } else { | |
| 1193 | dialogs::GetOpenFileNameW(&mut dialog) | |
| 1194 | } | |
| 1195 | }; | |
| 1196 | if chosen == 0 { | |
| 1197 | // Zero where the user cancelled. | |
| 1198 | let error = unsafe { dialogs::CommDlgExtendedError() }; | |
| 1199 | if error != 0 { | |
| 1200 | eprintln!("The file dialog failed: {error:#x}"); | |
| 1201 | } | |
| 1202 | return None; | |
| 1203 | } | |
| 1204 | Some(PathBuf::from(narrow(&file))) | |
| 1205 | } | |
| 1206 | ||
| 1207 | /// Tells the user something they asked for could not be done: `message`, then what to do. | |
| 1208 | pub fn alert(message: &str, detail: &str) { | |
| 1209 | let text = wide(format!("{message}\n\n{detail}")); | |
| 1210 | let caption = wide("Snowbound"); | |
| 1211 | let style = wm::MB_OK | wm::MB_ICONWARNING; | |
| 1212 | unsafe { wm::MessageBoxW(owner(), text.as_ptr(), caption.as_ptr(), style) }; | |
| 1213 | } | |
| 1214 | ||
| 1215 | /// Asks whether to go ahead with `action`, offering `cancel` too: a task dialog with those | |
| 1216 | /// buttons where the common controls have one, as from Windows Vista, else OK and Cancel. | |
| 1217 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { | |
| 1218 | use windows_sys::Win32::UI::Controls as controls; | |
| 1219 | type Indirect = unsafe extern "system" fn( | |
| 1220 | *const controls::TASKDIALOGCONFIG, | |
| 1221 | *mut i32, | |
| 1222 | *mut i32, | |
| 1223 | *mut BOOL, | |
| 1224 | ) -> i32; | |
| 1225 | let [title, instruction, content, cancel_text, action_text] = | |
| 1226 | ["Snowbound", message, detail, cancel, action].map(wide); | |
| 1227 | if let Some(indirect) = function::<Indirect>("comctl32.dll", c"TaskDialogIndirect") { | |
| 1228 | const ACTION: i32 = 100; | |
| 1229 | let buttons = [ | |
| 1230 | controls::TASKDIALOG_BUTTON { | |
| 1231 | nButtonID: ACTION, | |
| 1232 | pszButtonText: action_text.as_ptr(), | |
| 1233 | }, | |
| 1234 | controls::TASKDIALOG_BUTTON { | |
| 1235 | nButtonID: wm::IDCANCEL, | |
| 1236 | pszButtonText: cancel_text.as_ptr(), | |
| 1237 | }, | |
| 1238 | ]; | |
| 1239 | let mut config: controls::TASKDIALOGCONFIG = unsafe { std::mem::zeroed() }; | |
| 1240 | config.cbSize = size_of::<controls::TASKDIALOGCONFIG>() as u32; | |
| 1241 | config.hwndParent = owner(); | |
| 1242 | config.dwFlags = controls::TDF_ALLOW_DIALOG_CANCELLATION; | |
| 1243 | config.pszWindowTitle = title.as_ptr(); | |
| 1244 | config.pszMainInstruction = instruction.as_ptr(); | |
| 1245 | config.pszContent = content.as_ptr(); | |
| 1246 | config.cButtons = buttons.len() as u32; | |
| 1247 | config.pButtons = buttons.as_ptr(); | |
| 1248 | config.nDefaultButton = wm::IDCANCEL; | |
| 1249 | let mut pressed = 0; | |
| 1250 | let shown = unsafe { | |
| 1251 | indirect( | |
| 1252 | &config, | |
| 1253 | &mut pressed, | |
| 1254 | std::ptr::null_mut(), | |
| 1255 | std::ptr::null_mut(), | |
| 1256 | ) | |
| 1257 | }; | |
| 1258 | if shown >= 0 { | |
| 1259 | return pressed == ACTION; | |
| 1260 | } | |
| 1261 | } | |
| 1262 | let text = wide(format!("{message}\n\n{detail}")); | |
| 1263 | let answer = unsafe { | |
| 1264 | wm::MessageBoxW( | |
| 1265 | owner(), | |
| 1266 | text.as_ptr(), | |
| 1267 | title.as_ptr(), | |
| 1268 | wm::MB_OKCANCEL | wm::MB_ICONQUESTION | wm::MB_DEFBUTTON2, | |
| 1269 | ) | |
| 1270 | }; | |
| 1271 | answer == wm::IDOK | |
| 1272 | } | |
| 1273 | ||
| 1274 | /// The common controls' date and time picker in a dialog of its own. | |
| 1275 | mod picker { | |
| 1276 | use super::{DateField, SYSTEMTIME, owner, wm}; | |
| 1277 | use std::cell::Cell; | |
| 1278 | use windows_sys::Win32::{ | |
| 1279 | Foundation::{HWND, LPARAM, WPARAM}, | |
| 1280 | UI::Controls as controls, | |
| 1281 | }; | |
| 1282 | ||
| 1283 | const PICKER: i32 = 100; | |
| 1284 | ||
| 1285 | thread_local! { | |
| 1286 | static TIME: Cell<SYSTEMTIME> = Cell::new(SYSTEMTIME::default()); | |
| 1287 | } | |
| 1288 | ||
| 1289 | /// The dialog, as a template of 16-bit words: its frame, then the picker, OK and Cancel. | |
| 1290 | fn template(title: &str, style: u32) -> Vec<u16> { | |
| 1291 | let mut words = Vec::new(); | |
| 1292 | let dword = |words: &mut Vec<u16>, value: u32| { | |
| 1293 | words.extend([value as u16, (value >> 16) as u16]); | |
| 1294 | }; | |
| 1295 | let text = |words: &mut Vec<u16>, text: &str| { | |
| 1296 | words.extend(text.encode_utf16().chain([0])); | |
| 1297 | }; | |
| 1298 | dword( | |
| 1299 | &mut words, | |
| 1300 | wm::DS_MODALFRAME as u32 | |
| 1301 | | wm::DS_CENTER as u32 | |
| 1302 | | wm::DS_SETFONT as u32 | |
| 1303 | | wm::WS_POPUP | |
| 1304 | | wm::WS_CAPTION | |
| 1305 | | wm::WS_SYSMENU, | |
| 1306 | ); | |
| 1307 | dword(&mut words, 0); | |
| 1308 | // Three controls in a dialog of 160 by 62 dialog units. | |
| 1309 | words.extend([3, 0, 0, 160, 62]); | |
| 1310 | // No menu, the default class, the title, then 9-point Segoe UI. | |
| 1311 | words.extend([0, 0]); | |
| 1312 | text(&mut words, title); | |
| 1313 | words.push(9); | |
| 1314 | text(&mut words, "Segoe UI"); | |
| 1315 | let item = |words: &mut Vec<u16>, | |
| 1316 | style: u32, | |
| 1317 | [x, y, width, height]: [u16; 4], | |
| 1318 | id: i32, | |
| 1319 | class: &[u16], | |
| 1320 | title: &str| { | |
| 1321 | if words.len() % 2 == 1 { | |
| 1322 | words.push(0); | |
| 1323 | } | |
| 1324 | dword(words, style | wm::WS_CHILD | wm::WS_VISIBLE); | |
| 1325 | dword(words, 0); | |
| 1326 | words.extend([x, y, width, height, id as u16]); | |
| 1327 | words.extend(class); | |
| 1328 | text(words, title); | |
| 1329 | words.push(0); | |
| 1330 | }; | |
| 1331 | let class: Vec<u16> = "SysDateTimePick32".encode_utf16().chain([0]).collect(); | |
| 1332 | item( | |
| 1333 | &mut words, | |
| 1334 | style | wm::WS_TABSTOP, | |
| 1335 | [7, 7, 146, 14], | |
| 1336 | PICKER, | |
| 1337 | &class, | |
| 1338 | "", | |
| 1339 | ); | |
| 1340 | // 0xFFFF 0x0080 is the button class. | |
| 1341 | item( | |
| 1342 | &mut words, | |
| 1343 | wm::BS_DEFPUSHBUTTON as u32 | wm::WS_TABSTOP, | |
| 1344 | [49, 38, 50, 14], | |
| 1345 | wm::IDOK, | |
| 1346 | &[0xffff, 0x0080], | |
| 1347 | "OK", | |
| 1348 | ); | |
| 1349 | item( | |
| 1350 | &mut words, | |
| 1351 | wm::BS_PUSHBUTTON as u32 | wm::WS_TABSTOP, | |
| 1352 | [103, 38, 50, 14], | |
| 1353 | wm::IDCANCEL, | |
| 1354 | &[0xffff, 0x0080], | |
| 1355 | "Cancel", | |
| 1356 | ); | |
| 1357 | words | |
| 1358 | } | |
| 1359 | ||
| 1360 | unsafe extern "system" fn procedure( | |
| 1361 | dialog: HWND, | |
| 1362 | message: u32, | |
| 1363 | wparam: WPARAM, | |
| 1364 | _: LPARAM, | |
| 1365 | ) -> isize { | |
| 1366 | let picker = unsafe { wm::GetDlgItem(dialog, PICKER) }; | |
| 1367 | match message { | |
| 1368 | wm::WM_INITDIALOG => { | |
| 1369 | let time = TIME.get(); | |
| 1370 | unsafe { | |
| 1371 | wm::SendMessageW( | |
| 1372 | picker, | |
| 1373 | controls::DTM_SETSYSTEMTIME, | |
| 1374 | controls::GDT_VALID as WPARAM, | |
| 1375 | (&time as *const SYSTEMTIME) as LPARAM, | |
| 1376 | ) | |
| 1377 | }; | |
| 1378 | 1 | |
| 1379 | } | |
| 1380 | wm::WM_COMMAND => { | |
| 1381 | let id = (wparam & 0xffff) as i32; | |
| 1382 | if id == wm::IDOK { | |
| 1383 | let mut time = SYSTEMTIME::default(); | |
| 1384 | unsafe { | |
| 1385 | wm::SendMessageW( | |
| 1386 | picker, | |
| 1387 | controls::DTM_GETSYSTEMTIME, | |
| 1388 | 0, | |
| 1389 | (&mut time as *mut SYSTEMTIME) as LPARAM, | |
| 1390 | ) | |
| 1391 | }; | |
| 1392 | TIME.set(time); | |
| 1393 | } | |
| 1394 | if id == wm::IDOK || id == wm::IDCANCEL { | |
| 1395 | unsafe { wm::EndDialog(dialog, id as isize) }; | |
| 1396 | return 1; | |
| 1397 | } | |
| 1398 | 0 | |
| 1399 | } | |
| 1400 | _ => 0, | |
| 1401 | } | |
| 1402 | } | |
| 1403 | ||
| 1404 | /// The date or time the user picks, starting from `time`; none when cancelled. | |
| 1405 | pub(super) fn pick(time: &SYSTEMTIME, field: DateField, title: &str) -> Option<SYSTEMTIME> { | |
| 1406 | let classes = controls::INITCOMMONCONTROLSEX { | |
| 1407 | dwSize: size_of::<controls::INITCOMMONCONTROLSEX>() as u32, | |
| 1408 | dwICC: controls::ICC_DATE_CLASSES, | |
| 1409 | }; | |
| 1410 | unsafe { controls::InitCommonControlsEx(&classes) }; | |
| 1411 | let style = match field { | |
| 1412 | DateField::Date => controls::DTS_LONGDATEFORMAT, | |
| 1413 | DateField::Time => controls::DTS_TIMEFORMAT, | |
| 1414 | }; | |
| 1415 | TIME.set(*time); | |
| 1416 | let template = template(title, style); | |
| 1417 | let chosen = unsafe { | |
| 1418 | wm::DialogBoxIndirectParamW( | |
| 1419 | std::ptr::null_mut(), | |
| 1420 | template.as_ptr().cast(), | |
| 1421 | owner(), | |
| 1422 | Some(procedure), | |
| 1423 | 0, | |
| 1424 | ) | |
| 1425 | }; | |
| 1426 | (chosen == wm::IDOK as isize).then(|| TIME.get()) | |
| 1427 | } | |
| 1428 | } |
crates/snowbound/windows/snowbound.ico created| Binary files /dev/null and b/crates/snowbound/windows/snowbound.ico differ |
crates/snowbound/windows/snowbound.manifest created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | <?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| 2 | <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> | |
| 3 | <assemblyIdentity type="win32" name="net.paperclover.snowbound" version="1.0.0.0"/> | |
| 4 | <description>Snowbound</description> | |
| 5 | <!-- Common Controls 6: themed dialogs, and TaskDialogIndirect. --> | |
| 6 | <dependency> | |
| 7 | <dependentAssembly> | |
| 8 | <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" | |
| 9 | processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/> | |
| 10 | </dependentAssembly> | |
| 11 | </dependency> | |
| 12 | <!-- Windows 7 through 11, so none of them answers as an older release. --> | |
| 13 | <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> | |
| 14 | <application> | |
| 15 | <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/> | |
| 16 | <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/> | |
| 17 | <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/> | |
| 18 | <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/> | |
| 19 | </application> | |
| 20 | </compatibility> | |
| 21 | <application xmlns="urn:schemas-microsoft-com:asm.v3"> | |
| 22 | <windowsSettings> | |
| 23 | <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> | |
| 24 | <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> | |
| 25 | <longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware> | |
| 26 | </windowsSettings> | |
| 27 | </application> | |
| 28 | <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2"> | |
| 29 | <security> | |
| 30 | <requestedPrivileges> | |
| 31 | <requestedExecutionLevel level="asInvoker" uiAccess="false"/> | |
| 32 | </requestedPrivileges> | |
| 33 | </security> | |
| 34 | </trustInfo> | |
| 35 | </assembly> |
crates/snowbound/windows/snowbound.rc created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | // The executable's icon, which winit gives the window too, and its manifest. | |
| 2 | 1 ICON "snowbound.ico" | |
| 3 | 1 24 "snowbound.manifest" |
crates/ui/src/lib.rs+4| ... | ... | @@ -761,6 +761,10 @@ impl Ui { |
| 761 | 761 | |
| 762 | 762 | /// Queues input for the next frame. |
| 763 | 763 | pub fn event(&mut self, event: Event) { |
| 764 | // A chord the host looks up before the next frame reads the modifiers held now. | |
| 765 | if let Event::Modifiers(modifiers) = event { | |
| 766 | self.modifiers = modifiers; | |
| 767 | } | |
| 764 | 768 | self.queue.push(event); |
| 765 | 769 | } |
| 766 | 770 |
platform/snow-leopard/probe/src/render.rs+1-1| ... | ... | @@ -10,7 +10,7 @@ fn main() { |
| 10 | 10 | |
| 11 | 11 | fn run() { |
| 12 | 12 | let _context = context::current(); |
| 13 | let mut renderer = draw::Renderer::new().unwrap_or_else(|error| panic!("{error}")); | |
| 13 | let mut renderer = draw::Renderer::opengl().unwrap_or_else(|error| panic!("{error}")); | |
| 14 | 14 | let target = draw::Target::new(scene::SIZE).unwrap(); |
| 15 | 15 | let checker = scene::checker(); |
| 16 | 16 | let primitives = scene::primitives(&checker); |
platform/snow-leopard/probe/src/scene.rs+1-1| ... | ... | @@ -35,7 +35,7 @@ pub fn primitives(checker: &RasterImage) -> Vec<Primitive<'_>> { |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | pub fn layer<'a>(primitives: &'a [Primitive<'a>]) -> Layer<'a> { |
| 38 | Layer { scale: 1.0, origin: [0.0; 2], clip: None, backdrop: None, motion: None, primitives } | |
| 38 | Layer { scale: 1.0, origin: [0.0; 2], clip: None, backdrop: None, round: None, motion: None, primitives } | |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | 41 | /// Lines of interface text through `ui`: black on white like a Cocoa label, the same in |
platform/snow-leopard/probe/src/text.rs+1-1| ... | ... | @@ -7,7 +7,7 @@ mod scene; |
| 7 | 7 | fn main() { |
| 8 | 8 | objc2::rc::autoreleasepool(|_| { |
| 9 | 9 | let _context = context::current(); |
| 10 | let mut renderer = draw::Renderer::new().unwrap_or_else(|error| panic!("{error}")); | |
| 10 | let mut renderer = draw::Renderer::opengl().unwrap_or_else(|error| panic!("{error}")); | |
| 11 | 11 | let target = draw::Target::new(scene::TEXT_SIZE).unwrap(); |
| 12 | 12 | let ui = scene::interface(1.0); |
| 13 | 13 | let layers = ui.layers(); |
platform/windows/cargo.sh+7-3| ... | ... | @@ -19,17 +19,21 @@ x86_64) |
| 19 | 19 | target=x86_64-win7-windows-gnu |
| 20 | 20 | toolchain=+nightly |
| 21 | 21 | # rustc would infer a bare ld from the name `link.sh`; it is a C compiler driver. |
| 22 | # raw-dylib imports (the windows crates) are made by llvm-mingw's dlltool. Cargo | |
| 23 | # can't see link.sh's inputs; folding their hash into the flags relinks on change. | |
| 24 | runtime=$(cat "$here/link.sh" "$here"/rt/* | shasum | cut -c1-12) | |
| 22 | 25 | set -- -Zbuild-std=std,panic_unwind \ |
| 23 | 26 | --config "target.$target.linker='$here/link.sh'" \ |
| 24 | --config "target.$target.rustflags=['-C','linker-flavor=gcc']" "$@" | |
| 27 | --config "target.$target.rustflags=['-C','linker-flavor=gcc','-C','metadata=rt-$runtime','-C','dlltool=$LLVM_MINGW/bin/x86_64-w64-mingw32-dlltool']" "$@" | |
| 25 | 28 | ;; |
| 26 | 29 | aarch64) |
| 27 | 30 | target=aarch64-pc-windows-gnullvm |
| 28 | 31 | toolchain=+stable |
| 29 | 32 | rustup target add --toolchain stable "$target" >/dev/null |
| 30 | # crt-static links libunwind in rather than beside the executable. | |
| 33 | # crt-static links libunwind in rather than beside the executable. clang ignores | |
| 34 | # rustc's -no-pie. | |
| 31 | 35 | set -- --config "target.$target.linker='$LLVM_MINGW/bin/aarch64-w64-mingw32-clang'" \ |
| 32 | --config "target.$target.rustflags=['-C','target-feature=+crt-static']" "$@" | |
| 36 | --config "target.$target.rustflags=['-C','target-feature=+crt-static','-C','link-arg=-Wno-unused-command-line-argument']" "$@" | |
| 33 | 37 | ;; |
| 34 | 38 | *) |
| 35 | 39 | echo "usage: $0 x86_64|aarch64 COMMAND ARGS..." >&2 |
platform/windows/imports.py created+45| ... | ... | @@ -0,0 +1,45 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Lists what an executable imports that Windows 7 lacks, which stops it loading there: | |
| 3 | `imports.py EXE DLLS`, DLLS a folder of Windows 7's System32 DLLs (copied from the lab VM), | |
| 4 | reading both with llvm-mingw's llvm-readobj.""" | |
| 5 | import os | |
| 6 | from pathlib import Path | |
| 7 | import re | |
| 8 | import subprocess | |
| 9 | import sys | |
| 10 | ||
| 11 | ROOT = Path(__file__).resolve().parents[2] | |
| 12 | READOBJ = Path(os.environ.get('LLVM_MINGW', ROOT / 'target/windows/llvm-mingw')) / 'bin/llvm-readobj' | |
| 13 | ||
| 14 | ||
| 15 | def readobj(flag, path): | |
| 16 | return subprocess.run([READOBJ, flag, path], capture_output=True, text=True, check=True).stdout | |
| 17 | ||
| 18 | ||
| 19 | def imports(executable): | |
| 20 | """Each imported DLL, lowercased, with the names imported from it.""" | |
| 21 | found, dll = {}, None | |
| 22 | for line in readobj('--coff-imports', executable).splitlines(): | |
| 23 | if match := re.match(r'\s*Name: (\S+)', line): | |
| 24 | dll = found.setdefault(match[1].lower(), set()) | |
| 25 | elif (match := re.match(r'\s*Symbol: (\S+)', line)) and dll is not None: | |
| 26 | dll.add(match[1]) | |
| 27 | return found | |
| 28 | ||
| 29 | ||
| 30 | def main(): | |
| 31 | executable, folder = map(Path, sys.argv[1:3]) | |
| 32 | dlls = {path.name.lower(): path for path in folder.iterdir()} | |
| 33 | missing = [] | |
| 34 | for dll, names in sorted(imports(executable).items()): | |
| 35 | if dll not in dlls: | |
| 36 | missing.append(f'{dll} (no such DLL): {", ".join(sorted(names))}') | |
| 37 | continue | |
| 38 | exported = set(re.findall(r'Name: (\S+)', readobj('--coff-exports', dlls[dll]))) | |
| 39 | missing += [f'{dll}!{name}' for name in sorted(names - exported)] | |
| 40 | print('\n'.join(missing) or 'Every import exists on Windows 7.') | |
| 41 | sys.exit(1 if missing else 0) | |
| 42 | ||
| 43 | ||
| 44 | if __name__ == '__main__': | |
| 45 | main() |
platform/windows/link.sh+8-2| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | 1 | #!/bin/sh |
| 2 | 2 | # The linker for x86_64-win7-windows-gnu: llvm-mingw's clang, which links compiler-rt |
| 3 | # itself, with LLVM's libunwind (static) answering for libgcc's unwinder. | |
| 3 | # itself, with LLVM's libunwind (static) answering for libgcc's unwinder, and rt/shims.c | |
| 4 | # standing in for the imports Windows 7 lacks. | |
| 4 | 5 | set -eu |
| 6 | here=$(cd "$(dirname "$0")" && pwd) | |
| 7 | clang=$LLVM_MINGW/bin/x86_64-w64-mingw32-clang | |
| 5 | 8 | for arg do |
| 6 | 9 | shift |
| 7 | 10 | case $arg in |
| ... | ... | @@ -10,4 +13,7 @@ for arg do |
| 10 | 13 | *) set -- "$@" "$arg" ;; |
| 11 | 14 | esac |
| 12 | 15 | done |
| 13 | exec "$LLVM_MINGW/bin/x86_64-w64-mingw32-clang" "$@" | |
| 16 | work=$(mktemp -d "${TMPDIR:-/tmp}/windows-link.XXXXXX") | |
| 17 | trap 'rm -rf "$work"' EXIT | |
| 18 | "$clang" -O2 -Wall -c "$here/rt/shims.c" -o "$work/shims.o" | |
| 19 | "$clang" "$work/shims.o" "$@" |
platform/windows/rt/shims.c created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | // Functions the windows crates and arboard import from DLLs Windows 7 lacks, defined here | |
| 2 | // under their own names and as the `__imp_` pointers an import library would supply, so the | |
| 3 | // linker takes these and the executable never names those DLLs. | |
| 4 | #include <string.h> | |
| 5 | #include <wchar.h> | |
| 6 | ||
| 7 | typedef long HRESULT; | |
| 8 | typedef int BOOL; | |
| 9 | #define S_OK ((HRESULT)0) | |
| 10 | #define S_FALSE ((HRESULT)1) | |
| 11 | #define E_INVALIDARG ((HRESULT)0x80070057L) | |
| 12 | ||
| 13 | __declspec(dllimport) void *__stdcall LoadLibraryA(const char *name); | |
| 14 | __declspec(dllimport) void *__stdcall GetProcAddress(void *module, const char *name); | |
| 15 | ||
| 16 | // combase.dll (Windows 8): Windows 7's ole32.dll exports the same two. | |
| 17 | static void *ole32(const char *name) { | |
| 18 | return GetProcAddress(LoadLibraryA("ole32.dll"), name); | |
| 19 | } | |
| 20 | ||
| 21 | HRESULT __stdcall CoCreateFreeThreadedMarshaler(void *outer, void **marshaler) { | |
| 22 | static HRESULT(__stdcall * real)(void *, void **); | |
| 23 | if (!real) real = (HRESULT(__stdcall *)(void *, void **))ole32("CoCreateFreeThreadedMarshaler"); | |
| 24 | return real(outer, marshaler); | |
| 25 | } | |
| 26 | ||
| 27 | void __stdcall CoTaskMemFree(void *memory) { | |
| 28 | static void(__stdcall * real)(void *); | |
| 29 | if (!real) real = (void(__stdcall *)(void *))ole32("CoTaskMemFree"); | |
| 30 | real(memory); | |
| 31 | } | |
| 32 | ||
| 33 | // api-ms-win-core-winrt-error-l1-1-0.dll (Windows 8): nothing records an origin. | |
| 34 | BOOL __stdcall RoOriginateErrorW(HRESULT error, unsigned length, const wchar_t *message) { | |
| 35 | (void)error, (void)length, (void)message; | |
| 36 | return 0; | |
| 37 | } | |
| 38 | ||
| 39 | // api-ms-win-core-path-l1-1-0.dll (Windows 8): `\\?\C:\x` becomes `C:\x` and | |
| 40 | // `\\?\UNC\server\x` becomes `\\server\x`; S_FALSE where there was no prefix. | |
| 41 | HRESULT __stdcall PathCchStripPrefix(wchar_t *path, size_t length) { | |
| 42 | if (!path) return E_INVALIDARG; | |
| 43 | if (wcsncmp(path, L"\\\\?\\UNC\\", 8) == 0) { | |
| 44 | if (length < 8) return E_INVALIDARG; | |
| 45 | memmove(path + 2, path + 8, (wcslen(path + 8) + 1) * sizeof(wchar_t)); | |
| 46 | return S_OK; | |
| 47 | } | |
| 48 | if (wcsncmp(path, L"\\\\?\\", 4) == 0 && path[4] && path[5] == L':') { | |
| 49 | memmove(path, path + 4, (wcslen(path + 4) + 1) * sizeof(wchar_t)); | |
| 50 | return S_OK; | |
| 51 | } | |
| 52 | return S_FALSE; | |
| 53 | } | |
| 54 | ||
| 55 | void *__imp_CoCreateFreeThreadedMarshaler = CoCreateFreeThreadedMarshaler; | |
| 56 | void *__imp_CoTaskMemFree = CoTaskMemFree; | |
| 57 | void *__imp_RoOriginateErrorW = RoOriginateErrorW; | |
| 58 | void *__imp_PathCchStripPrefix = PathCchStripPrefix; |
platform/windows/toolchain.sh created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | #!/bin/sh | |
| 2 | # Fetches llvm-mingw into target/windows/llvm-mingw: clang, lld and the MinGW-w64 sysroots | |
| 3 | # for x86_64 and aarch64, built for msvcrt.dll, which Windows 7 has without updates, where | |
| 4 | # the UCRT needs KB2999226. llvm-mingw publishes msvcrt builds only for Linux and Windows | |
| 5 | # hosts, so on macOS its UCRT toolchain takes the msvcrt build's target files. | |
| 6 | set -eu | |
| 7 | here=$(cd "$(dirname "$0")" && pwd) | |
| 8 | root=$(cd "$here/../.." && pwd) | |
| 9 | release=20260922 | |
| 10 | out=$root/target/windows/llvm-mingw | |
| 11 | stage=$root/target/windows/llvm-mingw.partial | |
| 12 | base=https://github.com/mstorsjo/llvm-mingw/releases/download/$release | |
| 13 | msvcrt=llvm-mingw-$release-msvcrt-ubuntu-22.04-x86_64 | |
| 14 | [ ! -e "$out" ] || { echo "$out exists" >&2; exit 1; } | |
| 15 | rm -rf "${stage:?}" | |
| 16 | mkdir -p "$stage" | |
| 17 | curl -fsSL "$base/$msvcrt.tar.xz" | tar -xJ -C "$stage" | |
| 18 | if [ "$(uname -s)" = Darwin ]; then | |
| 19 | host=llvm-mingw-$release-ucrt-macos-universal | |
| 20 | curl -fsSL "$base/$host.tar.xz" | tar -xJ -C "$stage" | |
| 21 | clang=$(ls "$stage/$host/lib/clang") | |
| 22 | for part in x86_64-w64-mingw32 aarch64-w64-mingw32 generic-w64-mingw32 \ | |
| 23 | "lib/clang/$clang/lib/windows"; do | |
| 24 | rm -rf "${stage:?}/$host/$part" | |
| 25 | mv "$stage/$msvcrt/$part" "$stage/$host/$part" | |
| 26 | done | |
| 27 | else | |
| 28 | host=$msvcrt | |
| 29 | fi | |
| 30 | # Only x86_64 and aarch64 are built. | |
| 31 | rm -rf "${stage:?}/$host/i686-w64-mingw32" "${stage:?}/$host/armv7-w64-mingw32" | |
| 32 | find "$stage/$host/bin" \( -name 'i686-*' -o -name 'armv7-*' -o -name '*uwp*' \) -delete | |
| 33 | mv "$stage/$host" "$out" | |
| 34 | rm -rf "${stage:?}" | |
| 35 | echo "$out" |
tools/RELEASE.md+16-8| ... | ... | @@ -18,7 +18,7 @@ development builds, which never update themselves. |
| 18 | 18 | ## The published folder |
| 19 | 19 | |
| 20 | 20 | ```text |
| 21 | latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": ..., "linux-x86_64": ..., "linux-aarch64": ...} | |
| 21 | latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": ..., "linux-x86_64": ..., "windows-x86_64": ..., ...} | |
| 22 | 22 | 2026-09-29.r10/ |
| 23 | 23 | build.json version, commit, and per platform: file, size, sha256, signature |
| 24 | 24 | build.json.sig ed25519 signature of build.json, hex |
| ... | ... | @@ -26,6 +26,8 @@ latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": |
| 26 | 26 | Snowbound-2026-09-29-r10-macos-10.6.zip |
| 27 | 27 | snowbound-2026-09-29-r10-linux-x86_64 the executable itself |
| 28 | 28 | snowbound-2026-09-29-r10-linux-aarch64 |
| 29 | snowbound-2026-09-29-r10-windows-x86_64.exe Windows 7 SP1 to 11 | |
| 30 | snowbound-2026-09-29-r10-windows-aarch64.exe Windows 11 on Arm | |
| 29 | 31 | ``` |
| 30 | 32 | |
| 31 | 33 | A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into |
| ... | ... | @@ -80,7 +82,7 @@ quarantine and open directly. |
| 80 | 82 | ## Publishing |
| 81 | 83 | |
| 82 | 84 | ```sh |
| 83 | python3 tools/release.py # all four platforms | |
| 85 | python3 tools/release.py # all six platforms | |
| 84 | 86 | python3 tools/release.py --ad-hoc # the macOS app without Developer ID |
| 85 | 87 | python3 tools/release.py --dry-run # the same into a new temporary folder |
| 86 | 88 | python3 tools/release.py --platforms macos-aarch64 linux-x86_64 |
| ... | ... | @@ -89,14 +91,17 @@ python3 tools/release.py --platforms macos-aarch64 linux-x86_64 |
| 89 | 91 | The script refuses to run unless the working copy matches `main` (a dry run |
| 90 | 92 | only warns), runs Clippy (workspace, and `snowbound` without default features) |
| 91 | 93 | and the workspace tests, builds each platform with |
| 92 | `tools/canvas/build_macos.py` (`--snow-leopard` for 10.6) and | |
| 93 | `crates/snowbound/linux/package.sh` (taking only its executables), then checks | |
| 94 | `tools/canvas/build_macos.py` (`--snow-leopard` for 10.6), | |
| 95 | `crates/snowbound/linux/package.sh` (taking only its executables) and | |
| 96 | `platform/windows/cargo.sh`, then checks | |
| 94 | 97 | the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and |
| 95 | 98 | publishes as above. Run again for the same commit, it only brings |
| 96 | 99 | `latest.json` up to date; a different commit that derives the same version is |
| 97 | 100 | refused. The 10.6 build needs the SDK and nightly toolchain |
| 98 | 101 | `platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the |
| 99 | cross linker against glibc 2.31. All four build from an Apple silicon Mac. | |
| 102 | cross linker against glibc 2.31; the Windows builds need llvm-mingw, which | |
| 103 | `platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for | |
| 104 | x86_64. All six build from an Apple silicon Mac. | |
| 100 | 105 | |
| 101 | 106 | ## In the app |
| 102 | 107 | |
| ... | ... | @@ -108,11 +113,14 @@ palette elsewhere) checks at once and reports what it found. A check reads |
| 108 | 113 | downloads the archive for this platform, verifying size, SHA-256 and |
| 109 | 114 | signature. It stages the update beside the install, so the swap is a rename: |
| 110 | 115 | the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the |
| 111 | executable into `.snowbound.update` next to it on Linux. The sync status icon gets a dot, and its popup offers | |
| 116 | executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on | |
| 117 | Windows). The sync status icon gets a dot, and its popup offers | |
| 112 | 118 | Build Folder and Restart to Update. |
| 113 | 119 | |
| 114 | 120 | Restart to Update quits the app and starts the old executable with |
| 115 | `--finish-update`, which waits for the app to exit, renames the new bundle (or | |
| 116 | executable) over the old one, and opens it. Where the install can't be | |
| 121 | `--finish-update`, which waits for the app to exit, renames the old bundle (or | |
| 122 | executable) aside and the new one into its place, and opens it. Windows renames an | |
| 123 | executable while it runs, as this one does, but won't delete it: the old one waits beside | |
| 124 | the new as `.snowbound.exe.old` until the next update removes it. Where the install can't be | |
| 117 | 125 | written, and on Mac OS X 10.6, the popup and the check name the build and |
| 118 | 126 | open its folder to download instead. |
tools/release.py+18-1| ... | ... | @@ -17,7 +17,10 @@ PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') |
| 17 | 17 | URL = 'https://file.paperclover.net/shr/snowbound/' |
| 18 | 18 | KEY = Path.home() / '.config/snowbound/release-key' |
| 19 | 19 | ZONE = ZoneInfo('America/Los_Angeles') |
| 20 | PLATFORMS = ['macos-aarch64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64'] | |
| 20 | PLATFORMS = ['macos-aarch64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64', 'windows-x86_64', | |
| 21 | 'windows-aarch64'] | |
| 22 | # Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm. | |
| 23 | WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'} | |
| 21 | 24 | CHECKS = [ |
| 22 | 25 | ['cargo', 'clippy', '--workspace', '--all-targets', '--all-features', '--', '-D', 'warnings'], |
| 23 | 26 | ['cargo', 'clippy', '-p', 'snowbound', '--no-default-features', '--', '-D', 'warnings'], |
| ... | ... | @@ -128,6 +131,17 @@ def build_linux(architectures): |
| 128 | 131 | return {f'linux-{arch}': ROOT / f'target/{arch}-unknown-linux-gnu/release/snowbound' for arch in architectures} |
| 129 | 132 | |
| 130 | 133 | |
| 134 | def build_windows(architectures): | |
| 135 | """The executables, each all of Snowbound for its architecture, one running on every | |
| 136 | Windows it supports.""" | |
| 137 | built = {} | |
| 138 | for arch in architectures: | |
| 139 | run(['sh', ROOT / 'platform/windows/cargo.sh', arch, 'build', '--release', '-p', 'snowbound'], | |
| 140 | env={**os.environ, 'CARGO_PROFILE_RELEASE_STRIP': 'symbols'}) | |
| 141 | built[f'windows-{arch}'] = ROOT / f'target/windows/{WINDOWS[arch]}/release/snowbound.exe' | |
| 142 | return built | |
| 143 | ||
| 144 | ||
| 131 | 145 | def main(): |
| 132 | 146 | parser = argparse.ArgumentParser(description=__doc__) |
| 133 | 147 | parser.add_argument('--dry-run', action='store_true', |
| ... | ... | @@ -178,6 +192,9 @@ def main(): |
| 178 | 192 | linux = [platform.removeprefix('linux-') for platform in args.platforms if platform.startswith('linux')] |
| 179 | 193 | if linux: |
| 180 | 194 | built |= build_linux(linux) |
| 195 | windows = [platform.removeprefix('windows-') for platform in args.platforms if platform.startswith('windows')] | |
| 196 | if windows: | |
| 197 | built |= build_windows(windows) | |
| 181 | 198 | clean(args.dry_run, 'after the build') |
| 182 | 199 | files = {} |
| 183 | 200 | for platform, source in built.items(): |
tools/w7/README.md+33| ... | ... | @@ -96,6 +96,37 @@ The control agent starts minimized after login. Its taskbar button remains |
| 96 | 96 | available for diagnosis without covering OneNote or changing screenshot-based |
| 97 | 97 | test coordinates. |
| 98 | 98 | |
| 99 | ## Windows 10 and 11 | |
| 100 | ||
| 101 | `win10` (22H2 x64, emulated like Windows 7, so slow) and `win11` (24H2 arm64, | |
| 102 | hardware-virtualized) are further bases for clones. They install unattended and | |
| 103 | headless from Microsoft's Media Creation Tool catalog: Pro from the | |
| 104 | volume-license image, which installs without a product key and stays | |
| 105 | unactivated, with local administrator `one` / `one`. Setup's first logon runs | |
| 106 | [unattend/lab-setup.cmd](unattend/lab-setup.cmd), which installs the agent like | |
| 107 | the Windows 7 build and restarts into it. | |
| 108 | ||
| 109 | ```sh | |
| 110 | ./windows_media.py win11 # -> $ONE_VM_HOME/media/win11.iso (brew: wimlib xorriso) | |
| 111 | ./vm.py install --base win11 # headless; add --display to watch | |
| 112 | ./mcp_win7.py --target win11-build health # answers once the build reaches its desktop | |
| 113 | ./vm.py poweroff win11-build | |
| 114 | ./vm.py seal --base win11 | |
| 115 | ./vm.py up w11 --base win11 --wait # clones work as above; --base is kept per clone | |
| 116 | ./mcp_win7.py --target w11 shot | |
| 117 | ``` | |
| 118 | ||
| 119 | A Windows 11 install reaches its agent in about 10 minutes and a new clone in | |
| 120 | one; Windows 10 takes about 55 minutes and 7, so pass `--timeout 900` to its | |
| 121 | `up --wait`. | |
| 122 | ||
| 123 | Screens are 1024x768, the largest mode edk2 offers the arm64 guest's framebuffer. | |
| 124 | The arm64 guest runs x64 programs, including the agent, through Windows' own | |
| 125 | emulation. Windows on Arm has no inbox driver for any NIC QEMU emulates, so | |
| 126 | `windows_media.py win11` also keeps Red Hat's signed NetKVM driver from the | |
| 127 | virtio-win disc. Transparency is on and DWM's `ForceEffectMode` is set, so Mica | |
| 128 | and Acrylic render on the emulated display adapter. | |
| 129 | ||
| 99 | 130 | ## Abrupt-stop recovery tests |
| 100 | 131 | |
| 101 | 132 | `python3 tools/w7/crash.py windows NAME` and `python3 tools/w7/crash.py linux NAME` |
| ... | ... | @@ -147,6 +178,8 @@ a physical or network-reachable Windows machine. |
| 147 | 178 | |
| 148 | 179 | ## Gotchas |
| 149 | 180 | |
| 181 | - `-cpu qemu64` lacks SSSE3, which Mesa's llvmpipe (software OpenGL for Snowbound on a clone) | |
| 182 | needs: `ONE_VM_CPU=Nehalem ./vm.py up NAME --wait` picks another model. | |
| 150 | 183 | - Keep Windows display scaling at 100%. Anything else and the screenshot |
| 151 | 184 | coordinates no longer match where clicks land. |
| 152 | 185 | - The listener must run in the interactive logged-in session — as a scheduled |
tools/w7/mcp_win7.py+9-4| ... | ... | @@ -13,6 +13,7 @@ import urllib.error |
| 13 | 13 | import urllib.request |
| 14 | 14 | |
| 15 | 15 | from env import ROOT, setting |
| 16 | from vm import BASES | |
| 16 | 17 | |
| 17 | 18 | DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "local") |
| 18 | 19 | TARGETS_PATH = Path(setting("WIN7_TARGETS_FILE") or Path(setting("ONE_VM_HOME") or ROOT / "lab-unset") / "targets.json").expanduser() |
| ... | ... | @@ -25,6 +26,8 @@ class Win7Error(Exception): |
| 25 | 26 | |
| 26 | 27 | def resolve_target(name): |
| 27 | 28 | targets = {"local": {"base": "http://127.0.0.1:18777"}} |
| 29 | targets.update({base + "-build": {"base": "http://127.0.0.1:%d" % port} | |
| 30 | for base, (_stem, port) in BASES.items()}) | |
| 28 | 31 | if TARGETS_PATH.exists(): |
| 29 | 32 | try: |
| 30 | 33 | configured = json.loads(TARGETS_PATH.read_text()) |
| ... | ... | @@ -288,15 +291,17 @@ TOOLS += [ |
| 288 | 291 | { |
| 289 | 292 | "name": "win7_vm_up", |
| 290 | 293 | "description": ( |
| 291 | "Create a named Windows 7 clone when absent, then boot it. Creation settings " | |
| 292 | "are ignored for an existing clone. Set wait to return only when its " | |
| 293 | "authenticated desktop agent reports the expected hostname." | |
| 294 | "Create a named Windows clone when absent, then boot it. base picks Windows 7 " | |
| 295 | "(x64, OneNote 2010), 10 (x64, emulated and slow) or 11 (arm64, native speed). " | |
| 296 | "Creation settings are ignored for an existing clone. Set wait to return only " | |
| 297 | "when its authenticated desktop agent reports the expected hostname." | |
| 294 | 298 | ), |
| 295 | 299 | "inputSchema": { |
| 296 | 300 | "type": "object", |
| 297 | 301 | "properties": { |
| 298 | 302 | "name": {"type": "string", |
| 299 | 303 | "description": "Unique 1-11 character lowercase VM name."}, |
| 304 | "base": {"type": "string", "enum": sorted(BASES), "default": "win7"}, | |
| 300 | 305 | "hostname": {"type": "string", "description": "Optional Windows hostname."}, |
| 301 | 306 | "cpus": {"type": "integer", "default": 2, "minimum": 1, "maximum": 16}, |
| 302 | 307 | "memory_mb": {"type": "integer", "default": 4096, |
| ... | ... | @@ -375,7 +380,7 @@ def vm_tool(name, args): |
| 375 | 380 | argv = ["status"] + ([args["name"]] if args.get("name") else []) |
| 376 | 381 | elif verb == "up": |
| 377 | 382 | argv = ["up", args["name"]] |
| 378 | for key, option in (("hostname", "--hostname"), ("cpus", "--cpus"), | |
| 383 | for key, option in (("base", "--base"), ("hostname", "--hostname"), ("cpus", "--cpus"), | |
| 379 | 384 | ("memory_mb", "--memory"), ("port", "--port")): |
| 380 | 385 | if args.get(key) is not None: |
| 381 | 386 | argv += [option, str(args[key])] |
tools/w7/payload/bootstrap.cmd+22-22| ... | ... | @@ -1,22 +1,22 @@ |
| 1 | @echo off | |
| 2 | setlocal EnableExtensions | |
| 3 | set "ONEVM_HOSTNAME=" | |
| 4 | for %%D in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist "%%D:\onenote-vm.ini" for /f "usebackq tokens=1,* delims==" %%A in ("%%D:\onenote-vm.ini") do ( | |
| 5 | if /i "%%A"=="HOSTNAME" set "ONEVM_HOSTNAME=%%B" | |
| 6 | if /i "%%A"=="TOKEN" set "WIN7_TOKEN=%%B" | |
| 7 | ) | |
| 8 | if not defined ONEVM_HOSTNAME goto agent | |
| 9 | if /i "%COMPUTERNAME%"=="%ONEVM_HOSTNAME%" goto agent | |
| 10 | rem Windows 11 ships without wmic; Windows 7 PowerShell lacks Rename-Computer. | |
| 11 | where wmic >nul 2>&1 || goto rename_powershell | |
| 12 | wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1 | |
| 13 | find "ReturnValue = 0;" "%~dp0bootstrap.log" >nul || exit /b 1 | |
| 14 | goto restart | |
| 15 | :rename_powershell | |
| 16 | powershell -NoProfile -Command "Rename-Computer -NewName '%ONEVM_HOSTNAME%' -Force -ErrorAction Stop" >"%~dp0bootstrap.log" 2>&1 || exit /b 1 | |
| 17 | :restart | |
| 18 | shutdown /r /t 0 | |
| 19 | exit /b | |
| 20 | ||
| 21 | :agent | |
| 22 | call "%~dp0run.bat" | |
| 1 | @echo off | |
| 2 | setlocal EnableExtensions | |
| 3 | set "ONEVM_HOSTNAME=" | |
| 4 | for %%D in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist "%%D:\onenote-vm.ini" for /f "usebackq tokens=1,* delims==" %%A in ("%%D:\onenote-vm.ini") do ( | |
| 5 | if /i "%%A"=="HOSTNAME" set "ONEVM_HOSTNAME=%%B" | |
| 6 | if /i "%%A"=="TOKEN" set "WIN7_TOKEN=%%B" | |
| 7 | ) | |
| 8 | if not defined ONEVM_HOSTNAME goto agent | |
| 9 | if /i "%COMPUTERNAME%"=="%ONEVM_HOSTNAME%" goto agent | |
| 10 | rem Windows 11 ships without wmic; Windows 7 PowerShell lacks Rename-Computer. | |
| 11 | where wmic >nul 2>&1 || goto rename_powershell | |
| 12 | wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1 | |
| 13 | find "ReturnValue = 0;" "%~dp0bootstrap.log" >nul || exit /b 1 | |
| 14 | goto restart | |
| 15 | :rename_powershell | |
| 16 | powershell -NoProfile -Command "Rename-Computer -NewName '%ONEVM_HOSTNAME%' -Force -ErrorAction Stop" >"%~dp0bootstrap.log" 2>&1 || exit /b 1 | |
| 17 | :restart | |
| 18 | shutdown /r /t 0 | |
| 19 | exit /b | |
| 20 | ||
| 21 | :agent | |
| 22 | call "%~dp0run.bat" |
tools/w7/test_vm.py+20-10| ... | ... | @@ -12,8 +12,7 @@ import vm |
| 12 | 12 | class VmLifecycleTest(unittest.TestCase): |
| 13 | 13 | def setUp(self): |
| 14 | 14 | self.original = {name: getattr(vm, name) for name in ( |
| 15 | "VM_HOME", "IMAGES", "MEDIA", "INSTANCES", "RUN", "DISK", | |
| 16 | "BASE_DISK", "BASE_MANIFEST", "AGENT_ISO", "TARGETS", | |
| 15 | "VM_HOME", "IMAGES", "MEDIA", "INSTANCES", "RUN", "AGENT_ISO", "TARGETS", | |
| 17 | 16 | )} |
| 18 | 17 | self.temporary = tempfile.TemporaryDirectory(prefix="one-vm-test-") |
| 19 | 18 | home = Path(self.temporary.name) |
| ... | ... | @@ -22,17 +21,16 @@ class VmLifecycleTest(unittest.TestCase): |
| 22 | 21 | vm.MEDIA = home / "media" |
| 23 | 22 | vm.INSTANCES = home / "instances" |
| 24 | 23 | vm.RUN = home / "run" |
| 25 | vm.DISK = vm.IMAGES / "win7-office-build.qcow2" | |
| 26 | vm.BASE_DISK = vm.IMAGES / "win7-office-base.qcow2" | |
| 27 | vm.BASE_MANIFEST = vm.IMAGES / "win7-office-base.json" | |
| 28 | 24 | vm.AGENT_ISO = vm.MEDIA / "win7-agent.iso" |
| 29 | 25 | vm.TARGETS = home / "targets.json" |
| 30 | 26 | vm.IMAGES.mkdir(parents=True) |
| 31 | subprocess.run([ | |
| 32 | vm.qemu("qemu-img"), "create", "-q", "-f", "qcow2", str(vm.DISK), "64M" | |
| 33 | ], check=True) | |
| 34 | with contextlib.redirect_stdout(io.StringIO()): | |
| 35 | vm.seal() | |
| 27 | for base in ("win7", "win11"): | |
| 28 | subprocess.run([ | |
| 29 | vm.qemu("qemu-img"), "create", "-q", "-f", "qcow2", | |
| 30 | str(vm.build_disk(base)), "64M", | |
| 31 | ], check=True) | |
| 32 | with contextlib.redirect_stdout(io.StringIO()): | |
| 33 | vm.seal(base) | |
| 36 | 34 | |
| 37 | 35 | def tearDown(self): |
| 38 | 36 | self.temporary.cleanup() |
| ... | ... | @@ -66,6 +64,18 @@ class VmLifecycleTest(unittest.TestCase): |
| 66 | 64 | def test_reserved_target_name_is_rejected(self): |
| 67 | 65 | with self.assertRaisesRegex(SystemExit, "reserved"): |
| 68 | 66 | vm.create_instance("local") |
| 67 | with self.assertRaisesRegex(SystemExit, "reserved"): | |
| 68 | vm.create_instance("win11-build") | |
| 69 | ||
| 70 | def test_clone_records_its_base(self): | |
| 71 | with contextlib.redirect_stdout(io.StringIO()): | |
| 72 | vm.create_instance("arm", base="win11") | |
| 73 | self.assertEqual(json.loads(vm.instance_path("arm").read_text())["base"], "win11") | |
| 74 | backing = json.loads(subprocess.check_output([ | |
| 75 | vm.qemu("qemu-img"), "info", "--output=json", | |
| 76 | str(vm.IMAGES / "instances" / "arm.qcow2"), | |
| 77 | ]))["backing-filename"] | |
| 78 | self.assertEqual(backing, str(vm.base_disk("win11"))) | |
| 69 | 79 | |
| 70 | 80 | |
| 71 | 81 | if __name__ == "__main__": |
tools/w7/unattend/lab-setup.cmd+28-24| ... | ... | @@ -1,24 +1,28 @@ |
| 1 | @echo off | |
| 2 | rem First logon of a Windows 10/11 lab build: install the agent and quiet the desktop. | |
| 3 | set "AGENT=" | |
| 4 | for %%D in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist "%%D:\agent.py" set "AGENT=%%D:" | |
| 5 | if not defined AGENT exit /b 1 | |
| 6 | xcopy /e /i /y /q "%AGENT%\" C:\win7-agent\ || exit /b 1 | |
| 7 | attrib -r /s /d "C:\win7-agent\*" | |
| 8 | netsh advfirewall firewall add rule name="win7-agent" dir=in action=allow protocol=TCP localport=8777 | |
| 9 | net accounts /maxpwage:unlimited | |
| 10 | set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon | |
| 11 | reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d 1 /f | |
| 12 | reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d one /f | |
| 13 | reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d one /f | |
| 14 | reg delete "%WINLOGON%" /v AutoLogonCount /f | |
| 15 | powercfg /change monitor-timeout-ac 0 | |
| 16 | powercfg /change standby-timeout-ac 0 | |
| 17 | powercfg /hibernate off | |
| 18 | reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f | |
| 19 | reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoUpdate /t REG_DWORD /d 1 /f | |
| 20 | reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f | |
| 21 | reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v EnableTransparency /t REG_DWORD /d 1 /f | |
| 22 | reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f | |
| 23 | call C:\win7-agent\install-autostart.cmd | |
| 24 | shutdown /r /t 5 | |
| 1 | @echo off | |
| 2 | rem First logon of a Windows 10/11 lab build: install the agent and quiet the desktop. | |
| 3 | set "AGENT=" | |
| 4 | for %%D in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist "%%D:\agent.py" set "AGENT=%%D:" | |
| 5 | if not defined AGENT exit /b 1 | |
| 6 | if exist "%~dp0netkvm\netkvm.inf" pnputil /add-driver "%~dp0netkvm\netkvm.inf" /install | |
| 7 | xcopy /e /i /y /q "%AGENT%\" C:\win7-agent\ || exit /b 1 | |
| 8 | attrib -r /s /d "C:\win7-agent\*" | |
| 9 | netsh advfirewall firewall add rule name="win7-agent" dir=in action=allow protocol=TCP localport=8777 | |
| 10 | net accounts /maxpwage:unlimited | |
| 11 | set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon | |
| 12 | reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d 1 /f | |
| 13 | reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d one /f | |
| 14 | reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d one /f | |
| 15 | reg delete "%WINLOGON%" /v AutoLogonCount /f | |
| 16 | powercfg /change monitor-timeout-ac 0 | |
| 17 | powercfg /change standby-timeout-ac 0 | |
| 18 | powercfg /hibernate off | |
| 19 | reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f | |
| 20 | reg add "HKLM\SYSTEM\CurrentControlSet\Control\Network\NewNetworkWindowOff" /f | |
| 21 | reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoUpdate /t REG_DWORD /d 1 /f | |
| 22 | reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f | |
| 23 | reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v EnableTransparency /t REG_DWORD /d 1 /f | |
| 24 | rem DWM leaves blur off on the emulated display adapter unless forced. | |
| 25 | reg add "HKLM\SOFTWARE\Microsoft\Windows\Dwm" /v ForceEffectMode /t REG_DWORD /d 2 /f | |
| 26 | reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f | |
| 27 | call C:\win7-agent\install-autostart.cmd | |
| 28 | shutdown /r /t 5 |
tools/w7/unattend/startup.nsh created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | @echo -off | |
| 2 | # edk2 on arm64 lists no boot option for the USB installer disc, so its shell starts it. | |
| 3 | for %m in fs0 fs1 fs2 fs3 fs4 fs5 | |
| 4 | if exist %m:\efi\boot\bootaa64.efi then | |
| 5 | %m:\efi\boot\bootaa64.efi | |
| 6 | endif | |
| 7 | endfor |
tools/w7/vm.py+187-80| ... | ... | @@ -10,6 +10,7 @@ import re |
| 10 | 10 | import secrets |
| 11 | 11 | import shutil |
| 12 | 12 | import socket |
| 13 | import struct | |
| 13 | 14 | import subprocess |
| 14 | 15 | import tempfile |
| 15 | 16 | import time |
| ... | ... | @@ -30,12 +31,18 @@ IMAGES = VM_HOME / "images" |
| 30 | 31 | MEDIA = VM_HOME / "media" |
| 31 | 32 | INSTANCES = VM_HOME / "instances" |
| 32 | 33 | RUN = VM_HOME / "run" |
| 33 | DISK = IMAGES / "win7-office-build.qcow2" | |
| 34 | BASE_DISK = IMAGES / "win7-office-base.qcow2" | |
| 35 | BASE_MANIFEST = IMAGES / "win7-office-base.json" | |
| 36 | 34 | AGENT_ISO = MEDIA / "win7-agent.iso" |
| 37 | 35 | TARGETS = VM_HOME / "targets.json" |
| 36 | # Image stem and build-VM control port. Windows 7 is installed by hand from licensed | |
| 37 | # media; Windows 10 (x64) and 11 (arm64) install unattended from windows_media.py ISOs. | |
| 38 | BASES = { | |
| 39 | "win7": ("win7-office", 18777), | |
| 40 | "win10": ("win10", 18774), | |
| 41 | "win11": ("win11", 18775), | |
| 42 | } | |
| 38 | 43 | BUILD = "win7-build" |
| 44 | UNATTEND = Path(__file__).with_name("unattend") | |
| 45 | FIRMWARE = Path("/opt/homebrew/share/qemu") | |
| 39 | 46 | NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,9}[a-z0-9])?") |
| 40 | 47 | HOSTNAME = re.compile(r"[A-Z0-9](?:[A-Z0-9-]{0,13}[A-Z0-9])?") |
| 41 | 48 | |
| ... | ... | @@ -56,6 +63,18 @@ def require_vm_home(): |
| 56 | 63 | raise SystemExit("VM volume is not mounted: %s" % volume) |
| 57 | 64 | |
| 58 | 65 | |
| 66 | def build_disk(base): | |
| 67 | return IMAGES / ("%s-build.qcow2" % BASES[base][0]) | |
| 68 | ||
| 69 | ||
| 70 | def base_disk(base): | |
| 71 | return IMAGES / ("%s-base.qcow2" % BASES[base][0]) | |
| 72 | ||
| 73 | ||
| 74 | def base_manifest(base): | |
| 75 | return IMAGES / ("%s-base.json" % BASES[base][0]) | |
| 76 | ||
| 77 | ||
| 59 | 78 | def runtime(name): |
| 60 | 79 | root = RUN / name |
| 61 | 80 | return root, root / "qmp.sock", root / "qemu.pid", root / "qemu.log", root / "screen.png" |
| ... | ... | @@ -91,7 +110,72 @@ def qmp(name, command, arguments=None): |
| 91 | 110 | raise SystemExit(response["error"]["desc"]) |
| 92 | 111 | |
| 93 | 112 | |
| 94 | def launch(name, disk, port, mac, cpus, memory_mb, display, drives): | |
| 113 | def uefi_vars(path, template, width=1024, height=768): | |
| 114 | """Write an edk2 variable store whose PlatformConfig sets the GOP resolution. | |
| 115 | ||
| 116 | Windows keeps the firmware's framebuffer mode, and 1024x768 is the largest mode | |
| 117 | edk2 offers for ramfb on arm64.""" | |
| 118 | image = bytearray(template.read_bytes()) | |
| 119 | offset = struct.unpack_from("<H", image, 0x30)[0] + 28 | |
| 120 | while struct.unpack_from("<H", image, offset)[0] == 0x55AA: | |
| 121 | name_size, data_size = struct.unpack_from("<II", image, offset + 36) | |
| 122 | offset = (offset + 60 + name_size + data_size + 3) & ~3 | |
| 123 | name = "PlatformConfig\0".encode("utf-16-le") | |
| 124 | data = struct.pack("<II", width, height) | |
| 125 | guid = uuid.UUID("7235c51c-0c80-4cab-87ac-3b084a6304b1").bytes_le | |
| 126 | record = struct.pack("<HBBIQ16sIII16s", 0x55AA, 0x3F, 0, 7, 0, bytes(16), 0, | |
| 127 | len(name), len(data), guid) + name + data | |
| 128 | image[offset:offset + len(record)] = record | |
| 129 | path.write_bytes(image) | |
| 130 | ||
| 131 | ||
| 132 | def machine(base, disk): | |
| 133 | if base == "win7": | |
| 134 | return [qemu("qemu-system-x86_64"), "-machine", "pc", "-accel", "tcg,thread=multi", | |
| 135 | "-cpu", os.environ.get("ONE_VM_CPU", "qemu64"), | |
| 136 | "-vga", "std", "-usb", "-device", "usb-tablet", | |
| 137 | "-drive", "file=%s,if=ide,format=qcow2,cache=writeback" % disk], "e1000" | |
| 138 | variables = disk.with_suffix(".vars.fd") | |
| 139 | if base == "win10": | |
| 140 | if not variables.exists(): | |
| 141 | uefi_vars(variables, FIRMWARE / "edk2-i386-vars.fd") | |
| 142 | return [qemu("qemu-system-x86_64"), "-machine", "q35", "-accel", "tcg,thread=multi", | |
| 143 | "-cpu", "max", | |
| 144 | "-drive", "if=pflash,format=raw,readonly=on,file=%s" % (FIRMWARE / "edk2-x86_64-code.fd"), | |
| 145 | "-drive", "if=pflash,format=raw,file=%s" % variables, | |
| 146 | "-vga", "std", "-usb", "-device", "usb-tablet", | |
| 147 | "-drive", "file=%s,if=none,id=disk,format=qcow2,cache=writeback" % disk, | |
| 148 | "-device", "ide-hd,drive=disk,bus=ide.0,bootindex=0"], "e1000" | |
| 149 | if not variables.exists(): | |
| 150 | uefi_vars(variables, FIRMWARE / "edk2-arm-vars.fd") | |
| 151 | # Windows on Arm has inbox NVMe and xHCI drivers; lab-setup.cmd adds NetKVM. | |
| 152 | return [qemu("qemu-system-aarch64"), "-machine", "virt", "-accel", "hvf", "-cpu", "host", | |
| 153 | "-drive", "if=pflash,format=raw,readonly=on,file=%s" % (FIRMWARE / "edk2-aarch64-code.fd"), | |
| 154 | "-drive", "if=pflash,format=raw,file=%s" % variables, | |
| 155 | "-device", "ramfb", "-device", "qemu-xhci", | |
| 156 | "-device", "usb-kbd", "-device", "usb-tablet", | |
| 157 | "-drive", "file=%s,if=none,id=disk,format=qcow2,cache=writeback" % disk, | |
| 158 | "-device", "nvme,drive=disk,serial=one,bootindex=0"], "virtio-net-pci" | |
| 159 | ||
| 160 | ||
| 161 | def cdroms(base, images): | |
| 162 | """Attach read-only discs. UEFI guests try the disk first, so an installer CD | |
| 163 | boots only until Windows has made the disk bootable.""" | |
| 164 | drives = [] | |
| 165 | for index, image in enumerate(images, 1): | |
| 166 | drive = "file=%s,file.locking=off,media=cdrom,readonly=on" % image | |
| 167 | if base == "win7": | |
| 168 | drives += ["-drive", drive + ",if=ide"] | |
| 169 | continue | |
| 170 | drives += ["-drive", drive + ",if=none,id=cd%d" % index, "-device"] | |
| 171 | if base == "win10": | |
| 172 | drives.append("ide-cd,drive=cd%d,bus=ide.%d,bootindex=%d" % (index, index, index)) | |
| 173 | else: | |
| 174 | drives.append("usb-storage,drive=cd%d,bootindex=%d" % (index, index)) | |
| 175 | return drives | |
| 176 | ||
| 177 | ||
| 178 | def launch(name, base, disk, port, mac, cpus, memory_mb, display, images, answers=None): | |
| 95 | 179 | require_vm_home() |
| 96 | 180 | if running(name): |
| 97 | 181 | raise SystemExit("Windows is already running: %s" % name) |
| ... | ... | @@ -99,30 +183,26 @@ def launch(name, disk, port, mac, cpus, memory_mb, display, drives): |
| 99 | 183 | root.mkdir(parents=True, exist_ok=True) |
| 100 | 184 | qmp_socket.unlink(missing_ok=True) |
| 101 | 185 | lab_socket = ensure_hub(VM_HOME) |
| 102 | command = [ | |
| 103 | qemu("qemu-system-x86_64"), | |
| 186 | command, nic = machine(base, disk) | |
| 187 | command += [ | |
| 188 | # crash.py matches this name to confirm a process belongs to the clone. | |
| 104 | 189 | "-name", "OneNote Windows 7 " + name, |
| 105 | "-machine", "pc", | |
| 106 | "-accel", "tcg,thread=multi", | |
| 107 | "-cpu", "qemu64", | |
| 108 | 190 | "-smp", str(cpus), |
| 109 | 191 | "-m", str(memory_mb), |
| 110 | "-drive", "file=%s,if=ide,format=qcow2,cache=writeback" % disk, | |
| 111 | "-vga", "std", | |
| 112 | 192 | "-display", display, |
| 113 | "-usb", | |
| 114 | "-device", "usb-tablet", | |
| 115 | 193 | "-netdev", "user,id=control,hostfwd=tcp:127.0.0.1:%d-:8777" % port, |
| 116 | "-device", "e1000,netdev=control,mac=%s" % mac, | |
| 194 | "-device", "%s,netdev=control,mac=%s" % (nic, mac), | |
| 117 | 195 | "-netdev", "vde,id=lab,sock=%s" % lab_socket, |
| 118 | "-device", "e1000,netdev=lab,mac=%s" % lab_mac(name), | |
| 196 | "-device", "%s,netdev=lab,mac=%s" % (nic, lab_mac(name)), | |
| 119 | 197 | "-uuid", uuid_for(name), |
| 120 | 198 | "-rtc", "base=localtime,clock=host,driftfix=slew", |
| 121 | 199 | "-qmp", "unix:%s,server=on,wait=off" % qmp_socket, |
| 122 | 200 | "-pidfile", str(pid), |
| 123 | ] | |
| 124 | for drive in drives: | |
| 125 | command += ["-drive", drive] | |
| 201 | ] + cdroms(base, images) | |
| 202 | if answers: | |
| 203 | # Setup reads autounattend.xml from the root of a removable drive. | |
| 204 | command += ["-drive", "file=fat:%s,format=raw,if=none,id=answers,readonly=on" % answers, | |
| 205 | "-device", "usb-storage,drive=answers,removable=on"] | |
| 126 | 206 | with log_path.open("ab") as log: |
| 127 | 207 | subprocess.Popen( |
| 128 | 208 | command, |
| ... | ... | @@ -139,23 +219,45 @@ def launch(name, disk, port, mac, cpus, memory_mb, display, drives): |
| 139 | 219 | raise SystemExit("Windows did not open. Check: %s" % log_path) |
| 140 | 220 | |
| 141 | 221 | |
| 142 | def start_build(install): | |
| 143 | if install and not ISO.is_file(): | |
| 144 | raise SystemExit("Windows ISO not found: %s" % ISO) | |
| 145 | if not DISK.exists(): | |
| 222 | def stage_answers(base): | |
| 223 | """Copy unattend/ into the build's runtime directory with this base filled in.""" | |
| 224 | root = runtime(base + "-build")[0] / "answers" | |
| 225 | shutil.rmtree(root, ignore_errors=True) | |
| 226 | shutil.copytree(UNATTEND, root) | |
| 227 | answers = root / "autounattend.xml" | |
| 228 | text = answers.read_text().replace("{arch}", "arm64" if base == "win11" else "amd64") | |
| 229 | answers.write_text(text.replace("{hostname}", "ONE-" + base.upper())) | |
| 230 | if base == "win11": | |
| 231 | shutil.copytree(MEDIA / "netkvm-arm64", root / "netkvm") | |
| 232 | return root | |
| 233 | ||
| 234 | ||
| 235 | def start_build(base, install, display): | |
| 236 | disk = build_disk(base) | |
| 237 | iso = ISO if base == "win7" else MEDIA / ("%s.iso" % base) | |
| 238 | if install and not iso.is_file(): | |
| 239 | raise SystemExit("Windows ISO not found: %s (run ./windows_media.py %s)" % (iso, base)) | |
| 240 | if not disk.exists(): | |
| 146 | 241 | if not install: |
| 147 | raise SystemExit("No Windows disk found. Run: ./vm.py install") | |
| 242 | raise SystemExit("No Windows disk found. Run: ./vm.py install --base %s" % base) | |
| 148 | 243 | IMAGES.mkdir(parents=True, exist_ok=True) |
| 149 | 244 | subprocess.run( |
| 150 | [qemu("qemu-img"), "create", "-f", "qcow2", str(DISK), "64G"], | |
| 245 | [qemu("qemu-img"), "create", "-f", "qcow2", str(disk), "64G"], | |
| 151 | 246 | check=True, |
| 152 | 247 | ) |
| 153 | drives = [] | |
| 248 | images, answers = [], None | |
| 154 | 249 | if install: |
| 155 | drives.append("file=%s,file.locking=off,if=ide,media=cdrom,readonly=on" % ISO) | |
| 250 | images.append(iso) | |
| 251 | if base != "win7": | |
| 252 | build_agent_iso() | |
| 253 | images.append(AGENT_ISO) | |
| 254 | answers = stage_answers(base) | |
| 156 | 255 | elif AGENT_ISO.exists(): |
| 157 | drives.append("file=%s,file.locking=off,if=ide,media=cdrom,readonly=on" % AGENT_ISO) | |
| 158 | launch(BUILD, DISK, 18777, "52:54:00:10:77:01", 4, 4096, "cocoa", drives) | |
| 256 | images.append(AGENT_ISO) | |
| 257 | port = BASES[base][1] | |
| 258 | # The Windows 7 build is finished by hand, so it opens a window by default. | |
| 259 | launch(base + "-build", base, disk, port, mac_for(port), 4, 4096, | |
| 260 | "cocoa" if display or base == "win7" else "none", images, answers) | |
| 159 | 261 | |
| 160 | 262 | |
| 161 | 263 | def instance_path(name): |
| ... | ... | @@ -197,7 +299,7 @@ def configs(): |
| 197 | 299 | |
| 198 | 300 | |
| 199 | 301 | def available_port(requested=None): |
| 200 | used = {18777} | |
| 302 | used = {port for _stem, port in BASES.values()} | |
| 201 | 303 | used.update(config["port"] for _name, config in configs()) |
| 202 | 304 | candidates = [requested] if requested else range(18778, 18878) |
| 203 | 305 | for port in candidates: |
| ... | ... | @@ -271,10 +373,10 @@ def update_target(name, port=None, token=None): |
| 271 | 373 | TARGETS.chmod(0o600) |
| 272 | 374 | |
| 273 | 375 | |
| 274 | def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None): | |
| 376 | def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None, base="win7"): | |
| 275 | 377 | require_vm_home() |
| 276 | 378 | validate_name(name) |
| 277 | if name in ("local", BUILD): | |
| 379 | if name == "local" or name in ("%s-build" % b for b in BASES): | |
| 278 | 380 | raise SystemExit("VM name is reserved: %s" % name) |
| 279 | 381 | hostname = (hostname or ("ONE-" + name)).upper() |
| 280 | 382 | if not HOSTNAME.fullmatch(hostname): |
| ... | ... | @@ -283,8 +385,9 @@ def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None): |
| 283 | 385 | raise SystemExit("CPU count must be between 1 and 16") |
| 284 | 386 | if not 1024 <= memory_mb <= 65536: |
| 285 | 387 | raise SystemExit("Memory must be between 1024 and 65536 MiB") |
| 286 | if not BASE_DISK.is_file() or not BASE_MANIFEST.is_file(): | |
| 287 | raise SystemExit("No sealed base image. Finish the build, then run: ./vm.py seal") | |
| 388 | if not base_disk(base).is_file() or not base_manifest(base).is_file(): | |
| 389 | raise SystemExit("No sealed %s base image. Finish the build, then run: " | |
| 390 | "./vm.py seal --base %s" % (base, base)) | |
| 288 | 391 | VM_HOME.mkdir(parents=True, exist_ok=True) |
| 289 | 392 | with (VM_HOME / ".lock").open("a") as lock: |
| 290 | 393 | fcntl.flock(lock, fcntl.LOCK_EX) |
| ... | ... | @@ -303,13 +406,13 @@ def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None): |
| 303 | 406 | try: |
| 304 | 407 | subprocess.run([ |
| 305 | 408 | qemu("qemu-img"), "create", "-f", "qcow2", "-F", "qcow2", |
| 306 | "-b", str(BASE_DISK), str(temporary), | |
| 409 | "-b", str(base_disk(base)), str(temporary), | |
| 307 | 410 | ], check=True) |
| 308 | 411 | make_instance_iso(instance_iso, hostname, token) |
| 309 | 412 | instance_iso.chmod(0o600) |
| 310 | 413 | temporary.replace(overlay) |
| 311 | config = {"cpus": cpus, "hostname": hostname, "memory_mb": memory_mb, | |
| 312 | "port": port, "token": token} | |
| 414 | config = {"base": base, "cpus": cpus, "hostname": hostname, | |
| 415 | "memory_mb": memory_mb, "port": port, "token": token} | |
| 313 | 416 | atomic_json(instance_path(name), config) |
| 314 | 417 | instance_path(name).chmod(0o600) |
| 315 | 418 | update_target(name, port, token) |
| ... | ... | @@ -319,8 +422,8 @@ def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None): |
| 319 | 422 | overlay.unlink(missing_ok=True) |
| 320 | 423 | instance_path(name).unlink(missing_ok=True) |
| 321 | 424 | raise |
| 322 | print(json.dumps({"cpus": cpus, "hostname": hostname, "memory_mb": memory_mb, | |
| 323 | "name": name, "port": port}, sort_keys=True)) | |
| 425 | print(json.dumps({"base": base, "cpus": cpus, "hostname": hostname, | |
| 426 | "memory_mb": memory_mb, "name": name, "port": port}, sort_keys=True)) | |
| 324 | 427 | |
| 325 | 428 | |
| 326 | 429 | def start_instance(name, display=False): |
| ... | ... | @@ -329,12 +432,9 @@ def start_instance(name, display=False): |
| 329 | 432 | instance_iso = MEDIA / "instances" / (name + ".iso") |
| 330 | 433 | if not overlay.is_file() or not instance_iso.is_file(): |
| 331 | 434 | raise SystemExit("VM artifacts are incomplete: %s" % name) |
| 332 | drives = [] | |
| 333 | for drive in (AGENT_ISO, instance_iso): | |
| 334 | if drive.is_file(): | |
| 335 | drives.append("file=%s,file.locking=off,if=ide,media=cdrom,readonly=on" % drive) | |
| 336 | launch(name, overlay, config["port"], mac_for(config["port"]), config["cpus"], | |
| 337 | config["memory_mb"], "cocoa" if display else "none", drives) | |
| 435 | images = [image for image in (AGENT_ISO, instance_iso) if image.is_file()] | |
| 436 | launch(name, config.get("base", "win7"), overlay, config["port"], mac_for(config["port"]), | |
| 437 | config["cpus"], config["memory_mb"], "cocoa" if display else "none", images) | |
| 338 | 438 | |
| 339 | 439 | |
| 340 | 440 | def wait_instance(name, timeout): |
| ... | ... | @@ -391,6 +491,7 @@ def delete_instance(name): |
| 391 | 491 | if running(name): |
| 392 | 492 | raise SystemExit("Shut down Windows before deleting: %s" % name) |
| 393 | 493 | (IMAGES / "instances" / (name + ".qcow2")).unlink(missing_ok=True) |
| 494 | (IMAGES / "instances" / (name + ".vars.fd")).unlink(missing_ok=True) | |
| 394 | 495 | (MEDIA / "instances" / (name + ".iso")).unlink(missing_ok=True) |
| 395 | 496 | instance_path(name).unlink() |
| 396 | 497 | shutil.rmtree(runtime(name)[0], ignore_errors=True) |
| ... | ... | @@ -398,37 +499,39 @@ def delete_instance(name): |
| 398 | 499 | print("Deleted VM: %s" % name) |
| 399 | 500 | |
| 400 | 501 | |
| 401 | def seal(): | |
| 502 | def seal(base="win7"): | |
| 402 | 503 | require_vm_home() |
| 403 | if running(BUILD): | |
| 504 | disk, sealed, manifest = build_disk(base), base_disk(base), base_manifest(base) | |
| 505 | if running(base + "-build"): | |
| 404 | 506 | raise SystemExit("Shut down Windows before sealing the base image") |
| 405 | if not DISK.exists(): | |
| 507 | if not disk.exists(): | |
| 406 | 508 | raise SystemExit("No Windows build disk found") |
| 407 | temporary = BASE_DISK.with_suffix(".tmp.qcow2") | |
| 408 | if BASE_DISK.exists() or temporary.exists(): | |
| 509 | temporary = sealed.with_suffix(".tmp.qcow2") | |
| 510 | if sealed.exists() or temporary.exists(): | |
| 409 | 511 | raise SystemExit("Move the existing base image before sealing another") |
| 410 | subprocess.run([qemu("qemu-img"), "check", str(DISK)], check=True) | |
| 512 | subprocess.run([qemu("qemu-img"), "check", str(disk)], check=True) | |
| 411 | 513 | subprocess.run([qemu("qemu-img"), "convert", "-p", "-O", "qcow2", |
| 412 | "-o", "lazy_refcounts=off", str(DISK), str(temporary)], check=True) | |
| 514 | "-o", "lazy_refcounts=off", str(disk), str(temporary)], check=True) | |
| 413 | 515 | subprocess.run([qemu("qemu-img"), "check", str(temporary)], check=True) |
| 414 | temporary.replace(BASE_DISK) | |
| 516 | temporary.replace(sealed) | |
| 415 | 517 | digest = hashlib.sha256() |
| 416 | with BASE_DISK.open("rb") as image: | |
| 518 | with sealed.open("rb") as image: | |
| 417 | 519 | while chunk := image.read(8 * 1024 * 1024): |
| 418 | 520 | digest.update(chunk) |
| 419 | 521 | info = json.loads(subprocess.check_output([ |
| 420 | qemu("qemu-img"), "info", "--output=json", str(BASE_DISK), | |
| 522 | qemu("qemu-img"), "info", "--output=json", str(sealed), | |
| 421 | 523 | ])) |
| 422 | atomic_json(BASE_MANIFEST, {"file": BASE_DISK.name, "format": info["format"], | |
| 423 | "sha256": digest.hexdigest(), | |
| 424 | "virtual_size": info["virtual-size"]}) | |
| 425 | BASE_DISK.chmod(0o444) | |
| 426 | print(BASE_MANIFEST) | |
| 524 | atomic_json(manifest, {"file": sealed.name, "format": info["format"], | |
| 525 | "sha256": digest.hexdigest(), | |
| 526 | "virtual_size": info["virtual-size"]}) | |
| 527 | sealed.chmod(0o444) | |
| 528 | print(manifest) | |
| 427 | 529 | |
| 428 | 530 | |
| 429 | def fetch_base(manifest_url): | |
| 531 | def fetch_base(manifest_url, base="win7"): | |
| 430 | 532 | require_vm_home() |
| 431 | if BASE_DISK.exists() or BASE_MANIFEST.exists(): | |
| 533 | sealed, manifest_path = base_disk(base), base_manifest(base) | |
| 534 | if sealed.exists() or manifest_path.exists(): | |
| 432 | 535 | raise SystemExit("Move the existing base image before fetching another") |
| 433 | 536 | headers = {} |
| 434 | 537 | if os.environ.get("ONE_VM_AUTHORIZATION"): |
| ... | ... | @@ -440,7 +543,7 @@ def fetch_base(manifest_url): |
| 440 | 543 | raise SystemExit("Base manifest has no valid SHA-256") |
| 441 | 544 | image_url = urllib.parse.urljoin(manifest_url, manifest.get("file", "")) |
| 442 | 545 | IMAGES.mkdir(parents=True, exist_ok=True) |
| 443 | temporary = BASE_DISK.with_suffix(".download.qcow2") | |
| 546 | temporary = sealed.with_suffix(".download.qcow2") | |
| 444 | 547 | digest = hashlib.sha256() |
| 445 | 548 | try: |
| 446 | 549 | with urllib.request.urlopen(urllib.request.Request(image_url, headers=headers)) as response: |
| ... | ... | @@ -452,13 +555,13 @@ def fetch_base(manifest_url): |
| 452 | 555 | temporary.unlink(missing_ok=True) |
| 453 | 556 | raise SystemExit("Downloaded base image failed SHA-256 verification") |
| 454 | 557 | subprocess.run([qemu("qemu-img"), "check", str(temporary)], check=True) |
| 455 | temporary.replace(BASE_DISK) | |
| 456 | BASE_DISK.chmod(0o444) | |
| 457 | atomic_json(BASE_MANIFEST, dict(manifest, file=BASE_DISK.name)) | |
| 558 | temporary.replace(sealed) | |
| 559 | sealed.chmod(0o444) | |
| 560 | atomic_json(manifest_path, dict(manifest, file=sealed.name)) | |
| 458 | 561 | except Exception: |
| 459 | 562 | temporary.unlink(missing_ok=True) |
| 460 | 563 | raise |
| 461 | print(BASE_MANIFEST) | |
| 564 | print(manifest_path) | |
| 462 | 565 | |
| 463 | 566 | |
| 464 | 567 | def list_instances(): |
| ... | ... | @@ -468,25 +571,29 @@ def list_instances(): |
| 468 | 571 | return |
| 469 | 572 | for name, config in rows: |
| 470 | 573 | state = "running" if running(name) else "stopped" |
| 471 | print("%-11s %-15s %-7s http://127.0.0.1:%d" % | |
| 472 | (name, config["hostname"], state, config["port"])) | |
| 574 | print("%-11s %-5s %-15s %-7s http://127.0.0.1:%d" % | |
| 575 | (name, config.get("base", "win7"), config["hostname"], state, config["port"])) | |
| 473 | 576 | |
| 474 | 577 | |
| 475 | 578 | def main(): |
| 476 | parser = argparse.ArgumentParser(description="Run OneNote Windows 7 VMs") | |
| 579 | parser = argparse.ArgumentParser(description="Run OneNote Windows lab VMs") | |
| 477 | 580 | commands = parser.add_subparsers(dest="command", required=True) |
| 478 | commands.add_parser("install") | |
| 479 | commands.add_parser("run") | |
| 581 | base_option = {"choices": sorted(BASES), "default": "win7"} | |
| 582 | for verb in ("install", "run"): | |
| 583 | build = commands.add_parser(verb) | |
| 584 | build.add_argument("--base", **base_option) | |
| 585 | build.add_argument("--display", action="store_true") | |
| 480 | 586 | status = commands.add_parser("status") |
| 481 | 587 | status.add_argument("name", nargs="?") |
| 482 | 588 | power = commands.add_parser("poweroff") |
| 483 | 589 | power.add_argument("name", nargs="?", default=BUILD) |
| 484 | 590 | shot = commands.add_parser("screenshot") |
| 485 | 591 | shot.add_argument("name", nargs="?", default=BUILD) |
| 486 | commands.add_parser("seal") | |
| 592 | commands.add_parser("seal").add_argument("--base", **base_option) | |
| 487 | 593 | commands.add_parser("media") |
| 488 | 594 | up = commands.add_parser("up") |
| 489 | 595 | up.add_argument("name") |
| 596 | up.add_argument("--base", **base_option) | |
| 490 | 597 | up.add_argument("--hostname") |
| 491 | 598 | up.add_argument("--cpus", type=int, default=2) |
| 492 | 599 | up.add_argument("--memory", type=int, default=4096, dest="memory_mb") |
| ... | ... | @@ -500,14 +607,13 @@ def main(): |
| 500 | 607 | down.add_argument("--preserve-machine", action="store_true") |
| 501 | 608 | fetch = commands.add_parser("fetch") |
| 502 | 609 | fetch.add_argument("manifest_url") |
| 610 | fetch.add_argument("--base", **base_option) | |
| 503 | 611 | args = parser.parse_args() |
| 504 | if args.command == "install": | |
| 505 | start_build(True) | |
| 506 | elif args.command == "run": | |
| 507 | start_build(False) | |
| 612 | if args.command in ("install", "run"): | |
| 613 | start_build(args.base, args.command == "install", args.display) | |
| 508 | 614 | elif args.command == "status": |
| 509 | 615 | if args.name: |
| 510 | if args.name == BUILD: | |
| 616 | if args.name in ("%s-build" % b for b in BASES): | |
| 511 | 617 | print("running" if running(args.name) else "stopped") |
| 512 | 618 | elif not instance_path(args.name).exists(): |
| 513 | 619 | print("absent") |
| ... | ... | @@ -521,12 +627,13 @@ def main(): |
| 521 | 627 | elif args.command == "screenshot": |
| 522 | 628 | screenshot(args.name) |
| 523 | 629 | elif args.command == "seal": |
| 524 | seal() | |
| 630 | seal(args.base) | |
| 525 | 631 | elif args.command == "media": |
| 526 | 632 | build_agent_iso() |
| 527 | 633 | elif args.command == "up": |
| 528 | 634 | if not instance_path(args.name).exists(): |
| 529 | create_instance(args.name, args.hostname, args.cpus, args.memory_mb, args.port) | |
| 635 | create_instance(args.name, args.hostname, args.cpus, args.memory_mb, args.port, | |
| 636 | args.base) | |
| 530 | 637 | if running(args.name): |
| 531 | 638 | print("Windows already running: %s" % args.name) |
| 532 | 639 | else: |
| ... | ... | @@ -545,7 +652,7 @@ def main(): |
| 545 | 652 | else: |
| 546 | 653 | delete_instance(args.name) |
| 547 | 654 | else: |
| 548 | fetch_base(args.manifest_url) | |
| 655 | fetch_base(args.manifest_url, args.base) | |
| 549 | 656 | |
| 550 | 657 | |
| 551 | 658 | if __name__ == "__main__": |
tools/w7/windows_media.py+30-5| ... | ... | @@ -26,6 +26,7 @@ def tool(name): |
| 26 | 26 | return path |
| 27 | 27 | |
| 28 | 28 | |
| 29 | # Volume-license media install without asking for a product key and stay unactivated. | |
| 29 | 30 | def catalog_entry(base, work): |
| 30 | 31 | url, arch = CATALOGS[base] |
| 31 | 32 | cab = work / "products.cab" |
| ... | ... | @@ -34,9 +35,9 @@ def catalog_entry(base, work): |
| 34 | 35 | for entry in ET.parse(work / "products.xml").getroot().iter("File"): |
| 35 | 36 | field = lambda key: entry.findtext(key) or "" |
| 36 | 37 | if (field("LanguageCode") == "en-us" and field("Architecture") == arch |
| 37 | and "CLIENTCONSUMER_RET" in field("FileName")): | |
| 38 | and "CLIENTBUSINESS_VOL" in field("FileName")): | |
| 38 | 39 | return field("FilePath"), field("Sha1").lower() |
| 39 | raise SystemExit("The %s catalog has no en-us %s consumer image" % (base, arch)) | |
| 40 | raise SystemExit("The %s catalog has no en-us %s volume-license image" % (base, arch)) | |
| 40 | 41 | |
| 41 | 42 | |
| 42 | 43 | def download(url, sha1, path): |
| ... | ... | @@ -61,12 +62,36 @@ def pro_index(esd): |
| 61 | 62 | raise SystemExit("No Professional edition in %s" % esd) |
| 62 | 63 | |
| 63 | 64 | |
| 65 | # Windows on Arm has no inbox driver for any network card QEMU emulates. | |
| 66 | VIRTIO_WIN = ("https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/" | |
| 67 | "stable-virtio/virtio-win.iso") | |
| 68 | NETKVM = ("netkvm.inf", "netkvm.cat", "netkvm.sys", "netkvmco.exe", "netkvmp.exe") | |
| 69 | ||
| 70 | ||
| 71 | def fetch_netkvm(media): | |
| 72 | """Keep only Red Hat's signed arm64 virtio-net driver from the virtio-win disc.""" | |
| 73 | with tempfile.TemporaryDirectory(prefix="one-media-", dir=media) as temporary: | |
| 74 | work = Path(temporary) | |
| 75 | iso = work / "virtio-win.iso" | |
| 76 | urllib.request.urlretrieve(VIRTIO_WIN, iso) | |
| 77 | target = work / "netkvm-arm64" | |
| 78 | target.mkdir() | |
| 79 | for name in NETKVM: | |
| 80 | subprocess.run([tool("xorriso"), "-osirrox", "on", "-indev", str(iso), "-extract", | |
| 81 | "/NetKVM/w11/ARM64/" + name, str(target / name)], check=True) | |
| 82 | (target / name).chmod(0o644) | |
| 83 | target.replace(media / "netkvm-arm64") | |
| 84 | ||
| 85 | ||
| 64 | 86 | def build(base): |
| 65 | 87 | media = Path(require("ONE_VM_HOME")).expanduser() / "media" |
| 66 | 88 | iso = media / ("%s.iso" % base) |
| 67 | if iso.exists(): | |
| 68 | raise SystemExit("Move the existing ISO first: %s" % iso) | |
| 69 | 89 | media.mkdir(parents=True, exist_ok=True) |
| 90 | if base == "win11" and not (media / "netkvm-arm64").exists(): | |
| 91 | fetch_netkvm(media) | |
| 92 | if iso.exists(): | |
| 93 | print(iso) | |
| 94 | return | |
| 70 | 95 | wim = tool("wimlib-imagex") |
| 71 | 96 | with tempfile.TemporaryDirectory(prefix="one-media-", dir=media) as temporary: |
| 72 | 97 | work = Path(temporary) |
| ... | ... | @@ -81,7 +106,6 @@ def build(base): |
| 81 | 106 | "--compress=LZX"], check=True) |
| 82 | 107 | subprocess.run([wim, "export", str(esd), "3", str(sources / "boot.wim"), |
| 83 | 108 | "--boot"], check=True) |
| 84 | # Solid LZMS keeps install.esd under the 4 GiB ISO 9660 file limit. | |
| 85 | 109 | subprocess.run([wim, "export", str(esd), pro_index(esd), |
| 86 | 110 | str(sources / "install.esd"), "--compress=LZMS", "--solid"], |
| 87 | 111 | check=True) |
| ... | ... | @@ -89,6 +113,7 @@ def build(base): |
| 89 | 113 | partial = work / "out.iso" |
| 90 | 114 | # The no-prompt loader boots unattended instead of waiting for a key press. |
| 91 | 115 | subprocess.run([ |
| 116 | # Level 3 stores the 4+ GiB install.esd as multiple extents, which Setup reads. | |
| 92 | 117 | tool("xorriso"), "-as", "mkisofs", "-quiet", "-iso-level", "3", "-J", |
| 93 | 118 | "-joliet-long", "-V", base.upper(), |
| 94 | 119 | "-e", "efi/microsoft/boot/efisys_noprompt.bin", "-no-emul-boot", |