authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 09:54:04-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 12:10:03-07:00
logdf545058d6e66cccbce7f56c1814f5a922c8a6cb
tree5ced17aa0cc4a251379ec953f15c0528bcca3a65
parent142c3a3763f8e3648bbd92a9188e7773dad65b28
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat(windows): Snowbound on Windows 7 through 11 (#11)

- 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.5

58 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]
10951095
1096[[package]]
1097name = "file-guard"
1098version = "0.2.0"
1099source = "registry+https://github.com/rust-lang/crates.io-index"
1100checksum = "21ef72acf95ec3d7dbf61275be556299490a245f017cf084bd23b4f68cf9407c"
1101dependencies = [
1102 "libc",
1103 "winapi",
1104]
1105
1096[[package]]1106[[package]]
1097name = "find-msvc-tools"1107name = "find-msvc-tools"
1098version = "0.1.12"1108version = "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]
42674279
4280[[package]]
4281name = "winapi"
4282version = "0.3.9"
4283source = "registry+https://github.com/rust-lang/crates.io-index"
4284checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
4285dependencies = [
4286 "winapi-i686-pc-windows-gnu",
4287 "winapi-x86_64-pc-windows-gnu",
4288]
4289
4290[[package]]
4291name = "winapi-i686-pc-windows-gnu"
4292version = "0.4.0"
4293source = "registry+https://github.com/rust-lang/crates.io-index"
4294checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
4295
4268[[package]]4296[[package]]
4269name = "winapi-util"4297name = "winapi-util"
4270version = "0.1.11"4298version = "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]
42764304
4305[[package]]
4306name = "winapi-x86_64-pc-windows-gnu"
4307version = "0.4.0"
4308source = "registry+https://github.com/rust-lang/crates.io-index"
4309checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
4310
4277[[package]]4311[[package]]
4278name = "windows"4312name = "windows"
4279version = "0.62.2"4313version = "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
6drawn by Snowbound versus the operating system.6drawn by Snowbound versus the operating system.
77
8```text8```text
9 macOS Linux iOS9 macOS Linux Windows iOS
10shell snowbound + ui snowbound + ui UIKit (apps/ios, Swift)10shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios)
11glue snowbound/src/macos snowbound/src/linux crates/mobile (C ABI, staticlib)11glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI)
12page canvas ─────────────────────────────────────────────────────►12page canvas ─────────────────────────────────────────────────────────────────────►
13paint draw (wgpu: Metal) draw (Vulkan or GL) draw (Metal, CAMetalLayer)13paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12, or GL on 7) draw (Metal)
14data notebook::session + embedded SMB client ────────────────────►14data notebook::session + embedded SMB client ────────────────────────────────────►
15format onestore ───────────────────────────────────────────────────►15format onestore ───────────────────────────────────────────────────────────────────►
16```16```
1717
18## Desktop: `snowbound`18## Desktop: `snowbound`
1919
20The desktop app is a winit window with the `ui` kit's chrome and a `canvas`20The desktop app is a winit window with the `ui` kit's chrome and a `canvas`
21page inside it. `snowbound` picks a platform module at compile time21page inside it. `snowbound` picks a platform module at compile time
22(`macos.rs` or `linux.rs`, both mounted as `platform`), and everything else22(`macos.rs`, `linux.rs` or `windows.rs`, each mounted as `platform`), and
23in the crate is shared: library and settings, the sidebar, menus, page and23everything else in the crate is shared: library and settings, the sidebar,
24section management, templates, and screenshot and replay support.24menus, page and section management, templates, and screenshot and replay
25Accessibility goes through AccessKit's winit adapter on both: the interface's25support. Accessibility goes through AccessKit's winit adapter on all three:
26tree, with the page's grafted into it.26the 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 on27`commands.rs` is the one table of commands: each one's title, its chords on
28macOS and elsewhere, when it is enabled or checked, and what it does. The28macOS and elsewhere, when it is enabled or checked, and what it does. The
29keyboard, the toolbar and the macOS menu bar all run commands from it.29keyboard, 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 same94 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.
9696
97### Windows and older macOS97### Windows
9898
99The 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
100Keeping `ui` and `draw` free of platform toolkits is what keeps them within100 with llvm-mingw against `msvcrt.dll`, which every Windows has
101reach.101 (`platform/windows/cargo.sh`): x86_64 on nightly's tier-3
102The chrome's fill already has its seam: `platform::install_backdrop` lays a102 `x86_64-win7-windows-gnu`, whose standard library avoids Windows 8's
103system 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
104and `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.
105133
106## iOS: native around the canvas134## iOS: native around the canvas
107135
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 renderer622 renderer
621 .device623 .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 renderer1030 renderer
1029 .device1031 .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 renderer120 renderer
121 .device121 .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 renderer337 renderer
338 .device338 .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]
8default = ["wgpu"]8default = ["wgpu"]
9# The renderer; `edit` needs only parley. It submits through wgpu, or without it through9# 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.
11render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]12render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]
12wgpu = ["render", "dep:wgpu"]13wgpu = ["render", "dep:wgpu"]
1314
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))]
2mod dual;
3#[cfg(any(windows, not(feature = "wgpu")))]
2mod gl;4mod gl;
3mod icon;5mod icon;
4mod text;6mod text;
...@@ -7,12 +9,16 @@ mod translucent;...@@ -7,12 +9,16 @@ mod translucent;
7#[cfg(feature = "wgpu")]9#[cfg(feature = "wgpu")]
8mod webgpu;10mod webgpu;
911
12#[cfg(all(feature = "wgpu", windows))]
13use dual as backend;
10#[cfg(not(feature = "wgpu"))]14#[cfg(not(feature = "wgpu"))]
11use gl as backend;15use gl as backend;
12#[cfg(feature = "wgpu")]16#[cfg(all(feature = "wgpu", not(windows)))]
13use webgpu as backend;17use webgpu as backend;
1418
15pub use backend::Target;19pub use backend::Target;
20#[cfg(all(feature = "wgpu", windows))]
21pub use gl::Target as GlTarget;
16pub use icon::{Palette, picture_icon};22pub use icon::{Palette, picture_icon};
17pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};23pub 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}
163169
170/// What a backend submits: the prepared vertices and batches, and the images they paint.
171struct Frame<'a> {
172 vertices: &'a [Vertex],
173 batches: &'a [Batch],
174 groups: &'a [Group],
175 images: &'a HashMap<u64, CachedImage>,
176}
177
164struct CachedImage {178struct 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}
484498
485pub struct Renderer {499pub 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}
502512
503impl Renderer {513impl 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 renderer528 renderer
527 }529 }
528530
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 }
535546
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 }
672689
...@@ -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 }
19291948
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.readback1971 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 renderer1975 renderer
1955 .device1976 .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.
3use super::{gl, webgpu, *};
4
5/// What a frame is drawn into, for the backend the renderer draws with.
6pub enum Target {
7 Wgpu(wgpu::TextureView),
8 Gl(gl::Target),
9}
10
11impl From<wgpu::TextureView> for Target {
12 fn from(view: wgpu::TextureView) -> Self {
13 Self::Wgpu(view)
14 }
15}
16
17impl From<gl::Target> for Target {
18 fn from(target: gl::Target) -> Self {
19 Self::Gl(target)
20 }
21}
22
23pub(super) enum Image {
24 Wgpu(webgpu::Image),
25 Gl(gl::Image),
26}
27
28impl 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
37impl 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
46pub(super) enum Gpu {
47 Wgpu(webgpu::Gpu),
48 Gl(gl::Gpu),
49}
50
51impl 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
70impl 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 and1//! 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.framework2//! 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 linear3//! 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)]
6use super::*;7use super::*;
7use std::{8use std::{
...@@ -53,8 +54,95 @@ const COLOR_ATTACHMENT0: GLenum = 0x8CE0;...@@ -53,8 +54,95 @@ const COLOR_ATTACHMENT0: GLenum = 0x8CE0;
53const FRAMEBUFFER: GLenum = 0x8D40;54const FRAMEBUFFER: GLenum = 0x8D40;
54const FRAMEBUFFER_SRGB: GLenum = 0x8DB9;55const FRAMEBUFFER_SRGB: GLenum = 0x8DB9;
5556
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
57unsafe extern "C" {58/// loaded with the first context and called through `FUNCTIONS`.
59macro_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))]
103fn load() -> Result<(), String> {
104 Ok(())
105}
106
107#[cfg(windows)]
108mod 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
145functions! {
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}
205293
294impl 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 framebuffer300/// An offscreen frame a window shows by `present`, the context's default framebuffer
207/// being no sRGB target.301/// being no sRGB target.
208pub struct Target {302pub struct Target {
...@@ -290,9 +384,6 @@ impl Drop for Target {...@@ -290,9 +384,6 @@ impl Drop for Target {
290 }384 }
291}385}
292386
293/// Whether an offscreen picture's rows run bottom first.
294pub(super) const FLIPPED: bool = true;
295
296pub(super) struct Gpu {387pub(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];
320411
412#[cfg(not(feature = "wgpu"))]
321impl Renderer {413impl Renderer {
322 /// Draws with the OpenGL context current on this thread, which must stay current414 /// 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
427impl 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 }
353458
354 /// Copies `target` to the context's window, the same size, premultiplying each pixel459 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 }
383486
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_texture489 true
490 }
491
492 pub(super) fn max_texture_dimension(&self) -> u32 {
493 self.max_texture
387 }494 }
388495
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 }
392499
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 }
396503
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 {
417524
418 /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared525 /// 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 self534 if self
422 .gpu
423 .groups535 .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}
576685
577unsafe fn log(686/// The info log of `object`, a shader or else a program.
578 object: GLuint,687unsafe 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 @@
2use super::*;2use super::*;
33
4/// What a frame is drawn into.4/// What a frame is drawn into.
5#[cfg(not(windows))]
5pub type Target = wgpu::TextureView;6pub type Target = wgpu::TextureView;
67
7pub(super) type Image = wgpu::BindGroup;8/// A picture's texture, as its batches bind it.
9pub(super) struct Image(wgpu::BindGroup);
810
9/// Whether an offscreen picture's rows run bottom first.11impl AsRef<Image> for Image {
10pub(super) const FLIPPED: bool = false;12 fn as_ref(&self) -> &Image {
13 self
14 }
15}
1116
12pub(super) struct Gpu {17pub(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}
2633
34#[cfg(not(windows))]
27impl Renderer {35impl 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
49impl 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 }
147175
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_2d177 self.device.limits().max_texture_dimension_2d
151 }178 }
152179
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 }
156183
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 }
160187
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 }
228255
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 {
270297
271 /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared298 /// 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.queue303 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}
331367
crates/mobile/src/lib.rs+4-4
...@@ -683,7 +683,7 @@ impl View {...@@ -683,7 +683,7 @@ impl View {
683 let config = surface683 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 }
737737
...@@ -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}
848848
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}
459459
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.
461pub(crate) fn now() -> u64 {462pub(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 @@
1use super::*;1use super::*;
2use rusqlite::backup::{Backup, StepResult};2use rusqlite::backup::{Backup, StepResult};
3use std::{collections::BTreeMap, fs::File};3use std::collections::BTreeMap;
44
5const RECOVERY_ID: u32 = 0x4f4e4552;5const RECOVERY_ID: u32 = 0x4f4e4552;
66
...@@ -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}
159161
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]
25nix = { version = "0.31.3", default-features = false, features = ["fs"] }25nix = { version = "0.31.3", default-features = false, features = ["fs"] }
2626
27# OneNote's byte-range locks on a section, which std's whole-file locks would overlap.
28[target.'cfg(windows)'.dependencies]
29file-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;
1414
15#[cfg(any(unix, windows))]15#[cfg(any(unix, windows))]
16struct FileIo {16struct 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 }
5579
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 }
6186
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}
71103
72#[cfg(any(unix, windows))]104#[cfg(unix)]
73impl Drop for FileIo {105impl 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}
80112
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)]
117fn 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's135/// 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 CRC136/// `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 file137/// 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
39objc2-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"] }39objc2-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"] }
40block2 = "0.5.1"40block2 = "0.5.1"
4141
42[target.'cfg(windows)'.dependencies]
43windows-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]
43accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] }67accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] }
44smithay-clipboard = "0.7.3"68smithay-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
4fn 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.
22
3use notebook::session::{Background, Notebook, Section};3use notebook::session::{Background, Notebook, Section};
4use std::path::{Path, PathBuf};4use 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}
5959
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)]
63pub 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
60impl Mount {73impl Mount {
61 /// The mount of `source` (`//[domain;][user[:…]@]server/share[/folder]`), holding the74 /// 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 {
876mod tests {890mod tests {
877 use super::*;891 use super::*;
878892
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> {
762pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {762pub 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) = mounts765 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}
787790
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}
346346
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")]
2mod aqua;3mod aqua;
3mod art;4mod art;
4mod attachment;5mod attachment;
5mod background;6mod background;
6mod commands;7mod commands;
7#[cfg(all(test, feature = "wgpu"))]8#[cfg(all(test, feature = "wgpu", not(windows)))]
8mod conflict_render;9mod conflict_render;
9#[cfg(target_os = "linux")]10#[cfg(target_os = "linux")]
10#[path = "desktop_linux.rs"]11#[path = "desktop_linux.rs"]
11mod desktop;12mod desktop;
12mod history;13mod 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")]
15mod icloud;16mod icloud;
16mod library;17mod library;
...@@ -18,6 +19,7 @@ mod link;...@@ -18,6 +19,7 @@ mod link;
18mod manage;19mod 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")]
21mod media;23mod media;
22mod meeting;24mod meeting;
23#[cfg(target_os = "macos")]25#[cfg(target_os = "macos")]
...@@ -29,6 +31,7 @@ mod palette;...@@ -29,6 +31,7 @@ mod palette;
29mod pane;31mod 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")]
32mod platform;35mod platform;
33mod recording;36mod recording;
34mod rename;37mod rename;
...@@ -39,8 +42,10 @@ mod settings;...@@ -39,8 +42,10 @@ mod settings;
39mod sidebar;42mod 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")]
42mod spell;46mod 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")]
44mod surface;49mod surface;
45mod sync;50mod sync;
46mod tags;51mod 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.
155const PILL_MARGIN: f32 = 3.0;160const 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))]
157const DATE_UNCHOSEN: &str = "Choose another date or time.";163const DATE_UNCHOSEN: &str = "Choose another date or time.";
158const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range.";164const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range.";
159165
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
5use std::{
6 path::Path,
7 sync::atomic::{AtomicU32, Ordering},
8};
9
10#[link(name = "winmm")]
11unsafe 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.
16fn 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.
32fn 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.
38fn 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.
44pub struct Player {
45 alias: String,
46 playing: bool,
47}
48
49impl 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
104impl 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.
111pub struct Recorder {
112 alias: String,
113 path: String,
114}
115
116impl 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
157impl 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 {
163fn local_day(time: u64) -> i64 {163fn 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_gmtoff170 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}
173176
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
6use canvas::spelling::{Dictionary, pick};
7use std::{
8 collections::HashMap,
9 ffi::c_void,
10 sync::mpsc::{Sender, channel},
11};
12use 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.
20const FACTORY: GUID = GUID::from_u128(0x7ab36653_1796_484b_bdfa_e74f1db7c1dc);
21const 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.
24struct Object(*mut *const usize);
25
26impl 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
37impl 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
44type Out = *mut *mut *const usize;
45
46/// The object a method answered through `out`, where it answered one.
47fn 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.
52fn 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
74fn 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.
79struct 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
86impl 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
169enum Request {
170 Misspelled(Vec<(String, u32)>, Sender<Vec<bool>>),
171 Suggest(String, u32, Sender<Vec<String>>),
172 Learn(String),
173}
174
175struct Checker(Sender<Request>);
176
177impl 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
187impl 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.
206pub 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}
1820
19pub struct Frame {21pub 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}
2426
25pub struct Offscreen {27pub struct Offscreen {
26 pub target: wgpu::TextureView,28 pub target: draw::Target,
27 texture: wgpu::Texture,29 pub(super) texture: wgpu::Texture,
28}30}
2931
30impl Surface {32impl 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 = instance55 let adapter = instance
...@@ -50,27 +63,32 @@ impl Surface {...@@ -50,27 +63,32 @@ impl Surface {
50 let mut config = surface63 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, which68 // 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 }
87105
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 }
92110
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 }
154173
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 }
176195
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))]
245fn 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 context70 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"]
6mod webgpu;
7
8use draw::{GlTarget, Renderer, Target};
9use std::{error::Error, sync::Arc};
10use windows_sys::Win32::{
11 Foundation::HWND,
12 Graphics::{
13 Gdi::{GetDC, HDC},
14 OpenGL as gl,
15 },
16};
17use winit::{
18 raw_window_handle::{HasWindowHandle, RawWindowHandle},
19 window::Window,
20};
21
22pub 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
28enum 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
40pub 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
47pub struct Offscreen {
48 pub target: Target,
49 texture: Option<wgpu::Texture>,
50}
51
52impl 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> {
207fn staged(folder: &Path) -> PathBuf {207fn 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.
247fn apply(staged: &Path, install: &Path) -> std::io::Result<()> {250fn 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.
537pub fn finish(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn std::error::Error>> {546pub 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}
567571
572/// Whether process `pid` is gone within `patience`.
573#[cfg(unix)]
574fn 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)]
587fn 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)]
569mod tests {605mod 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 on1//! 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 instead2//! 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.
45
5use std::path::{Path, PathBuf};6use std::path::{Path, PathBuf};
67
...@@ -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, and26 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}
7463
64/// smbfs reports another client's change only in a folder watched for itself, and names
65/// just that folder.
66#[cfg(unix)]
67fn 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)]
83fn share_folders(root: &Path) -> Vec<PathBuf> {
84 vec![root.to_owned()]
85}
86
87#[cfg(unix)]
75fn statfs(path: &Path) -> Option<libc::statfs> {88fn 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 false125 false
113}126}
114127
128/// The type of the drive holding `path`, as `GetDriveTypeW` names it.
129#[cfg(windows)]
130fn 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)]
142fn 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)]
148fn smbfs(drive: &u32) -> bool {
149 !local(drive)
150}
151
115type Changed = Box<dyn Fn(Vec<std::path::PathBuf>) + Send + Sync>;152type Changed = Box<dyn Fn(Vec<std::path::PathBuf>) + Send + Sync>;
116153
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}
469506
507#[cfg(windows)]
508mod 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)]
471mod tests {672mod 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
8use canvas::date::DateField;
9use std::{
10 ffi::c_void,
11 path::PathBuf,
12 sync::{
13 OnceLock,
14 atomic::{AtomicBool, AtomicIsize, AtomicU32, Ordering},
15 },
16 time::Duration,
17};
18use ui::{Flags, Spec, Ui, px};
19use 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};
25use windows_sys::core::BOOL;
26use 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.
35pub 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.
38pub const TRAILING: f32 = GAP;
39const GAP: f32 = 6.0;
40/// A drawn caption button's width, as Windows 10 draws its own.
41const CAPTION_BUTTON: f32 = 46.0;
42/// How far inside the window's edges a press resizes it, at 96 dpi.
43const EDGE: f32 = 8.0;
44
45static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
46/// The window, which dialogs belong to.
47static WINDOW: AtomicIsize = AtomicIsize::new(0);
48/// The window procedure the frame's own passes the rest to.
49static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
50/// Whether the desktop composes windows, which Windows 7's basic and classic themes don't.
51static COMPOSED: AtomicBool = AtomicBool::new(false);
52/// The window's scale factor, as `f32` bits, for hit-testing outside a frame.
53static 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.
57static CAPTION_HOVERED: AtomicU32 = AtomicU32::new(0);
58
59/// The Windows release: its major and minor version and its build.
60fn 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.
91fn eleven() -> bool {
92 version().0 >= 10 && version().2 >= 22000
93}
94
95/// Who draws the caption buttons.
96#[derive(Clone, Copy, PartialEq)]
97enum 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
106fn 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.
117fn 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.
130fn 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.
136fn 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
144fn 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.
152fn owner() -> HWND {
153 WINDOW.load(Ordering::Relaxed) as HWND
154}
155
156fn composed() -> bool {
157 let mut on: BOOL = 0;
158 unsafe { Dwm::DwmIsCompositionEnabled(&mut on) >= 0 && on != 0 }
159}
160
161pub 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.
171pub 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.
183pub 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.
196fn 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
201fn 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.
214fn 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
224unsafe 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
255type 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.
259fn 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.
301fn 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.
327fn 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.
332fn 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.
342fn 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.
395pub 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.
400pub 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
408pub 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.
414static ACRYLIC: AtomicBool = AtomicBool::new(false);
415static ACRYLIC_TINT: AtomicU32 = AtomicU32::new(0);
416
417enum 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.
425fn 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.
468fn 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.
478pub 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.
521pub 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.
547pub fn menu(_: Theme) -> Option<ui::Menu> {
548 None
549}
550
551const MINIMIZE: &[&str] = &[include_str!("../assets/windows/caption-minimize.svg")];
552const MAXIMIZE: &[&str] = &[include_str!("../assets/windows/caption-maximize.svg")];
553const RESTORE: &[&str] = &[include_str!("../assets/windows/caption-restore.svg")];
554const 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.
561pub 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.
677pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::ResizeDirection> {
678 None
679}
680
681pub fn zoom(window: &Window) {
682 window.set_maximized(!window.is_maximized());
683}
684
685/// Winit delivers typed text with its key events on Windows.
686pub fn install_text_input(_: &Window) {}
687
688pub fn move_cursor() -> winit::window::CursorIcon {
689 winit::window::CursorIcon::Move
690}
691
692/// No system border lies under the chrome.
693pub fn cover_border_line(_: &mut Ui, _: f32) {}
694
695pub 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.
700pub fn system_interface(_: &mut Ui) {}
701
702/// Resizing takes the window's edges.
703pub 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.
707pub fn install_menu() {}
708
709pub fn update_menu(_: impl FnOnce() -> Vec<crate::commands::Status>) {}
710
711pub fn update_tag_menu(_: &[canvas::editor::NoteTag]) {}
712
713pub fn clear_marked_text(_: &Window) {}
714
715#[cfg(feature = "wgpu")]
716pub fn configure_presentation(_: &wgpu::Surface<'_>) {}
717
718#[cfg(feature = "wgpu")]
719pub fn commit_presentation(_: &Window) {}
720
721/// winit reports no pen pressure here, so every stroke keeps its pen's width.
722pub fn pen_pressure() -> Option<f32> {
723 None
724}
725
726pub 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.
732pub fn appearance(window: &Window) -> Theme {
733 window.theme().unwrap_or(Theme::Light)
734}
735
736pub struct Clipboard(arboard::Clipboard);
737
738impl 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.
753fn 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.
772pub fn represent(_: &Window, _: Option<&std::path::Path>) {}
773
774pub const SHOW_FILE: &str = "Show in Explorer";
775
776/// Opens an Explorer window with `file` selected.
777pub 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.
791pub 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.
810pub fn open_file(path: &std::path::Path) {
811 reveal(path);
812}
813
814/// Opens Character Map, whose picks the user copies in.
815pub 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.
822pub 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.
827pub fn drop_point(_: &Window) -> Option<[f32; 2]> {
828 None
829}
830
831pub fn cache_dir() -> Option<PathBuf> {
832 Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Snowbound"))
833}
834
835pub 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….
842pub 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.
847fn 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.
853pub 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.
933pub 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.
939pub 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.
970pub 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.
984pub 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.
1000fn 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.
1015fn 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.
1027fn 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.
1044fn 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.
1061pub 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.
1067pub fn short_date(filetime: u64) -> String {
1068 date_format(
1069 &local_time(filetime),
1070 windows_sys::Win32::Globalization::DATE_SHORTDATE,
1071 )
1072}
1073
1074fn 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.
1083pub 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.
1088pub 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`.
1113pub 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`.
1123pub 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.
1138pub 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.
1149fn 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.
1208pub 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.
1217pub 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.
1275mod 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.
21 ICON "snowbound.ico"
31 24 "snowbound.manifest"
crates/ui/src/lib.rs+4
...@@ -761,6 +761,10 @@ impl Ui {...@@ -761,6 +761,10 @@ impl Ui {
761761
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 }
766770
platform/snow-leopard/probe/src/render.rs+1-1
...@@ -10,7 +10,7 @@ fn main() {...@@ -10,7 +10,7 @@ fn main() {
1010
11fn run() {11fn 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}
3636
37pub fn layer<'a>(primitives: &'a [Primitive<'a>]) -> Layer<'a> {37pub 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}
4040
41/// Lines of interface text through `ui`: black on white like a Cocoa label, the same in41/// 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;
7fn main() {7fn 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-gnu19 target=x86_64-win7-windows-gnu
20 toolchain=+nightly20 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 ;;
26aarch64)29aarch64)
27 target=aarch64-pc-windows-gnullvm30 target=aarch64-pc-windows-gnullvm
28 toolchain=+stable31 toolchain=+stable
29 rustup target add --toolchain stable "$target" >/dev/null32 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..." >&239 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),
4reading both with llvm-mingw's llvm-readobj."""
5import os
6from pathlib import Path
7import re
8import subprocess
9import sys
10
11ROOT = Path(__file__).resolve().parents[2]
12READOBJ = Path(os.environ.get('LLVM_MINGW', ROOT / 'target/windows/llvm-mingw')) / 'bin/llvm-readobj'
13
14
15def readobj(flag, path):
16 return subprocess.run([READOBJ, flag, path], capture_output=True, text=True, check=True).stdout
17
18
19def 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
30def 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
44if __name__ == '__main__':
45 main()
platform/windows/link.sh+8-2
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1#!/bin/sh1#!/bin/sh
2# The linker for x86_64-win7-windows-gnu: llvm-mingw's clang, which links compiler-rt2# 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.
4set -eu5set -eu
6here=$(cd "$(dirname "$0")" && pwd)
7clang=$LLVM_MINGW/bin/x86_64-w64-mingw32-clang
5for arg do8for arg do
6 shift9 shift
7 case $arg in10 case $arg in
...@@ -10,4 +13,7 @@ for arg do...@@ -10,4 +13,7 @@ for arg do
10 *) set -- "$@" "$arg" ;;13 *) set -- "$@" "$arg" ;;
11 esac14 esac
12done15done
13exec "$LLVM_MINGW/bin/x86_64-w64-mingw32-clang" "$@"16work=$(mktemp -d "${TMPDIR:-/tmp}/windows-link.XXXXXX")
17trap '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
7typedef long HRESULT;
8typedef 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.
17static void *ole32(const char *name) {
18 return GetProcAddress(LoadLibraryA("ole32.dll"), name);
19}
20
21HRESULT __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
27void __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.
34BOOL __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.
41HRESULT __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
55void *__imp_CoCreateFreeThreadedMarshaler = CoCreateFreeThreadedMarshaler;
56void *__imp_CoTaskMemFree = CoTaskMemFree;
57void *__imp_RoOriginateErrorW = RoOriginateErrorW;
58void *__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.
6set -eu
7here=$(cd "$(dirname "$0")" && pwd)
8root=$(cd "$here/../.." && pwd)
9release=20260922
10out=$root/target/windows/llvm-mingw
11stage=$root/target/windows/llvm-mingw.partial
12base=https://github.com/mstorsjo/llvm-mingw/releases/download/$release
13msvcrt=llvm-mingw-$release-msvcrt-ubuntu-22.04-x86_64
14[ ! -e "$out" ] || { echo "$out exists" >&2; exit 1; }
15rm -rf "${stage:?}"
16mkdir -p "$stage"
17curl -fsSL "$base/$msvcrt.tar.xz" | tar -xJ -C "$stage"
18if [ "$(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
27else
28 host=$msvcrt
29fi
30# Only x86_64 and aarch64 are built.
31rm -rf "${stage:?}/$host/i686-w64-mingw32" "${stage:?}/$host/armv7-w64-mingw32"
32find "$stage/$host/bin" \( -name 'i686-*' -o -name 'armv7-*' -o -name '*uwp*' \) -delete
33mv "$stage/$host" "$out"
34rm -rf "${stage:?}"
35echo "$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 folder18## The published folder
1919
20```text20```text
21latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": ..., "linux-x86_64": ..., "linux-aarch64": ...}21latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": ..., "linux-x86_64": ..., "windows-x86_64": ..., ...}
222026-09-29.r10/222026-09-29.r10/
23 build.json version, commit, and per platform: file, size, sha256, signature23 build.json version, commit, and per platform: file, size, sha256, signature
24 build.json.sig ed25519 signature of build.json, hex24 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.zip26 Snowbound-2026-09-29-r10-macos-10.6.zip
27 snowbound-2026-09-29-r10-linux-x86_64 the executable itself27 snowbound-2026-09-29-r10-linux-x86_64 the executable itself
28 snowbound-2026-09-29-r10-linux-aarch6428 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```
3032
31A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into33A 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## Publishing82## Publishing
8183
82```sh84```sh
83python3 tools/release.py # all four platforms85python3 tools/release.py # all six platforms
84python3 tools/release.py --ad-hoc # the macOS app without Developer ID86python3 tools/release.py --ad-hoc # the macOS app without Developer ID
85python3 tools/release.py --dry-run # the same into a new temporary folder87python3 tools/release.py --dry-run # the same into a new temporary folder
86python3 tools/release.py --platforms macos-aarch64 linux-x86_6488python3 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
89The script refuses to run unless the working copy matches `main` (a dry run91The script refuses to run unless the working copy matches `main` (a dry run
90only warns), runs Clippy (workspace, and `snowbound` without default features)92only warns), runs Clippy (workspace, and `snowbound` without default features)
91and the workspace tests, builds each platform with93and the workspace tests, builds each platform with
92`tools/canvas/build_macos.py` (`--snow-leopard` for 10.6) and94`tools/canvas/build_macos.py` (`--snow-leopard` for 10.6),
93`crates/snowbound/linux/package.sh` (taking only its executables), then checks95`crates/snowbound/linux/package.sh` (taking only its executables) and
96`platform/windows/cargo.sh`, then checks
94the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and97the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and
95publishes as above. Run again for the same commit, it only brings98publishes 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 is99`latest.json` up to date; a different commit that derives the same version is
97refused. The 10.6 build needs the SDK and nightly toolchain100refused. The 10.6 build needs the SDK and nightly toolchain
98`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the101`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the
99cross linker against glibc 2.31. All four build from an Apple silicon Mac.102cross linker against glibc 2.31; the Windows builds need llvm-mingw, which
103`platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for
104x86_64. All six build from an Apple silicon Mac.
100105
101## In the app106## In the app
102107
...@@ -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
108downloads the archive for this platform, verifying size, SHA-256 and113downloads the archive for this platform, verifying size, SHA-256 and
109signature. It stages the update beside the install, so the swap is a rename:114signature. It stages the update beside the install, so the swap is a rename:
110the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the115the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the
111executable into `.snowbound.update` next to it on Linux. The sync status icon gets a dot, and its popup offers116executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on
117Windows). The sync status icon gets a dot, and its popup offers
112Build Folder and Restart to Update.118Build Folder and Restart to Update.
113119
114Restart to Update quits the app and starts the old executable with120Restart 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 (or121`--finish-update`, which waits for the app to exit, renames the old bundle (or
116executable) over the old one, and opens it. Where the install can't be122executable) aside and the new one into its place, and opens it. Windows renames an
123executable while it runs, as this one does, but won't delete it: the old one waits beside
124the new as `.snowbound.exe.old` until the next update removes it. Where the install can't be
117written, and on Mac OS X 10.6, the popup and the check name the build and125written, and on Mac OS X 10.6, the popup and the check name the build and
118open its folder to download instead.126open 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')
17URL = 'https://file.paperclover.net/shr/snowbound/'17URL = 'https://file.paperclover.net/shr/snowbound/'
18KEY = Path.home() / '.config/snowbound/release-key'18KEY = Path.home() / '.config/snowbound/release-key'
19ZONE = ZoneInfo('America/Los_Angeles')19ZONE = ZoneInfo('America/Los_Angeles')
20PLATFORMS = ['macos-aarch64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64']20PLATFORMS = ['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.
23WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'}
21CHECKS = [24CHECKS = [
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}
129132
130133
134def 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
131def main():145def 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
96available for diagnosis without covering OneNote or changing screenshot-based96available for diagnosis without covering OneNote or changing screenshot-based
97test coordinates.97test coordinates.
9898
99## Windows 10 and 11
100
101`win10` (22H2 x64, emulated like Windows 7, so slow) and `win11` (24H2 arm64,
102hardware-virtualized) are further bases for clones. They install unattended and
103headless from Microsoft's Media Creation Tool catalog: Pro from the
104volume-license image, which installs without a product key and stays
105unactivated, with local administrator `one` / `one`. Setup's first logon runs
106[unattend/lab-setup.cmd](unattend/lab-setup.cmd), which installs the agent like
107the 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
119A Windows 11 install reaches its agent in about 10 minutes and a new clone in
120one; Windows 10 takes about 55 minutes and 7, so pass `--timeout 900` to its
121`up --wait`.
122
123Screens are 1024x768, the largest mode edk2 offers the arm64 guest's framebuffer.
124The arm64 guest runs x64 programs, including the agent, through Windows' own
125emulation. 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
127virtio-win disc. Transparency is on and DWM's `ForceEffectMode` is set, so Mica
128and Acrylic render on the emulated display adapter.
129
99## Abrupt-stop recovery tests130## Abrupt-stop recovery tests
100131
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.
147178
148## Gotchas179## Gotchas
149180
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 screenshot183- 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 scheduled185- 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
13import urllib.request13import urllib.request
1414
15from env import ROOT, setting15from env import ROOT, setting
16from vm import BASES
1617
17DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "local")18DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "local")
18TARGETS_PATH = Path(setting("WIN7_TARGETS_FILE") or Path(setting("ONE_VM_HOME") or ROOT / "lab-unset") / "targets.json").expanduser()19TARGETS_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):
2526
26def resolve_target(name):27def 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 off1@echo off
2setlocal EnableExtensions2setlocal EnableExtensions
3set "ONEVM_HOSTNAME="3set "ONEVM_HOSTNAME="
4for %%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 (4for %%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)
8if not defined ONEVM_HOSTNAME goto agent8if not defined ONEVM_HOSTNAME goto agent
9if /i "%COMPUTERNAME%"=="%ONEVM_HOSTNAME%" goto agent9if /i "%COMPUTERNAME%"=="%ONEVM_HOSTNAME%" goto agent
10rem Windows 11 ships without wmic; Windows 7 PowerShell lacks Rename-Computer.10rem Windows 11 ships without wmic; Windows 7 PowerShell lacks Rename-Computer.
11where wmic >nul 2>&1 || goto rename_powershell11where wmic >nul 2>&1 || goto rename_powershell
12wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&112wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1
13find "ReturnValue = 0;" "%~dp0bootstrap.log" >nul || exit /b 113find "ReturnValue = 0;" "%~dp0bootstrap.log" >nul || exit /b 1
14goto restart14goto restart
15:rename_powershell15:rename_powershell
16powershell -NoProfile -Command "Rename-Computer -NewName '%ONEVM_HOSTNAME%' -Force -ErrorAction Stop" >"%~dp0bootstrap.log" 2>&1 || exit /b 116powershell -NoProfile -Command "Rename-Computer -NewName '%ONEVM_HOSTNAME%' -Force -ErrorAction Stop" >"%~dp0bootstrap.log" 2>&1 || exit /b 1
17:restart17:restart
18shutdown /r /t 018shutdown /r /t 0
19exit /b19exit /b
2020
21:agent21:agent
22call "%~dp0run.bat"22call "%~dp0run.bat"
tools/w7/test_vm.py+20-10
...@@ -12,8 +12,7 @@ import vm...@@ -12,8 +12,7 @@ import vm
12class VmLifecycleTest(unittest.TestCase):12class 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)
3634
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")))
6979
7080
71if __name__ == "__main__":81if __name__ == "__main__":
tools/w7/unattend/lab-setup.cmd+28-24
...@@ -1,24 +1,28 @@...@@ -1,24 +1,28 @@
1@echo off1@echo off
2rem First logon of a Windows 10/11 lab build: install the agent and quiet the desktop.2rem First logon of a Windows 10/11 lab build: install the agent and quiet the desktop.
3set "AGENT="3set "AGENT="
4for %%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:"4for %%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:"
5if not defined AGENT exit /b 15if not defined AGENT exit /b 1
6xcopy /e /i /y /q "%AGENT%\" C:\win7-agent\ || exit /b 16if exist "%~dp0netkvm\netkvm.inf" pnputil /add-driver "%~dp0netkvm\netkvm.inf" /install
7attrib -r /s /d "C:\win7-agent\*"7xcopy /e /i /y /q "%AGENT%\" C:\win7-agent\ || exit /b 1
8netsh advfirewall firewall add rule name="win7-agent" dir=in action=allow protocol=TCP localport=87778attrib -r /s /d "C:\win7-agent\*"
9net accounts /maxpwage:unlimited9netsh advfirewall firewall add rule name="win7-agent" dir=in action=allow protocol=TCP localport=8777
10set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon10net accounts /maxpwage:unlimited
11reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d 1 /f11set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon
12reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d one /f12reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d 1 /f
13reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d one /f13reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d one /f
14reg delete "%WINLOGON%" /v AutoLogonCount /f14reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d one /f
15powercfg /change monitor-timeout-ac 015reg delete "%WINLOGON%" /v AutoLogonCount /f
16powercfg /change standby-timeout-ac 016powercfg /change monitor-timeout-ac 0
17powercfg /hibernate off17powercfg /change standby-timeout-ac 0
18reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f18powercfg /hibernate off
19reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoUpdate /t REG_DWORD /d 1 /f19reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f
20reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f20reg add "HKLM\SYSTEM\CurrentControlSet\Control\Network\NewNetworkWindowOff" /f
21reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v EnableTransparency /t REG_DWORD /d 1 /f21reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoUpdate /t REG_DWORD /d 1 /f
22reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f22reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f
23call C:\win7-agent\install-autostart.cmd23reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v EnableTransparency /t REG_DWORD /d 1 /f
24shutdown /r /t 524rem DWM leaves blur off on the emulated display adapter unless forced.
25reg add "HKLM\SOFTWARE\Microsoft\Windows\Dwm" /v ForceEffectMode /t REG_DWORD /d 2 /f
26reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f
27call C:\win7-agent\install-autostart.cmd
28shutdown /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.
3for %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
7endfor
tools/w7/vm.py+187-80
...@@ -10,6 +10,7 @@ import re...@@ -10,6 +10,7 @@ import re
10import secrets10import secrets
11import shutil11import shutil
12import socket12import socket
13import struct
13import subprocess14import subprocess
14import tempfile15import tempfile
15import time16import time
...@@ -30,12 +31,18 @@ IMAGES = VM_HOME / "images"...@@ -30,12 +31,18 @@ IMAGES = VM_HOME / "images"
30MEDIA = VM_HOME / "media"31MEDIA = VM_HOME / "media"
31INSTANCES = VM_HOME / "instances"32INSTANCES = VM_HOME / "instances"
32RUN = VM_HOME / "run"33RUN = VM_HOME / "run"
33DISK = IMAGES / "win7-office-build.qcow2"
34BASE_DISK = IMAGES / "win7-office-base.qcow2"
35BASE_MANIFEST = IMAGES / "win7-office-base.json"
36AGENT_ISO = MEDIA / "win7-agent.iso"34AGENT_ISO = MEDIA / "win7-agent.iso"
37TARGETS = VM_HOME / "targets.json"35TARGETS = 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.
38BASES = {
39 "win7": ("win7-office", 18777),
40 "win10": ("win10", 18774),
41 "win11": ("win11", 18775),
42}
38BUILD = "win7-build"43BUILD = "win7-build"
44UNATTEND = Path(__file__).with_name("unattend")
45FIRMWARE = Path("/opt/homebrew/share/qemu")
39NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,9}[a-z0-9])?")46NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,9}[a-z0-9])?")
40HOSTNAME = re.compile(r"[A-Z0-9](?:[A-Z0-9-]{0,13}[A-Z0-9])?")47HOSTNAME = re.compile(r"[A-Z0-9](?:[A-Z0-9-]{0,13}[A-Z0-9])?")
4148
...@@ -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)
5764
5865
66def build_disk(base):
67 return IMAGES / ("%s-build.qcow2" % BASES[base][0])
68
69
70def base_disk(base):
71 return IMAGES / ("%s-base.qcow2" % BASES[base][0])
72
73
74def base_manifest(base):
75 return IMAGES / ("%s-base.json" % BASES[base][0])
76
77
59def runtime(name):78def runtime(name):
60 root = RUN / name79 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"])
92111
93112
94def launch(name, disk, port, mac, cpus, memory_mb, display, drives):113def 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
132def 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
161def 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
178def 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)
140220
141221
142def start_build(install):222def 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
235def 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)
159261
160262
161def instance_path(name):263def instance_path(name):
...@@ -197,7 +299,7 @@ def configs():...@@ -197,7 +299,7 @@ def configs():
197299
198300
199def available_port(requested=None):301def 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)
272374
273375
274def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None):376def 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 raise424 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))
324427
325428
326def start_instance(name, display=False):429def 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)
338438
339439
340def wait_instance(name, timeout):440def 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)
399500
400501
401def seal():502def 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)
427529
428530
429def fetch_base(manifest_url):531def 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 raise563 raise
461 print(BASE_MANIFEST)564 print(manifest_path)
462565
463566
464def list_instances():567def list_instances():
...@@ -468,25 +571,29 @@ def list_instances():...@@ -468,25 +571,29 @@ def list_instances():
468 return571 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"]))
473576
474577
475def main():578def 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)
549656
550657
551if __name__ == "__main__":658if __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 path26 return path
2727
2828
29# Volume-license media install without asking for a product key and stay unactivated.
29def catalog_entry(base, work):30def 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") == arch37 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))
4041
4142
42def download(url, sha1, path):43def 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)
6263
6364
65# Windows on Arm has no inbox driver for any network card QEMU emulates.
66VIRTIO_WIN = ("https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/"
67 "stable-virtio/virtio-win.iso")
68NETKVM = ("netkvm.inf", "netkvm.cat", "netkvm.sys", "netkvmco.exe", "netkvmp.exe")
69
70
71def 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
64def build(base):86def 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",