| 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,6 +1093,16 @@ dependencies = [ |
| 1093 | "simd-adler32", | 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 | [[package]] | 1106 | [[package]] |
| 1097 | name = "find-msvc-tools" | 1107 | name = "find-msvc-tools" |
| 1098 | version = "0.1.12" | 1108 | version = "0.1.12" |
| ... | @@ -2401,6 +2411,7 @@ dependencies = [ | ... | @@ -2401,6 +2411,7 @@ dependencies = [ |
| 2401 | "base64 0.22.1", | 2411 | "base64 0.22.1", |
| 2402 | "bumpalo", | 2412 | "bumpalo", |
| 2403 | "cbc", | 2413 | "cbc", |
| 2414 | "file-guard", | ||
| 2404 | "getrandom 0.4.3", | 2415 | "getrandom 0.4.3", |
| 2405 | "md5", | 2416 | "md5", |
| 2406 | "nix", | 2417 | "nix", |
| ... | @@ -3346,6 +3357,7 @@ dependencies = [ | ... | @@ -3346,6 +3357,7 @@ dependencies = [ |
| 3346 | "wayland-client", | 3357 | "wayland-client", |
| 3347 | "wayland-protocols", | 3358 | "wayland-protocols", |
| 3348 | "wgpu", | 3359 | "wgpu", |
| 3360 | "windows-sys 0.61.2", | ||
| 3349 | "winit", | 3361 | "winit", |
| 3350 | "zbus", | 3362 | "zbus", |
| 3351 | ] | 3363 | ] |
| ... | @@ -4265,6 +4277,22 @@ dependencies = [ | ... | @@ -4265,6 +4277,22 @@ dependencies = [ |
| 4265 | "web-sys", | 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 | [[package]] | 4296 | [[package]] |
| 4269 | name = "winapi-util" | 4297 | name = "winapi-util" |
| 4270 | version = "0.1.11" | 4298 | version = "0.1.11" |
| ... | @@ -4274,6 +4302,12 @@ dependencies = [ | ... | @@ -4274,6 +4302,12 @@ dependencies = [ |
| 4274 | "windows-sys 0.61.2", | 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 | [[package]] | 4311 | [[package]] |
| 4278 | name = "windows" | 4312 | name = "windows" |
| 4279 | version = "0.62.2" | 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,24 +6,24 @@ platform is the window, the input plumbing, and how much of the interface is |
| 6 | drawn by Snowbound versus the operating system. | 6 | drawn by Snowbound versus the operating system. |
| 7 | 7 | ||
| 8 | ```text | 8 | ```text |
| 9 | macOS Linux iOS | 9 | macOS Linux Windows iOS |
| 10 | shell snowbound + ui snowbound + ui UIKit (apps/ios, Swift) | 10 | shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios) |
| 11 | glue snowbound/src/macos snowbound/src/linux crates/mobile (C ABI, staticlib) | 11 | glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI) |
| 12 | page canvas ─────────────────────────────────────────────────────► | 12 | page canvas ─────────────────────────────────────────────────────────────────────► |
| 13 | paint draw (wgpu: Metal) draw (Vulkan or GL) draw (Metal, CAMetalLayer) | 13 | paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12, or GL on 7) draw (Metal) |
| 14 | data notebook::session + embedded SMB client ────────────────────► | 14 | data notebook::session + embedded SMB client ────────────────────────────────────► |
| 15 | format onestore ───────────────────────────────────────────────────► | 15 | format onestore ───────────────────────────────────────────────────────────────────► |
| 16 | ``` | 16 | ``` |
| 17 | 17 | ||
| 18 | ## Desktop: `snowbound` | 18 | ## Desktop: `snowbound` |
| 19 | 19 | ||
| 20 | The desktop app is a winit window with the `ui` kit's chrome and a `canvas` | 20 | The desktop app is a winit window with the `ui` kit's chrome and a `canvas` |
| 21 | page inside it. `snowbound` picks a platform module at compile time | 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 | 22 | (`macos.rs`, `linux.rs` or `windows.rs`, each mounted as `platform`), and |
| 23 | in the crate is shared: library and settings, the sidebar, menus, page and | 23 | everything else in the crate is shared: library and settings, the sidebar, |
| 24 | section management, templates, and screenshot and replay support. | 24 | menus, page and section management, templates, and screenshot and replay |
| 25 | Accessibility goes through AccessKit's winit adapter on both: the interface's | 25 | support. Accessibility goes through AccessKit's winit adapter on all three: |
| 26 | tree, with the page's grafted into it. | 26 | the interface's tree, with the page's grafted into it. |
| 27 | `commands.rs` is the one table of commands: each one's title, its chords on | 27 | `commands.rs` is the one table of commands: each one's title, its chords on |
| 28 | macOS and elsewhere, when it is enabled or checked, and what it does. The | 28 | macOS and elsewhere, when it is enabled or checked, and what it does. The |
| 29 | keyboard, the toolbar and the macOS menu bar all run commands from it. | 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,14 +94,42 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. |
| 94 | executable to `~/.local/bin` and writes the visible entry under the same | 94 | executable to `~/.local/bin` and writes the visible entry under the same |
| 95 | name, so the menu never lists two; Uninstall in Options removes it. | 95 | name, so the menu never lists two; Uninstall in Options removes it. |
| 96 | 96 | ||
| 97 | ### Windows and older macOS | 97 | ### Windows |
| 98 | 98 | ||
| 99 | The readme names both as goals. Nothing platform-specific exists for them yet. | 99 | - One executable runs on Windows 7 SP1 through 11. It is built from macOS |
| 100 | Keeping `ui` and `draw` free of platform toolkits is what keeps them within | 100 | with llvm-mingw against `msvcrt.dll`, which every Windows has |
| 101 | reach. | 101 | (`platform/windows/cargo.sh`): x86_64 on nightly's tier-3 |
| 102 | The chrome's fill already has its seam: `platform::install_backdrop` lays a | 102 | `x86_64-win7-windows-gnu`, whose standard library avoids Windows 8's |
| 103 | system material under a transparent surface, as Mica or Aero glass would, | 103 | imports, and aarch64 for Windows 11 on Arm. The few imports of Windows 8 |
| 104 | and `platform::titlebar` names opaque fills where the system has those. | 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 | ## iOS: native around the canvas | 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,7 +85,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 85 | &[viewport.layer(&primitives)], | 85 | &[viewport.layer(&primitives)], |
| 86 | ) | 86 | ) |
| 87 | .map_err(|e| format!("Page comparison rendering failed: {e:?}"))?; | 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 | encoder.copy_texture_to_buffer( | 91 | encoder.copy_texture_to_buffer( |
| 90 | target.as_image_copy(), | 92 | target.as_image_copy(), |
| 91 | wgpu::TexelCopyBufferInfo { | 93 | wgpu::TexelCopyBufferInfo { |
| ... | @@ -102,12 +104,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -102,12 +104,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 102 | depth_or_array_layers: 1, | 104 | depth_or_array_layers: 1, |
| 103 | }, | 105 | }, |
| 104 | ); | 106 | ); |
| 105 | renderer.queue.submit([encoder.finish()]); | 107 | renderer.queue().submit([encoder.finish()]); |
| 106 | let (sender, receiver) = std::sync::mpsc::channel(); | 108 | let (sender, receiver) = std::sync::mpsc::channel(); |
| 107 | buffer.map_async(wgpu::MapMode::Read, .., move |result| { | 109 | buffer.map_async(wgpu::MapMode::Read, .., move |result| { |
| 108 | let _ = sender.send(result); | 110 | let _ = sender.send(result); |
| 109 | }); | 111 | }); |
| 110 | renderer.device.poll(wgpu::PollType::Wait { | 112 | renderer.device().poll(wgpu::PollType::Wait { |
| 111 | submission_index: None, | 113 | submission_index: None, |
| 112 | timeout: Some(Duration::from_secs(10)), | 114 | timeout: Some(Duration::from_secs(10)), |
| 113 | })?; | 115 | })?; |
crates/canvas/src/gpu/mod.rs+5-3| ... | @@ -598,7 +598,9 @@ mod tests { | ... | @@ -598,7 +598,9 @@ mod tests { |
| 598 | &[viewport.layer(&primitives)], | 598 | &[viewport.layer(&primitives)], |
| 599 | ) | 599 | ) |
| 600 | .unwrap(); | 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 | encoder.copy_texture_to_buffer( | 604 | encoder.copy_texture_to_buffer( |
| 603 | target.as_image_copy(), | 605 | target.as_image_copy(), |
| 604 | wgpu::TexelCopyBufferInfo { | 606 | wgpu::TexelCopyBufferInfo { |
| ... | @@ -615,10 +617,10 @@ mod tests { | ... | @@ -615,10 +617,10 @@ mod tests { |
| 615 | depth_or_array_layers: 1, | 617 | depth_or_array_layers: 1, |
| 616 | }, | 618 | }, |
| 617 | ); | 619 | ); |
| 618 | renderer.queue.submit([encoder.finish()]); | 620 | renderer.queue().submit([encoder.finish()]); |
| 619 | readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); | 621 | readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); |
| 620 | renderer | 622 | renderer |
| 621 | .device | 623 | .device() |
| 622 | .poll(wgpu::PollType::Wait { | 624 | .poll(wgpu::PollType::Wait { |
| 623 | submission_index: None, | 625 | submission_index: None, |
| 624 | timeout: Some(Duration::from_secs(5)), | 626 | timeout: Some(Duration::from_secs(5)), |
crates/canvas/src/gpu/page/background.rs+5-3| ... | @@ -1006,7 +1006,9 @@ mod tests { | ... | @@ -1006,7 +1006,9 @@ mod tests { |
| 1006 | &[viewport.layer(&primitives)], | 1006 | &[viewport.layer(&primitives)], |
| 1007 | ) | 1007 | ) |
| 1008 | .unwrap(); | 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 | encoder.copy_texture_to_buffer( | 1012 | encoder.copy_texture_to_buffer( |
| 1011 | target.as_image_copy(), | 1013 | target.as_image_copy(), |
| 1012 | wgpu::TexelCopyBufferInfo { | 1014 | wgpu::TexelCopyBufferInfo { |
| ... | @@ -1023,10 +1025,10 @@ mod tests { | ... | @@ -1023,10 +1025,10 @@ mod tests { |
| 1023 | depth_or_array_layers: 1, | 1025 | depth_or_array_layers: 1, |
| 1024 | }, | 1026 | }, |
| 1025 | ); | 1027 | ); |
| 1026 | renderer.queue.submit([encoder.finish()]); | 1028 | renderer.queue().submit([encoder.finish()]); |
| 1027 | readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); | 1029 | readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); |
| 1028 | renderer | 1030 | renderer |
| 1029 | .device | 1031 | .device() |
| 1030 | .poll(wgpu::PollType::Wait { | 1032 | .poll(wgpu::PollType::Wait { |
| 1031 | submission_index: None, | 1033 | submission_index: None, |
| 1032 | timeout: Some(Duration::from_secs(5)), | 1034 | timeout: Some(Duration::from_secs(5)), |
crates/canvas/src/gpu/profile.rs+2-2| ... | @@ -118,7 +118,7 @@ fn renderer_cost() { | ... | @@ -118,7 +118,7 @@ fn renderer_cost() { |
| 118 | .unwrap(); | 118 | .unwrap(); |
| 119 | let submitted = start.elapsed().as_nanos(); | 119 | let submitted = start.elapsed().as_nanos(); |
| 120 | renderer | 120 | renderer |
| 121 | .device | 121 | .device() |
| 122 | .poll(wgpu::PollType::Wait { | 122 | .poll(wgpu::PollType::Wait { |
| 123 | submission_index: None, | 123 | submission_index: None, |
| 124 | timeout: Some(Duration::from_secs(10)), | 124 | timeout: Some(Duration::from_secs(10)), |
| ... | @@ -335,7 +335,7 @@ fn frame_cost() { | ... | @@ -335,7 +335,7 @@ fn frame_cost() { |
| 335 | let drawn = start.elapsed(); | 335 | let drawn = start.elapsed(); |
| 336 | drop(primitives); | 336 | drop(primitives); |
| 337 | renderer | 337 | renderer |
| 338 | .device | 338 | .device() |
| 339 | .poll(wgpu::PollType::Wait { | 339 | .poll(wgpu::PollType::Wait { |
| 340 | submission_index: None, | 340 | submission_index: None, |
| 341 | timeout: Some(Duration::from_secs(10)), | 341 | timeout: Some(Duration::from_secs(10)), |
crates/draw/Cargo.toml+2-1| ... | @@ -7,7 +7,8 @@ publish = false | ... | @@ -7,7 +7,8 @@ publish = false |
| 7 | [features] | 7 | [features] |
| 8 | default = ["wgpu"] | 8 | default = ["wgpu"] |
| 9 | # The renderer; `edit` needs only parley. It submits through wgpu, or without it through | 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 | render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] | 12 | render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] |
| 12 | wgpu = ["render", "dep:wgpu"] | 13 | wgpu = ["render", "dep:wgpu"] |
| 13 | 14 |
crates/draw/src/render.rs+48-26| ... | @@ -1,4 +1,6 @@ | ... | @@ -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 | mod gl; | 4 | mod gl; |
| 3 | mod icon; | 5 | mod icon; |
| 4 | mod text; | 6 | mod text; |
| ... | @@ -7,12 +9,16 @@ mod translucent; | ... | @@ -7,12 +9,16 @@ mod translucent; |
| 7 | #[cfg(feature = "wgpu")] | 9 | #[cfg(feature = "wgpu")] |
| 8 | mod webgpu; | 10 | mod webgpu; |
| 9 | 11 | ||
| 12 | #[cfg(all(feature = "wgpu", windows))] | ||
| 13 | use dual as backend; | ||
| 10 | #[cfg(not(feature = "wgpu"))] | 14 | #[cfg(not(feature = "wgpu"))] |
| 11 | use gl as backend; | 15 | use gl as backend; |
| 12 | #[cfg(feature = "wgpu")] | 16 | #[cfg(all(feature = "wgpu", not(windows)))] |
| 13 | use webgpu as backend; | 17 | use webgpu as backend; |
| 14 | 18 | ||
| 15 | pub use backend::Target; | 19 | pub use backend::Target; |
| 20 | #[cfg(all(feature = "wgpu", windows))] | ||
| 21 | pub use gl::Target as GlTarget; | ||
| 16 | pub use icon::{Palette, picture_icon}; | 22 | pub use icon::{Palette, picture_icon}; |
| 17 | pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; | 23 | pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; |
| 18 | #[cfg(feature = "wgpu")] | 24 | #[cfg(feature = "wgpu")] |
| ... | @@ -161,6 +167,14 @@ impl RasterImage { | ... | @@ -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 | struct CachedImage { | 178 | struct CachedImage { |
| 165 | texture: backend::Image, | 179 | texture: backend::Image, |
| 166 | bytes: u64, | 180 | bytes: u64, |
| ... | @@ -483,10 +497,6 @@ pub struct Occupancy { | ... | @@ -483,10 +497,6 @@ pub struct Occupancy { |
| 483 | } | 497 | } |
| 484 | 498 | ||
| 485 | pub struct Renderer { | 499 | pub struct Renderer { |
| 486 | #[cfg(feature = "wgpu")] | ||
| 487 | pub device: wgpu::Device, | ||
| 488 | #[cfg(feature = "wgpu")] | ||
| 489 | pub queue: wgpu::Queue, | ||
| 490 | gpu: backend::Gpu, | 500 | gpu: backend::Gpu, |
| 491 | vertices: Vec<Vertex>, | 501 | vertices: Vec<Vertex>, |
| 492 | glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>, | 502 | glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>, |
| ... | @@ -501,16 +511,8 @@ pub struct Renderer { | ... | @@ -501,16 +511,8 @@ pub struct Renderer { |
| 501 | } | 511 | } |
| 502 | 512 | ||
| 503 | impl Renderer { | 513 | impl Renderer { |
| 504 | fn with_gpu( | 514 | fn with_gpu(gpu: backend::Gpu) -> Self { |
| 505 | #[cfg(feature = "wgpu")] device: wgpu::Device, | ||
| 506 | #[cfg(feature = "wgpu")] queue: wgpu::Queue, | ||
| 507 | gpu: backend::Gpu, | ||
| 508 | ) -> Self { | ||
| 509 | let renderer = Self { | 515 | let renderer = Self { |
| 510 | #[cfg(feature = "wgpu")] | ||
| 511 | device, | ||
| 512 | #[cfg(feature = "wgpu")] | ||
| 513 | queue, | ||
| 514 | gpu, | 516 | gpu, |
| 515 | vertices: Vec::new(), | 517 | vertices: Vec::new(), |
| 516 | glyphs: HashMap::new(), | 518 | glyphs: HashMap::new(), |
| ... | @@ -522,15 +524,24 @@ impl Renderer { | ... | @@ -522,15 +524,24 @@ impl Renderer { |
| 522 | pen: [1, 0], | 524 | pen: [1, 0], |
| 523 | row_height: 1, | 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 | renderer | 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 | /// Replaces the atlas with an empty one twice as wide and tall. | 540 | /// Replaces the atlas with an empty one twice as wide and tall. |
| 530 | fn grow_atlas(&mut self) { | 541 | fn grow_atlas(&mut self) { |
| 531 | self.new_atlas(self.atlas_side() * 2); | 542 | self.gpu.new_atlas(self.atlas_side() * 2); |
| 532 | self.clear_glyph_cache(); | 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 | fn glyph_limit(&self) -> usize { | 547 | fn glyph_limit(&self) -> usize { |
| ... | @@ -666,7 +677,13 @@ impl Renderer { | ... | @@ -666,7 +677,13 @@ impl Renderer { |
| 666 | break; | 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 | Ok(()) | 687 | Ok(()) |
| 671 | } | 688 | } |
| 672 | 689 | ||
| ... | @@ -740,12 +757,13 @@ impl Renderer { | ... | @@ -740,12 +757,13 @@ impl Renderer { |
| 740 | return Err(RenderError::FrameTooLarge); | 757 | return Err(RenderError::FrameTooLarge); |
| 741 | } | 758 | } |
| 742 | let from = self.vertices.len() as u32; | 759 | let from = self.vertices.len() as u32; |
| 760 | let flipped = self.gpu.flipped(); | ||
| 743 | let corner = |x: f32, y: f32| { | 761 | let corner = |x: f32, y: f32| { |
| 744 | let [shown_x, shown_y] = motion.project([x, y], height); | 762 | let [shown_x, shown_y] = motion.project([x, y], height); |
| 745 | let v = y / height; | 763 | let v = y / height; |
| 746 | Vertex { | 764 | Vertex { |
| 747 | position: [shown_x * 2.0 / width - 1.0, 1.0 - shown_y * 2.0 / height], | 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 | color: [motion.opacity; 4], | 767 | color: [motion.opacity; 4], |
| 750 | local: [0.0; 2], | 768 | local: [0.0; 2], |
| 751 | shape: [0.0; 4], | 769 | shape: [0.0; 4], |
| ... | @@ -946,7 +964,7 @@ impl Renderer { | ... | @@ -946,7 +964,7 @@ impl Renderer { |
| 946 | self.images.insert( | 964 | self.images.insert( |
| 947 | image.id(), | 965 | image.id(), |
| 948 | CachedImage { | 966 | CachedImage { |
| 949 | texture: self.upload_image(image), | 967 | texture: self.gpu.upload_image(image), |
| 950 | bytes, | 968 | bytes, |
| 951 | pixels: image.pixels.downgrade(), | 969 | pixels: image.pixels.downgrade(), |
| 952 | }, | 970 | }, |
| ... | @@ -1123,7 +1141,8 @@ impl Renderer { | ... | @@ -1123,7 +1141,8 @@ impl Renderer { |
| 1123 | .collect(), | 1141 | .collect(), |
| 1124 | _ => image.data, | 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 | self.pen[0] += p.width + 1; | 1146 | self.pen[0] += p.width + 1; |
| 1128 | self.row_height = self.row_height.max(p.height); | 1147 | self.row_height = self.row_height.max(p.height); |
| 1129 | Ok(cached) | 1148 | Ok(cached) |
| ... | @@ -1928,7 +1947,9 @@ mod tests { | ... | @@ -1928,7 +1947,9 @@ mod tests { |
| 1928 | } | 1947 | } |
| 1929 | 1948 | ||
| 1930 | fn capture(&self, renderer: &Renderer) -> Vec<u8> { | 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 | encoder.copy_texture_to_buffer( | 1953 | encoder.copy_texture_to_buffer( |
| 1933 | self.texture.as_image_copy(), | 1954 | self.texture.as_image_copy(), |
| 1934 | wgpu::TexelCopyBufferInfo { | 1955 | wgpu::TexelCopyBufferInfo { |
| ... | @@ -1945,14 +1966,14 @@ mod tests { | ... | @@ -1945,14 +1966,14 @@ mod tests { |
| 1945 | depth_or_array_layers: 1, | 1966 | depth_or_array_layers: 1, |
| 1946 | }, | 1967 | }, |
| 1947 | ); | 1968 | ); |
| 1948 | renderer.queue.submit([encoder.finish()]); | 1969 | renderer.queue().submit([encoder.finish()]); |
| 1949 | let (sender, receiver) = std::sync::mpsc::channel(); | 1970 | let (sender, receiver) = std::sync::mpsc::channel(); |
| 1950 | self.readback | 1971 | self.readback |
| 1951 | .map_async(wgpu::MapMode::Read, .., move |result| { | 1972 | .map_async(wgpu::MapMode::Read, .., move |result| { |
| 1952 | sender.send(result).unwrap(); | 1973 | sender.send(result).unwrap(); |
| 1953 | }); | 1974 | }); |
| 1954 | renderer | 1975 | renderer |
| 1955 | .device | 1976 | .device() |
| 1956 | .poll(wgpu::PollType::Wait { | 1977 | .poll(wgpu::PollType::Wait { |
| 1957 | submission_index: None, | 1978 | submission_index: None, |
| 1958 | timeout: Some(Duration::from_secs(5)), | 1979 | timeout: Some(Duration::from_secs(5)), |
| ... | @@ -2130,7 +2151,8 @@ mod tests { | ... | @@ -2130,7 +2151,8 @@ mod tests { |
| 2130 | renderer.clear_glyph_cache(); | 2151 | renderer.clear_glyph_cache(); |
| 2131 | renderer.images.clear(); | 2152 | renderer.images.clear(); |
| 2132 | } else if pass == 3 { | 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 | let clipping = [ | 2157 | let clipping = [ |
| 2136 | Primitive::Text { | 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,7 +1,8 @@ |
| 1 | //! Submission through OpenGL 2.1 with EXT_framebuffer_object, EXT_framebuffer_blit and | 1 | //! Submission through OpenGL 2.1 with EXT_framebuffer_object, EXT_framebuffer_blit and |
| 2 | //! the sRGB extensions, which every Mac OS X 10.6 driver has and OpenGL.framework | 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 | 3 | //! exports; on Windows, where opengl32.dll exports only OpenGL 1.1, the driver supplies |
| 4 | //! light as it does in wgpu's sRGB targets. | 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 | #![allow(non_snake_case)] | 6 | #![allow(non_snake_case)] |
| 6 | use super::*; | 7 | use super::*; |
| 7 | use std::{ | 8 | use std::{ |
| ... | @@ -53,8 +54,95 @@ const COLOR_ATTACHMENT0: GLenum = 0x8CE0; | ... | @@ -53,8 +54,95 @@ const COLOR_ATTACHMENT0: GLenum = 0x8CE0; |
| 53 | const FRAMEBUFFER: GLenum = 0x8D40; | 54 | const FRAMEBUFFER: GLenum = 0x8D40; |
| 54 | const FRAMEBUFFER_SRGB: GLenum = 0x8DB9; | 55 | const FRAMEBUFFER_SRGB: GLenum = 0x8DB9; |
| 55 | 56 | ||
| 56 | #[cfg_attr(target_os = "macos", link(name = "OpenGL", kind = "framework"))] | 57 | /// The entry points: linked where the system library exports them all, and on Windows |
| 57 | unsafe extern "C" { | 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 | fn glEnable(cap: GLenum); | 146 | fn glEnable(cap: GLenum); |
| 59 | fn glDisable(cap: GLenum); | 147 | fn glDisable(cap: GLenum); |
| 60 | fn glGetIntegerv(name: GLenum, value: *mut GLint); | 148 | fn glGetIntegerv(name: GLenum, value: *mut GLint); |
| ... | @@ -203,6 +291,12 @@ impl Drop for Image { | ... | @@ -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 | /// An offscreen frame a window shows by `present`, the context's default framebuffer | 300 | /// An offscreen frame a window shows by `present`, the context's default framebuffer |
| 207 | /// being no sRGB target. | 301 | /// being no sRGB target. |
| 208 | pub struct Target { | 302 | pub struct Target { |
| ... | @@ -290,9 +384,6 @@ impl Drop for 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 | pub(super) struct Gpu { | 387 | pub(super) struct Gpu { |
| 297 | program: GLuint, | 388 | program: GLuint, |
| 298 | buffer: GLuint, | 389 | buffer: GLuint, |
| ... | @@ -318,10 +409,24 @@ const ATTRIBUTES: [(&CStr, GLint, usize); 9] = [ | ... | @@ -318,10 +409,24 @@ const ATTRIBUTES: [(&CStr, GLint, usize); 9] = [ |
| 318 | (c"blur", 1, offset_of!(Vertex, blur)), | 409 | (c"blur", 1, offset_of!(Vertex, blur)), |
| 319 | ]; | 410 | ]; |
| 320 | 411 | ||
| 412 | #[cfg(not(feature = "wgpu"))] | ||
| 321 | impl Renderer { | 413 | impl Renderer { |
| 322 | /// Draws with the OpenGL context current on this thread, which must stay current | 414 | /// Draws with the OpenGL context current on this thread, which must stay current |
| 323 | /// whenever the renderer or a `Target` is used. | 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 | let names: Vec<_> = ATTRIBUTES.iter().map(|(name, _, _)| *name).collect(); | 430 | let names: Vec<_> = ATTRIBUTES.iter().map(|(name, _, _)| *name).collect(); |
| 326 | let draw = unsafe { program(include_str!("../draw.glsl"), &names)? }; | 431 | let draw = unsafe { program(include_str!("../draw.glsl"), &names)? }; |
| 327 | let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? }; | 432 | let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? }; |
| ... | @@ -340,7 +445,7 @@ impl Renderer { | ... | @@ -340,7 +445,7 @@ impl Renderer { |
| 340 | ); | 445 | ); |
| 341 | glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture); | 446 | glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture); |
| 342 | } | 447 | } |
| 343 | Ok(Self::with_gpu(Gpu { | 448 | Ok(Gpu { |
| 344 | program: draw, | 449 | program: draw, |
| 345 | buffer, | 450 | buffer, |
| 346 | translucent, | 451 | translucent, |
| ... | @@ -348,14 +453,12 @@ impl Renderer { | ... | @@ -348,14 +453,12 @@ impl Renderer { |
| 348 | atlas: Image::new([ATLAS_SIZE; 2], NEAREST, None), | 453 | atlas: Image::new([ATLAS_SIZE; 2], NEAREST, None), |
| 349 | max_texture: max_texture as u32, | 454 | max_texture: max_texture as u32, |
| 350 | groups: Vec::new(), | 455 | groups: Vec::new(), |
| 351 | })) | 456 | }) |
| 352 | } | 457 | } |
| 353 | 458 | ||
| 354 | /// Copies `target` to the context's window, the same size, premultiplying each pixel | 459 | pub(super) fn present_translucent(&self, target: &Target) { |
| 355 | /// again in sRGB, as a window server compositing a transparent surface needs. | ||
| 356 | pub fn present_translucent(&self, target: &Target) { | ||
| 357 | let [width, height] = target.size().map(|side| side as f32); | 460 | let [width, height] = target.size().map(|side| side as f32); |
| 358 | let program = self.gpu.translucent; | 461 | let program = self.translucent; |
| 359 | unsafe { | 462 | unsafe { |
| 360 | glBindFramebufferEXT(FRAMEBUFFER, 0); | 463 | glBindFramebufferEXT(FRAMEBUFFER, 0); |
| 361 | glDisable(FRAMEBUFFER_SRGB); | 464 | glDisable(FRAMEBUFFER_SRGB); |
| ... | @@ -371,7 +474,7 @@ impl Renderer { | ... | @@ -371,7 +474,7 @@ impl Renderer { |
| 371 | ); | 474 | ); |
| 372 | glActiveTexture(TEXTURE0); | 475 | glActiveTexture(TEXTURE0); |
| 373 | glBindTexture(TEXTURE_2D, target.texture.name); | 476 | glBindTexture(TEXTURE_2D, target.texture.name); |
| 374 | glBindBuffer(ARRAY_BUFFER, self.gpu.triangle); | 477 | glBindBuffer(ARRAY_BUFFER, self.triangle); |
| 375 | for index in 1..ATTRIBUTES.len() { | 478 | for index in 1..ATTRIBUTES.len() { |
| 376 | glDisableVertexAttribArray(index as GLuint); | 479 | glDisableVertexAttribArray(index as GLuint); |
| 377 | } | 480 | } |
| ... | @@ -381,22 +484,26 @@ impl Renderer { | ... | @@ -381,22 +484,26 @@ impl Renderer { |
| 381 | } | 484 | } |
| 382 | } | 485 | } |
| 383 | 486 | ||
| 384 | /// The widest and tallest texture the driver takes, in pixels. | 487 | /// Offscreen pictures' rows run bottom first. |
| 385 | pub fn max_texture_dimension(&self) -> u32 { | 488 | pub(super) fn flipped(&self) -> bool { |
| 386 | self.gpu.max_texture | 489 | true |
| 490 | } | ||
| 491 | |||
| 492 | pub(super) fn max_texture_dimension(&self) -> u32 { | ||
| 493 | self.max_texture | ||
| 387 | } | 494 | } |
| 388 | 495 | ||
| 389 | pub(super) fn atlas_side(&self) -> u32 { | 496 | pub(super) fn atlas_side(&self) -> u32 { |
| 390 | self.gpu.atlas.size[0] | 497 | self.atlas.size[0] |
| 391 | } | 498 | } |
| 392 | 499 | ||
| 393 | pub(super) fn new_atlas(&mut self, side: u32) { | 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 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { | 504 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { |
| 398 | unsafe { | 505 | unsafe { |
| 399 | glBindTexture(TEXTURE_2D, self.gpu.atlas.name); | 506 | glBindTexture(TEXTURE_2D, self.atlas.name); |
| 400 | glTexSubImage2D( | 507 | glTexSubImage2D( |
| 401 | TEXTURE_2D, | 508 | TEXTURE_2D, |
| 402 | 0, | 509 | 0, |
| ... | @@ -417,23 +524,28 @@ impl Renderer { | ... | @@ -417,23 +524,28 @@ impl Renderer { |
| 417 | 524 | ||
| 418 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared | 525 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared |
| 419 | /// batches, each group's offscreen first. | 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 | if self | 534 | if self |
| 422 | .gpu | ||
| 423 | .groups | 535 | .groups |
| 424 | .first() | 536 | .first() |
| 425 | .is_some_and(|picture| picture.size() != size) | 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 | match Target::new(size) { | 542 | match Target::new(size) { |
| 431 | Ok(picture) => unsafe { | 543 | Ok(picture) => unsafe { |
| 432 | // Filtered, so a picture leaning back stays smooth. | 544 | // Filtered, so a picture leaning back stays smooth. |
| 433 | glBindTexture(TEXTURE_2D, picture.texture.name); | 545 | glBindTexture(TEXTURE_2D, picture.texture.name); |
| 434 | glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR); | 546 | glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR); |
| 435 | glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR); | 547 | glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR); |
| 436 | self.gpu.groups.push(picture); | 548 | self.groups.push(picture); |
| 437 | }, | 549 | }, |
| 438 | Err(error) => { | 550 | Err(error) => { |
| 439 | eprintln!("{error}"); | 551 | eprintln!("{error}"); |
| ... | @@ -442,7 +554,7 @@ impl Renderer { | ... | @@ -442,7 +554,7 @@ impl Renderer { |
| 442 | } | 554 | } |
| 443 | } | 555 | } |
| 444 | let [width, height] = size.map(|side| side as GLsizei); | 556 | let [width, height] = size.map(|side| side as GLsizei); |
| 445 | let gpu = &self.gpu; | 557 | let gpu = &*self; |
| 446 | let begin = |target: &Target, clear: [f32; 4]| unsafe { | 558 | let begin = |target: &Target, clear: [f32; 4]| unsafe { |
| 447 | glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); | 559 | glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); |
| 448 | glDisable(SCISSOR_TEST); | 560 | glDisable(SCISSOR_TEST); |
| ... | @@ -459,7 +571,10 @@ impl Renderer { | ... | @@ -459,7 +571,10 @@ impl Renderer { |
| 459 | gpu.atlas.name, | 571 | gpu.atlas.name, |
| 460 | [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], | 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 | Blend::Erase => ( | 578 | Blend::Erase => ( |
| 464 | gpu.atlas.name, | 579 | gpu.atlas.name, |
| 465 | [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA], | 580 | [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA], |
| ... | @@ -486,8 +601,8 @@ impl Renderer { | ... | @@ -486,8 +601,8 @@ impl Renderer { |
| 486 | glBindBuffer(ARRAY_BUFFER, gpu.buffer); | 601 | glBindBuffer(ARRAY_BUFFER, gpu.buffer); |
| 487 | glBufferData( | 602 | glBufferData( |
| 488 | ARRAY_BUFFER, | 603 | ARRAY_BUFFER, |
| 489 | size_of_val(self.vertices.as_slice()) as isize, | 604 | size_of_val(frame.vertices) as isize, |
| 490 | self.vertices.as_ptr().cast(), | 605 | frame.vertices.as_ptr().cast(), |
| 491 | STREAM_DRAW, | 606 | STREAM_DRAW, |
| 492 | ); | 607 | ); |
| 493 | for (index, (_, components, offset)) in ATTRIBUTES.iter().enumerate() { | 608 | for (index, (_, components, offset)) in ATTRIBUTES.iter().enumerate() { |
| ... | @@ -502,14 +617,14 @@ impl Renderer { | ... | @@ -502,14 +617,14 @@ impl Renderer { |
| 502 | ); | 617 | ); |
| 503 | } | 618 | } |
| 504 | glEnable(BLEND); | 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 | begin(picture, [0.0; 4]); | 621 | begin(picture, [0.0; 4]); |
| 507 | draw(&self.batches[group.batches.clone()]); | 622 | draw(&frame.batches[group.batches.clone()]); |
| 508 | } | 623 | } |
| 509 | begin(target, clear); | 624 | begin(target, clear); |
| 510 | let mut next = 0; | 625 | let mut next = 0; |
| 511 | for (group, picture) in self.groups.iter().zip(&gpu.groups) { | 626 | for (group, picture) in frame.groups.iter().zip(&gpu.groups) { |
| 512 | draw(&self.batches[next..group.batches.start]); | 627 | draw(&frame.batches[next..group.batches.start]); |
| 513 | glScissor(0, 0, width, height); | 628 | glScissor(0, 0, width, height); |
| 514 | let [src_rgb, dst_rgb, src_alpha, dst_alpha] = PREMULTIPLIED; | 629 | let [src_rgb, dst_rgb, src_alpha, dst_alpha] = PREMULTIPLIED; |
| 515 | glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); | 630 | glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); |
| ... | @@ -521,7 +636,7 @@ impl Renderer { | ... | @@ -521,7 +636,7 @@ impl Renderer { |
| 521 | ); | 636 | ); |
| 522 | next = group.batches.end; | 637 | next = group.batches.end; |
| 523 | } | 638 | } |
| 524 | draw(&self.batches[next..]); | 639 | draw(&frame.batches[next..]); |
| 525 | glDisable(SCISSOR_TEST); | 640 | glDisable(SCISSOR_TEST); |
| 526 | glDisable(BLEND); | 641 | glDisable(BLEND); |
| 527 | glBindFramebufferEXT(FRAMEBUFFER, 0); | 642 | glBindFramebufferEXT(FRAMEBUFFER, 0); |
| ... | @@ -547,10 +662,7 @@ unsafe fn program(source: &str, attributes: &[&CStr]) -> Result<GLuint, String> | ... | @@ -547,10 +662,7 @@ unsafe fn program(source: &str, attributes: &[&CStr]) -> Result<GLuint, String> |
| 547 | let mut linked = 0; | 662 | let mut linked = 0; |
| 548 | glGetProgramiv(program, LINK_STATUS, &mut linked); | 663 | glGetProgramiv(program, LINK_STATUS, &mut linked); |
| 549 | if linked == 0 { | 664 | if linked == 0 { |
| 550 | return Err(format!( | 665 | return Err(format!("Linking a shader failed: {}", log(program, false))); |
| 551 | "Linking a shader failed: {}", | ||
| 552 | log(program, glGetProgramiv, glGetProgramInfoLog) | ||
| 553 | )); | ||
| 554 | } | 666 | } |
| 555 | Ok(program) | 667 | Ok(program) |
| 556 | } | 668 | } |
| ... | @@ -565,25 +677,28 @@ unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> { | ... | @@ -565,25 +677,28 @@ unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> { |
| 565 | let mut compiled = 0; | 677 | let mut compiled = 0; |
| 566 | glGetShaderiv(shader, COMPILE_STATUS, &mut compiled); | 678 | glGetShaderiv(shader, COMPILE_STATUS, &mut compiled); |
| 567 | if compiled == 0 { | 679 | if compiled == 0 { |
| 568 | return Err(format!( | 680 | return Err(format!("Compiling a shader failed: {}", log(shader, true))); |
| 569 | "Compiling a shader failed: {}", | ||
| 570 | log(shader, glGetShaderiv, glGetShaderInfoLog) | ||
| 571 | )); | ||
| 572 | } | 681 | } |
| 573 | Ok(shader) | 682 | Ok(shader) |
| 574 | } | 683 | } |
| 575 | } | 684 | } |
| 576 | 685 | ||
| 577 | unsafe fn log( | 686 | /// The info log of `object`, a shader or else a program. |
| 578 | object: GLuint, | 687 | unsafe fn log(object: GLuint, shader: bool) -> String { |
| 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 { | ||
| 582 | unsafe { | 688 | unsafe { |
| 583 | let mut length = 0; | 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 | let mut text = vec![0u8; length.max(1) as usize]; | 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 | String::from_utf8_lossy(&text) | 702 | String::from_utf8_lossy(&text) |
| 588 | .trim_end_matches('\0') | 703 | .trim_end_matches('\0') |
| 589 | .to_owned() | 704 | .to_owned() |
crates/draw/src/render/webgpu.rs+81-45| ... | @@ -2,14 +2,21 @@ | ... | @@ -2,14 +2,21 @@ |
| 2 | use super::*; | 2 | use super::*; |
| 3 | 3 | ||
| 4 | /// What a frame is drawn into. | 4 | /// What a frame is drawn into. |
| 5 | #[cfg(not(windows))] | ||
| 5 | pub type Target = wgpu::TextureView; | 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. | 11 | impl AsRef<Image> for Image { |
| 10 | pub(super) const FLIPPED: bool = false; | 12 | fn as_ref(&self) -> &Image { |
| 13 | self | ||
| 14 | } | ||
| 15 | } | ||
| 11 | 16 | ||
| 12 | pub(super) struct Gpu { | 17 | pub(super) struct Gpu { |
| 18 | device: wgpu::Device, | ||
| 19 | queue: wgpu::Queue, | ||
| 13 | pipeline: wgpu::RenderPipeline, | 20 | pipeline: wgpu::RenderPipeline, |
| 14 | image_pipeline: wgpu::RenderPipeline, | 21 | image_pipeline: wgpu::RenderPipeline, |
| 15 | erase_pipeline: wgpu::RenderPipeline, | 22 | erase_pipeline: wgpu::RenderPipeline, |
| ... | @@ -24,8 +31,27 @@ pub(super) struct Gpu { | ... | @@ -24,8 +31,27 @@ pub(super) struct Gpu { |
| 24 | groups_size: [u32; 2], | 31 | groups_size: [u32; 2], |
| 25 | } | 32 | } |
| 26 | 33 | ||
| 34 | #[cfg(not(windows))] | ||
| 27 | impl Renderer { | 35 | impl Renderer { |
| 28 | pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { | 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 | let shader = device.create_shader_module(wgpu::include_wgsl!("../draw.wgsl")); | 55 | let shader = device.create_shader_module(wgpu::include_wgsl!("../draw.wgsl")); |
| 30 | let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { | 56 | let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { |
| 31 | label: Some("Draw texture"), | 57 | label: Some("Draw texture"), |
| ... | @@ -126,36 +152,37 @@ impl Renderer { | ... | @@ -126,36 +152,37 @@ impl Renderer { |
| 126 | min_filter: wgpu::FilterMode::Linear, | 152 | min_filter: wgpu::FilterMode::Linear, |
| 127 | ..Default::default() | 153 | ..Default::default() |
| 128 | }); | 154 | }); |
| 129 | Self::with_gpu( | 155 | Self { |
| 130 | device, | 156 | device, |
| 131 | queue, | 157 | queue, |
| 132 | Gpu { | 158 | pipeline, |
| 133 | pipeline, | 159 | image_pipeline, |
| 134 | image_pipeline, | 160 | erase_pipeline, |
| 135 | erase_pipeline, | 161 | multiply_pipeline, |
| 136 | multiply_pipeline, | 162 | atlas, |
| 137 | atlas, | 163 | bind_group, |
| 138 | bind_group, | 164 | image_sampler, |
| 139 | image_sampler, | 165 | vertex_buffer, |
| 140 | vertex_buffer, | 166 | format, |
| 141 | format, | 167 | groups: Vec::new(), |
| 142 | groups: Vec::new(), | 168 | groups_size: [0; 2], |
| 143 | groups_size: [0; 2], | 169 | } |
| 144 | }, | 170 | } |
| 145 | ) | 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. | 176 | pub(super) fn max_texture_dimension(&self) -> u32 { |
| 149 | pub fn max_texture_dimension(&self) -> u32 { | ||
| 150 | self.device.limits().max_texture_dimension_2d | 177 | self.device.limits().max_texture_dimension_2d |
| 151 | } | 178 | } |
| 152 | 179 | ||
| 153 | pub(super) fn atlas_side(&self) -> u32 { | 180 | pub(super) fn atlas_side(&self) -> u32 { |
| 154 | self.gpu.atlas.width() | 181 | self.atlas.width() |
| 155 | } | 182 | } |
| 156 | 183 | ||
| 157 | pub(super) fn new_atlas(&mut self, side: u32) { | 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 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { | 188 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { |
| ... | @@ -166,7 +193,7 @@ impl Renderer { | ... | @@ -166,7 +193,7 @@ impl Renderer { |
| 166 | y: origin[1], | 193 | y: origin[1], |
| 167 | z: 0, | 194 | z: 0, |
| 168 | }, | 195 | }, |
| 169 | ..self.gpu.atlas.as_image_copy() | 196 | ..self.atlas.as_image_copy() |
| 170 | }, | 197 | }, |
| 171 | rgba, | 198 | rgba, |
| 172 | wgpu::TexelCopyBufferLayout { | 199 | wgpu::TexelCopyBufferLayout { |
| ... | @@ -208,9 +235,9 @@ impl Renderer { | ... | @@ -208,9 +235,9 @@ impl Renderer { |
| 208 | }, | 235 | }, |
| 209 | size, | 236 | size, |
| 210 | ); | 237 | ); |
| 211 | self.device.create_bind_group(&wgpu::BindGroupDescriptor { | 238 | Image(self.device.create_bind_group(&wgpu::BindGroupDescriptor { |
| 212 | label: Some("Draw image"), | 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 | entries: &[ | 241 | entries: &[ |
| 215 | wgpu::BindGroupEntry { | 242 | wgpu::BindGroupEntry { |
| 216 | binding: 0, | 243 | binding: 0, |
| ... | @@ -220,21 +247,21 @@ impl Renderer { | ... | @@ -220,21 +247,21 @@ impl Renderer { |
| 220 | }, | 247 | }, |
| 221 | wgpu::BindGroupEntry { | 248 | wgpu::BindGroupEntry { |
| 222 | binding: 1, | 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 | /// An offscreen picture `size` large for each group, kept for later frames. | 256 | /// An offscreen picture `size` large for each group, kept for later frames. |
| 230 | fn group_pictures(&mut self, size: [u32; 2]) { | 257 | fn group_pictures(&mut self, size: [u32; 2], count: usize) { |
| 231 | let gpu = &mut self.gpu; | 258 | let gpu = self; |
| 232 | if gpu.groups_size != size { | 259 | if gpu.groups_size != size { |
| 233 | gpu.groups.clear(); | 260 | gpu.groups.clear(); |
| 234 | gpu.groups_size = size; | 261 | gpu.groups_size = size; |
| 235 | } | 262 | } |
| 236 | while gpu.groups.len() < self.groups.len() { | 263 | while gpu.groups.len() < count { |
| 237 | let texture = self.device.create_texture(&wgpu::TextureDescriptor { | 264 | let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { |
| 238 | label: Some("Draw group"), | 265 | label: Some("Draw group"), |
| 239 | size: wgpu::Extent3d { | 266 | size: wgpu::Extent3d { |
| 240 | width: size[0], | 267 | width: size[0], |
| ... | @@ -250,7 +277,7 @@ impl Renderer { | ... | @@ -250,7 +277,7 @@ impl Renderer { |
| 250 | view_formats: &[], | 277 | view_formats: &[], |
| 251 | }); | 278 | }); |
| 252 | let view = texture.create_view(&Default::default()); | 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 | label: Some("Draw group"), | 281 | label: Some("Draw group"), |
| 255 | layout: &gpu.pipeline.get_bind_group_layout(0), | 282 | layout: &gpu.pipeline.get_bind_group_layout(0), |
| 256 | entries: &[ | 283 | entries: &[ |
| ... | @@ -270,19 +297,28 @@ impl Renderer { | ... | @@ -270,19 +297,28 @@ impl Renderer { |
| 270 | 297 | ||
| 271 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared | 298 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared |
| 272 | /// batches, each group's offscreen first. | 299 | /// batches, each group's offscreen first. |
| 273 | pub(super) fn submit(&mut self, target: &Target, size: [u32; 2], clear: [f32; 4]) { | 300 | pub(super) fn submit( |
| 274 | self.group_pictures(size); | 301 | &mut self, |
| 275 | let gpu = &self.gpu; | 302 | frame: &Frame<'_>, |
| 276 | self.queue | 303 | target: &wgpu::TextureView, |
| 277 | .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); | 304 | size: [u32; 2], |
| 278 | let mut encoder = self.device.create_command_encoder(&Default::default()); | 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 | let draw = |pass: &mut wgpu::RenderPass, batches: &[Batch]| { | 312 | let draw = |pass: &mut wgpu::RenderPass, batches: &[Batch]| { |
| 280 | for batch in batches { | 313 | for batch in batches { |
| 281 | let [x, y, width, height] = batch.scissor; | 314 | let [x, y, width, height] = batch.scissor; |
| 282 | pass.set_scissor_rect(x, y, width, height); | 315 | pass.set_scissor_rect(x, y, width, height); |
| 283 | let (pipeline, binding) = match batch.blend { | 316 | let (pipeline, binding) = match batch.blend { |
| 284 | Blend::Over => (&gpu.pipeline, &gpu.bind_group), | 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 | Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group), | 322 | Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group), |
| 287 | Blend::Multiply => (&gpu.multiply_pipeline, &gpu.bind_group), | 323 | Blend::Multiply => (&gpu.multiply_pipeline, &gpu.bind_group), |
| 288 | }; | 324 | }; |
| ... | @@ -309,23 +345,23 @@ impl Renderer { | ... | @@ -309,23 +345,23 @@ impl Renderer { |
| 309 | pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..)); | 345 | pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..)); |
| 310 | pass.forget_lifetime() | 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 | let mut pass = begin(&mut encoder, view, [0.0; 4]); | 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 | let mut pass = begin(&mut encoder, target, clear); | 352 | let mut pass = begin(&mut encoder, target, clear); |
| 317 | let mut next = 0; | 353 | let mut next = 0; |
| 318 | for (group, (_, binding)) in self.groups.iter().zip(&gpu.groups) { | 354 | for (group, (_, binding)) in frame.groups.iter().zip(&gpu.groups) { |
| 319 | draw(&mut pass, &self.batches[next..group.batches.start]); | 355 | draw(&mut pass, &frame.batches[next..group.batches.start]); |
| 320 | pass.set_scissor_rect(0, 0, size[0], size[1]); | 356 | pass.set_scissor_rect(0, 0, size[0], size[1]); |
| 321 | pass.set_pipeline(&gpu.image_pipeline); | 357 | pass.set_pipeline(&gpu.image_pipeline); |
| 322 | pass.set_bind_group(0, binding, &[]); | 358 | pass.set_bind_group(0, binding, &[]); |
| 323 | pass.draw(group.composite.clone(), 0..1); | 359 | pass.draw(group.composite.clone(), 0..1); |
| 324 | next = group.batches.end; | 360 | next = group.batches.end; |
| 325 | } | 361 | } |
| 326 | draw(&mut pass, &self.batches[next..]); | 362 | draw(&mut pass, &frame.batches[next..]); |
| 327 | drop(pass); | 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,7 +683,7 @@ impl View { |
| 683 | let config = surface | 683 | let config = surface |
| 684 | .get_default_config(&gpu.adapter, pixels[0], pixels[1]) | 684 | .get_default_config(&gpu.adapter, pixels[0], pixels[1]) |
| 685 | .ok_or("No supported surface configuration")?; | 685 | .ok_or("No supported surface configuration")?; |
| 686 | surface.configure(&gpu.renderer.device, &config); | 686 | surface.configure(gpu.renderer.device(), &config); |
| 687 | let (page, read_only) = section.shared.page(space)?; | 687 | let (page, read_only) = section.shared.page(space)?; |
| 688 | let frame = Arc::<Frame>::default(); | 688 | let frame = Arc::<Frame>::default(); |
| 689 | #[cfg_attr(not(target_os = "ios"), expect(unused_mut))] | 689 | #[cfg_attr(not(target_os = "ios"), expect(unused_mut))] |
| ... | @@ -712,7 +712,7 @@ impl View { | ... | @@ -712,7 +712,7 @@ impl View { |
| 712 | wgpu::CurrentSurfaceTexture::Success(frame) | 712 | wgpu::CurrentSurfaceTexture::Success(frame) |
| 713 | | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, | 713 | | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, |
| 714 | wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { | 714 | wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { |
| 715 | self.surface.configure(&renderer.device, &self.config); | 715 | self.surface.configure(renderer.device(), &self.config); |
| 716 | return Ok(()); | 716 | return Ok(()); |
| 717 | } | 717 | } |
| 718 | _ => return Ok(()), | 718 | _ => return Ok(()), |
| ... | @@ -731,7 +731,7 @@ impl View { | ... | @@ -731,7 +731,7 @@ impl View { |
| 731 | &[canvas.page.viewport.layer(&primitives)], | 731 | &[canvas.page.viewport.layer(&primitives)], |
| 732 | ) | 732 | ) |
| 733 | .map_err(|error| format!("Page drawing failed: {error:?}"))?; | 733 | .map_err(|error| format!("Page drawing failed: {error:?}"))?; |
| 734 | renderer.queue.present(frame); | 734 | renderer.queue().present(frame); |
| 735 | Ok(()) | 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,7 +842,7 @@ pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale |
| 842 | view.config.width = pixels[0]; | 842 | view.config.width = pixels[0]; |
| 843 | view.config.height = pixels[1]; | 843 | view.config.height = pixels[1]; |
| 844 | if let Some(gpu) = &*gpu() { | 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,10 +457,20 @@ fn write_ahead(connection: &Connection) -> Result<()> { |
| 457 | Ok(()) | 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 | pub(crate) fn now() -> u64 { | 462 | pub(crate) fn now() -> u64 { |
| 463 | use std::sync::atomic::{AtomicU64, Ordering}; | ||
| 464 | static LAST: AtomicU64 = AtomicU64::new(0); | ||
| 462 | let unix = std::time::SystemTime::now() | 465 | let unix = std::time::SystemTime::now() |
| 463 | .duration_since(std::time::UNIX_EPOCH) | 466 | .duration_since(std::time::UNIX_EPOCH) |
| 464 | .unwrap_or_default(); | 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,6 +1,6 @@ |
| 1 | use super::*; | 1 | use super::*; |
| 2 | use rusqlite::backup::{Backup, StepResult}; | 2 | use rusqlite::backup::{Backup, StepResult}; |
| 3 | use std::{collections::BTreeMap, fs::File}; | 3 | use std::collections::BTreeMap; |
| 4 | 4 | ||
| 5 | const RECOVERY_ID: u32 = 0x4f4e4552; | 5 | const RECOVERY_ID: u32 = 0x4f4e4552; |
| 6 | 6 | ||
| ... | @@ -153,7 +153,9 @@ pub(crate) fn export(source: &Connection, path: &Path, replace: bool) -> Result< | ... | @@ -153,7 +153,9 @@ pub(crate) fn export(source: &Connection, path: &Path, replace: bool) -> Result< |
| 153 | .persist_noclobber(path) | 153 | .persist_noclobber(path) |
| 154 | .map_err(|error| error.error)?; | 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 | Ok(()) | 159 | Ok(()) |
| 158 | } | 160 | } |
| 159 | 161 |
crates/onestore/Cargo.toml+4| ... | @@ -24,4 +24,8 @@ zeroize = { version = "1.9.0", optional = true } | ... | @@ -24,4 +24,8 @@ zeroize = { version = "1.9.0", optional = true } |
| 24 | [target.'cfg(target_os = "macos")'.dependencies] | 24 | [target.'cfg(target_os = "macos")'.dependencies] |
| 25 | nix = { version = "0.31.3", default-features = false, features = ["fs"] } | 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 | [dev-dependencies] | 31 | [dev-dependencies] |
crates/onestore/src/commit.rs+111-2| ... | @@ -14,7 +14,13 @@ use std::os::windows::fs::FileExt; | ... | @@ -14,7 +14,13 @@ use std::os::windows::fs::FileExt; |
| 14 | 14 | ||
| 15 | #[cfg(any(unix, windows))] | 15 | #[cfg(any(unix, windows))] |
| 16 | struct FileIo { | 16 | struct FileIo { |
| 17 | #[cfg(unix)] | ||
| 17 | file: File, | 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 | _process: MutexGuard<'static, ()>, | 24 | _process: MutexGuard<'static, ()>, |
| 19 | unlock_on_drop: bool, | 25 | unlock_on_drop: bool, |
| 20 | } | 26 | } |
| ... | @@ -43,22 +49,48 @@ impl FileIo { | ... | @@ -43,22 +49,48 @@ impl FileIo { |
| 43 | }; | 49 | }; |
| 44 | options.custom_flags(lock | nix::libc::O_NONBLOCK); | 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 | let file = options.open(path)?; | 68 | let file = options.open(path)?; |
| 47 | #[cfg(not(target_os = "macos"))] | 69 | #[cfg(all(unix, not(target_os = "macos")))] |
| 48 | file.try_lock()?; | 70 | file.try_lock()?; |
| 49 | Ok(Self { | 71 | Ok(Self { |
| 72 | #[cfg(windows)] | ||
| 73 | locks: onenote_locks(&file, write)?, | ||
| 50 | file, | 74 | file, |
| 51 | _process: process, | 75 | _process: process, |
| 52 | unlock_on_drop: true, | 76 | unlock_on_drop: true, |
| 53 | }) | 77 | }) |
| 54 | } | 78 | } |
| 55 | 79 | ||
| 80 | #[cfg(unix)] | ||
| 56 | fn release(&mut self) -> io::Result<()> { | 81 | fn release(&mut self) -> io::Result<()> { |
| 57 | // A failed unlock may have reached the server; Drop must not repeat it. | 82 | // A failed unlock may have reached the server; Drop must not repeat it. |
| 58 | self.unlock_on_drop = false; | 83 | self.unlock_on_drop = false; |
| 59 | self.file.unlock() | 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 | fn finish(mut self, result: Result<(), CommitError>) -> Result<(), CommitError> { | 94 | fn finish(mut self, result: Result<(), CommitError>) -> Result<(), CommitError> { |
| 63 | let released = self.release(); | 95 | let released = self.release(); |
| 64 | result?; | 96 | result?; |
| ... | @@ -69,7 +101,7 @@ impl FileIo { | ... | @@ -69,7 +101,7 @@ impl FileIo { |
| 69 | } | 101 | } |
| 70 | } | 102 | } |
| 71 | 103 | ||
| 72 | #[cfg(any(unix, windows))] | 104 | #[cfg(unix)] |
| 73 | impl Drop for FileIo { | 105 | impl Drop for FileIo { |
| 74 | fn drop(&mut self) { | 106 | fn drop(&mut self) { |
| 75 | if self.unlock_on_drop { | 107 | if self.unlock_on_drop { |
| ... | @@ -78,6 +110,28 @@ impl Drop for FileIo { | ... | @@ -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 | /// Places a file in its notebook the way OneNote does on adoption: the header's | 135 | /// Places a file in its notebook the way OneNote does on adoption: the header's |
| 82 | /// `guidAncestor` becomes the parent table of contents' file identity and `crcName` the CRC | 136 | /// `guidAncestor` becomes the parent table of contents' file identity and `crcName` the CRC |
| 83 | /// of `name` (a section's file name, a group's folder name). OneNote re-identifies a file | 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,4 +582,59 @@ mod tests { |
| 528 | b"unfinished" | 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,6 +39,30 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl |
| 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"] } | 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 | block2 = "0.5.1" | 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 | [target.'cfg(target_os = "linux")'.dependencies] | 66 | [target.'cfg(target_os = "linux")'.dependencies] |
| 43 | accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] } | 67 | accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] } |
| 44 | smithay-clipboard = "0.7.3" | 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,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 | use notebook::session::{Background, Notebook, Section}; | 3 | use notebook::session::{Background, Notebook, Section}; |
| 4 | use std::path::{Path, PathBuf}; | 4 | use std::path::{Path, PathBuf}; |
crates/snowbound/src/library.rs+30| ... | @@ -57,10 +57,24 @@ pub struct Mount { | ... | @@ -57,10 +57,24 @@ pub struct Mount { |
| 57 | pub root: String, | 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 | impl Mount { | 73 | impl Mount { |
| 61 | /// The mount of `source` (`//[domain;][user[:…]@]server/share[/folder]`), holding the | 74 | /// The mount of `source` (`//[domain;][user[:…]@]server/share[/folder]`), holding the |
| 62 | /// folder `within` its mount point; `options` are the mount's (`username=`, `user=`, | 75 | /// folder `within` its mount point; `options` are the mount's (`username=`, `user=`, |
| 63 | /// `domain=`), where the source names no account. | 76 | /// `domain=`), where the source names no account. |
| 77 | #[cfg_attr(windows, allow(dead_code))] | ||
| 64 | pub fn parse(source: &str, within: &str, options: &str) -> Option<Self> { | 78 | pub fn parse(source: &str, within: &str, options: &str) -> Option<Self> { |
| 65 | let source = source.strip_prefix("//")?; | 79 | let source = source.strip_prefix("//")?; |
| 66 | let (account, rest) = match source.rsplit_once('@') { | 80 | let (account, rest) = match source.rsplit_once('@') { |
| ... | @@ -876,6 +890,22 @@ pub fn locate(path: &Path) -> Located { | ... | @@ -876,6 +890,22 @@ pub fn locate(path: &Path) -> Located { |
| 876 | mod tests { | 890 | mod tests { |
| 877 | use super::*; | 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 | /// Removes the folder at `path` on the share and everything in it. | 909 | /// Removes the folder at `path` on the share and everything in it. |
| 880 | fn remove_tree(client: &Client, path: &str) { | 910 | fn remove_tree(client: &Client, path: &str) { |
| 881 | for entry in client.read_dir(path, 10_000).unwrap_or_default() { | 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,7 +762,7 @@ pub fn cache_dir() -> Option<PathBuf> { |
| 762 | pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { | 762 | pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { |
| 763 | let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?; | 763 | let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?; |
| 764 | // The mount holding the path is the one at its longest ancestor. | 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 | .lines() | 766 | .lines() |
| 767 | .filter_map(|line| { | 767 | .filter_map(|line| { |
| 768 | let mut fields = line.split_whitespace(); | 768 | let mut fields = line.split_whitespace(); |
| ... | @@ -772,16 +772,19 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { | ... | @@ -772,16 +772,19 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { |
| 772 | fields.next()?, | 772 | fields.next()?, |
| 773 | fields.next()?, | 773 | fields.next()?, |
| 774 | ); | 774 | ); |
| 775 | if !matches!(kind, "cifs" | "smb3") { | ||
| 776 | return None; | ||
| 777 | } | ||
| 775 | let point = point.replace("\\040", " "); | 778 | let point = point.replace("\\040", " "); |
| 776 | (matches!(kind, "cifs" | "smb3") && path.starts_with(&point)) | 779 | let within = crate::library::within_mount(path, &point)?; |
| 777 | .then(|| (source.replace("\\040", " "), point, options.to_owned())) | 780 | Some(( |
| 781 | source.replace("\\040", " "), | ||
| 782 | point, | ||
| 783 | options.to_owned(), | ||
| 784 | within, | ||
| 785 | )) | ||
| 778 | }) | 786 | }) |
| 779 | .max_by_key(|(_, point, _)| point.len())?; | 787 | .max_by_key(|(_, point, _, _)| point.len())?; |
| 780 | let within = path | ||
| 781 | .strip_prefix(&point) | ||
| 782 | .ok()? | ||
| 783 | .to_string_lossy() | ||
| 784 | .into_owned(); | ||
| 785 | crate::library::Mount::parse(&source, &within, &options) | 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,8 +340,8 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { |
| 340 | if text(&mount.f_fstypename) != "smbfs" { | 340 | if text(&mount.f_fstypename) != "smbfs" { |
| 341 | return None; | 341 | return None; |
| 342 | } | 342 | } |
| 343 | let within = path.strip_prefix(text(&mount.f_mntonname)).ok()?; | 343 | let within = crate::library::within_mount(path, &text(&mount.f_mntonname))?; |
| 344 | crate::library::Mount::parse(&text(&mount.f_mntfromname), &within.to_string_lossy(), "") | 344 | crate::library::Mount::parse(&text(&mount.f_mntfromname), &within, "") |
| 345 | } | 345 | } |
| 346 | 346 | ||
| 347 | #[allow(non_camel_case_types)] | 347 | #[allow(non_camel_case_types)] |
crates/snowbound/src/main.rs+8-2| ... | @@ -1,16 +1,17 @@ | ... | @@ -1,16 +1,17 @@ |
| 1 | #![cfg_attr(windows, windows_subsystem = "windows")] | ||
| 1 | #[cfg(target_os = "macos")] | 2 | #[cfg(target_os = "macos")] |
| 2 | mod aqua; | 3 | mod aqua; |
| 3 | mod art; | 4 | mod art; |
| 4 | mod attachment; | 5 | mod attachment; |
| 5 | mod background; | 6 | mod background; |
| 6 | mod commands; | 7 | mod commands; |
| 7 | #[cfg(all(test, feature = "wgpu"))] | 8 | #[cfg(all(test, feature = "wgpu", not(windows)))] |
| 8 | mod conflict_render; | 9 | mod conflict_render; |
| 9 | #[cfg(target_os = "linux")] | 10 | #[cfg(target_os = "linux")] |
| 10 | #[path = "desktop_linux.rs"] | 11 | #[path = "desktop_linux.rs"] |
| 11 | mod desktop; | 12 | mod desktop; |
| 12 | mod history; | 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 | #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] | 15 | #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] |
| 15 | mod icloud; | 16 | mod icloud; |
| 16 | mod library; | 17 | mod library; |
| ... | @@ -18,6 +19,7 @@ mod link; | ... | @@ -18,6 +19,7 @@ mod link; |
| 18 | mod manage; | 19 | mod manage; |
| 19 | #[cfg_attr(target_os = "linux", path = "media_linux.rs")] | 20 | #[cfg_attr(target_os = "linux", path = "media_linux.rs")] |
| 20 | #[cfg_attr(target_os = "macos", path = "media_macos.rs")] | 21 | #[cfg_attr(target_os = "macos", path = "media_macos.rs")] |
| 22 | #[cfg_attr(windows, path = "media_windows.rs")] | ||
| 21 | mod media; | 23 | mod media; |
| 22 | mod meeting; | 24 | mod meeting; |
| 23 | #[cfg(target_os = "macos")] | 25 | #[cfg(target_os = "macos")] |
| ... | @@ -29,6 +31,7 @@ mod palette; | ... | @@ -29,6 +31,7 @@ mod palette; |
| 29 | mod pane; | 31 | mod pane; |
| 30 | #[cfg_attr(target_os = "linux", path = "linux.rs")] | 32 | #[cfg_attr(target_os = "linux", path = "linux.rs")] |
| 31 | #[cfg_attr(target_os = "macos", path = "macos.rs")] | 33 | #[cfg_attr(target_os = "macos", path = "macos.rs")] |
| 34 | #[cfg_attr(windows, path = "windows.rs")] | ||
| 32 | mod platform; | 35 | mod platform; |
| 33 | mod recording; | 36 | mod recording; |
| 34 | mod rename; | 37 | mod rename; |
| ... | @@ -39,8 +42,10 @@ mod settings; | ... | @@ -39,8 +42,10 @@ mod settings; |
| 39 | mod sidebar; | 42 | mod sidebar; |
| 40 | #[cfg_attr(target_os = "linux", path = "spell_linux.rs")] | 43 | #[cfg_attr(target_os = "linux", path = "spell_linux.rs")] |
| 41 | #[cfg_attr(target_os = "macos", path = "spell_macos.rs")] | 44 | #[cfg_attr(target_os = "macos", path = "spell_macos.rs")] |
| 45 | #[cfg_attr(windows, path = "spell_windows.rs")] | ||
| 42 | mod spell; | 46 | mod spell; |
| 43 | #[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")] | 47 | #[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")] |
| 48 | #[cfg_attr(windows, path = "surface_windows.rs")] | ||
| 44 | mod surface; | 49 | mod surface; |
| 45 | mod sync; | 50 | mod sync; |
| 46 | mod tags; | 51 | mod tags; |
| ... | @@ -154,6 +159,7 @@ const ROW_GAP: f32 = 3.0; | ... | @@ -154,6 +159,7 @@ const ROW_GAP: f32 = 3.0; |
| 154 | /// Space between the page and a page tab that isn't open. | 159 | /// Space between the page and a page tab that isn't open. |
| 155 | const PILL_MARGIN: f32 = 3.0; | 160 | const PILL_MARGIN: f32 = 3.0; |
| 156 | /// Why the platform's date dialog could not change the page's date. | 161 | /// Why the platform's date dialog could not change the page's date. |
| 162 | #[cfg_attr(windows, allow(dead_code))] | ||
| 157 | const DATE_UNCHOSEN: &str = "Choose another date or time."; | 163 | const DATE_UNCHOSEN: &str = "Choose another date or time."; |
| 158 | const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range."; | 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,12 +163,15 @@ impl TagScope { |
| 163 | fn local_day(time: u64) -> i64 { | 163 | fn local_day(time: u64) -> i64 { |
| 164 | let unix = time as i64 + 315_532_800; | 164 | let unix = time as i64 + 315_532_800; |
| 165 | // SAFETY: `localtime_r` writes only the `tm` it is given. | 165 | // SAFETY: `localtime_r` writes only the `tm` it is given. |
| 166 | #[cfg(unix)] | ||
| 166 | let offset = unsafe { | 167 | let offset = unsafe { |
| 167 | let mut tm: libc::tm = std::mem::zeroed(); | 168 | let mut tm: libc::tm = std::mem::zeroed(); |
| 168 | libc::localtime_r(&unix, &mut tm); | 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 | /// Day `day` counted from 1970 as a year, month and day. | 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,6 +9,8 @@ pub struct Surface { |
| 9 | /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. | 9 | /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. |
| 10 | pub size: [u32; 2], | 10 | pub size: [u32; 2], |
| 11 | window: Arc<Window>, | 11 | window: Arc<Window>, |
| 12 | device: wgpu::Device, | ||
| 13 | queue: wgpu::Queue, | ||
| 12 | instance: wgpu::Instance, | 14 | instance: wgpu::Instance, |
| 13 | surface: wgpu::Surface<'static>, | 15 | surface: wgpu::Surface<'static>, |
| 14 | config: wgpu::SurfaceConfiguration, | 16 | config: wgpu::SurfaceConfiguration, |
| ... | @@ -17,14 +19,14 @@ pub struct Surface { | ... | @@ -17,14 +19,14 @@ pub struct Surface { |
| 17 | } | 19 | } |
| 18 | 20 | ||
| 19 | pub struct Frame { | 21 | pub struct Frame { |
| 20 | pub target: wgpu::TextureView, | 22 | pub target: draw::Target, |
| 21 | texture: wgpu::SurfaceTexture, | 23 | pub(super) texture: wgpu::SurfaceTexture, |
| 22 | reconfigure: bool, | 24 | pub(super) reconfigure: bool, |
| 23 | } | 25 | } |
| 24 | 26 | ||
| 25 | pub struct Offscreen { | 27 | pub struct Offscreen { |
| 26 | pub target: wgpu::TextureView, | 28 | pub target: draw::Target, |
| 27 | texture: wgpu::Texture, | 29 | pub(super) texture: wgpu::Texture, |
| 28 | } | 30 | } |
| 29 | 31 | ||
| 30 | impl Surface { | 32 | impl Surface { |
| ... | @@ -34,9 +36,20 @@ impl Surface { | ... | @@ -34,9 +36,20 @@ impl Surface { |
| 34 | window: Arc<Window>, | 36 | window: Arc<Window>, |
| 35 | backdrop: bool, | 37 | backdrop: bool, |
| 36 | ) -> Result<(Self, Renderer), Box<dyn Error>> { | 38 | ) -> Result<(Self, Renderer), Box<dyn Error>> { |
| 37 | let instance = wgpu::Instance::new( | 39 | #[cfg_attr(not(windows), expect(unused_mut))] |
| 38 | wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())), | 40 | let mut descriptor = |
| 39 | ); | 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 | let surface = instance.create_surface(window.clone())?; | 53 | let surface = instance.create_surface(window.clone())?; |
| 41 | platform::configure_presentation(&surface); | 54 | platform::configure_presentation(&surface); |
| 42 | let adapter = instance | 55 | let adapter = instance |
| ... | @@ -50,27 +63,32 @@ impl Surface { | ... | @@ -50,27 +63,32 @@ impl Surface { |
| 50 | let mut config = surface | 63 | let mut config = surface |
| 51 | .get_default_config(&adapter, size.width, size.height) | 64 | .get_default_config(&adapter, size.width, size.height) |
| 52 | .ok_or("No supported canvas surface")?; | 65 | .ok_or("No supported canvas surface")?; |
| 66 | let alpha = surface.get_capabilities(&adapter).alpha_modes; | ||
| 53 | let translucent = backdrop.then(|| { | 67 | let translucent = backdrop.then(|| { |
| 54 | // What wgpu calls post-multiplied is Core Animation's non-opaque layer, which | 68 | // DirectComposition's premultiplied swap chains and Core Animation's non-opaque |
| 55 | // composites colour premultiplied in sRGB, as `Translucent` leaves it. | 69 | // layers both composite colour premultiplied in sRGB, as `Translucent` leaves |
| 56 | config.alpha_mode = wgpu::CompositeAlphaMode::PostMultiplied; | 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 | let window_format = config.format.remove_srgb_suffix(); | 76 | let window_format = config.format.remove_srgb_suffix(); |
| 58 | config.view_formats.push(window_format); | 77 | config.view_formats.push(window_format); |
| 59 | draw::Translucent::new(&device, config.format, window_format) | 78 | draw::Translucent::new(&device, config.format, window_format) |
| 60 | }); | 79 | }); |
| 61 | if platform::cuts_corners() { | 80 | if platform::cuts_corners() && alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { |
| 62 | let alpha = &surface.get_capabilities(&adapter).alpha_modes; | 81 | config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied; |
| 63 | if alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { | ||
| 64 | config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied; | ||
| 65 | } | ||
| 66 | } | 82 | } |
| 67 | surface.configure(&device, &config); | 83 | surface.configure(&device, &config); |
| 68 | eprintln!("Canvas GPU: {:?}", adapter.get_info()); | 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 | Ok(( | 86 | Ok(( |
| 71 | Self { | 87 | Self { |
| 72 | size: [config.width, config.height], | 88 | size: [config.width, config.height], |
| 73 | window, | 89 | window, |
| 90 | device, | ||
| 91 | queue, | ||
| 74 | instance, | 92 | instance, |
| 75 | surface, | 93 | surface, |
| 76 | config, | 94 | config, |
| ... | @@ -85,9 +103,9 @@ impl Surface { | ... | @@ -85,9 +103,9 @@ impl Surface { |
| 85 | self.translucent.is_some() | 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 | [self.config.width, self.config.height] = self.size; | 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 | /// The window's next frame, or none to skip this one. | 111 | /// The window's next frame, or none to skip this one. |
| ... | @@ -123,9 +141,10 @@ impl Surface { | ... | @@ -123,9 +141,10 @@ impl Surface { |
| 123 | } | 141 | } |
| 124 | }; | 142 | }; |
| 125 | let target = match &mut self.translucent { | 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 | None => texture.texture.create_view(&Default::default()), | 145 | None => texture.texture.create_view(&Default::default()), |
| 128 | }; | 146 | }; |
| 147 | let target = target_of(target); | ||
| 129 | Ok(Some(Frame { | 148 | Ok(Some(Frame { |
| 130 | target, | 149 | target, |
| 131 | texture, | 150 | texture, |
| ... | @@ -142,10 +161,10 @@ impl Surface { | ... | @@ -142,10 +161,10 @@ impl Surface { |
| 142 | format: Some(self.config.format.remove_srgb_suffix()), | 161 | format: Some(self.config.format.remove_srgb_suffix()), |
| 143 | ..Default::default() | 162 | ..Default::default() |
| 144 | }); | 163 | }); |
| 145 | translucent.present(&renderer.device, &renderer.queue, &window); | 164 | translucent.present(&self.device, &self.queue, &window); |
| 146 | } | 165 | } |
| 147 | self.window.pre_present_notify(); | 166 | self.window.pre_present_notify(); |
| 148 | renderer.queue.present(frame.texture); | 167 | self.queue.present(frame.texture); |
| 149 | platform::commit_presentation(&self.window); | 168 | platform::commit_presentation(&self.window); |
| 150 | if frame.reconfigure { | 169 | if frame.reconfigure { |
| 151 | self.configure(renderer); | 170 | self.configure(renderer); |
| ... | @@ -153,8 +172,8 @@ impl Surface { | ... | @@ -153,8 +172,8 @@ impl Surface { |
| 153 | } | 172 | } |
| 154 | 173 | ||
| 155 | /// A target the size of the window's frames that `read` reads back. | 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>> { | 175 | pub fn offscreen(&self, _: &Renderer) -> Result<Offscreen, Box<dyn Error>> { |
| 157 | let texture = renderer.device.create_texture(&wgpu::TextureDescriptor { | 176 | let texture = self.device.create_texture(&wgpu::TextureDescriptor { |
| 158 | label: Some("Snapshot"), | 177 | label: Some("Snapshot"), |
| 159 | size: wgpu::Extent3d { | 178 | size: wgpu::Extent3d { |
| 160 | width: self.size[0], | 179 | width: self.size[0], |
| ... | @@ -169,26 +188,22 @@ impl Surface { | ... | @@ -169,26 +188,22 @@ impl Surface { |
| 169 | view_formats: &[], | 188 | view_formats: &[], |
| 170 | }); | 189 | }); |
| 171 | Ok(Offscreen { | 190 | Ok(Offscreen { |
| 172 | target: texture.create_view(&Default::default()), | 191 | target: target_of(texture.create_view(&Default::default())), |
| 173 | texture, | 192 | texture, |
| 174 | }) | 193 | }) |
| 175 | } | 194 | } |
| 176 | 195 | ||
| 177 | /// The offscreen target's sRGB RGBA rows, top first. | 196 | /// The offscreen target's sRGB RGBA rows, top first. |
| 178 | pub fn read( | 197 | pub fn read(&self, _: &Renderer, offscreen: Offscreen) -> Result<Vec<u8>, Box<dyn Error>> { |
| 179 | &self, | ||
| 180 | renderer: &Renderer, | ||
| 181 | offscreen: Offscreen, | ||
| 182 | ) -> Result<Vec<u8>, Box<dyn Error>> { | ||
| 183 | let size = self.size; | 198 | let size = self.size; |
| 184 | let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); | 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 | label: Some("Snapshot readback"), | 201 | label: Some("Snapshot readback"), |
| 187 | size: u64::from(row) * u64::from(size[1]), | 202 | size: u64::from(row) * u64::from(size[1]), |
| 188 | usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, | 203 | usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, |
| 189 | mapped_at_creation: false, | 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 | encoder.copy_texture_to_buffer( | 207 | encoder.copy_texture_to_buffer( |
| 193 | offscreen.texture.as_image_copy(), | 208 | offscreen.texture.as_image_copy(), |
| 194 | wgpu::TexelCopyBufferInfo { | 209 | wgpu::TexelCopyBufferInfo { |
| ... | @@ -201,9 +216,9 @@ impl Surface { | ... | @@ -201,9 +216,9 @@ impl Surface { |
| 201 | }, | 216 | }, |
| 202 | offscreen.texture.size(), | 217 | offscreen.texture.size(), |
| 203 | ); | 218 | ); |
| 204 | renderer.queue.submit([encoder.finish()]); | 219 | self.queue.submit([encoder.finish()]); |
| 205 | buffer.map_async(wgpu::MapMode::Read, .., |_| {}); | 220 | buffer.map_async(wgpu::MapMode::Read, .., |_| {}); |
| 206 | renderer.device.poll(wgpu::PollType::Wait { | 221 | self.device.poll(wgpu::PollType::Wait { |
| 207 | submission_index: None, | 222 | submission_index: None, |
| 208 | timeout: Some(std::time::Duration::from_secs(5)), | 223 | timeout: Some(std::time::Duration::from_secs(5)), |
| 209 | })?; | 224 | })?; |
| ... | @@ -223,3 +238,10 @@ impl Surface { | ... | @@ -223,3 +238,10 @@ impl Surface { |
| 223 | Ok(pixels) | 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,7 +69,7 @@ impl Surface { |
| 69 | } | 69 | } |
| 70 | context | 70 | context |
| 71 | }; | 71 | }; |
| 72 | let renderer = Renderer::new()?; | 72 | let renderer = Renderer::opengl()?; |
| 73 | eprintln!("Canvas GPU: OpenGL"); | 73 | eprintln!("Canvas GPU: OpenGL"); |
| 74 | let size = window.inner_size(); | 74 | let size = window.inner_size(); |
| 75 | Ok(( | 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,6 +207,8 @@ fn staging(install: &Path) -> Option<PathBuf> { |
| 207 | fn staged(folder: &Path) -> PathBuf { | 207 | fn staged(folder: &Path) -> PathBuf { |
| 208 | folder.join(if cfg!(target_os = "macos") { | 208 | folder.join(if cfg!(target_os = "macos") { |
| 209 | "Snowbound.app" | 209 | "Snowbound.app" |
| 210 | } else if cfg!(windows) { | ||
| 211 | "snowbound.exe" | ||
| 210 | } else { | 212 | } else { |
| 211 | "snowbound" | 213 | "snowbound" |
| 212 | }) | 214 | }) |
| ... | @@ -236,6 +238,7 @@ fn stage(bytes: &[u8], folder: &Path, version: &Version) -> std::io::Result<Path | ... | @@ -236,6 +238,7 @@ fn stage(bytes: &[u8], folder: &Path, version: &Version) -> std::io::Result<Path |
| 236 | } | 238 | } |
| 237 | } else { | 239 | } else { |
| 238 | std::fs::write(&item, bytes)?; | 240 | std::fs::write(&item, bytes)?; |
| 241 | #[cfg(unix)] | ||
| 239 | std::fs::set_permissions(&item, std::os::unix::fs::PermissionsExt::from_mode(0o755))?; | 242 | std::fs::set_permissions(&item, std::os::unix::fs::PermissionsExt::from_mode(0o755))?; |
| 240 | } | 243 | } |
| 241 | std::fs::write(folder.join("version"), version.name())?; | 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,17 +249,23 @@ fn stage(bytes: &[u8], folder: &Path, version: &Version) -> std::io::Result<Path |
| 246 | /// place, then removes the staging folder. | 249 | /// place, then removes the staging folder. |
| 247 | fn apply(staged: &Path, install: &Path) -> std::io::Result<()> { | 250 | fn apply(staged: &Path, install: &Path) -> std::io::Result<()> { |
| 248 | let folder = staging(install).ok_or_else(|| std::io::Error::other("No install name"))?; | 251 | let folder = staging(install).ok_or_else(|| std::io::Error::other("No install name"))?; |
| 252 | let old = folder.with_extension("old"); | ||
| 249 | if install.is_dir() { | 253 | if install.is_dir() { |
| 250 | let old = folder.with_extension("old"); | ||
| 251 | let _ = std::fs::remove_dir_all(&old); | 254 | let _ = std::fs::remove_dir_all(&old); |
| 252 | std::fs::rename(install, &old)?; | 255 | } else { |
| 253 | if let Err(error) = std::fs::rename(staged, install) { | 256 | let _ = std::fs::remove_file(&old); |
| 254 | std::fs::rename(&old, install)?; | 257 | } |
| 255 | return Err(error); | 258 | // Windows renames a running executable, as the finisher is, but doesn't replace it. |
| 256 | } | 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 | let _ = std::fs::remove_dir_all(&old); | 265 | let _ = std::fs::remove_dir_all(&old); |
| 258 | } else { | 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 | let _ = std::fs::remove_dir_all(folder); | 270 | let _ = std::fs::remove_dir_all(folder); |
| 262 | Ok(()) | 271 | Ok(()) |
| ... | @@ -536,16 +545,11 @@ pub fn relaunch(staged: &Path) -> std::io::Result<()> { | ... | @@ -536,16 +545,11 @@ pub fn relaunch(staged: &Path) -> std::io::Result<()> { |
| 536 | /// for the app to quit, swaps the update in and opens the app, updated or not. | 545 | /// for the app to quit, swaps the update in and opens the app, updated or not. |
| 537 | pub fn finish(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn std::error::Error>> { | 546 | pub fn finish(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn std::error::Error>> { |
| 538 | let usage = "Usage: snowbound --finish-update PID STAGED INSTALL [ARGUMENT]..."; | 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 | let staged = PathBuf::from(args.next().ok_or(usage)?); | 549 | let staged = PathBuf::from(args.next().ok_or(usage)?); |
| 541 | let install = PathBuf::from(args.next().ok_or(usage)?); | 550 | let install = PathBuf::from(args.next().ok_or(usage)?); |
| 542 | let deadline = Instant::now() + Duration::from_secs(60); | 551 | if !quits(pid, Duration::from_secs(60)) { |
| 543 | // Signal 0 only asks whether the process is still there. | 552 | return Err("Snowbound didn’t quit, so the update wasn’t installed.".into()); |
| 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)); | ||
| 549 | } | 553 | } |
| 550 | let applied = apply(&staged, &install); | 554 | let applied = apply(&staged, &install); |
| 551 | let mut open = if cfg!(target_os = "macos") { | 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,6 +569,38 @@ pub fn finish(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn st |
| 565 | Ok(applied?) | 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 | #[cfg(test)] | 604 | #[cfg(test)] |
| 569 | mod tests { | 605 | mod tests { |
| 570 | use super::*; | 606 | use super::*; |
| ... | @@ -892,11 +928,7 @@ mod tests { | ... | @@ -892,11 +928,7 @@ mod tests { |
| 892 | #[ignore = "reads the published builds"] | 928 | #[ignore = "reads the published builds"] |
| 893 | fn the_published_build_installs() { | 929 | fn the_published_build_installs() { |
| 894 | let folder = scratch("published"); | 930 | let folder = scratch("published"); |
| 895 | let install = folder.join(if cfg!(target_os = "macos") { | 931 | let install = staged(&folder); |
| 896 | "Snowbound.app" | ||
| 897 | } else { | ||
| 898 | "snowbound" | ||
| 899 | }); | ||
| 900 | let old = version("2000-01-01-r1"); | 932 | let old = version("2000-01-01-r1"); |
| 901 | let status = check( | 933 | let status = check( |
| 902 | &download, | 934 | &download, |
crates/snowbound/src/watch.rs+216-15| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | //! Changes below a notebook folder, as the system reports them: FSEvents on macOS, inotify on | 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 | 2 | //! Linux, ReadDirectoryChangesW on Windows. A notebook on a share learns of its changes from |
| 3 | //! (`notebook::session::Background::smb`). | 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 | use std::path::{Path, PathBuf}; | 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,19 +23,7 @@ pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static) |
| 22 | let folders = if local(&volume) { | 23 | let folders = if local(&volume) { |
| 23 | vec![root.clone()] | 24 | vec![root.clone()] |
| 24 | } else if smbfs(&volume) { | 25 | } else if smbfs(&volume) { |
| 25 | // smbfs reports another client's change only in a folder watched for itself, and | 26 | share_folders(&root) |
| 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 | ||
| 38 | } else { | 27 | } else { |
| 39 | return None; | 28 | return None; |
| 40 | }; | 29 | }; |
| ... | @@ -72,6 +61,30 @@ pub fn on_this_computer(path: &Path) -> bool { | ... | @@ -72,6 +61,30 @@ pub fn on_this_computer(path: &Path) -> bool { |
| 72 | statfs(path).is_none_or(|volume| local(&volume)) | 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 | fn statfs(path: &Path) -> Option<libc::statfs> { | 88 | fn statfs(path: &Path) -> Option<libc::statfs> { |
| 76 | use std::os::unix::ffi::OsStrExt; | 89 | use std::os::unix::ffi::OsStrExt; |
| 77 | let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?; | 90 | let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?; |
| ... | @@ -112,6 +125,30 @@ fn smbfs(_: &libc::statfs) -> bool { | ... | @@ -112,6 +125,30 @@ fn smbfs(_: &libc::statfs) -> bool { |
| 112 | false | 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 | type Changed = Box<dyn Fn(Vec<std::path::PathBuf>) + Send + Sync>; | 152 | type Changed = Box<dyn Fn(Vec<std::path::PathBuf>) + Send + Sync>; |
| 116 | 153 | ||
| 117 | #[cfg(target_os = "macos")] | 154 | #[cfg(target_os = "macos")] |
| ... | @@ -467,6 +504,170 @@ mod platform { | ... | @@ -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 | #[cfg(test)] | 671 | #[cfg(test)] |
| 471 | mod tests { | 672 | mod tests { |
| 472 | use super::*; | 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,6 +761,10 @@ impl Ui { |
| 761 | 761 | ||
| 762 | /// Queues input for the next frame. | 762 | /// Queues input for the next frame. |
| 763 | pub fn event(&mut self, event: Event) { | 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 | self.queue.push(event); | 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,7 +10,7 @@ fn main() { |
| 10 | 10 | ||
| 11 | fn run() { | 11 | fn run() { |
| 12 | let _context = context::current(); | 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 | let target = draw::Target::new(scene::SIZE).unwrap(); | 14 | let target = draw::Target::new(scene::SIZE).unwrap(); |
| 15 | let checker = scene::checker(); | 15 | let checker = scene::checker(); |
| 16 | let primitives = scene::primitives(&checker); | 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,7 +35,7 @@ pub fn primitives(checker: &RasterImage) -> Vec<Primitive<'_>> { |
| 35 | } | 35 | } |
| 36 | 36 | ||
| 37 | pub fn layer<'a>(primitives: &'a [Primitive<'a>]) -> Layer<'a> { | 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 | /// Lines of interface text through `ui`: black on white like a Cocoa label, the same in | 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 +7,7 @@ mod scene; |
| 7 | fn main() { | 7 | fn main() { |
| 8 | objc2::rc::autoreleasepool(|_| { | 8 | objc2::rc::autoreleasepool(|_| { |
| 9 | let _context = context::current(); | 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 | let target = draw::Target::new(scene::TEXT_SIZE).unwrap(); | 11 | let target = draw::Target::new(scene::TEXT_SIZE).unwrap(); |
| 12 | let ui = scene::interface(1.0); | 12 | let ui = scene::interface(1.0); |
| 13 | let layers = ui.layers(); | 13 | let layers = ui.layers(); |
platform/windows/cargo.sh+7-3| ... | @@ -19,17 +19,21 @@ x86_64) | ... | @@ -19,17 +19,21 @@ x86_64) |
| 19 | target=x86_64-win7-windows-gnu | 19 | target=x86_64-win7-windows-gnu |
| 20 | toolchain=+nightly | 20 | toolchain=+nightly |
| 21 | # rustc would infer a bare ld from the name `link.sh`; it is a C compiler driver. | 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 | set -- -Zbuild-std=std,panic_unwind \ | 25 | set -- -Zbuild-std=std,panic_unwind \ |
| 23 | --config "target.$target.linker='$here/link.sh'" \ | 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 | aarch64) | 29 | aarch64) |
| 27 | target=aarch64-pc-windows-gnullvm | 30 | target=aarch64-pc-windows-gnullvm |
| 28 | toolchain=+stable | 31 | toolchain=+stable |
| 29 | rustup target add --toolchain stable "$target" >/dev/null | 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 | set -- --config "target.$target.linker='$LLVM_MINGW/bin/aarch64-w64-mingw32-clang'" \ | 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 | echo "usage: $0 x86_64|aarch64 COMMAND ARGS..." >&2 | 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,7 +1,10 @@ |
| 1 | #!/bin/sh | 1 | #!/bin/sh |
| 2 | # The linker for x86_64-win7-windows-gnu: llvm-mingw's clang, which links compiler-rt | 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 | set -eu | 5 | set -eu |
| 6 | here=$(cd "$(dirname "$0")" && pwd) | ||
| 7 | clang=$LLVM_MINGW/bin/x86_64-w64-mingw32-clang | ||
| 5 | for arg do | 8 | for arg do |
| 6 | shift | 9 | shift |
| 7 | case $arg in | 10 | case $arg in |
| ... | @@ -10,4 +13,7 @@ for arg do | ... | @@ -10,4 +13,7 @@ for arg do |
| 10 | *) set -- "$@" "$arg" ;; | 13 | *) set -- "$@" "$arg" ;; |
| 11 | esac | 14 | esac |
| 12 | done | 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,7 +18,7 @@ development builds, which never update themselves. |
| 18 | ## The published folder | 18 | ## The published folder |
| 19 | 19 | ||
| 20 | ```text | 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 | 2026-09-29.r10/ | 22 | 2026-09-29.r10/ |
| 23 | build.json version, commit, and per platform: file, size, sha256, signature | 23 | build.json version, commit, and per platform: file, size, sha256, signature |
| 24 | build.json.sig ed25519 signature of build.json, hex | 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,6 +26,8 @@ latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": |
| 26 | Snowbound-2026-09-29-r10-macos-10.6.zip | 26 | Snowbound-2026-09-29-r10-macos-10.6.zip |
| 27 | snowbound-2026-09-29-r10-linux-x86_64 the executable itself | 27 | snowbound-2026-09-29-r10-linux-x86_64 the executable itself |
| 28 | snowbound-2026-09-29-r10-linux-aarch64 | 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 | A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into | 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,7 +82,7 @@ quarantine and open directly. |
| 80 | ## Publishing | 82 | ## Publishing |
| 81 | 83 | ||
| 82 | ```sh | 84 | ```sh |
| 83 | python3 tools/release.py # all four platforms | 85 | python3 tools/release.py # all six platforms |
| 84 | python3 tools/release.py --ad-hoc # the macOS app without Developer ID | 86 | python3 tools/release.py --ad-hoc # the macOS app without Developer ID |
| 85 | python3 tools/release.py --dry-run # the same into a new temporary folder | 87 | python3 tools/release.py --dry-run # the same into a new temporary folder |
| 86 | python3 tools/release.py --platforms macos-aarch64 linux-x86_64 | 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,14 +91,17 @@ python3 tools/release.py --platforms macos-aarch64 linux-x86_64 |
| 89 | The script refuses to run unless the working copy matches `main` (a dry run | 91 | The script refuses to run unless the working copy matches `main` (a dry run |
| 90 | only warns), runs Clippy (workspace, and `snowbound` without default features) | 92 | only warns), runs Clippy (workspace, and `snowbound` without default features) |
| 91 | and the workspace tests, builds each platform with | 93 | and the workspace tests, builds each platform with |
| 92 | `tools/canvas/build_macos.py` (`--snow-leopard` for 10.6) and | 94 | `tools/canvas/build_macos.py` (`--snow-leopard` for 10.6), |
| 93 | `crates/snowbound/linux/package.sh` (taking only its executables), then checks | 95 | `crates/snowbound/linux/package.sh` (taking only its executables) and |
| 96 | `platform/windows/cargo.sh`, then checks | ||
| 94 | the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and | 97 | the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and |
| 95 | publishes as above. Run again for the same commit, it only brings | 98 | publishes as above. Run again for the same commit, it only brings |
| 96 | `latest.json` up to date; a different commit that derives the same version is | 99 | `latest.json` up to date; a different commit that derives the same version is |
| 97 | refused. The 10.6 build needs the SDK and nightly toolchain | 100 | refused. The 10.6 build needs the SDK and nightly toolchain |
| 98 | `platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the | 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 | ## In the app | 106 | ## In the app |
| 102 | 107 | ||
| ... | @@ -108,11 +113,14 @@ palette elsewhere) checks at once and reports what it found. A check reads | ... | @@ -108,11 +113,14 @@ palette elsewhere) checks at once and reports what it found. A check reads |
| 108 | downloads the archive for this platform, verifying size, SHA-256 and | 113 | downloads the archive for this platform, verifying size, SHA-256 and |
| 109 | signature. It stages the update beside the install, so the swap is a rename: | 114 | signature. It stages the update beside the install, so the swap is a rename: |
| 110 | the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the | 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 | Build Folder and Restart to Update. | 118 | Build Folder and Restart to Update. |
| 113 | 119 | ||
| 114 | Restart to Update quits the app and starts the old executable with | 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 | 121 | `--finish-update`, which waits for the app to exit, renames the old bundle (or |
| 116 | executable) over the old one, and opens it. Where the install can't be | 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 | written, and on Mac OS X 10.6, the popup and the check name the build and | 125 | written, and on Mac OS X 10.6, the popup and the check name the build and |
| 118 | open its folder to download instead. | 126 | open its folder to download instead. |
tools/release.py+18-1| ... | @@ -17,7 +17,10 @@ PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') | ... | @@ -17,7 +17,10 @@ PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') |
| 17 | URL = 'https://file.paperclover.net/shr/snowbound/' | 17 | URL = 'https://file.paperclover.net/shr/snowbound/' |
| 18 | KEY = Path.home() / '.config/snowbound/release-key' | 18 | KEY = Path.home() / '.config/snowbound/release-key' |
| 19 | ZONE = ZoneInfo('America/Los_Angeles') | 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 | CHECKS = [ | 24 | CHECKS = [ |
| 22 | ['cargo', 'clippy', '--workspace', '--all-targets', '--all-features', '--', '-D', 'warnings'], | 25 | ['cargo', 'clippy', '--workspace', '--all-targets', '--all-features', '--', '-D', 'warnings'], |
| 23 | ['cargo', 'clippy', '-p', 'snowbound', '--no-default-features', '--', '-D', 'warnings'], | 26 | ['cargo', 'clippy', '-p', 'snowbound', '--no-default-features', '--', '-D', 'warnings'], |
| ... | @@ -128,6 +131,17 @@ def build_linux(architectures): | ... | @@ -128,6 +131,17 @@ def build_linux(architectures): |
| 128 | return {f'linux-{arch}': ROOT / f'target/{arch}-unknown-linux-gnu/release/snowbound' for arch in architectures} | 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 | def main(): | 145 | def main(): |
| 132 | parser = argparse.ArgumentParser(description=__doc__) | 146 | parser = argparse.ArgumentParser(description=__doc__) |
| 133 | parser.add_argument('--dry-run', action='store_true', | 147 | parser.add_argument('--dry-run', action='store_true', |
| ... | @@ -178,6 +192,9 @@ def main(): | ... | @@ -178,6 +192,9 @@ def main(): |
| 178 | linux = [platform.removeprefix('linux-') for platform in args.platforms if platform.startswith('linux')] | 192 | linux = [platform.removeprefix('linux-') for platform in args.platforms if platform.startswith('linux')] |
| 179 | if linux: | 193 | if linux: |
| 180 | built |= build_linux(linux) | 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 | clean(args.dry_run, 'after the build') | 198 | clean(args.dry_run, 'after the build') |
| 182 | files = {} | 199 | files = {} |
| 183 | for platform, source in built.items(): | 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,6 +96,37 @@ The control agent starts minimized after login. Its taskbar button remains |
| 96 | available for diagnosis without covering OneNote or changing screenshot-based | 96 | available for diagnosis without covering OneNote or changing screenshot-based |
| 97 | test coordinates. | 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 | ## Abrupt-stop recovery tests | 130 | ## Abrupt-stop recovery tests |
| 100 | 131 | ||
| 101 | `python3 tools/w7/crash.py windows NAME` and `python3 tools/w7/crash.py linux NAME` | 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,6 +178,8 @@ a physical or network-reachable Windows machine. |
| 147 | 178 | ||
| 148 | ## Gotchas | 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 | - Keep Windows display scaling at 100%. Anything else and the screenshot | 183 | - Keep Windows display scaling at 100%. Anything else and the screenshot |
| 151 | coordinates no longer match where clicks land. | 184 | coordinates no longer match where clicks land. |
| 152 | - The listener must run in the interactive logged-in session — as a scheduled | 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,6 +13,7 @@ import urllib.error |
| 13 | import urllib.request | 13 | import urllib.request |
| 14 | 14 | ||
| 15 | from env import ROOT, setting | 15 | from env import ROOT, setting |
| 16 | from vm import BASES | ||
| 16 | 17 | ||
| 17 | DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "local") | 18 | DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "local") |
| 18 | TARGETS_PATH = Path(setting("WIN7_TARGETS_FILE") or Path(setting("ONE_VM_HOME") or ROOT / "lab-unset") / "targets.json").expanduser() | 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,6 +26,8 @@ class Win7Error(Exception): |
| 25 | 26 | ||
| 26 | def resolve_target(name): | 27 | def resolve_target(name): |
| 27 | targets = {"local": {"base": "http://127.0.0.1:18777"}} | 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 | if TARGETS_PATH.exists(): | 31 | if TARGETS_PATH.exists(): |
| 29 | try: | 32 | try: |
| 30 | configured = json.loads(TARGETS_PATH.read_text()) | 33 | configured = json.loads(TARGETS_PATH.read_text()) |
| ... | @@ -288,15 +291,17 @@ TOOLS += [ | ... | @@ -288,15 +291,17 @@ TOOLS += [ |
| 288 | { | 291 | { |
| 289 | "name": "win7_vm_up", | 292 | "name": "win7_vm_up", |
| 290 | "description": ( | 293 | "description": ( |
| 291 | "Create a named Windows 7 clone when absent, then boot it. Creation settings " | 294 | "Create a named Windows clone when absent, then boot it. base picks Windows 7 " |
| 292 | "are ignored for an existing clone. Set wait to return only when its " | 295 | "(x64, OneNote 2010), 10 (x64, emulated and slow) or 11 (arm64, native speed). " |
| 293 | "authenticated desktop agent reports the expected hostname." | 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 | "inputSchema": { | 299 | "inputSchema": { |
| 296 | "type": "object", | 300 | "type": "object", |
| 297 | "properties": { | 301 | "properties": { |
| 298 | "name": {"type": "string", | 302 | "name": {"type": "string", |
| 299 | "description": "Unique 1-11 character lowercase VM name."}, | 303 | "description": "Unique 1-11 character lowercase VM name."}, |
| 304 | "base": {"type": "string", "enum": sorted(BASES), "default": "win7"}, | ||
| 300 | "hostname": {"type": "string", "description": "Optional Windows hostname."}, | 305 | "hostname": {"type": "string", "description": "Optional Windows hostname."}, |
| 301 | "cpus": {"type": "integer", "default": 2, "minimum": 1, "maximum": 16}, | 306 | "cpus": {"type": "integer", "default": 2, "minimum": 1, "maximum": 16}, |
| 302 | "memory_mb": {"type": "integer", "default": 4096, | 307 | "memory_mb": {"type": "integer", "default": 4096, |
| ... | @@ -375,7 +380,7 @@ def vm_tool(name, args): | ... | @@ -375,7 +380,7 @@ def vm_tool(name, args): |
| 375 | argv = ["status"] + ([args["name"]] if args.get("name") else []) | 380 | argv = ["status"] + ([args["name"]] if args.get("name") else []) |
| 376 | elif verb == "up": | 381 | elif verb == "up": |
| 377 | argv = ["up", args["name"]] | 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 | ("memory_mb", "--memory"), ("port", "--port")): | 384 | ("memory_mb", "--memory"), ("port", "--port")): |
| 380 | if args.get(key) is not None: | 385 | if args.get(key) is not None: |
| 381 | argv += [option, str(args[key])] | 386 | argv += [option, str(args[key])] |
tools/w7/payload/bootstrap.cmd+22-22| ... | @@ -1,22 +1,22 @@ | ... | @@ -1,22 +1,22 @@ |
| 1 | @echo off | 1 | @echo off |
| 2 | setlocal EnableExtensions | 2 | setlocal EnableExtensions |
| 3 | set "ONEVM_HOSTNAME=" | 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 ( | 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" | 5 | if /i "%%A"=="HOSTNAME" set "ONEVM_HOSTNAME=%%B" |
| 6 | if /i "%%A"=="TOKEN" set "WIN7_TOKEN=%%B" | 6 | if /i "%%A"=="TOKEN" set "WIN7_TOKEN=%%B" |
| 7 | ) | 7 | ) |
| 8 | if not defined ONEVM_HOSTNAME goto agent | 8 | if not defined ONEVM_HOSTNAME goto agent |
| 9 | if /i "%COMPUTERNAME%"=="%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. | 10 | rem Windows 11 ships without wmic; Windows 7 PowerShell lacks Rename-Computer. |
| 11 | where wmic >nul 2>&1 || goto rename_powershell | 11 | where wmic >nul 2>&1 || goto rename_powershell |
| 12 | wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1 | 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 | 13 | find "ReturnValue = 0;" "%~dp0bootstrap.log" >nul || exit /b 1 |
| 14 | goto restart | 14 | goto restart |
| 15 | :rename_powershell | 15 | :rename_powershell |
| 16 | powershell -NoProfile -Command "Rename-Computer -NewName '%ONEVM_HOSTNAME%' -Force -ErrorAction Stop" >"%~dp0bootstrap.log" 2>&1 || exit /b 1 | 16 | powershell -NoProfile -Command "Rename-Computer -NewName '%ONEVM_HOSTNAME%' -Force -ErrorAction Stop" >"%~dp0bootstrap.log" 2>&1 || exit /b 1 |
| 17 | :restart | 17 | :restart |
| 18 | shutdown /r /t 0 | 18 | shutdown /r /t 0 |
| 19 | exit /b | 19 | exit /b |
| 20 | 20 | ||
| 21 | :agent | 21 | :agent |
| 22 | call "%~dp0run.bat" | 22 | call "%~dp0run.bat" |
tools/w7/test_vm.py+20-10| ... | @@ -12,8 +12,7 @@ import vm | ... | @@ -12,8 +12,7 @@ import vm |
| 12 | class VmLifecycleTest(unittest.TestCase): | 12 | class VmLifecycleTest(unittest.TestCase): |
| 13 | def setUp(self): | 13 | def setUp(self): |
| 14 | self.original = {name: getattr(vm, name) for name in ( | 14 | self.original = {name: getattr(vm, name) for name in ( |
| 15 | "VM_HOME", "IMAGES", "MEDIA", "INSTANCES", "RUN", "DISK", | 15 | "VM_HOME", "IMAGES", "MEDIA", "INSTANCES", "RUN", "AGENT_ISO", "TARGETS", |
| 16 | "BASE_DISK", "BASE_MANIFEST", "AGENT_ISO", "TARGETS", | ||
| 17 | )} | 16 | )} |
| 18 | self.temporary = tempfile.TemporaryDirectory(prefix="one-vm-test-") | 17 | self.temporary = tempfile.TemporaryDirectory(prefix="one-vm-test-") |
| 19 | home = Path(self.temporary.name) | 18 | home = Path(self.temporary.name) |
| ... | @@ -22,17 +21,16 @@ class VmLifecycleTest(unittest.TestCase): | ... | @@ -22,17 +21,16 @@ class VmLifecycleTest(unittest.TestCase): |
| 22 | vm.MEDIA = home / "media" | 21 | vm.MEDIA = home / "media" |
| 23 | vm.INSTANCES = home / "instances" | 22 | vm.INSTANCES = home / "instances" |
| 24 | vm.RUN = home / "run" | 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 | vm.AGENT_ISO = vm.MEDIA / "win7-agent.iso" | 24 | vm.AGENT_ISO = vm.MEDIA / "win7-agent.iso" |
| 29 | vm.TARGETS = home / "targets.json" | 25 | vm.TARGETS = home / "targets.json" |
| 30 | vm.IMAGES.mkdir(parents=True) | 26 | vm.IMAGES.mkdir(parents=True) |
| 31 | subprocess.run([ | 27 | for base in ("win7", "win11"): |
| 32 | vm.qemu("qemu-img"), "create", "-q", "-f", "qcow2", str(vm.DISK), "64M" | 28 | subprocess.run([ |
| 33 | ], check=True) | 29 | vm.qemu("qemu-img"), "create", "-q", "-f", "qcow2", |
| 34 | with contextlib.redirect_stdout(io.StringIO()): | 30 | str(vm.build_disk(base)), "64M", |
| 35 | vm.seal() | 31 | ], check=True) |
| 32 | with contextlib.redirect_stdout(io.StringIO()): | ||
| 33 | vm.seal(base) | ||
| 36 | 34 | ||
| 37 | def tearDown(self): | 35 | def tearDown(self): |
| 38 | self.temporary.cleanup() | 36 | self.temporary.cleanup() |
| ... | @@ -66,6 +64,18 @@ class VmLifecycleTest(unittest.TestCase): | ... | @@ -66,6 +64,18 @@ class VmLifecycleTest(unittest.TestCase): |
| 66 | def test_reserved_target_name_is_rejected(self): | 64 | def test_reserved_target_name_is_rejected(self): |
| 67 | with self.assertRaisesRegex(SystemExit, "reserved"): | 65 | with self.assertRaisesRegex(SystemExit, "reserved"): |
| 68 | vm.create_instance("local") | 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 | if __name__ == "__main__": | 81 | if __name__ == "__main__": |
tools/w7/unattend/lab-setup.cmd+28-24| ... | @@ -1,24 +1,28 @@ | ... | @@ -1,24 +1,28 @@ |
| 1 | @echo off | 1 | @echo off |
| 2 | rem First logon of a Windows 10/11 lab build: install the agent and quiet the desktop. | 2 | rem First logon of a Windows 10/11 lab build: install the agent and quiet the desktop. |
| 3 | set "AGENT=" | 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:" | 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 | 5 | if not defined AGENT exit /b 1 |
| 6 | xcopy /e /i /y /q "%AGENT%\" C:\win7-agent\ || exit /b 1 | 6 | if exist "%~dp0netkvm\netkvm.inf" pnputil /add-driver "%~dp0netkvm\netkvm.inf" /install |
| 7 | attrib -r /s /d "C:\win7-agent\*" | 7 | xcopy /e /i /y /q "%AGENT%\" C:\win7-agent\ || exit /b 1 |
| 8 | netsh advfirewall firewall add rule name="win7-agent" dir=in action=allow protocol=TCP localport=8777 | 8 | attrib -r /s /d "C:\win7-agent\*" |
| 9 | net accounts /maxpwage:unlimited | 9 | netsh advfirewall firewall add rule name="win7-agent" dir=in action=allow protocol=TCP localport=8777 |
| 10 | set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon | 10 | net accounts /maxpwage:unlimited |
| 11 | reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d 1 /f | 11 | set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon |
| 12 | reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d one /f | 12 | reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d 1 /f |
| 13 | reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d one /f | 13 | reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d one /f |
| 14 | reg delete "%WINLOGON%" /v AutoLogonCount /f | 14 | reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d one /f |
| 15 | powercfg /change monitor-timeout-ac 0 | 15 | reg delete "%WINLOGON%" /v AutoLogonCount /f |
| 16 | powercfg /change standby-timeout-ac 0 | 16 | powercfg /change monitor-timeout-ac 0 |
| 17 | powercfg /hibernate off | 17 | powercfg /change standby-timeout-ac 0 |
| 18 | reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f | 18 | powercfg /hibernate off |
| 19 | reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoUpdate /t REG_DWORD /d 1 /f | 19 | reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f |
| 20 | reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f | 20 | reg add "HKLM\SYSTEM\CurrentControlSet\Control\Network\NewNetworkWindowOff" /f |
| 21 | reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v EnableTransparency /t REG_DWORD /d 1 /f | 21 | reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoUpdate /t REG_DWORD /d 1 /f |
| 22 | reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f | 22 | reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f |
| 23 | call C:\win7-agent\install-autostart.cmd | 23 | reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v EnableTransparency /t REG_DWORD /d 1 /f |
| 24 | shutdown /r /t 5 | 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,6 +10,7 @@ import re |
| 10 | import secrets | 10 | import secrets |
| 11 | import shutil | 11 | import shutil |
| 12 | import socket | 12 | import socket |
| 13 | import struct | ||
| 13 | import subprocess | 14 | import subprocess |
| 14 | import tempfile | 15 | import tempfile |
| 15 | import time | 16 | import time |
| ... | @@ -30,12 +31,18 @@ IMAGES = VM_HOME / "images" | ... | @@ -30,12 +31,18 @@ IMAGES = VM_HOME / "images" |
| 30 | MEDIA = VM_HOME / "media" | 31 | MEDIA = VM_HOME / "media" |
| 31 | INSTANCES = VM_HOME / "instances" | 32 | INSTANCES = VM_HOME / "instances" |
| 32 | RUN = VM_HOME / "run" | 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 | AGENT_ISO = MEDIA / "win7-agent.iso" | 34 | AGENT_ISO = MEDIA / "win7-agent.iso" |
| 37 | TARGETS = VM_HOME / "targets.json" | 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 | BUILD = "win7-build" | 43 | BUILD = "win7-build" |
| 44 | UNATTEND = Path(__file__).with_name("unattend") | ||
| 45 | FIRMWARE = Path("/opt/homebrew/share/qemu") | ||
| 39 | NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,9}[a-z0-9])?") | 46 | NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,9}[a-z0-9])?") |
| 40 | HOSTNAME = re.compile(r"[A-Z0-9](?:[A-Z0-9-]{0,13}[A-Z0-9])?") | 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,6 +63,18 @@ def require_vm_home(): |
| 56 | raise SystemExit("VM volume is not mounted: %s" % volume) | 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 | def runtime(name): | 78 | def runtime(name): |
| 60 | root = RUN / name | 79 | root = RUN / name |
| 61 | return root, root / "qmp.sock", root / "qemu.pid", root / "qemu.log", root / "screen.png" | 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,7 +110,72 @@ def qmp(name, command, arguments=None): |
| 91 | raise SystemExit(response["error"]["desc"]) | 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 | require_vm_home() | 179 | require_vm_home() |
| 96 | if running(name): | 180 | if running(name): |
| 97 | raise SystemExit("Windows is already running: %s" % name) | 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,30 +183,26 @@ def launch(name, disk, port, mac, cpus, memory_mb, display, drives): |
| 99 | root.mkdir(parents=True, exist_ok=True) | 183 | root.mkdir(parents=True, exist_ok=True) |
| 100 | qmp_socket.unlink(missing_ok=True) | 184 | qmp_socket.unlink(missing_ok=True) |
| 101 | lab_socket = ensure_hub(VM_HOME) | 185 | lab_socket = ensure_hub(VM_HOME) |
| 102 | command = [ | 186 | command, nic = machine(base, disk) |
| 103 | qemu("qemu-system-x86_64"), | 187 | command += [ |
| 188 | # crash.py matches this name to confirm a process belongs to the clone. | ||
| 104 | "-name", "OneNote Windows 7 " + name, | 189 | "-name", "OneNote Windows 7 " + name, |
| 105 | "-machine", "pc", | ||
| 106 | "-accel", "tcg,thread=multi", | ||
| 107 | "-cpu", "qemu64", | ||
| 108 | "-smp", str(cpus), | 190 | "-smp", str(cpus), |
| 109 | "-m", str(memory_mb), | 191 | "-m", str(memory_mb), |
| 110 | "-drive", "file=%s,if=ide,format=qcow2,cache=writeback" % disk, | ||
| 111 | "-vga", "std", | ||
| 112 | "-display", display, | 192 | "-display", display, |
| 113 | "-usb", | ||
| 114 | "-device", "usb-tablet", | ||
| 115 | "-netdev", "user,id=control,hostfwd=tcp:127.0.0.1:%d-:8777" % port, | 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 | "-netdev", "vde,id=lab,sock=%s" % lab_socket, | 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 | "-uuid", uuid_for(name), | 197 | "-uuid", uuid_for(name), |
| 120 | "-rtc", "base=localtime,clock=host,driftfix=slew", | 198 | "-rtc", "base=localtime,clock=host,driftfix=slew", |
| 121 | "-qmp", "unix:%s,server=on,wait=off" % qmp_socket, | 199 | "-qmp", "unix:%s,server=on,wait=off" % qmp_socket, |
| 122 | "-pidfile", str(pid), | 200 | "-pidfile", str(pid), |
| 123 | ] | 201 | ] + cdroms(base, images) |
| 124 | for drive in drives: | 202 | if answers: |
| 125 | command += ["-drive", drive] | 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 | with log_path.open("ab") as log: | 206 | with log_path.open("ab") as log: |
| 127 | subprocess.Popen( | 207 | subprocess.Popen( |
| 128 | command, | 208 | command, |
| ... | @@ -139,23 +219,45 @@ def launch(name, disk, port, mac, cpus, memory_mb, display, drives): | ... | @@ -139,23 +219,45 @@ def launch(name, disk, port, mac, cpus, memory_mb, display, drives): |
| 139 | raise SystemExit("Windows did not open. Check: %s" % log_path) | 219 | raise SystemExit("Windows did not open. Check: %s" % log_path) |
| 140 | 220 | ||
| 141 | 221 | ||
| 142 | def start_build(install): | 222 | def stage_answers(base): |
| 143 | if install and not ISO.is_file(): | 223 | """Copy unattend/ into the build's runtime directory with this base filled in.""" |
| 144 | raise SystemExit("Windows ISO not found: %s" % ISO) | 224 | root = runtime(base + "-build")[0] / "answers" |
| 145 | if not DISK.exists(): | 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 | if not install: | 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 | IMAGES.mkdir(parents=True, exist_ok=True) | 243 | IMAGES.mkdir(parents=True, exist_ok=True) |
| 149 | subprocess.run( | 244 | subprocess.run( |
| 150 | [qemu("qemu-img"), "create", "-f", "qcow2", str(DISK), "64G"], | 245 | [qemu("qemu-img"), "create", "-f", "qcow2", str(disk), "64G"], |
| 151 | check=True, | 246 | check=True, |
| 152 | ) | 247 | ) |
| 153 | drives = [] | 248 | images, answers = [], None |
| 154 | if install: | 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 | elif AGENT_ISO.exists(): | 255 | elif AGENT_ISO.exists(): |
| 157 | drives.append("file=%s,file.locking=off,if=ide,media=cdrom,readonly=on" % AGENT_ISO) | 256 | images.append(AGENT_ISO) |
| 158 | launch(BUILD, DISK, 18777, "52:54:00:10:77:01", 4, 4096, "cocoa", drives) | 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 | def instance_path(name): | 263 | def instance_path(name): |
| ... | @@ -197,7 +299,7 @@ def configs(): | ... | @@ -197,7 +299,7 @@ def configs(): |
| 197 | 299 | ||
| 198 | 300 | ||
| 199 | def available_port(requested=None): | 301 | def available_port(requested=None): |
| 200 | used = {18777} | 302 | used = {port for _stem, port in BASES.values()} |
| 201 | used.update(config["port"] for _name, config in configs()) | 303 | used.update(config["port"] for _name, config in configs()) |
| 202 | candidates = [requested] if requested else range(18778, 18878) | 304 | candidates = [requested] if requested else range(18778, 18878) |
| 203 | for port in candidates: | 305 | for port in candidates: |
| ... | @@ -271,10 +373,10 @@ def update_target(name, port=None, token=None): | ... | @@ -271,10 +373,10 @@ def update_target(name, port=None, token=None): |
| 271 | TARGETS.chmod(0o600) | 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 | require_vm_home() | 377 | require_vm_home() |
| 276 | validate_name(name) | 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 | raise SystemExit("VM name is reserved: %s" % name) | 380 | raise SystemExit("VM name is reserved: %s" % name) |
| 279 | hostname = (hostname or ("ONE-" + name)).upper() | 381 | hostname = (hostname or ("ONE-" + name)).upper() |
| 280 | if not HOSTNAME.fullmatch(hostname): | 382 | if not HOSTNAME.fullmatch(hostname): |
| ... | @@ -283,8 +385,9 @@ def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None): | ... | @@ -283,8 +385,9 @@ def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None): |
| 283 | raise SystemExit("CPU count must be between 1 and 16") | 385 | raise SystemExit("CPU count must be between 1 and 16") |
| 284 | if not 1024 <= memory_mb <= 65536: | 386 | if not 1024 <= memory_mb <= 65536: |
| 285 | raise SystemExit("Memory must be between 1024 and 65536 MiB") | 387 | raise SystemExit("Memory must be between 1024 and 65536 MiB") |
| 286 | if not BASE_DISK.is_file() or not BASE_MANIFEST.is_file(): | 388 | if not base_disk(base).is_file() or not base_manifest(base).is_file(): |
| 287 | raise SystemExit("No sealed base image. Finish the build, then run: ./vm.py seal") | 389 | raise SystemExit("No sealed %s base image. Finish the build, then run: " |
| 390 | "./vm.py seal --base %s" % (base, base)) | ||
| 288 | VM_HOME.mkdir(parents=True, exist_ok=True) | 391 | VM_HOME.mkdir(parents=True, exist_ok=True) |
| 289 | with (VM_HOME / ".lock").open("a") as lock: | 392 | with (VM_HOME / ".lock").open("a") as lock: |
| 290 | fcntl.flock(lock, fcntl.LOCK_EX) | 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,13 +406,13 @@ def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None): |
| 303 | try: | 406 | try: |
| 304 | subprocess.run([ | 407 | subprocess.run([ |
| 305 | qemu("qemu-img"), "create", "-f", "qcow2", "-F", "qcow2", | 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 | ], check=True) | 410 | ], check=True) |
| 308 | make_instance_iso(instance_iso, hostname, token) | 411 | make_instance_iso(instance_iso, hostname, token) |
| 309 | instance_iso.chmod(0o600) | 412 | instance_iso.chmod(0o600) |
| 310 | temporary.replace(overlay) | 413 | temporary.replace(overlay) |
| 311 | config = {"cpus": cpus, "hostname": hostname, "memory_mb": memory_mb, | 414 | config = {"base": base, "cpus": cpus, "hostname": hostname, |
| 312 | "port": port, "token": token} | 415 | "memory_mb": memory_mb, "port": port, "token": token} |
| 313 | atomic_json(instance_path(name), config) | 416 | atomic_json(instance_path(name), config) |
| 314 | instance_path(name).chmod(0o600) | 417 | instance_path(name).chmod(0o600) |
| 315 | update_target(name, port, token) | 418 | update_target(name, port, token) |
| ... | @@ -319,8 +422,8 @@ def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None): | ... | @@ -319,8 +422,8 @@ def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None): |
| 319 | overlay.unlink(missing_ok=True) | 422 | overlay.unlink(missing_ok=True) |
| 320 | instance_path(name).unlink(missing_ok=True) | 423 | instance_path(name).unlink(missing_ok=True) |
| 321 | raise | 424 | raise |
| 322 | print(json.dumps({"cpus": cpus, "hostname": hostname, "memory_mb": memory_mb, | 425 | print(json.dumps({"base": base, "cpus": cpus, "hostname": hostname, |
| 323 | "name": name, "port": port}, sort_keys=True)) | 426 | "memory_mb": memory_mb, "name": name, "port": port}, sort_keys=True)) |
| 324 | 427 | ||
| 325 | 428 | ||
| 326 | def start_instance(name, display=False): | 429 | def start_instance(name, display=False): |
| ... | @@ -329,12 +432,9 @@ def start_instance(name, display=False): | ... | @@ -329,12 +432,9 @@ def start_instance(name, display=False): |
| 329 | instance_iso = MEDIA / "instances" / (name + ".iso") | 432 | instance_iso = MEDIA / "instances" / (name + ".iso") |
| 330 | if not overlay.is_file() or not instance_iso.is_file(): | 433 | if not overlay.is_file() or not instance_iso.is_file(): |
| 331 | raise SystemExit("VM artifacts are incomplete: %s" % name) | 434 | raise SystemExit("VM artifacts are incomplete: %s" % name) |
| 332 | drives = [] | 435 | images = [image for image in (AGENT_ISO, instance_iso) if image.is_file()] |
| 333 | for drive in (AGENT_ISO, instance_iso): | 436 | launch(name, config.get("base", "win7"), overlay, config["port"], mac_for(config["port"]), |
| 334 | if drive.is_file(): | 437 | config["cpus"], config["memory_mb"], "cocoa" if display else "none", images) |
| 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) | ||
| 338 | 438 | ||
| 339 | 439 | ||
| 340 | def wait_instance(name, timeout): | 440 | def wait_instance(name, timeout): |
| ... | @@ -391,6 +491,7 @@ def delete_instance(name): | ... | @@ -391,6 +491,7 @@ def delete_instance(name): |
| 391 | if running(name): | 491 | if running(name): |
| 392 | raise SystemExit("Shut down Windows before deleting: %s" % name) | 492 | raise SystemExit("Shut down Windows before deleting: %s" % name) |
| 393 | (IMAGES / "instances" / (name + ".qcow2")).unlink(missing_ok=True) | 493 | (IMAGES / "instances" / (name + ".qcow2")).unlink(missing_ok=True) |
| 494 | (IMAGES / "instances" / (name + ".vars.fd")).unlink(missing_ok=True) | ||
| 394 | (MEDIA / "instances" / (name + ".iso")).unlink(missing_ok=True) | 495 | (MEDIA / "instances" / (name + ".iso")).unlink(missing_ok=True) |
| 395 | instance_path(name).unlink() | 496 | instance_path(name).unlink() |
| 396 | shutil.rmtree(runtime(name)[0], ignore_errors=True) | 497 | shutil.rmtree(runtime(name)[0], ignore_errors=True) |
| ... | @@ -398,37 +499,39 @@ def delete_instance(name): | ... | @@ -398,37 +499,39 @@ def delete_instance(name): |
| 398 | print("Deleted VM: %s" % name) | 499 | print("Deleted VM: %s" % name) |
| 399 | 500 | ||
| 400 | 501 | ||
| 401 | def seal(): | 502 | def seal(base="win7"): |
| 402 | require_vm_home() | 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 | raise SystemExit("Shut down Windows before sealing the base image") | 506 | raise SystemExit("Shut down Windows before sealing the base image") |
| 405 | if not DISK.exists(): | 507 | if not disk.exists(): |
| 406 | raise SystemExit("No Windows build disk found") | 508 | raise SystemExit("No Windows build disk found") |
| 407 | temporary = BASE_DISK.with_suffix(".tmp.qcow2") | 509 | temporary = sealed.with_suffix(".tmp.qcow2") |
| 408 | if BASE_DISK.exists() or temporary.exists(): | 510 | if sealed.exists() or temporary.exists(): |
| 409 | raise SystemExit("Move the existing base image before sealing another") | 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 | subprocess.run([qemu("qemu-img"), "convert", "-p", "-O", "qcow2", | 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 | subprocess.run([qemu("qemu-img"), "check", str(temporary)], check=True) | 515 | subprocess.run([qemu("qemu-img"), "check", str(temporary)], check=True) |
| 414 | temporary.replace(BASE_DISK) | 516 | temporary.replace(sealed) |
| 415 | digest = hashlib.sha256() | 517 | digest = hashlib.sha256() |
| 416 | with BASE_DISK.open("rb") as image: | 518 | with sealed.open("rb") as image: |
| 417 | while chunk := image.read(8 * 1024 * 1024): | 519 | while chunk := image.read(8 * 1024 * 1024): |
| 418 | digest.update(chunk) | 520 | digest.update(chunk) |
| 419 | info = json.loads(subprocess.check_output([ | 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"], | 524 | atomic_json(manifest, {"file": sealed.name, "format": info["format"], |
| 423 | "sha256": digest.hexdigest(), | 525 | "sha256": digest.hexdigest(), |
| 424 | "virtual_size": info["virtual-size"]}) | 526 | "virtual_size": info["virtual-size"]}) |
| 425 | BASE_DISK.chmod(0o444) | 527 | sealed.chmod(0o444) |
| 426 | print(BASE_MANIFEST) | 528 | print(manifest) |
| 427 | 529 | ||
| 428 | 530 | ||
| 429 | def fetch_base(manifest_url): | 531 | def fetch_base(manifest_url, base="win7"): |
| 430 | require_vm_home() | 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 | raise SystemExit("Move the existing base image before fetching another") | 535 | raise SystemExit("Move the existing base image before fetching another") |
| 433 | headers = {} | 536 | headers = {} |
| 434 | if os.environ.get("ONE_VM_AUTHORIZATION"): | 537 | if os.environ.get("ONE_VM_AUTHORIZATION"): |
| ... | @@ -440,7 +543,7 @@ def fetch_base(manifest_url): | ... | @@ -440,7 +543,7 @@ def fetch_base(manifest_url): |
| 440 | raise SystemExit("Base manifest has no valid SHA-256") | 543 | raise SystemExit("Base manifest has no valid SHA-256") |
| 441 | image_url = urllib.parse.urljoin(manifest_url, manifest.get("file", "")) | 544 | image_url = urllib.parse.urljoin(manifest_url, manifest.get("file", "")) |
| 442 | IMAGES.mkdir(parents=True, exist_ok=True) | 545 | IMAGES.mkdir(parents=True, exist_ok=True) |
| 443 | temporary = BASE_DISK.with_suffix(".download.qcow2") | 546 | temporary = sealed.with_suffix(".download.qcow2") |
| 444 | digest = hashlib.sha256() | 547 | digest = hashlib.sha256() |
| 445 | try: | 548 | try: |
| 446 | with urllib.request.urlopen(urllib.request.Request(image_url, headers=headers)) as response: | 549 | with urllib.request.urlopen(urllib.request.Request(image_url, headers=headers)) as response: |
| ... | @@ -452,13 +555,13 @@ def fetch_base(manifest_url): | ... | @@ -452,13 +555,13 @@ def fetch_base(manifest_url): |
| 452 | temporary.unlink(missing_ok=True) | 555 | temporary.unlink(missing_ok=True) |
| 453 | raise SystemExit("Downloaded base image failed SHA-256 verification") | 556 | raise SystemExit("Downloaded base image failed SHA-256 verification") |
| 454 | subprocess.run([qemu("qemu-img"), "check", str(temporary)], check=True) | 557 | subprocess.run([qemu("qemu-img"), "check", str(temporary)], check=True) |
| 455 | temporary.replace(BASE_DISK) | 558 | temporary.replace(sealed) |
| 456 | BASE_DISK.chmod(0o444) | 559 | sealed.chmod(0o444) |
| 457 | atomic_json(BASE_MANIFEST, dict(manifest, file=BASE_DISK.name)) | 560 | atomic_json(manifest_path, dict(manifest, file=sealed.name)) |
| 458 | except Exception: | 561 | except Exception: |
| 459 | temporary.unlink(missing_ok=True) | 562 | temporary.unlink(missing_ok=True) |
| 460 | raise | 563 | raise |
| 461 | print(BASE_MANIFEST) | 564 | print(manifest_path) |
| 462 | 565 | ||
| 463 | 566 | ||
| 464 | def list_instances(): | 567 | def list_instances(): |
| ... | @@ -468,25 +571,29 @@ def list_instances(): | ... | @@ -468,25 +571,29 @@ def list_instances(): |
| 468 | return | 571 | return |
| 469 | for name, config in rows: | 572 | for name, config in rows: |
| 470 | state = "running" if running(name) else "stopped" | 573 | state = "running" if running(name) else "stopped" |
| 471 | print("%-11s %-15s %-7s http://127.0.0.1:%d" % | 574 | print("%-11s %-5s %-15s %-7s http://127.0.0.1:%d" % |
| 472 | (name, config["hostname"], state, config["port"])) | 575 | (name, config.get("base", "win7"), config["hostname"], state, config["port"])) |
| 473 | 576 | ||
| 474 | 577 | ||
| 475 | def main(): | 578 | def main(): |
| 476 | parser = argparse.ArgumentParser(description="Run OneNote Windows 7 VMs") | 579 | parser = argparse.ArgumentParser(description="Run OneNote Windows lab VMs") |
| 477 | commands = parser.add_subparsers(dest="command", required=True) | 580 | commands = parser.add_subparsers(dest="command", required=True) |
| 478 | commands.add_parser("install") | 581 | base_option = {"choices": sorted(BASES), "default": "win7"} |
| 479 | commands.add_parser("run") | 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 | status = commands.add_parser("status") | 586 | status = commands.add_parser("status") |
| 481 | status.add_argument("name", nargs="?") | 587 | status.add_argument("name", nargs="?") |
| 482 | power = commands.add_parser("poweroff") | 588 | power = commands.add_parser("poweroff") |
| 483 | power.add_argument("name", nargs="?", default=BUILD) | 589 | power.add_argument("name", nargs="?", default=BUILD) |
| 484 | shot = commands.add_parser("screenshot") | 590 | shot = commands.add_parser("screenshot") |
| 485 | shot.add_argument("name", nargs="?", default=BUILD) | 591 | shot.add_argument("name", nargs="?", default=BUILD) |
| 486 | commands.add_parser("seal") | 592 | commands.add_parser("seal").add_argument("--base", **base_option) |
| 487 | commands.add_parser("media") | 593 | commands.add_parser("media") |
| 488 | up = commands.add_parser("up") | 594 | up = commands.add_parser("up") |
| 489 | up.add_argument("name") | 595 | up.add_argument("name") |
| 596 | up.add_argument("--base", **base_option) | ||
| 490 | up.add_argument("--hostname") | 597 | up.add_argument("--hostname") |
| 491 | up.add_argument("--cpus", type=int, default=2) | 598 | up.add_argument("--cpus", type=int, default=2) |
| 492 | up.add_argument("--memory", type=int, default=4096, dest="memory_mb") | 599 | up.add_argument("--memory", type=int, default=4096, dest="memory_mb") |
| ... | @@ -500,14 +607,13 @@ def main(): | ... | @@ -500,14 +607,13 @@ def main(): |
| 500 | down.add_argument("--preserve-machine", action="store_true") | 607 | down.add_argument("--preserve-machine", action="store_true") |
| 501 | fetch = commands.add_parser("fetch") | 608 | fetch = commands.add_parser("fetch") |
| 502 | fetch.add_argument("manifest_url") | 609 | fetch.add_argument("manifest_url") |
| 610 | fetch.add_argument("--base", **base_option) | ||
| 503 | args = parser.parse_args() | 611 | args = parser.parse_args() |
| 504 | if args.command == "install": | 612 | if args.command in ("install", "run"): |
| 505 | start_build(True) | 613 | start_build(args.base, args.command == "install", args.display) |
| 506 | elif args.command == "run": | ||
| 507 | start_build(False) | ||
| 508 | elif args.command == "status": | 614 | elif args.command == "status": |
| 509 | if args.name: | 615 | if args.name: |
| 510 | if args.name == BUILD: | 616 | if args.name in ("%s-build" % b for b in BASES): |
| 511 | print("running" if running(args.name) else "stopped") | 617 | print("running" if running(args.name) else "stopped") |
| 512 | elif not instance_path(args.name).exists(): | 618 | elif not instance_path(args.name).exists(): |
| 513 | print("absent") | 619 | print("absent") |
| ... | @@ -521,12 +627,13 @@ def main(): | ... | @@ -521,12 +627,13 @@ def main(): |
| 521 | elif args.command == "screenshot": | 627 | elif args.command == "screenshot": |
| 522 | screenshot(args.name) | 628 | screenshot(args.name) |
| 523 | elif args.command == "seal": | 629 | elif args.command == "seal": |
| 524 | seal() | 630 | seal(args.base) |
| 525 | elif args.command == "media": | 631 | elif args.command == "media": |
| 526 | build_agent_iso() | 632 | build_agent_iso() |
| 527 | elif args.command == "up": | 633 | elif args.command == "up": |
| 528 | if not instance_path(args.name).exists(): | 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 | if running(args.name): | 637 | if running(args.name): |
| 531 | print("Windows already running: %s" % args.name) | 638 | print("Windows already running: %s" % args.name) |
| 532 | else: | 639 | else: |
| ... | @@ -545,7 +652,7 @@ def main(): | ... | @@ -545,7 +652,7 @@ def main(): |
| 545 | else: | 652 | else: |
| 546 | delete_instance(args.name) | 653 | delete_instance(args.name) |
| 547 | else: | 654 | else: |
| 548 | fetch_base(args.manifest_url) | 655 | fetch_base(args.manifest_url, args.base) |
| 549 | 656 | ||
| 550 | 657 | ||
| 551 | if __name__ == "__main__": | 658 | if __name__ == "__main__": |
tools/w7/windows_media.py+30-5| ... | @@ -26,6 +26,7 @@ def tool(name): | ... | @@ -26,6 +26,7 @@ def tool(name): |
| 26 | return path | 26 | return path |
| 27 | 27 | ||
| 28 | 28 | ||
| 29 | # Volume-license media install without asking for a product key and stay unactivated. | ||
| 29 | def catalog_entry(base, work): | 30 | def catalog_entry(base, work): |
| 30 | url, arch = CATALOGS[base] | 31 | url, arch = CATALOGS[base] |
| 31 | cab = work / "products.cab" | 32 | cab = work / "products.cab" |
| ... | @@ -34,9 +35,9 @@ def catalog_entry(base, work): | ... | @@ -34,9 +35,9 @@ def catalog_entry(base, work): |
| 34 | for entry in ET.parse(work / "products.xml").getroot().iter("File"): | 35 | for entry in ET.parse(work / "products.xml").getroot().iter("File"): |
| 35 | field = lambda key: entry.findtext(key) or "" | 36 | field = lambda key: entry.findtext(key) or "" |
| 36 | if (field("LanguageCode") == "en-us" and field("Architecture") == arch | 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 | return field("FilePath"), field("Sha1").lower() | 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 | def download(url, sha1, path): | 43 | def download(url, sha1, path): |
| ... | @@ -61,12 +62,36 @@ def pro_index(esd): | ... | @@ -61,12 +62,36 @@ def pro_index(esd): |
| 61 | raise SystemExit("No Professional edition in %s" % esd) | 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 | def build(base): | 86 | def build(base): |
| 65 | media = Path(require("ONE_VM_HOME")).expanduser() / "media" | 87 | media = Path(require("ONE_VM_HOME")).expanduser() / "media" |
| 66 | iso = media / ("%s.iso" % base) | 88 | iso = media / ("%s.iso" % base) |
| 67 | if iso.exists(): | ||
| 68 | raise SystemExit("Move the existing ISO first: %s" % iso) | ||
| 69 | media.mkdir(parents=True, exist_ok=True) | 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 | wim = tool("wimlib-imagex") | 95 | wim = tool("wimlib-imagex") |
| 71 | with tempfile.TemporaryDirectory(prefix="one-media-", dir=media) as temporary: | 96 | with tempfile.TemporaryDirectory(prefix="one-media-", dir=media) as temporary: |
| 72 | work = Path(temporary) | 97 | work = Path(temporary) |
| ... | @@ -81,7 +106,6 @@ def build(base): | ... | @@ -81,7 +106,6 @@ def build(base): |
| 81 | "--compress=LZX"], check=True) | 106 | "--compress=LZX"], check=True) |
| 82 | subprocess.run([wim, "export", str(esd), "3", str(sources / "boot.wim"), | 107 | subprocess.run([wim, "export", str(esd), "3", str(sources / "boot.wim"), |
| 83 | "--boot"], check=True) | 108 | "--boot"], check=True) |
| 84 | # Solid LZMS keeps install.esd under the 4 GiB ISO 9660 file limit. | ||
| 85 | subprocess.run([wim, "export", str(esd), pro_index(esd), | 109 | subprocess.run([wim, "export", str(esd), pro_index(esd), |
| 86 | str(sources / "install.esd"), "--compress=LZMS", "--solid"], | 110 | str(sources / "install.esd"), "--compress=LZMS", "--solid"], |
| 87 | check=True) | 111 | check=True) |
| ... | @@ -89,6 +113,7 @@ def build(base): | ... | @@ -89,6 +113,7 @@ def build(base): |
| 89 | partial = work / "out.iso" | 113 | partial = work / "out.iso" |
| 90 | # The no-prompt loader boots unattended instead of waiting for a key press. | 114 | # The no-prompt loader boots unattended instead of waiting for a key press. |
| 91 | subprocess.run([ | 115 | subprocess.run([ |
| 116 | # Level 3 stores the 4+ GiB install.esd as multiple extents, which Setup reads. | ||
| 92 | tool("xorriso"), "-as", "mkisofs", "-quiet", "-iso-level", "3", "-J", | 117 | tool("xorriso"), "-as", "mkisofs", "-quiet", "-iso-level", "3", "-J", |
| 93 | "-joliet-long", "-V", base.upper(), | 118 | "-joliet-long", "-V", base.upper(), |
| 94 | "-e", "efi/microsoft/boot/efisys_noprompt.bin", "-no-emul-boot", | 119 | "-e", "efi/microsoft/boot/efisys_noprompt.bin", "-no-emul-boot", |