From df545058d6e66cccbce7f56c1814f5a922c8a6cb Mon Sep 17 00:00:00 2001 From: clover caruso Date: Wed, 30 Sep 2026 09:54:04 -0700 Subject: [PATCH] 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 --- Cargo.lock | 34 + arc/platforms.md | 66 +- crates/canvas/examples/render_page.rs | 8 +- crates/canvas/src/gpu/mod.rs | 8 +- crates/canvas/src/gpu/page/background.rs | 8 +- crates/canvas/src/gpu/profile.rs | 4 +- crates/draw/Cargo.toml | 3 +- crates/draw/src/render.rs | 74 +- crates/draw/src/render/dual.rs | 126 ++ crates/draw/src/render/gl.rs | 215 ++- crates/draw/src/render/webgpu.rs | 126 +- crates/mobile/src/lib.rs | 8 +- crates/notebook/src/lib.rs | 14 +- crates/notebook/src/recovery.rs | 6 +- crates/onestore/Cargo.toml | 4 + crates/onestore/src/commit.rs | 113 +- crates/snowbound/Cargo.toml | 24 + .../assets/windows/caption-close.svg | 3 + .../assets/windows/caption-maximize.svg | 3 + .../assets/windows/caption-minimize.svg | 3 + .../assets/windows/caption-restore.svg | 4 + crates/snowbound/build.rs | 26 + crates/snowbound/src/icloud_linux.rs | 2 +- crates/snowbound/src/library.rs | 30 + crates/snowbound/src/linux.rs | 21 +- crates/snowbound/src/macos.rs | 4 +- crates/snowbound/src/main.rs | 10 +- crates/snowbound/src/media_windows.rs | 161 ++ crates/snowbound/src/pane.rs | 7 +- crates/snowbound/src/spell_windows.rs | 240 +++ crates/snowbound/src/surface.rs | 90 +- crates/snowbound/src/surface_gl.rs | 2 +- crates/snowbound/src/surface_windows.rs | 228 +++ crates/snowbound/src/update.rs | 72 +- crates/snowbound/src/watch.rs | 231 ++- crates/snowbound/src/windows.rs | 1428 +++++++++++++++++ crates/snowbound/windows/snowbound.ico | Bin 0 -> 87108 bytes crates/snowbound/windows/snowbound.manifest | 35 + crates/snowbound/windows/snowbound.rc | 3 + crates/ui/src/lib.rs | 4 + platform/snow-leopard/probe/src/render.rs | 2 +- platform/snow-leopard/probe/src/scene.rs | 2 +- platform/snow-leopard/probe/src/text.rs | 2 +- platform/windows/cargo.sh | 10 +- platform/windows/imports.py | 45 + platform/windows/link.sh | 10 +- platform/windows/rt/shims.c | 58 + platform/windows/toolchain.sh | 35 + tools/RELEASE.md | 24 +- tools/release.py | 19 +- tools/w7/README.md | 33 + tools/w7/mcp_win7.py | 13 +- tools/w7/payload/bootstrap.cmd | 44 +- tools/w7/test_vm.py | 30 +- tools/w7/unattend/lab-setup.cmd | 52 +- tools/w7/unattend/startup.nsh | 7 + tools/w7/vm.py | 267 ++- tools/w7/windows_media.py | 35 +- 58 files changed, 3726 insertions(+), 410 deletions(-) create mode 100644 crates/draw/src/render/dual.rs create mode 100644 crates/snowbound/assets/windows/caption-close.svg create mode 100644 crates/snowbound/assets/windows/caption-maximize.svg create mode 100644 crates/snowbound/assets/windows/caption-minimize.svg create mode 100644 crates/snowbound/assets/windows/caption-restore.svg create mode 100644 crates/snowbound/build.rs create mode 100644 crates/snowbound/src/media_windows.rs create mode 100644 crates/snowbound/src/spell_windows.rs create mode 100644 crates/snowbound/src/surface_windows.rs create mode 100644 crates/snowbound/src/windows.rs create mode 100644 crates/snowbound/windows/snowbound.ico create mode 100644 crates/snowbound/windows/snowbound.manifest create mode 100644 crates/snowbound/windows/snowbound.rc create mode 100755 platform/windows/imports.py create mode 100644 platform/windows/rt/shims.c create mode 100755 platform/windows/toolchain.sh create mode 100644 tools/w7/unattend/startup.nsh diff --git a/Cargo.lock b/Cargo.lock index 39dde40d5cb822acd8b1d4f75bfc24bdf5620915..b6a15291fdcbdb831ac12c18250d3d85c26e70cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1093,6 +1093,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "file-guard" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ef72acf95ec3d7dbf61275be556299490a245f017cf084bd23b4f68cf9407c" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -2401,6 +2411,7 @@ dependencies = [ "base64 0.22.1", "bumpalo", "cbc", + "file-guard", "getrandom 0.4.3", "md5", "nix", @@ -3346,6 +3357,7 @@ dependencies = [ "wayland-client", "wayland-protocols", "wgpu", + "windows-sys 0.61.2", "winit", "zbus", ] @@ -4265,6 +4277,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4274,6 +4302,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" diff --git a/arc/platforms.md b/arc/platforms.md index f16b196bb09af1475dcc24ce19e86da7006bfb52..56f89b2deebf6b5cd04af2e58d1b2337b474fd4b 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -6,24 +6,24 @@ platform is the window, the input plumbing, and how much of the interface is drawn by Snowbound versus the operating system. ```text - macOS Linux iOS -shell snowbound + ui snowbound + ui UIKit (apps/ios, Swift) -glue snowbound/src/macos snowbound/src/linux crates/mobile (C ABI, staticlib) -page canvas ─────────────────────────────────────────────────────► -paint draw (wgpu: Metal) draw (Vulkan or GL) draw (Metal, CAMetalLayer) -data notebook::session + embedded SMB client ────────────────────► -format onestore ───────────────────────────────────────────────────► + macOS Linux Windows iOS +shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios) +glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI) +page canvas ─────────────────────────────────────────────────────────────────────► +paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12, or GL on 7) draw (Metal) +data notebook::session + embedded SMB client ────────────────────────────────────► +format onestore ───────────────────────────────────────────────────────────────────► ``` ## Desktop: `snowbound` The desktop app is a winit window with the `ui` kit's chrome and a `canvas` page inside it. `snowbound` picks a platform module at compile time -(`macos.rs` or `linux.rs`, both mounted as `platform`), and everything else -in the crate is shared: library and settings, the sidebar, menus, page and -section management, templates, and screenshot and replay support. -Accessibility goes through AccessKit's winit adapter on both: the interface's -tree, with the page's grafted into it. +(`macos.rs`, `linux.rs` or `windows.rs`, each mounted as `platform`), and +everything else in the crate is shared: library and settings, the sidebar, +menus, page and section management, templates, and screenshot and replay +support. Accessibility goes through AccessKit's winit adapter on all three: +the interface's tree, with the page's grafted into it. `commands.rs` is the one table of commands: each one's title, its chords on macOS and elsewhere, when it is enabled or checked, and what it does. The 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. executable to `~/.local/bin` and writes the visible entry under the same name, so the menu never lists two; Uninstall in Options removes it. -### Windows and older macOS +### Windows -The readme names both as goals. Nothing platform-specific exists for them yet. -Keeping `ui` and `draw` free of platform toolkits is what keeps them within -reach. -The chrome's fill already has its seam: `platform::install_backdrop` lays a -system material under a transparent surface, as Mica or Aero glass would, -and `platform::titlebar` names opaque fills where the system has those. +- One executable runs on Windows 7 SP1 through 11. It is built from macOS + with llvm-mingw against `msvcrt.dll`, which every Windows has + (`platform/windows/cargo.sh`): x86_64 on nightly's tier-3 + `x86_64-win7-windows-gnu`, whose standard library avoids Windows 8's + imports, and aarch64 for Windows 11 on Arm. The few imports of Windows 8 + and later that dependencies still name are answered by + `platform/windows/rt/shims.c`; everything newer is looked up at run time. +- `draw` builds both backends on Windows and the surface picks one: + Direct3D 12 through wgpu where Windows has it (10 and 11), otherwise + OpenGL 2.1 on a WGL context, as on Windows 7 (`surface_windows.rs`). +- The toolbar's row is the title bar wherever the desktop composes windows. + On Windows 7 with Aero and on 11, the system's frame keeps its sides, + its caption gives way to the row, its material (Aero glass, Mica) lies + under the whole window, and the system hit-tests and runs its own + caption buttons over the row, which brings 11's snap layouts. 7 draws + them over the glass; 11 doesn't over the Direct3D surface, so the row + draws them as 11 does, lit where the system reports the pointer. On 8 + and 10, whose frames are opaque, the window has no system frame and the + row draws and runs caption buttons as 10 does, over acrylic on 10. With + Windows 7's basic or classic theme the system draws the title bar and + the row lies beneath it, as on KDE. +- A notebook on a share opens by its UNC path through Windows' own SMB + client, which takes OneNote's opens and locks natively: `onestore` opens a + section as OneNote does (a reader shares it with everyone, a writer denies + other writers) and takes OneNote's coordination bytes with byte-range + locks, so both apps can have a section open on one machine. + ReadDirectoryChangesW reports changes below a notebook, including other + clients' on a share. Open Notebook from Server… still uses the embedded + client, its passwords in the Credential Manager. +- The common file dialogs, task dialogs, the date and time picker, and the + Spell Checking API (from Windows 8) are the system's; audio plays and + records through MCI. Dates follow the user's locale as OneNote's do. + Windows has no menu bar: the toolbar and the command palette run the + command table, with OneNote 2010's Ctrl chords. ## iOS: native around the canvas diff --git a/crates/canvas/examples/render_page.rs b/crates/canvas/examples/render_page.rs index 437c22fdc536dee602850161aa3fb4d3e212ec59..8b5c51c2a08752264aaca33739996d16fcad3284 100644 --- a/crates/canvas/examples/render_page.rs +++ b/crates/canvas/examples/render_page.rs @@ -85,7 +85,9 @@ fn main() -> Result<(), Box> { &[viewport.layer(&primitives)], ) .map_err(|e| format!("Page comparison rendering failed: {e:?}"))?; - let mut encoder = renderer.device.create_command_encoder(&Default::default()); + let mut encoder = renderer + .device() + .create_command_encoder(&Default::default()); encoder.copy_texture_to_buffer( target.as_image_copy(), wgpu::TexelCopyBufferInfo { @@ -102,12 +104,12 @@ fn main() -> Result<(), Box> { depth_or_array_layers: 1, }, ); - renderer.queue.submit([encoder.finish()]); + renderer.queue().submit([encoder.finish()]); let (sender, receiver) = std::sync::mpsc::channel(); buffer.map_async(wgpu::MapMode::Read, .., move |result| { let _ = sender.send(result); }); - renderer.device.poll(wgpu::PollType::Wait { + renderer.device().poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(Duration::from_secs(10)), })?; diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index c1b50953f137f97248f6dfffe5858e0adfa167ce..8a3e7579f7f85a85dda0e737e224e533d31e67e3 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -598,7 +598,9 @@ mod tests { &[viewport.layer(&primitives)], ) .unwrap(); - let mut encoder = renderer.device.create_command_encoder(&Default::default()); + let mut encoder = renderer + .device() + .create_command_encoder(&Default::default()); encoder.copy_texture_to_buffer( target.as_image_copy(), wgpu::TexelCopyBufferInfo { @@ -615,10 +617,10 @@ mod tests { depth_or_array_layers: 1, }, ); - renderer.queue.submit([encoder.finish()]); + renderer.queue().submit([encoder.finish()]); readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); renderer - .device + .device() .poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(Duration::from_secs(5)), diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs index 6e86cf7b9d99c0604345ccf253e2486462e97126..eae3db1f59eb491602a9e6cb47dafebd1d64d3fc 100644 --- a/crates/canvas/src/gpu/page/background.rs +++ b/crates/canvas/src/gpu/page/background.rs @@ -1006,7 +1006,9 @@ mod tests { &[viewport.layer(&primitives)], ) .unwrap(); - let mut encoder = renderer.device.create_command_encoder(&Default::default()); + let mut encoder = renderer + .device() + .create_command_encoder(&Default::default()); encoder.copy_texture_to_buffer( target.as_image_copy(), wgpu::TexelCopyBufferInfo { @@ -1023,10 +1025,10 @@ mod tests { depth_or_array_layers: 1, }, ); - renderer.queue.submit([encoder.finish()]); + renderer.queue().submit([encoder.finish()]); readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); renderer - .device + .device() .poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(Duration::from_secs(5)), diff --git a/crates/canvas/src/gpu/profile.rs b/crates/canvas/src/gpu/profile.rs index 890dac06f6bc361afbc4691dc9f1fda170f76634..e6cdbcf7d183afc6729842d9dea7da4d45dfa49a 100644 --- a/crates/canvas/src/gpu/profile.rs +++ b/crates/canvas/src/gpu/profile.rs @@ -118,7 +118,7 @@ fn renderer_cost() { .unwrap(); let submitted = start.elapsed().as_nanos(); renderer - .device + .device() .poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(Duration::from_secs(10)), @@ -335,7 +335,7 @@ fn frame_cost() { let drawn = start.elapsed(); drop(primitives); renderer - .device + .device() .poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(Duration::from_secs(10)), diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index 641b9351add7ddf34cce3c689fd15eaf458bddde..571f2a3c0753d51db4bb8e725488bb32347bccb4 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -7,7 +7,8 @@ publish = false [features] default = ["wgpu"] # The renderer; `edit` needs only parley. It submits through wgpu, or without it through -# OpenGL 2.1, for Mac OS X 10.6. +# OpenGL 2.1, for Mac OS X 10.6. On Windows it has both, and its maker picks one, as +# Windows 7 has no Direct3D 12. render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] wgpu = ["render", "dep:wgpu"] diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index 0a1563b061f99fdd25f2978fb6cec2d1c1f8ea6c..9ffeb1cd045adffae3d836421e9450fa5ebdaf3c 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -1,4 +1,6 @@ -#[cfg(not(feature = "wgpu"))] +#[cfg(all(feature = "wgpu", windows))] +mod dual; +#[cfg(any(windows, not(feature = "wgpu")))] mod gl; mod icon; mod text; @@ -7,12 +9,16 @@ mod translucent; #[cfg(feature = "wgpu")] mod webgpu; +#[cfg(all(feature = "wgpu", windows))] +use dual as backend; #[cfg(not(feature = "wgpu"))] use gl as backend; -#[cfg(feature = "wgpu")] +#[cfg(all(feature = "wgpu", not(windows)))] use webgpu as backend; pub use backend::Target; +#[cfg(all(feature = "wgpu", windows))] +pub use gl::Target as GlTarget; pub use icon::{Palette, picture_icon}; pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; #[cfg(feature = "wgpu")] @@ -161,6 +167,14 @@ impl RasterImage { } } +/// What a backend submits: the prepared vertices and batches, and the images they paint. +struct Frame<'a> { + vertices: &'a [Vertex], + batches: &'a [Batch], + groups: &'a [Group], + images: &'a HashMap, +} + struct CachedImage { texture: backend::Image, bytes: u64, @@ -483,10 +497,6 @@ pub struct Occupancy { } pub struct Renderer { - #[cfg(feature = "wgpu")] - pub device: wgpu::Device, - #[cfg(feature = "wgpu")] - pub queue: wgpu::Queue, gpu: backend::Gpu, vertices: Vec, glyphs: HashMap>, @@ -501,16 +511,8 @@ pub struct Renderer { } impl Renderer { - fn with_gpu( - #[cfg(feature = "wgpu")] device: wgpu::Device, - #[cfg(feature = "wgpu")] queue: wgpu::Queue, - gpu: backend::Gpu, - ) -> Self { + fn with_gpu(gpu: backend::Gpu) -> Self { let renderer = Self { - #[cfg(feature = "wgpu")] - device, - #[cfg(feature = "wgpu")] - queue, gpu, vertices: Vec::new(), glyphs: HashMap::new(), @@ -522,15 +524,24 @@ impl Renderer { pen: [1, 0], row_height: 1, }; - renderer.write_atlas([0, 0], [1, 1], &[255; 4]); + renderer.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); renderer } + /// The widest and tallest texture the device takes, in pixels. + pub fn max_texture_dimension(&self) -> u32 { + self.gpu.max_texture_dimension() + } + + fn atlas_side(&self) -> u32 { + self.gpu.atlas_side() + } + /// Replaces the atlas with an empty one twice as wide and tall. fn grow_atlas(&mut self) { - self.new_atlas(self.atlas_side() * 2); + self.gpu.new_atlas(self.atlas_side() * 2); self.clear_glyph_cache(); - self.write_atlas([0, 0], [1, 1], &[255; 4]); + self.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); } fn glyph_limit(&self) -> usize { @@ -666,7 +677,13 @@ impl Renderer { break; } } - self.submit(target, size, clear); + let frame = Frame { + vertices: &self.vertices, + batches: &self.batches, + groups: &self.groups, + images: &self.images, + }; + self.gpu.submit(&frame, target, size, clear); Ok(()) } @@ -740,12 +757,13 @@ impl Renderer { return Err(RenderError::FrameTooLarge); } let from = self.vertices.len() as u32; + let flipped = self.gpu.flipped(); let corner = |x: f32, y: f32| { let [shown_x, shown_y] = motion.project([x, y], height); let v = y / height; Vertex { position: [shown_x * 2.0 / width - 1.0, 1.0 - shown_y * 2.0 / height], - uv: [x / width, if backend::FLIPPED { 1.0 - v } else { v }], + uv: [x / width, if flipped { 1.0 - v } else { v }], color: [motion.opacity; 4], local: [0.0; 2], shape: [0.0; 4], @@ -946,7 +964,7 @@ impl Renderer { self.images.insert( image.id(), CachedImage { - texture: self.upload_image(image), + texture: self.gpu.upload_image(image), bytes, pixels: image.pixels.downgrade(), }, @@ -1123,7 +1141,8 @@ impl Renderer { .collect(), _ => image.data, }; - self.write_atlas([cached.x, cached.y], [p.width, p.height], &rgba); + self.gpu + .write_atlas([cached.x, cached.y], [p.width, p.height], &rgba); self.pen[0] += p.width + 1; self.row_height = self.row_height.max(p.height); Ok(cached) @@ -1928,7 +1947,9 @@ mod tests { } fn capture(&self, renderer: &Renderer) -> Vec { - let mut encoder = renderer.device.create_command_encoder(&Default::default()); + let mut encoder = renderer + .device() + .create_command_encoder(&Default::default()); encoder.copy_texture_to_buffer( self.texture.as_image_copy(), wgpu::TexelCopyBufferInfo { @@ -1945,14 +1966,14 @@ mod tests { depth_or_array_layers: 1, }, ); - renderer.queue.submit([encoder.finish()]); + renderer.queue().submit([encoder.finish()]); let (sender, receiver) = std::sync::mpsc::channel(); self.readback .map_async(wgpu::MapMode::Read, .., move |result| { sender.send(result).unwrap(); }); renderer - .device + .device() .poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(Duration::from_secs(5)), @@ -2130,7 +2151,8 @@ mod tests { renderer.clear_glyph_cache(); renderer.images.clear(); } else if pass == 3 { - renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format); + renderer = + Renderer::new(renderer.device().clone(), renderer.queue().clone(), format); } let clipping = [ Primitive::Text { diff --git a/crates/draw/src/render/dual.rs b/crates/draw/src/render/dual.rs new file mode 100644 index 0000000000000000000000000000000000000000..de7d1d57c0d433edba7b2a822e43e1fdb6ae0082 --- /dev/null +++ b/crates/draw/src/render/dual.rs @@ -0,0 +1,126 @@ +//! Both backends in one build, for Windows: wgpu where Direct3D 12 answers, and OpenGL 2.1 +//! where it doesn't, as on Windows 7. Whoever makes the renderer picks one. +use super::{gl, webgpu, *}; + +/// What a frame is drawn into, for the backend the renderer draws with. +pub enum Target { + Wgpu(wgpu::TextureView), + Gl(gl::Target), +} + +impl From for Target { + fn from(view: wgpu::TextureView) -> Self { + Self::Wgpu(view) + } +} + +impl From for Target { + fn from(target: gl::Target) -> Self { + Self::Gl(target) + } +} + +pub(super) enum Image { + Wgpu(webgpu::Image), + Gl(gl::Image), +} + +impl AsRef for Image { + fn as_ref(&self) -> &webgpu::Image { + match self { + Self::Wgpu(image) => image, + Self::Gl(_) => unreachable!("Images belong to the renderer's backend"), + } + } +} + +impl AsRef for Image { + fn as_ref(&self) -> &gl::Image { + match self { + Self::Gl(image) => image, + Self::Wgpu(_) => unreachable!("Images belong to the renderer's backend"), + } + } +} + +pub(super) enum Gpu { + Wgpu(webgpu::Gpu), + Gl(gl::Gpu), +} + +impl Renderer { + pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { + Self::with_gpu(Gpu::Wgpu(webgpu::Gpu::new(device, queue, format))) + } + + /// Draws with the OpenGL context current on this thread, which must stay current + /// whenever the renderer or a `Target` is used. + pub fn opengl() -> Result { + gl::Gpu::new().map(|gpu| Self::with_gpu(Gpu::Gl(gpu))) + } + + /// Copies `target` to the OpenGL context's window, as `gl`'s renderer does. + pub fn present_translucent(&self, target: &gl::Target) { + if let Gpu::Gl(gpu) = &self.gpu { + gpu.present_translucent(target); + } + } +} + +impl Gpu { + pub(super) fn flipped(&self) -> bool { + match self { + Self::Wgpu(gpu) => gpu.flipped(), + Self::Gl(gpu) => gpu.flipped(), + } + } + + pub(super) fn max_texture_dimension(&self) -> u32 { + match self { + Self::Wgpu(gpu) => gpu.max_texture_dimension(), + Self::Gl(gpu) => gpu.max_texture_dimension(), + } + } + + pub(super) fn atlas_side(&self) -> u32 { + match self { + Self::Wgpu(gpu) => gpu.atlas_side(), + Self::Gl(gpu) => gpu.atlas_side(), + } + } + + pub(super) fn new_atlas(&mut self, side: u32) { + match self { + Self::Wgpu(gpu) => gpu.new_atlas(side), + Self::Gl(gpu) => gpu.new_atlas(side), + } + } + + pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { + match self { + Self::Wgpu(gpu) => gpu.write_atlas(origin, size, rgba), + Self::Gl(gpu) => gpu.write_atlas(origin, size, rgba), + } + } + + pub(super) fn upload_image(&self, image: &RasterImage) -> Image { + match self { + Self::Wgpu(gpu) => Image::Wgpu(gpu.upload_image(image)), + Self::Gl(gpu) => Image::Gl(gpu.upload_image(image)), + } + } + + pub(super) fn submit( + &mut self, + frame: &Frame<'_>, + target: &Target, + size: [u32; 2], + clear: [f32; 4], + ) { + match (self, target) { + (Self::Wgpu(gpu), Target::Wgpu(view)) => gpu.submit(frame, view, size, clear), + (Self::Gl(gpu), Target::Gl(target)) => gpu.submit(frame, target, size, clear), + _ => unreachable!("Frames are drawn into the renderer's backend's targets"), + } + } +} diff --git a/crates/draw/src/render/gl.rs b/crates/draw/src/render/gl.rs index 14622e9342f814c23e3ec80dd12419582f25c754..396ab186e4738d319638c81f69070a547b58ac61 100644 --- a/crates/draw/src/render/gl.rs +++ b/crates/draw/src/render/gl.rs @@ -1,7 +1,8 @@ //! Submission through OpenGL 2.1 with EXT_framebuffer_object, EXT_framebuffer_blit and //! the sRGB extensions, which every Mac OS X 10.6 driver has and OpenGL.framework -//! exports. Frames draw into an sRGB framebuffer object, so blending happens in linear -//! light as it does in wgpu's sRGB targets. +//! exports; on Windows, where opengl32.dll exports only OpenGL 1.1, the driver supplies +//! them through `wglGetProcAddress`. Frames draw into an sRGB framebuffer object, so +//! blending happens in linear light as it does in wgpu's sRGB targets. #![allow(non_snake_case)] use super::*; use std::{ @@ -53,8 +54,95 @@ const COLOR_ATTACHMENT0: GLenum = 0x8CE0; const FRAMEBUFFER: GLenum = 0x8D40; const FRAMEBUFFER_SRGB: GLenum = 0x8DB9; -#[cfg_attr(target_os = "macos", link(name = "OpenGL", kind = "framework"))] -unsafe extern "C" { +/// The entry points: linked where the system library exports them all, and on Windows +/// loaded with the first context and called through `FUNCTIONS`. +macro_rules! functions { + ($(fn $name:ident($($arg:ident: $ty:ty),* $(,)?) $(-> $ret:ty)?;)*) => { + #[cfg(not(windows))] + #[cfg_attr(target_os = "macos", link(name = "OpenGL", kind = "framework"))] + unsafe extern "C" { + $(fn $name($($arg: $ty),*) $(-> $ret)?;)* + } + + #[cfg(windows)] + struct Functions { + $($name: unsafe extern "system" fn($($ty),*) $(-> $ret)?,)* + } + + #[cfg(windows)] + static FUNCTIONS: std::sync::OnceLock = std::sync::OnceLock::new(); + + /// Loads the entry points from the context current on this thread. + #[cfg(windows)] + fn load() -> Result<(), String> { + if FUNCTIONS.get().is_none() { + let functions = Functions { + $($name: unsafe { + std::mem::transmute::< + *const c_void, + unsafe extern "system" fn($($ty),*) $(-> $ret)?, + >(windows::address(concat!(stringify!($name), "\0"))?) + },)* + }; + let _ = FUNCTIONS.set(functions); + } + Ok(()) + } + + $( + #[cfg(windows)] + #[allow(clippy::too_many_arguments)] + unsafe fn $name($($arg: $ty),*) $(-> $ret)? { + unsafe { (FUNCTIONS.get().expect("OpenGL is loaded").$name)($($arg),*) } + } + )* + }; +} + +#[cfg(not(windows))] +fn load() -> Result<(), String> { + Ok(()) +} + +#[cfg(windows)] +mod windows { + use std::ffi::{c_char, c_void}; + + #[link(name = "opengl32")] + unsafe extern "system" { + fn wglGetProcAddress(name: *const c_char) -> *const c_void; + } + + unsafe extern "system" { + fn GetModuleHandleA(name: *const c_char) -> *mut c_void; + fn GetProcAddress(module: *mut c_void, name: *const c_char) -> *const c_void; + } + + /// OpenGL function `name`, NUL-terminated: from the driver, or opengl32.dll's own for + /// OpenGL 1.1. An `EXT` name the driver lacks falls back to its core name. + pub(super) fn address(name: &str) -> Result<*const c_void, String> { + let find = |name: &str| unsafe { + let driver = wglGetProcAddress(name.as_ptr().cast()); + // Some drivers answer a missing name with a small integer rather than null. + if !matches!(driver as isize, -1..=3) { + return Some(driver); + } + let system = GetProcAddress( + GetModuleHandleA(c"opengl32.dll".as_ptr()), + name.as_ptr().cast(), + ); + (!system.is_null()).then_some(system) + }; + find(name) + .or_else(|| { + let core = name.strip_suffix("EXT\0")?; + find(&format!("{core}\0")) + }) + .ok_or_else(|| format!("OpenGL has no {}", name.trim_end_matches('\0'))) + } +} + +functions! { fn glEnable(cap: GLenum); fn glDisable(cap: GLenum); fn glGetIntegerv(name: GLenum, value: *mut GLint); @@ -203,6 +291,12 @@ impl Drop for Image { } } +impl AsRef for Image { + fn as_ref(&self) -> &Image { + self + } +} + /// An offscreen frame a window shows by `present`, the context's default framebuffer /// being no sRGB target. pub struct Target { @@ -290,9 +384,6 @@ impl Drop for Target { } } -/// Whether an offscreen picture's rows run bottom first. -pub(super) const FLIPPED: bool = true; - pub(super) struct Gpu { program: GLuint, buffer: GLuint, @@ -318,10 +409,24 @@ const ATTRIBUTES: [(&CStr, GLint, usize); 9] = [ (c"blur", 1, offset_of!(Vertex, blur)), ]; +#[cfg(not(feature = "wgpu"))] impl Renderer { /// Draws with the OpenGL context current on this thread, which must stay current /// whenever the renderer or a `Target` is used. - pub fn new() -> Result { + pub fn opengl() -> Result { + Gpu::new().map(Self::with_gpu) + } + + /// Copies `target` to the context's window, the same size, premultiplying each pixel + /// again in sRGB, as a window server compositing a transparent surface needs. + pub fn present_translucent(&self, target: &Target) { + self.gpu.present_translucent(target); + } +} + +impl Gpu { + pub(super) fn new() -> Result { + load()?; let names: Vec<_> = ATTRIBUTES.iter().map(|(name, _, _)| *name).collect(); let draw = unsafe { program(include_str!("../draw.glsl"), &names)? }; let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? }; @@ -340,7 +445,7 @@ impl Renderer { ); glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture); } - Ok(Self::with_gpu(Gpu { + Ok(Gpu { program: draw, buffer, translucent, @@ -348,14 +453,12 @@ impl Renderer { atlas: Image::new([ATLAS_SIZE; 2], NEAREST, None), max_texture: max_texture as u32, groups: Vec::new(), - })) + }) } - /// Copies `target` to the context's window, the same size, premultiplying each pixel - /// again in sRGB, as a window server compositing a transparent surface needs. - pub fn present_translucent(&self, target: &Target) { + pub(super) fn present_translucent(&self, target: &Target) { let [width, height] = target.size().map(|side| side as f32); - let program = self.gpu.translucent; + let program = self.translucent; unsafe { glBindFramebufferEXT(FRAMEBUFFER, 0); glDisable(FRAMEBUFFER_SRGB); @@ -371,7 +474,7 @@ impl Renderer { ); glActiveTexture(TEXTURE0); glBindTexture(TEXTURE_2D, target.texture.name); - glBindBuffer(ARRAY_BUFFER, self.gpu.triangle); + glBindBuffer(ARRAY_BUFFER, self.triangle); for index in 1..ATTRIBUTES.len() { glDisableVertexAttribArray(index as GLuint); } @@ -381,22 +484,26 @@ impl Renderer { } } - /// The widest and tallest texture the driver takes, in pixels. - pub fn max_texture_dimension(&self) -> u32 { - self.gpu.max_texture + /// Offscreen pictures' rows run bottom first. + pub(super) fn flipped(&self) -> bool { + true + } + + pub(super) fn max_texture_dimension(&self) -> u32 { + self.max_texture } pub(super) fn atlas_side(&self) -> u32 { - self.gpu.atlas.size[0] + self.atlas.size[0] } pub(super) fn new_atlas(&mut self, side: u32) { - self.gpu.atlas = Image::new([side; 2], NEAREST, None); + self.atlas = Image::new([side; 2], NEAREST, None); } pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { unsafe { - glBindTexture(TEXTURE_2D, self.gpu.atlas.name); + glBindTexture(TEXTURE_2D, self.atlas.name); glTexSubImage2D( TEXTURE_2D, 0, @@ -417,23 +524,28 @@ impl Renderer { /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared /// batches, each group's offscreen first. - pub(super) fn submit(&mut self, target: &Target, size: [u32; 2], clear: [f32; 4]) { + pub(super) fn submit( + &mut self, + frame: &Frame<'_>, + target: &Target, + size: [u32; 2], + clear: [f32; 4], + ) { if self - .gpu .groups .first() .is_some_and(|picture| picture.size() != size) { - self.gpu.groups.clear(); + self.groups.clear(); } - while self.gpu.groups.len() < self.groups.len() { + while self.groups.len() < frame.groups.len() { match Target::new(size) { Ok(picture) => unsafe { // Filtered, so a picture leaning back stays smooth. glBindTexture(TEXTURE_2D, picture.texture.name); glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR); glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR); - self.gpu.groups.push(picture); + self.groups.push(picture); }, Err(error) => { eprintln!("{error}"); @@ -442,7 +554,7 @@ impl Renderer { } } let [width, height] = size.map(|side| side as GLsizei); - let gpu = &self.gpu; + let gpu = &*self; let begin = |target: &Target, clear: [f32; 4]| unsafe { glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); glDisable(SCISSOR_TEST); @@ -459,7 +571,10 @@ impl Renderer { gpu.atlas.name, [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], ), - Blend::Image(id) => (self.images[&id].texture.name, PREMULTIPLIED), + Blend::Image(id) => { + let image: &Image = frame.images[&id].texture.as_ref(); + (image.name, PREMULTIPLIED) + } Blend::Erase => ( gpu.atlas.name, [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA], @@ -486,8 +601,8 @@ impl Renderer { glBindBuffer(ARRAY_BUFFER, gpu.buffer); glBufferData( ARRAY_BUFFER, - size_of_val(self.vertices.as_slice()) as isize, - self.vertices.as_ptr().cast(), + size_of_val(frame.vertices) as isize, + frame.vertices.as_ptr().cast(), STREAM_DRAW, ); for (index, (_, components, offset)) in ATTRIBUTES.iter().enumerate() { @@ -502,14 +617,14 @@ impl Renderer { ); } glEnable(BLEND); - for (group, picture) in self.groups.iter().zip(&gpu.groups) { + for (group, picture) in frame.groups.iter().zip(&gpu.groups) { begin(picture, [0.0; 4]); - draw(&self.batches[group.batches.clone()]); + draw(&frame.batches[group.batches.clone()]); } begin(target, clear); let mut next = 0; - for (group, picture) in self.groups.iter().zip(&gpu.groups) { - draw(&self.batches[next..group.batches.start]); + for (group, picture) in frame.groups.iter().zip(&gpu.groups) { + draw(&frame.batches[next..group.batches.start]); glScissor(0, 0, width, height); let [src_rgb, dst_rgb, src_alpha, dst_alpha] = PREMULTIPLIED; glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); @@ -521,7 +636,7 @@ impl Renderer { ); next = group.batches.end; } - draw(&self.batches[next..]); + draw(&frame.batches[next..]); glDisable(SCISSOR_TEST); glDisable(BLEND); glBindFramebufferEXT(FRAMEBUFFER, 0); @@ -547,10 +662,7 @@ unsafe fn program(source: &str, attributes: &[&CStr]) -> Result let mut linked = 0; glGetProgramiv(program, LINK_STATUS, &mut linked); if linked == 0 { - return Err(format!( - "Linking a shader failed: {}", - log(program, glGetProgramiv, glGetProgramInfoLog) - )); + return Err(format!("Linking a shader failed: {}", log(program, false))); } Ok(program) } @@ -565,25 +677,28 @@ unsafe fn compile(kind: GLenum, source: &str) -> Result { let mut compiled = 0; glGetShaderiv(shader, COMPILE_STATUS, &mut compiled); if compiled == 0 { - return Err(format!( - "Compiling a shader failed: {}", - log(shader, glGetShaderiv, glGetShaderInfoLog) - )); + return Err(format!("Compiling a shader failed: {}", log(shader, true))); } Ok(shader) } } -unsafe fn log( - object: GLuint, - get: unsafe extern "C" fn(GLuint, GLenum, *mut GLint), - read: unsafe extern "C" fn(GLuint, GLsizei, *mut GLsizei, *mut c_char), -) -> String { +/// The info log of `object`, a shader or else a program. +unsafe fn log(object: GLuint, shader: bool) -> String { unsafe { let mut length = 0; - get(object, INFO_LOG_LENGTH, &mut length); + if shader { + glGetShaderiv(object, INFO_LOG_LENGTH, &mut length); + } else { + glGetProgramiv(object, INFO_LOG_LENGTH, &mut length); + } let mut text = vec![0u8; length.max(1) as usize]; - read(object, length, ptr::null_mut(), text.as_mut_ptr().cast()); + let log = text.as_mut_ptr().cast(); + if shader { + glGetShaderInfoLog(object, length, ptr::null_mut(), log); + } else { + glGetProgramInfoLog(object, length, ptr::null_mut(), log); + } String::from_utf8_lossy(&text) .trim_end_matches('\0') .to_owned() diff --git a/crates/draw/src/render/webgpu.rs b/crates/draw/src/render/webgpu.rs index c1a3862cd0d2bcfcd55379c79fea7104f7165f99..1aca9a1cd6f86ce33c3803c68f9559dd578aeadf 100644 --- a/crates/draw/src/render/webgpu.rs +++ b/crates/draw/src/render/webgpu.rs @@ -2,14 +2,21 @@ use super::*; /// What a frame is drawn into. +#[cfg(not(windows))] pub type Target = wgpu::TextureView; -pub(super) type Image = wgpu::BindGroup; +/// A picture's texture, as its batches bind it. +pub(super) struct Image(wgpu::BindGroup); -/// Whether an offscreen picture's rows run bottom first. -pub(super) const FLIPPED: bool = false; +impl AsRef for Image { + fn as_ref(&self) -> &Image { + self + } +} pub(super) struct Gpu { + device: wgpu::Device, + queue: wgpu::Queue, pipeline: wgpu::RenderPipeline, image_pipeline: wgpu::RenderPipeline, erase_pipeline: wgpu::RenderPipeline, @@ -24,8 +31,27 @@ pub(super) struct Gpu { groups_size: [u32; 2], } +#[cfg(not(windows))] impl Renderer { pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { + Self::with_gpu(Gpu::new(device, queue, format)) + } + + pub fn device(&self) -> &wgpu::Device { + &self.gpu.device + } + + pub fn queue(&self) -> &wgpu::Queue { + &self.gpu.queue + } +} + +impl Gpu { + pub(super) fn new( + device: wgpu::Device, + queue: wgpu::Queue, + format: wgpu::TextureFormat, + ) -> Self { let shader = device.create_shader_module(wgpu::include_wgsl!("../draw.wgsl")); let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Draw texture"), @@ -126,36 +152,37 @@ impl Renderer { min_filter: wgpu::FilterMode::Linear, ..Default::default() }); - Self::with_gpu( + Self { device, queue, - Gpu { - pipeline, - image_pipeline, - erase_pipeline, - multiply_pipeline, - atlas, - bind_group, - image_sampler, - vertex_buffer, - format, - groups: Vec::new(), - groups_size: [0; 2], - }, - ) + pipeline, + image_pipeline, + erase_pipeline, + multiply_pipeline, + atlas, + bind_group, + image_sampler, + vertex_buffer, + format, + groups: Vec::new(), + groups_size: [0; 2], + } } - /// The widest and tallest texture the device takes, in pixels. - pub fn max_texture_dimension(&self) -> u32 { + pub(super) fn flipped(&self) -> bool { + false + } + + pub(super) fn max_texture_dimension(&self) -> u32 { self.device.limits().max_texture_dimension_2d } pub(super) fn atlas_side(&self) -> u32 { - self.gpu.atlas.width() + self.atlas.width() } pub(super) fn new_atlas(&mut self, side: u32) { - (self.gpu.atlas, self.gpu.bind_group) = atlas(&self.device, &self.gpu.pipeline, side); + (self.atlas, self.bind_group) = atlas(&self.device, &self.pipeline, side); } pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { @@ -166,7 +193,7 @@ impl Renderer { y: origin[1], z: 0, }, - ..self.gpu.atlas.as_image_copy() + ..self.atlas.as_image_copy() }, rgba, wgpu::TexelCopyBufferLayout { @@ -208,9 +235,9 @@ impl Renderer { }, size, ); - self.device.create_bind_group(&wgpu::BindGroupDescriptor { + Image(self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Draw image"), - layout: &self.gpu.pipeline.get_bind_group_layout(0), + layout: &self.pipeline.get_bind_group_layout(0), entries: &[ wgpu::BindGroupEntry { binding: 0, @@ -220,21 +247,21 @@ impl Renderer { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::Sampler(&self.gpu.image_sampler), + resource: wgpu::BindingResource::Sampler(&self.image_sampler), }, ], - }) + })) } /// An offscreen picture `size` large for each group, kept for later frames. - fn group_pictures(&mut self, size: [u32; 2]) { - let gpu = &mut self.gpu; + fn group_pictures(&mut self, size: [u32; 2], count: usize) { + let gpu = self; if gpu.groups_size != size { gpu.groups.clear(); gpu.groups_size = size; } - while gpu.groups.len() < self.groups.len() { - let texture = self.device.create_texture(&wgpu::TextureDescriptor { + while gpu.groups.len() < count { + let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { label: Some("Draw group"), size: wgpu::Extent3d { width: size[0], @@ -250,7 +277,7 @@ impl Renderer { view_formats: &[], }); let view = texture.create_view(&Default::default()); - let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + let binding = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Draw group"), layout: &gpu.pipeline.get_bind_group_layout(0), entries: &[ @@ -270,19 +297,28 @@ impl Renderer { /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared /// batches, each group's offscreen first. - pub(super) fn submit(&mut self, target: &Target, size: [u32; 2], clear: [f32; 4]) { - self.group_pictures(size); - let gpu = &self.gpu; - self.queue - .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); - let mut encoder = self.device.create_command_encoder(&Default::default()); + pub(super) fn submit( + &mut self, + frame: &Frame<'_>, + target: &wgpu::TextureView, + size: [u32; 2], + clear: [f32; 4], + ) { + self.group_pictures(size, frame.groups.len()); + let gpu = &*self; + gpu.queue + .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(frame.vertices)); + let mut encoder = gpu.device.create_command_encoder(&Default::default()); let draw = |pass: &mut wgpu::RenderPass, batches: &[Batch]| { for batch in batches { let [x, y, width, height] = batch.scissor; pass.set_scissor_rect(x, y, width, height); let (pipeline, binding) = match batch.blend { Blend::Over => (&gpu.pipeline, &gpu.bind_group), - Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture), + Blend::Image(id) => { + let image: &Image = frame.images[&id].texture.as_ref(); + (&gpu.image_pipeline, &image.0) + } Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group), Blend::Multiply => (&gpu.multiply_pipeline, &gpu.bind_group), }; @@ -309,23 +345,23 @@ impl Renderer { pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..)); pass.forget_lifetime() }; - for (group, (view, _)) in self.groups.iter().zip(&gpu.groups) { + for (group, (view, _)) in frame.groups.iter().zip(&gpu.groups) { let mut pass = begin(&mut encoder, view, [0.0; 4]); - draw(&mut pass, &self.batches[group.batches.clone()]); + draw(&mut pass, &frame.batches[group.batches.clone()]); } let mut pass = begin(&mut encoder, target, clear); let mut next = 0; - for (group, (_, binding)) in self.groups.iter().zip(&gpu.groups) { - draw(&mut pass, &self.batches[next..group.batches.start]); + for (group, (_, binding)) in frame.groups.iter().zip(&gpu.groups) { + draw(&mut pass, &frame.batches[next..group.batches.start]); pass.set_scissor_rect(0, 0, size[0], size[1]); pass.set_pipeline(&gpu.image_pipeline); pass.set_bind_group(0, binding, &[]); pass.draw(group.composite.clone(), 0..1); next = group.batches.end; } - draw(&mut pass, &self.batches[next..]); + draw(&mut pass, &frame.batches[next..]); drop(pass); - self.queue.submit([encoder.finish()]); + gpu.queue.submit([encoder.finish()]); } } diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index b61e49bba1a45f71b8f78c9249aa1421f44d5ef6..bb7e47b4efd3594c9d93a7759dddc1f7bfe37c84 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -683,7 +683,7 @@ impl View { let config = surface .get_default_config(&gpu.adapter, pixels[0], pixels[1]) .ok_or("No supported surface configuration")?; - surface.configure(&gpu.renderer.device, &config); + surface.configure(gpu.renderer.device(), &config); let (page, read_only) = section.shared.page(space)?; let frame = Arc::::default(); #[cfg_attr(not(target_os = "ios"), expect(unused_mut))] @@ -712,7 +712,7 @@ impl View { wgpu::CurrentSurfaceTexture::Success(frame) | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { - self.surface.configure(&renderer.device, &self.config); + self.surface.configure(renderer.device(), &self.config); return Ok(()); } _ => return Ok(()), @@ -731,7 +731,7 @@ impl View { &[canvas.page.viewport.layer(&primitives)], ) .map_err(|error| format!("Page drawing failed: {error:?}"))?; - renderer.queue.present(frame); + renderer.queue().present(frame); Ok(()) } @@ -842,7 +842,7 @@ pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale view.config.width = pixels[0]; view.config.height = pixels[1]; if let Some(gpu) = &*gpu() { - view.surface.configure(&gpu.renderer.device, &view.config); + view.surface.configure(gpu.renderer.device(), &view.config); } } diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index f37c295b0484a2ac9f4ba6dbf55f0edf3ed4356e..daea3066e210c21026b023c8002a90bfd20e2c21 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -457,10 +457,20 @@ fn write_ahead(connection: &Connection) -> Result<()> { Ok(()) } -/// FILETIME now, as an edit's `at`. +/// FILETIME now, as an edit's `at`: later than any this process took before, as Windows 7's +/// clock ticks only every 15.6 ms and a later edit must still order after an earlier one. pub(crate) fn now() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static LAST: AtomicU64 = AtomicU64::new(0); let unix = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(); - (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) + let clock = + (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100); + let previous = LAST + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |last| { + Some(clock.max(last + 1)) + }) + .unwrap_or_default(); + clock.max(previous + 1) } diff --git a/crates/notebook/src/recovery.rs b/crates/notebook/src/recovery.rs index dbb9a08142bc220b206d48227810a44d2b36bf01..d4a660e51ca851869de8c66355b2aa22b592a1e4 100644 --- a/crates/notebook/src/recovery.rs +++ b/crates/notebook/src/recovery.rs @@ -1,6 +1,6 @@ use super::*; use rusqlite::backup::{Backup, StepResult}; -use std::{collections::BTreeMap, fs::File}; +use std::collections::BTreeMap; const RECOVERY_ID: u32 = 0x4f4e4552; @@ -153,7 +153,9 @@ pub(crate) fn export(source: &Connection, path: &Path, replace: bool) -> Result< .persist_noclobber(path) .map_err(|error| error.error)?; } - File::open(parent)?.sync_all()?; + // Windows opens no folder as a file; NTFS journals the rename. + #[cfg(unix)] + std::fs::File::open(parent)?.sync_all()?; Ok(()) } diff --git a/crates/onestore/Cargo.toml b/crates/onestore/Cargo.toml index 5b7027da55aba3b3df2bf558ddc6d1732d8853e8..2ed9a944a1fcb4fd27ef124559f00c92497c80a9 100644 --- a/crates/onestore/Cargo.toml +++ b/crates/onestore/Cargo.toml @@ -24,4 +24,8 @@ zeroize = { version = "1.9.0", optional = true } [target.'cfg(target_os = "macos")'.dependencies] nix = { version = "0.31.3", default-features = false, features = ["fs"] } +# OneNote's byte-range locks on a section, which std's whole-file locks would overlap. +[target.'cfg(windows)'.dependencies] +file-guard = "0.2.0" + [dev-dependencies] diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index 182ef5bc650b0e99bb4e5dbf570aa9ce2c91cca8..f01ce266633b9b0a75448f71244e369ae270c0b7 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -14,7 +14,13 @@ use std::os::windows::fs::FileExt; #[cfg(any(unix, windows))] struct FileIo { + #[cfg(unix)] file: File, + #[cfg(windows)] + file: std::sync::Arc, + /// OneNote's coordination bytes, unlocked when dropped. + #[cfg(windows)] + locks: Vec>>, _process: MutexGuard<'static, ()>, unlock_on_drop: bool, } @@ -43,22 +49,48 @@ impl FileIo { }; options.custom_flags(lock | nix::libc::O_NONBLOCK); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // As OneNote opens a section: a reader shares it with everyone, a writer denies + // other writers (FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_SHARE_DELETE). + options.share_mode(if write { 0x1 | 0x4 } else { 0x1 | 0x2 | 0x4 }); + } + #[cfg(windows)] + let file = std::sync::Arc::new(options.open(path).map_err(|error| { + // ERROR_SHARING_VIOLATION: another writer has it open. + match error.raw_os_error() { + Some(32) => ErrorKind::WouldBlock.into(), + _ => error, + } + })?); + #[cfg(unix)] let file = options.open(path)?; - #[cfg(not(target_os = "macos"))] + #[cfg(all(unix, not(target_os = "macos")))] file.try_lock()?; Ok(Self { + #[cfg(windows)] + locks: onenote_locks(&file, write)?, file, _process: process, unlock_on_drop: true, }) } + #[cfg(unix)] fn release(&mut self) -> io::Result<()> { // A failed unlock may have reached the server; Drop must not repeat it. self.unlock_on_drop = false; self.file.unlock() } + #[cfg(windows)] + fn release(&mut self) -> io::Result<()> { + self.unlock_on_drop = false; + self.locks.clear(); + Ok(()) + } + fn finish(mut self, result: Result<(), CommitError>) -> Result<(), CommitError> { let released = self.release(); result?; @@ -69,7 +101,7 @@ impl FileIo { } } -#[cfg(any(unix, windows))] +#[cfg(unix)] impl Drop for FileIo { fn drop(&mut self) { if self.unlock_on_drop { @@ -78,6 +110,28 @@ impl Drop for FileIo { } } +/// Windows takes OneNote 2010's own locks on the file, one byte each past any data, so +/// neither app's locks bar the other's reads: the reader byte shared, and to write, the +/// writer byte exclusively, as `notebook::smb` takes them on a share. +#[cfg(windows)] +fn onenote_locks( + file: &std::sync::Arc, + write: bool, +) -> io::Result>>> { + use file_guard::Lock; + let reader = file_guard::try_lock(file.clone(), Lock::Shared, 0xffff_fffb, 1)?; + let mut locks = vec![reader]; + if write { + locks.push(file_guard::try_lock( + file.clone(), + Lock::Exclusive, + 0xffff_fffd, + 1, + )?); + } + Ok(locks) +} + /// Places a file in its notebook the way OneNote does on adoption: the header's /// `guidAncestor` becomes the parent table of contents' file identity and `crcName` the CRC /// of `name` (a section's file name, a group's folder name). OneNote re-identifies a file @@ -528,4 +582,59 @@ mod tests { b"unfinished" ); } + + /// OneNote's reads go on through Snowbound's reads and writes, and its writers wait for + /// Snowbound's, as its opens and coordination bytes meet Snowbound's. + #[cfg(windows)] + #[test] + fn windows_takes_onenote_s_opens_and_bytes() { + use file_guard::Lock; + use std::io::Read; + use std::os::windows::fs::OpenOptionsExt; + let folder = std::env::temp_dir().join(format!("onestore-locks-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let path = folder.join("Locks.one"); + std::fs::write(&path, b"section").unwrap(); + let onenote = |write: bool| { + File::options() + .read(true) + .write(write) + .share_mode(if write { 0x5 } else { 0x7 }) + .open(&path) + .map(std::sync::Arc::new) + }; + let busy = |result: io::Result>>| { + result.is_err_and(|error| error.kind() == ErrorKind::WouldBlock) + }; + + let reading = FileIo::open(&path, false).unwrap(); + let writer = onenote(true).unwrap(); + let reader_byte = file_guard::try_lock(writer.clone(), Lock::Shared, 0xffff_fffb, 1); + assert!(reader_byte.is_ok(), "OneNote writes beside a reader"); + drop((reader_byte, writer)); + assert!(!busy(file_guard::try_lock( + onenote(false).unwrap(), + Lock::Exclusive, + 0xffff_fffd, + 1 + ))); + drop(reading); + + let writing = FileIo::open(&path, true).unwrap(); + let mut text = String::new(); + (&*onenote(false).unwrap()) + .read_to_string(&mut text) + .unwrap(); + assert_eq!(text, "section", "OneNote reads through a commit"); + assert!(onenote(true).is_err(), "a second writer can't open it"); + let other = onenote(false).unwrap(); + assert!(busy(file_guard::try_lock( + other, + Lock::Exclusive, + 0xffff_fffd, + 1 + ))); + drop(writing); + std::fs::remove_dir_all(&folder).unwrap(); + } } diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 1498298105192821ff455c7e5325a4f7db1caee0..708a3a25438b6121fb13e2fdfbb0454afc67d278 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -39,6 +39,30 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray", "NSRange", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileVersion", "NSFileManager", "NSNotification", "NSOperation", "block2"] } block2 = "0.5.1" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Globalization", + "Win32_Graphics_Dwm", + "Win32_Graphics_Gdi", + "Win32_Graphics_OpenGL", + "Win32_Security_Authentication_Identity", + "Win32_Security_Credentials", + "Win32_Storage_FileSystem", + "Win32_System_Com", + "Win32_System_IO", + "Win32_System_LibraryLoader", + "Win32_System_Registry", + "Win32_System_Threading", + "Win32_System_Time", + "Win32_System_WindowsProgramming", + "Win32_UI_Controls", + "Win32_UI_Controls_Dialogs", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } + [target.'cfg(target_os = "linux")'.dependencies] accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] } smithay-clipboard = "0.7.3" diff --git a/crates/snowbound/assets/windows/caption-close.svg b/crates/snowbound/assets/windows/caption-close.svg new file mode 100644 index 0000000000000000000000000000000000000000..eccccbafb9aeec5870b3f596884ba218ae5a9397 --- /dev/null +++ b/crates/snowbound/assets/windows/caption-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/windows/caption-maximize.svg b/crates/snowbound/assets/windows/caption-maximize.svg new file mode 100644 index 0000000000000000000000000000000000000000..616460ca805d724c5ee829e087f65ab4e9b7cc72 --- /dev/null +++ b/crates/snowbound/assets/windows/caption-maximize.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/windows/caption-minimize.svg b/crates/snowbound/assets/windows/caption-minimize.svg new file mode 100644 index 0000000000000000000000000000000000000000..202ec8a9b7638a4ea2a63c21fff2457ee9c69966 --- /dev/null +++ b/crates/snowbound/assets/windows/caption-minimize.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/windows/caption-restore.svg b/crates/snowbound/assets/windows/caption-restore.svg new file mode 100644 index 0000000000000000000000000000000000000000..7c45fa5271d52af3f0a9709c30af850c4110ffb6 --- /dev/null +++ b/crates/snowbound/assets/windows/caption-restore.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/build.rs b/crates/snowbound/build.rs new file mode 100644 index 0000000000000000000000000000000000000000..c0326ee89ed5b086255eba7ad7a826fb126b350c --- /dev/null +++ b/crates/snowbound/build.rs @@ -0,0 +1,26 @@ +//! On Windows, links in the executable's icon and manifest, compiled by llvm-mingw's windres, +//! which `platform/windows/cargo.sh` names. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let folder = std::path::Path::new("windows"); + for input in ["snowbound.rc", "snowbound.ico", "snowbound.manifest"] { + println!("cargo:rerun-if-changed={}", folder.join(input).display()); + } + println!("cargo:rerun-if-env-changed=LLVM_MINGW"); + let tools = std::env::var("LLVM_MINGW") + .expect("Windows builds need llvm-mingw: build through platform/windows/cargo.sh"); + let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap(); + let output = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("resources.o"); + let status = std::process::Command::new(format!("{tools}/bin/{arch}-w64-mingw32-windres")) + .current_dir(folder) + .arg("snowbound.rc") + .arg(&output) + .status() + .expect("windres runs"); + assert!(status.success(), "windres compiled the resources"); + println!("cargo:rustc-link-arg-bins={}", output.display()); +} diff --git a/crates/snowbound/src/icloud_linux.rs b/crates/snowbound/src/icloud_linux.rs index aa6efeb5dd0819c13586a8a0de0d11184d006bec..f55457a7e772261ccdb5564884772f845d9086b7 100644 --- a/crates/snowbound/src/icloud_linux.rs +++ b/crates/snowbound/src/icloud_linux.rs @@ -1,4 +1,4 @@ -//! iCloud Drive is Apple's; on Linux no folder is in it. +//! iCloud Drive is Apple's; on Linux and Windows no folder is in it. use notebook::session::{Background, Notebook, Section}; use std::path::{Path, PathBuf}; diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index f423a72c98de66374ab3d0f927a90fc063ca85d8..00f39b9e5e96855780cf67edf1169f6f6335dcdd 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -57,10 +57,24 @@ pub struct Mount { pub root: String, } +/// Where `path` sits below the mount point `point`, matched on its resolved path since +/// the system reports mount points with symlinks (`/tmp`, `/var`) resolved. +#[cfg(unix)] +pub fn within_mount(path: &std::path::Path, point: &str) -> Option { + let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned()); + Some( + path.strip_prefix(point) + .ok()? + .to_string_lossy() + .into_owned(), + ) +} + impl Mount { /// The mount of `source` (`//[domain;][user[:…]@]server/share[/folder]`), holding the /// folder `within` its mount point; `options` are the mount's (`username=`, `user=`, /// `domain=`), where the source names no account. + #[cfg_attr(windows, allow(dead_code))] pub fn parse(source: &str, within: &str, options: &str) -> Option { let source = source.strip_prefix("//")?; let (account, rest) = match source.rsplit_once('@') { @@ -876,6 +890,22 @@ pub fn locate(path: &Path) -> Located { mod tests { use super::*; + #[cfg(unix)] + #[test] + fn within_mount_resolves_symlinks() { + let base = std::env::temp_dir().join(format!("snowbound-within-{}", std::process::id())); + let point = base.join("point"); + std::fs::create_dir_all(point.join("notes")).unwrap(); + std::os::unix::fs::symlink(&point, base.join("link")).unwrap(); + let point = std::fs::canonicalize(&point).unwrap(); + let point = point.to_str().unwrap(); + let found = within_mount(&base.join("link/notes"), point); + let missing = within_mount(&base.join("link/gone"), point); + std::fs::remove_dir_all(&base).unwrap(); + assert_eq!(found.as_deref(), Some("notes")); + assert_eq!(missing, None); + } + /// Removes the folder at `path` on the share and everything in it. fn remove_tree(client: &Client, path: &str) { for entry in client.read_dir(path, 10_000).unwrap_or_default() { diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index b855de7caaf684fceae9bef3dfebd037f3dd19bc..a5531d423c2597dbd6ad1dd118fd398cf30f8a4f 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -762,7 +762,7 @@ pub fn cache_dir() -> Option { pub fn smb_mount(path: &std::path::Path) -> Option { let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?; // The mount holding the path is the one at its longest ancestor. - let (source, point, options) = mounts + let (source, point, options, within) = mounts .lines() .filter_map(|line| { let mut fields = line.split_whitespace(); @@ -772,16 +772,19 @@ pub fn smb_mount(path: &std::path::Path) -> Option { fields.next()?, fields.next()?, ); + if !matches!(kind, "cifs" | "smb3") { + return None; + } let point = point.replace("\\040", " "); - (matches!(kind, "cifs" | "smb3") && path.starts_with(&point)) - .then(|| (source.replace("\\040", " "), point, options.to_owned())) + let within = crate::library::within_mount(path, &point)?; + Some(( + source.replace("\\040", " "), + point, + options.to_owned(), + within, + )) }) - .max_by_key(|(_, point, _)| point.len())?; - let within = path - .strip_prefix(&point) - .ok()? - .to_string_lossy() - .into_owned(); + .max_by_key(|(_, point, _, _)| point.len())?; crate::library::Mount::parse(&source, &within, &options) } diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 5308be98c962c50f4aef34484a500aae3ba3f3a7..58fab3643cba5284e64fb749cdf036437103f56f 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -340,8 +340,8 @@ pub fn smb_mount(path: &std::path::Path) -> Option { if text(&mount.f_fstypename) != "smbfs" { return None; } - let within = path.strip_prefix(text(&mount.f_mntonname)).ok()?; - crate::library::Mount::parse(&text(&mount.f_mntfromname), &within.to_string_lossy(), "") + let within = crate::library::within_mount(path, &text(&mount.f_mntonname))?; + crate::library::Mount::parse(&text(&mount.f_mntfromname), &within, "") } #[allow(non_camel_case_types)] diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index d8b68d2c2da4285becde7cbc779185af4e5e4b26..9a57260a1271fd71e983715162da952e72872338 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1,16 +1,17 @@ +#![cfg_attr(windows, windows_subsystem = "windows")] #[cfg(target_os = "macos")] mod aqua; mod art; mod attachment; mod background; mod commands; -#[cfg(all(test, feature = "wgpu"))] +#[cfg(all(test, feature = "wgpu", not(windows)))] mod conflict_render; #[cfg(target_os = "linux")] #[path = "desktop_linux.rs"] mod desktop; mod history; -#[cfg_attr(target_os = "linux", path = "icloud_linux.rs")] +#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")] #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] mod icloud; mod library; @@ -18,6 +19,7 @@ mod link; mod manage; #[cfg_attr(target_os = "linux", path = "media_linux.rs")] #[cfg_attr(target_os = "macos", path = "media_macos.rs")] +#[cfg_attr(windows, path = "media_windows.rs")] mod media; mod meeting; #[cfg(target_os = "macos")] @@ -29,6 +31,7 @@ mod palette; mod pane; #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] +#[cfg_attr(windows, path = "windows.rs")] mod platform; mod recording; mod rename; @@ -39,8 +42,10 @@ mod settings; mod sidebar; #[cfg_attr(target_os = "linux", path = "spell_linux.rs")] #[cfg_attr(target_os = "macos", path = "spell_macos.rs")] +#[cfg_attr(windows, path = "spell_windows.rs")] mod spell; #[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")] +#[cfg_attr(windows, path = "surface_windows.rs")] mod surface; mod sync; mod tags; @@ -154,6 +159,7 @@ const ROW_GAP: f32 = 3.0; /// Space between the page and a page tab that isn't open. const PILL_MARGIN: f32 = 3.0; /// Why the platform's date dialog could not change the page's date. +#[cfg_attr(windows, allow(dead_code))] const DATE_UNCHOSEN: &str = "Choose another date or time."; const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range."; diff --git a/crates/snowbound/src/media_windows.rs b/crates/snowbound/src/media_windows.rs new file mode 100644 index 0000000000000000000000000000000000000000..8e9b5c98cc9a198b800d927d25537dff82c0b561 --- /dev/null +++ b/crates/snowbound/src/media_windows.rs @@ -0,0 +1,161 @@ +//! Audio through the Media Control Interface, which every Windows since 3.1 has: DirectShow's +//! `mpegvideo` device plays whatever codecs are installed (WAV, Windows Media, MP3), and +//! `waveaudio` records the default microphone as PCM WAV. Video isn't recorded here. + +use std::{ + path::Path, + sync::atomic::{AtomicU32, Ordering}, +}; + +#[link(name = "winmm")] +unsafe extern "system" { + fn mciSendStringW(command: *const u16, answer: *mut u16, length: u32, window: isize) -> u32; +} + +/// Sends `command`, answering its return string; none where MCI refuses it. +fn send(command: &str) -> Option { + let command: Vec = command.encode_utf16().chain([0]).collect(); + let mut answer = [0u16; 256]; + let error = unsafe { + mciSendStringW( + command.as_ptr(), + answer.as_mut_ptr(), + answer.len() as u32, + 0, + ) + }; + let end = answer.iter().position(|&unit| unit == 0).unwrap_or(0); + (error == 0).then(|| String::from_utf16_lossy(&answer[..end])) +} + +/// A device alias no other device of this process has. +fn alias(kind: &str) -> String { + static NEXT: AtomicU32 = AtomicU32::new(0); + format!("snowbound_{kind}{}", NEXT.fetch_add(1, Ordering::Relaxed)) +} + +/// `path` quoted for a command; none where a quote would end it early. +fn quoted(path: &Path) -> Option { + let path = path.to_str()?; + (!path.contains('"')).then(|| format!("\"{path}\"")) +} + +/// A recording or other audio file playing, paused or ended. +pub struct Player { + alias: String, + playing: bool, +} + +impl Player { + /// The file at `path`, ready to play, silent unless `audible`; none when no codec + /// decodes it. + pub fn open(path: &Path, audible: bool) -> Option { + let alias = alias("player"); + send(&format!( + "open {} type mpegvideo alias {alias}", + quoted(path)? + ))?; + let player = Self { + alias, + playing: false, + }; + send(&format!("set {} time format milliseconds", player.alias))?; + if !audible { + send(&format!("setaudio {} off", player.alias))?; + } + Some(player) + } + + pub fn play(&mut self) { + self.playing = send(&format!("play {}", self.alias)).is_some(); + } + + pub fn pause(&mut self) { + send(&format!("pause {}", self.alias)); + self.playing = false; + } + + pub fn playing(&self) -> bool { + self.playing && send(&format!("status {} mode", self.alias)).as_deref() == Some("playing") + } + + fn status(&self, item: &str) -> u32 { + send(&format!("status {} {item}", self.alias)) + .and_then(|answer| answer.trim().parse().ok()) + .unwrap_or(0) + } + + pub fn position_ms(&self) -> u32 { + self.status("position") + } + + pub fn duration_ms(&self) -> u32 { + self.status("length") + } + + pub fn seek(&mut self, ms: u32) { + send(&format!("seek {} to {ms}", self.alias)); + if self.playing { + self.play(); + } + } +} + +impl Drop for Player { + fn drop(&mut self) { + send(&format!("close {}", self.alias)); + } +} + +/// The default microphone recording to a file. +pub struct Recorder { + alias: String, + path: String, +} + +impl Recorder { + /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. + pub fn audio(path: &Path, rate: u32) -> Result { + let path = quoted(path).ok_or("Try recording again.")?; + let alias = alias("recorder"); + send(&format!("open new type waveaudio alias {alias}")) + .ok_or("Connect a microphone and try again.")?; + let recorder = Self { alias, path }; + let alias = &recorder.alias; + send(&format!( + "set {alias} time format milliseconds bitspersample 16 channels 1 \ + samplespersec {rate} bytespersec {} alignment 2", + rate * 2 + )) + .ok_or("Try recording again.")?; + send(&format!("record {alias}")).ok_or("Connect a microphone and try again.")?; + Ok(recorder) + } + + pub fn video(_: &Path) -> Result { + Err("Snowbound can't record video on Windows yet. Record audio instead.".into()) + } + + /// Pauses or resumes recording. + pub fn pause(&mut self, paused: bool) { + let command = if paused { "pause" } else { "resume" }; + send(&format!("{command} {}", self.alias)); + } + + /// Ends the recording and writes its file, with nothing left to run. + pub fn stop(self) -> Result Result<(), String> + Send, String> { + send(&format!("stop {}", self.alias)); + let saved = send(&format!("save {} {}", self.alias, self.path)); + drop(self); + match saved { + Some(_) => Ok(|| Ok(())), + None => Err("The recording couldn't be saved. Try recording again.".into()), + } + } +} + +impl Drop for Recorder { + fn drop(&mut self) { + send(&format!("close {}", self.alias)); + } +} diff --git a/crates/snowbound/src/pane.rs b/crates/snowbound/src/pane.rs index 8817dc1d36fcfd8df82b43d404a049b72b79b26e..5ab37abf056e316a355e491df9ea5039aec1dd45 100644 --- a/crates/snowbound/src/pane.rs +++ b/crates/snowbound/src/pane.rs @@ -163,12 +163,15 @@ impl TagScope { fn local_day(time: u64) -> i64 { let unix = time as i64 + 315_532_800; // SAFETY: `localtime_r` writes only the `tm` it is given. + #[cfg(unix)] let offset = unsafe { let mut tm: libc::tm = std::mem::zeroed(); libc::localtime_r(&unix, &mut tm); - tm.tm_gmtoff + tm.tm_gmtoff as i64 }; - (unix + offset as i64).div_euclid(86_400) + #[cfg(windows)] + let offset = crate::platform::utc_offset(unix); + (unix + offset).div_euclid(86_400) } /// Day `day` counted from 1970 as a year, month and day. diff --git a/crates/snowbound/src/spell_windows.rs b/crates/snowbound/src/spell_windows.rs new file mode 100644 index 0000000000000000000000000000000000000000..16e12dd24f2a1fcc5517ab05a928ef4fa78456a5 --- /dev/null +++ b/crates/snowbound/src/spell_windows.rs @@ -0,0 +1,240 @@ +//! Spelling through the Spell Checking API that Windows 8 added, with the dictionaries of +//! the languages installed; Windows 7 has none, and words go unmarked there. The checkers +//! live on a thread of their own in COM's multithreaded apartment, which every call reaches +//! through a channel, whatever thread it comes from. + +use canvas::spelling::{Dictionary, pick}; +use std::{ + collections::HashMap, + ffi::c_void, + sync::mpsc::{Sender, channel}, +}; +use windows_sys::{ + Win32::System::Com::{ + CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree, + }, + core::GUID, +}; + +/// CLSID_SpellCheckerFactory and IID_ISpellCheckerFactory. +const FACTORY: GUID = GUID::from_u128(0x7ab36653_1796_484b_bdfa_e74f1db7c1dc); +const IID_FACTORY: GUID = GUID::from_u128(0x8e018a9d_2415_4677_bf08_794ea61f94bb); + +/// A COM object: a pointer to its table of methods, which begins with IUnknown's. +struct Object(*mut *const usize); + +impl Object { + /// Method `index` of the object's table, of signature `F`. + unsafe fn method(&self, index: usize) -> F { + unsafe { std::mem::transmute_copy(&*(*self.0).add(index)) } + } + + fn this(&self) -> *mut c_void { + self.0.cast() + } +} + +impl Drop for Object { + fn drop(&mut self) { + type Release = unsafe extern "system" fn(*mut c_void) -> u32; + unsafe { self.method::(2)(self.this()) }; + } +} + +type Out = *mut *mut *const usize; + +/// The object a method answered through `out`, where it answered one. +fn answered(result: i32, out: *mut *const usize) -> Option { + (result >= 0 && !out.is_null()).then_some(Object(out)) +} + +/// The strings an IEnumString holds, freed as they are read. +fn strings(enumeration: Object) -> Vec { + type Next = unsafe extern "system" fn(*mut c_void, u32, *mut *mut u16, *mut u32) -> i32; + let mut all = Vec::new(); + loop { + let mut text = std::ptr::null_mut(); + let mut fetched = 0; + let result = unsafe { + enumeration.method::(3)(enumeration.this(), 1, &mut text, &mut fetched) + }; + if result != 0 || fetched == 0 || text.is_null() { + return all; + } + let length = (0..) + .take_while(|&at| unsafe { *text.add(at) } != 0) + .count(); + all.push(String::from_utf16_lossy(unsafe { + std::slice::from_raw_parts(text, length) + })); + unsafe { CoTaskMemFree(text.cast()) }; + } +} + +fn wide(text: &str) -> Vec { + text.encode_utf16().chain([0]).collect() +} + +/// The factory and the checkers made so far, owned by the spelling thread. +struct Checkers { + factory: Object, + /// The languages the system spells, as `en-US`. + languages: Vec, + by_language: HashMap, +} + +impl Checkers { + fn new() -> Option { + let mut factory = std::ptr::null_mut(); + let created = unsafe { + CoCreateInstance( + &FACTORY, + std::ptr::null_mut(), + CLSCTX_INPROC_SERVER, + &IID_FACTORY, + &mut factory, + ) + }; + let factory = answered(created, factory.cast())?; + type Languages = unsafe extern "system" fn(*mut c_void, Out) -> i32; + let mut languages = std::ptr::null_mut(); + let listed = unsafe { factory.method::(3)(factory.this(), &mut languages) }; + let languages = strings(answered(listed, languages)?); + Some(Self { + factory, + languages, + by_language: HashMap::new(), + }) + } + + /// The checker for `lcid`'s language, where the system spells it. + fn checker(&mut self, lcid: u32) -> Option<&Object> { + let language = pick(lcid, &self.languages)?.to_owned(); + if !self.by_language.contains_key(&language) { + type Create = unsafe extern "system" fn(*mut c_void, *const u16, Out) -> i32; + let mut checker = std::ptr::null_mut(); + let tag = wide(&language); + let factory = &self.factory; + let result = + unsafe { factory.method::(5)(factory.this(), tag.as_ptr(), &mut checker) }; + self.by_language + .insert(language.clone(), answered(result, checker)?); + } + self.by_language.get(&language) + } + + fn misspelled(&mut self, word: &str, lcid: u32) -> bool { + type Check = unsafe extern "system" fn(*mut c_void, *const u16, Out) -> i32; + type Next = unsafe extern "system" fn(*mut c_void, Out) -> i32; + let Some(checker) = self.checker(lcid) else { + return false; + }; + let text = wide(word); + let mut errors = std::ptr::null_mut(); + let checked = + unsafe { checker.method::(4)(checker.this(), text.as_ptr(), &mut errors) }; + let Some(errors) = answered(checked, errors) else { + return false; + }; + let mut error = std::ptr::null_mut(); + // S_OK with an error; S_FALSE once there are none. + let found = unsafe { errors.method::(3)(errors.this(), &mut error) }; + found == 0 && answered(found, error).is_some() + } + + fn suggest(&mut self, word: &str, lcid: u32) -> Vec { + type Suggest = unsafe extern "system" fn(*mut c_void, *const u16, Out) -> i32; + let Some(checker) = self.checker(lcid) else { + return Vec::new(); + }; + let text = wide(word); + let mut suggestions = std::ptr::null_mut(); + let result = unsafe { + checker.method::(5)(checker.this(), text.as_ptr(), &mut suggestions) + }; + answered(result, suggestions).map_or_else(Vec::new, strings) + } + + /// Adds `word` to the user's dictionary, which every language's checker shares. + fn learn(&mut self, word: &str) { + type Add = unsafe extern "system" fn(*mut c_void, *const u16) -> i32; + let checker = self.by_language.values().next(); + if let Some(checker) = checker { + let text = wide(word); + unsafe { checker.method::(6)(checker.this(), text.as_ptr()) }; + } + } +} + +enum Request { + Misspelled(Vec<(String, u32)>, Sender>), + Suggest(String, u32, Sender>), + Learn(String), +} + +struct Checker(Sender); + +impl Checker { + fn ask(&self, request: impl FnOnce(Sender) -> Request) -> R { + let (answer, answered) = channel(); + if self.0.send(request(answer)).is_err() { + return R::default(); + } + answered.recv().unwrap_or_default() + } +} + +impl Dictionary for Checker { + fn misspelled(&self, words: &[(&str, u32)]) -> Vec { + let words = words + .iter() + .map(|(word, lcid)| ((*word).to_owned(), *lcid)) + .collect(); + self.ask(|answer| Request::Misspelled(words, answer)) + } + + fn suggest(&self, word: &str, lcid: u32) -> Vec { + self.ask(|answer| Request::Suggest(word.to_owned(), lcid, answer)) + } + + fn learn(&self, word: &str) { + let _ = self.0.send(Request::Learn(word.to_owned())); + } +} + +/// The system's spell checker with the languages installed, from Windows 8. +pub fn dictionary() -> Option> { + let (requests, received) = channel(); + let (ready, started) = channel(); + std::thread::Builder::new() + .name("snowbound-spell".into()) + .spawn(move || { + unsafe { CoInitializeEx(std::ptr::null(), COINIT_MULTITHREADED as u32) }; + let Some(mut checkers) = Checkers::new() else { + let _ = ready.send(false); + return; + }; + let _ = ready.send(true); + for request in received { + match request { + Request::Misspelled(words, answer) => { + let _ = answer.send( + words + .iter() + .map(|(word, lcid)| checkers.misspelled(word, *lcid)) + .collect(), + ); + } + Request::Suggest(word, lcid, answer) => { + let _ = answer.send(checkers.suggest(&word, lcid)); + } + Request::Learn(word) => checkers.learn(&word), + } + } + }) + .ok()?; + started + .recv() + .ok()? + .then(|| Box::new(Checker(requests)) as Box) +} diff --git a/crates/snowbound/src/surface.rs b/crates/snowbound/src/surface.rs index 84a82401f932597898e612cdf67970d9b7fe2d0c..bdde702e1cfd4cc57457856b02460e7193cb41d5 100644 --- a/crates/snowbound/src/surface.rs +++ b/crates/snowbound/src/surface.rs @@ -9,6 +9,8 @@ pub struct Surface { /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. pub size: [u32; 2], window: Arc, + device: wgpu::Device, + queue: wgpu::Queue, instance: wgpu::Instance, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, @@ -17,14 +19,14 @@ pub struct Surface { } pub struct Frame { - pub target: wgpu::TextureView, - texture: wgpu::SurfaceTexture, - reconfigure: bool, + pub target: draw::Target, + pub(super) texture: wgpu::SurfaceTexture, + pub(super) reconfigure: bool, } pub struct Offscreen { - pub target: wgpu::TextureView, - texture: wgpu::Texture, + pub target: draw::Target, + pub(super) texture: wgpu::Texture, } impl Surface { @@ -34,9 +36,20 @@ impl Surface { window: Arc, backdrop: bool, ) -> Result<(Self, Renderer), Box> { - let instance = wgpu::Instance::new( - wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())), - ); + #[cfg_attr(not(windows), expect(unused_mut))] + let mut descriptor = + wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())); + // Direct3D 12, which Windows 10 and 11 always have, and through DirectComposition + // where the backdrop shows through; elsewhere `surface_windows` draws with OpenGL. + #[cfg(windows)] + { + descriptor.backends = wgpu::Backends::DX12; + if backdrop { + descriptor.backend_options.dx12.presentation_system = + wgpu::Dx12SwapchainKind::DxgiFromVisual; + } + } + let instance = wgpu::Instance::new(descriptor); let surface = instance.create_surface(window.clone())?; platform::configure_presentation(&surface); let adapter = instance @@ -50,27 +63,32 @@ impl Surface { let mut config = surface .get_default_config(&adapter, size.width, size.height) .ok_or("No supported canvas surface")?; + let alpha = surface.get_capabilities(&adapter).alpha_modes; let translucent = backdrop.then(|| { - // What wgpu calls post-multiplied is Core Animation's non-opaque layer, which - // composites colour premultiplied in sRGB, as `Translucent` leaves it. - config.alpha_mode = wgpu::CompositeAlphaMode::PostMultiplied; + // DirectComposition's premultiplied swap chains and Core Animation's non-opaque + // layers both composite colour premultiplied in sRGB, as `Translucent` leaves + // it; wgpu calls the second, the only one Metal offers, post-multiplied. + config.alpha_mode = if alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { + wgpu::CompositeAlphaMode::PreMultiplied + } else { + wgpu::CompositeAlphaMode::PostMultiplied + }; let window_format = config.format.remove_srgb_suffix(); config.view_formats.push(window_format); draw::Translucent::new(&device, config.format, window_format) }); - if platform::cuts_corners() { - let alpha = &surface.get_capabilities(&adapter).alpha_modes; - if alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { - config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied; - } + if platform::cuts_corners() && alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { + config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied; } surface.configure(&device, &config); eprintln!("Canvas GPU: {:?}", adapter.get_info()); - let renderer = Renderer::new(device, queue, config.format); + let renderer = Renderer::new(device.clone(), queue.clone(), config.format); Ok(( Self { size: [config.width, config.height], window, + device, + queue, instance, surface, config, @@ -85,9 +103,9 @@ impl Surface { self.translucent.is_some() } - pub fn configure(&mut self, renderer: &Renderer) { + pub fn configure(&mut self, _: &Renderer) { [self.config.width, self.config.height] = self.size; - self.surface.configure(&renderer.device, &self.config); + self.surface.configure(&self.device, &self.config); } /// The window's next frame, or none to skip this one. @@ -123,9 +141,10 @@ impl Surface { } }; let target = match &mut self.translucent { - Some(translucent) => translucent.target(&renderer.device, self.size), + Some(translucent) => translucent.target(&self.device, self.size), None => texture.texture.create_view(&Default::default()), }; + let target = target_of(target); Ok(Some(Frame { target, texture, @@ -142,10 +161,10 @@ impl Surface { format: Some(self.config.format.remove_srgb_suffix()), ..Default::default() }); - translucent.present(&renderer.device, &renderer.queue, &window); + translucent.present(&self.device, &self.queue, &window); } self.window.pre_present_notify(); - renderer.queue.present(frame.texture); + self.queue.present(frame.texture); platform::commit_presentation(&self.window); if frame.reconfigure { self.configure(renderer); @@ -153,8 +172,8 @@ impl Surface { } /// A target the size of the window's frames that `read` reads back. - pub fn offscreen(&self, renderer: &Renderer) -> Result> { - let texture = renderer.device.create_texture(&wgpu::TextureDescriptor { + pub fn offscreen(&self, _: &Renderer) -> Result> { + let texture = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("Snapshot"), size: wgpu::Extent3d { width: self.size[0], @@ -169,26 +188,22 @@ impl Surface { view_formats: &[], }); Ok(Offscreen { - target: texture.create_view(&Default::default()), + target: target_of(texture.create_view(&Default::default())), texture, }) } /// The offscreen target's sRGB RGBA rows, top first. - pub fn read( - &self, - renderer: &Renderer, - offscreen: Offscreen, - ) -> Result, Box> { + pub fn read(&self, _: &Renderer, offscreen: Offscreen) -> Result, Box> { let size = self.size; let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); - let buffer = renderer.device.create_buffer(&wgpu::BufferDescriptor { + let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Snapshot readback"), size: u64::from(row) * u64::from(size[1]), usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, }); - let mut encoder = renderer.device.create_command_encoder(&Default::default()); + let mut encoder = self.device.create_command_encoder(&Default::default()); encoder.copy_texture_to_buffer( offscreen.texture.as_image_copy(), wgpu::TexelCopyBufferInfo { @@ -201,9 +216,9 @@ impl Surface { }, offscreen.texture.size(), ); - renderer.queue.submit([encoder.finish()]); + self.queue.submit([encoder.finish()]); buffer.map_async(wgpu::MapMode::Read, .., |_| {}); - renderer.device.poll(wgpu::PollType::Wait { + self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(std::time::Duration::from_secs(5)), })?; @@ -223,3 +238,10 @@ impl Surface { Ok(pixels) } } + +/// `view` as what the renderer draws into, which is the view itself unless `draw` also +/// paints through OpenGL, as on Windows. +#[cfg_attr(not(windows), expect(clippy::useless_conversion))] +fn target_of(view: wgpu::TextureView) -> draw::Target { + view.into() +} diff --git a/crates/snowbound/src/surface_gl.rs b/crates/snowbound/src/surface_gl.rs index 3d0b620a7908ab4518361b3e405a3cb148fa7cb1..ef60f62dbcb27a4548003990a8ddeb6465706a04 100644 --- a/crates/snowbound/src/surface_gl.rs +++ b/crates/snowbound/src/surface_gl.rs @@ -69,7 +69,7 @@ impl Surface { } context }; - let renderer = Renderer::new()?; + let renderer = Renderer::opengl()?; eprintln!("Canvas GPU: OpenGL"); let size = window.inner_size(); Ok(( diff --git a/crates/snowbound/src/surface_windows.rs b/crates/snowbound/src/surface_windows.rs new file mode 100644 index 0000000000000000000000000000000000000000..bbf5d1beb0f89f504f3ffe120db2e7a3db016155 --- /dev/null +++ b/crates/snowbound/src/surface_windows.rs @@ -0,0 +1,228 @@ +//! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), and +//! otherwise, as on Windows 7, through OpenGL 2.1 on a WGL context: frames draw into an sRGB +//! target, copied to the window's back buffer and swapped. + +#[path = "surface.rs"] +mod webgpu; + +use draw::{GlTarget, Renderer, Target}; +use std::{error::Error, sync::Arc}; +use windows_sys::Win32::{ + Foundation::HWND, + Graphics::{ + Gdi::{GetDC, HDC}, + OpenGL as gl, + }, +}; +use winit::{ + raw_window_handle::{HasWindowHandle, RawWindowHandle}, + window::Window, +}; + +pub struct Surface { + /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. + pub size: [u32; 2], + backend: Backend, +} + +enum Backend { + Wgpu(Box), + Gl { + /// The window's device context, which the context draws to. + device: HDC, + /// Frames leave the desktop's glass showing through their transparent pixels. + translucent: bool, + /// The frame target, kept between frames. + target: Option, + }, +} + +pub struct Frame { + pub target: Target, + /// The swap chain's texture, and whether it asks for reconfiguring, where Direct3D + /// draws. + texture: Option<(wgpu::SurfaceTexture, bool)>, +} + +pub struct Offscreen { + pub target: Target, + texture: Option, +} + +impl Surface { + /// With `backdrop`, frames leave the system's material showing through their + /// transparent pixels. + pub async fn new( + window: Arc, + backdrop: bool, + ) -> Result<(Self, Renderer), Box> { + let forced = std::env::var("SNOWBOUND_RENDERER").ok(); + if forced.as_deref() != Some("gl") { + match webgpu::Surface::new(window.clone(), backdrop).await { + Ok((surface, renderer)) => { + let backend = Backend::Wgpu(Box::new(surface)); + let size = window.inner_size(); + let size = [size.width, size.height]; + return Ok((Self { size, backend }, renderer)); + } + Err(error) if forced.is_some() => return Err(error), + Err(error) => eprintln!("No Direct3D 12 ({error}); drawing with OpenGL"), + } + } + let RawWindowHandle::Win32(handle) = window.window_handle()?.as_raw() else { + unreachable!() + }; + let device = unsafe { GetDC(handle.hwnd.get() as HWND) }; + let alpha = if backdrop { 8 } else { 0 }; + let format = gl::PIXELFORMATDESCRIPTOR { + nSize: size_of::() as u16, + nVersion: 1, + // PFD_SUPPORT_COMPOSITION: the desktop composes the window's alpha. + dwFlags: gl::PFD_DRAW_TO_WINDOW + | gl::PFD_SUPPORT_OPENGL + | gl::PFD_DOUBLEBUFFER + | 0x8000, + iPixelType: gl::PFD_TYPE_RGBA, + cColorBits: 32, + cAlphaBits: alpha, + iLayerType: gl::PFD_MAIN_PLANE as u8, + ..unsafe { std::mem::zeroed() } + }; + unsafe { + let chosen = gl::ChoosePixelFormat(device, &format); + if chosen == 0 || gl::SetPixelFormat(device, chosen, &format) == 0 { + return Err("No OpenGL pixel format".into()); + } + let context = gl::wglCreateContext(device); + if context.is_null() || gl::wglMakeCurrent(device, context) == 0 { + return Err("No OpenGL context".into()); + } + // WGL_EXT_swap_control: present on the display's refresh. + type SwapInterval = unsafe extern "system" fn(i32) -> i32; + if let Some(interval) = gl::wglGetProcAddress(c"wglSwapIntervalEXT".as_ptr().cast()) { + std::mem::transmute:: isize, SwapInterval>(interval)( + 1, + ); + } + } + let renderer = Renderer::opengl()?; + eprintln!("Canvas GPU: OpenGL"); + let size = window.inner_size(); + Ok(( + Self { + size: [size.width, size.height], + backend: Backend::Gl { + device, + translucent: backdrop, + target: None, + }, + }, + renderer, + )) + } + + /// Whether frames leave the system's backdrop showing. + pub fn translucent(&self) -> bool { + match &self.backend { + Backend::Wgpu(surface) => surface.translucent(), + Backend::Gl { translucent, .. } => *translucent, + } + } + + pub fn configure(&mut self, renderer: &Renderer) { + if let Backend::Wgpu(surface) = &mut self.backend { + surface.size = self.size; + surface.configure(renderer); + } + } + + /// The window's next frame, or none to skip this one. + pub fn frame(&mut self, renderer: &Renderer) -> Result, Box> { + if self.size.contains(&0) { + return Ok(None); + } + match &mut self.backend { + Backend::Wgpu(surface) => { + surface.size = self.size; + Ok(surface.frame(renderer)?.map(|frame| Frame { + target: frame.target, + texture: Some((frame.texture, frame.reconfigure)), + })) + } + Backend::Gl { target, .. } => { + let target = match target.take() { + Some(target) if target.size() == self.size => target, + _ => GlTarget::new(self.size)?, + }; + Ok(Some(Frame { + target: target.into(), + texture: None, + })) + } + } + } + + pub fn present(&mut self, renderer: &Renderer, frame: Frame) { + match (&mut self.backend, frame.target, frame.texture) { + (Backend::Wgpu(surface), target, Some((texture, reconfigure))) => { + let frame = webgpu::Frame { + target, + texture, + reconfigure, + }; + surface.present(renderer, frame); + } + ( + Backend::Gl { + device, + translucent, + target: kept, + }, + Target::Gl(target), + None, + ) => { + if *translucent { + renderer.present_translucent(&target); + } else { + target.present(); + } + unsafe { gl::SwapBuffers(*device) }; + *kept = Some(target); + } + _ => unreachable!("Frames come from the surface's own backend"), + } + } + + /// A target the size of the window's frames that `read` reads back. + pub fn offscreen(&mut self, renderer: &Renderer) -> Result> { + match &mut self.backend { + Backend::Wgpu(surface) => { + surface.size = self.size; + let offscreen = surface.offscreen(renderer)?; + Ok(Offscreen { + target: offscreen.target, + texture: Some(offscreen.texture), + }) + } + Backend::Gl { .. } => Ok(Offscreen { + target: GlTarget::new(self.size)?.into(), + texture: None, + }), + } + } + + /// The offscreen target's sRGB RGBA rows, top first. + pub fn read( + &self, + renderer: &Renderer, + offscreen: Offscreen, + ) -> Result, Box> { + match (&self.backend, offscreen.target, offscreen.texture) { + (Backend::Wgpu(surface), target, Some(texture)) => { + surface.read(renderer, webgpu::Offscreen { target, texture }) + } + (Backend::Gl { .. }, Target::Gl(target), None) => Ok(target.read_pixels()), + _ => unreachable!("Snapshots come from the surface's own backend"), + } + } +} diff --git a/crates/snowbound/src/update.rs b/crates/snowbound/src/update.rs index d9a1571649741dd6ecd656aa4b90ba5804025cc4..fceaa6392ebef81163c99268a14be316167653ed 100644 --- a/crates/snowbound/src/update.rs +++ b/crates/snowbound/src/update.rs @@ -207,6 +207,8 @@ fn staging(install: &Path) -> Option { fn staged(folder: &Path) -> PathBuf { folder.join(if cfg!(target_os = "macos") { "Snowbound.app" + } else if cfg!(windows) { + "snowbound.exe" } else { "snowbound" }) @@ -236,6 +238,7 @@ fn stage(bytes: &[u8], folder: &Path, version: &Version) -> std::io::Result std::io::Result std::io::Result<()> { let folder = staging(install).ok_or_else(|| std::io::Error::other("No install name"))?; + let old = folder.with_extension("old"); if install.is_dir() { - let old = folder.with_extension("old"); let _ = std::fs::remove_dir_all(&old); - std::fs::rename(install, &old)?; - if let Err(error) = std::fs::rename(staged, install) { - std::fs::rename(&old, install)?; - return Err(error); - } + } else { + let _ = std::fs::remove_file(&old); + } + // Windows renames a running executable, as the finisher is, but doesn't replace it. + std::fs::rename(install, &old)?; + if let Err(error) = std::fs::rename(staged, install) { + std::fs::rename(&old, install)?; + return Err(error); + } + if install.is_dir() { let _ = std::fs::remove_dir_all(&old); } else { - std::fs::rename(staged, install)?; + // Where it is still running, the next update removes it. + let _ = std::fs::remove_file(&old); } let _ = std::fs::remove_dir_all(folder); Ok(()) @@ -536,16 +545,11 @@ pub fn relaunch(staged: &Path) -> std::io::Result<()> { /// for the app to quit, swaps the update in and opens the app, updated or not. pub fn finish(mut args: impl Iterator) -> Result<(), Box> { let usage = "Usage: snowbound --finish-update PID STAGED INSTALL [ARGUMENT]..."; - let pid: libc::pid_t = args.next().ok_or(usage)?.to_str().ok_or(usage)?.parse()?; + let pid: u32 = args.next().ok_or(usage)?.to_str().ok_or(usage)?.parse()?; let staged = PathBuf::from(args.next().ok_or(usage)?); let install = PathBuf::from(args.next().ok_or(usage)?); - let deadline = Instant::now() + Duration::from_secs(60); - // Signal 0 only asks whether the process is still there. - while unsafe { libc::kill(pid, 0) } == 0 { - if Instant::now() > deadline { - return Err("Snowbound didn’t quit, so the update wasn’t installed.".into()); - } - std::thread::sleep(Duration::from_millis(100)); + if !quits(pid, Duration::from_secs(60)) { + return Err("Snowbound didn’t quit, so the update wasn’t installed.".into()); } let applied = apply(&staged, &install); let mut open = if cfg!(target_os = "macos") { @@ -565,6 +569,38 @@ pub fn finish(mut args: impl Iterator) -> Result<(), Box bool { + let deadline = Instant::now() + patience; + // Signal 0 only asks whether the process is still there. + while unsafe { libc::kill(pid as libc::pid_t, 0) } == 0 { + if Instant::now() > deadline { + return false; + } + std::thread::sleep(Duration::from_millis(100)); + } + true +} + +#[cfg(windows)] +fn quits(pid: u32, patience: Duration) -> bool { + use windows_sys::Win32::{ + Foundation::{CloseHandle, WAIT_TIMEOUT}, + System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject}, + }; + unsafe { + let process = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); + // A process that can't be opened has gone. + if process.is_null() { + return true; + } + let waited = WaitForSingleObject(process, patience.as_millis() as u32); + CloseHandle(process); + waited != WAIT_TIMEOUT + } +} + #[cfg(test)] mod tests { use super::*; @@ -892,11 +928,7 @@ mod tests { #[ignore = "reads the published builds"] fn the_published_build_installs() { let folder = scratch("published"); - let install = folder.join(if cfg!(target_os = "macos") { - "Snowbound.app" - } else { - "snowbound" - }); + let install = staged(&folder); let old = version("2000-01-01-r1"); let status = check( &download, diff --git a/crates/snowbound/src/watch.rs b/crates/snowbound/src/watch.rs index 8bc68799dc31e8785d96e0c11ebb6228ff2bc8a9..9632bc156064afbc8e59e1a5a2b1f1380b6922c4 100644 --- a/crates/snowbound/src/watch.rs +++ b/crates/snowbound/src/watch.rs @@ -1,6 +1,7 @@ //! Changes below a notebook folder, as the system reports them: FSEvents on macOS, inotify on -//! Linux. A notebook on a share learns of its changes from the server instead -//! (`notebook::session::Background::smb`). +//! Linux, ReadDirectoryChangesW on Windows. A notebook on a share learns of its changes from +//! the server instead (`notebook::session::Background::smb`), except on Windows, whose own +//! SMB client relays the server's change notifications. use std::path::{Path, PathBuf}; @@ -22,19 +23,7 @@ pub fn watch(root: &Path, changed: impl Fn(Vec) + Send + Sync + 'static) let folders = if local(&volume) { vec![root.clone()] } else if smbfs(&volume) { - // smbfs reports another client's change only in a folder watched for itself, and - // names just that folder. - let mut folders = vec![root.clone()]; - let mut at = 0; - while let Some(folder) = folders.get(at).cloned() { - at += 1; - for entry in std::fs::read_dir(folder).into_iter().flatten().flatten() { - if entry.file_type().is_ok_and(|kind| kind.is_dir()) { - folders.push(entry.path()); - } - } - } - folders + share_folders(&root) } else { return None; }; @@ -72,6 +61,30 @@ pub fn on_this_computer(path: &Path) -> bool { statfs(path).is_none_or(|volume| local(&volume)) } +/// smbfs reports another client's change only in a folder watched for itself, and names +/// just that folder. +#[cfg(unix)] +fn share_folders(root: &Path) -> Vec { + let mut folders = vec![root.to_owned()]; + let mut at = 0; + while let Some(folder) = folders.get(at).cloned() { + at += 1; + for entry in std::fs::read_dir(folder).into_iter().flatten().flatten() { + if entry.file_type().is_ok_and(|kind| kind.is_dir()) { + folders.push(entry.path()); + } + } + } + folders +} + +/// Windows' SMB client watches a whole tree for the server to report on. +#[cfg(windows)] +fn share_folders(root: &Path) -> Vec { + vec![root.to_owned()] +} + +#[cfg(unix)] fn statfs(path: &Path) -> Option { use std::os::unix::ffi::OsStrExt; let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?; @@ -112,6 +125,30 @@ fn smbfs(_: &libc::statfs) -> bool { false } +/// The type of the drive holding `path`, as `GetDriveTypeW` names it. +#[cfg(windows)] +fn statfs(path: &Path) -> Option { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{GetDriveTypeW, GetVolumePathNameW}; + let path: Vec = path.as_os_str().encode_wide().chain([0]).collect(); + let mut root = [0u16; 1024]; + unsafe { + (GetVolumePathNameW(path.as_ptr(), root.as_mut_ptr(), root.len() as u32) != 0) + .then(|| GetDriveTypeW(root.as_ptr())) + } +} + +#[cfg(windows)] +fn local(drive: &u32) -> bool { + *drive != windows_sys::Win32::System::WindowsProgramming::DRIVE_REMOTE +} + +/// A network drive or UNC path, which Windows reaches over SMB. +#[cfg(windows)] +fn smbfs(drive: &u32) -> bool { + !local(drive) +} + type Changed = Box) + Send + Sync>; #[cfg(target_os = "macos")] @@ -467,6 +504,170 @@ mod platform { } } +#[cfg(windows)] +mod platform { + use super::Changed; + use std::{io, os::windows::ffi::OsStrExt, path::PathBuf, thread::JoinHandle}; + use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0}, + Storage::FileSystem::{ + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, FILE_LIST_DIRECTORY, + FILE_NOTIFY_CHANGE_ATTRIBUTES, FILE_NOTIFY_CHANGE_DIR_NAME, + FILE_NOTIFY_CHANGE_FILE_NAME, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_CHANGE_SIZE, + FILE_NOTIFY_INFORMATION, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, ReadDirectoryChangesW, + }, + System::{ + IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED}, + Threading::{CreateEventW, INFINITE, SetEvent, WaitForMultipleObjects}, + }, + }; + + /// A thread waiting on the folder's changes, or on being stopped. + pub struct Stream { + stop: HANDLE, + thread: Option>, + } + + // The handle is only signalled and closed, each safe from any thread. + unsafe impl Send for Stream {} + unsafe impl Sync for Stream {} + + /// Owns a handle for a thread that moves it. + struct Owned(HANDLE); + unsafe impl Send for Owned {} + + impl Stream { + /// Watches the tree at `folders[0]`, which Windows reports on as a whole. + pub fn start(folders: &[PathBuf], changed: Changed) -> io::Result { + let root = folders[0].clone(); + let name: Vec = root.as_os_str().encode_wide().chain([0]).collect(); + let folder = unsafe { + CreateFileW( + name.as_ptr(), + FILE_LIST_DIRECTORY, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, + std::ptr::null_mut(), + ) + }; + if folder == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let [stop, done] = + [(); 2].map(|_| unsafe { CreateEventW(std::ptr::null(), 1, 0, std::ptr::null()) }); + if stop.is_null() || done.is_null() { + let error = io::Error::last_os_error(); + unsafe { CloseHandle(folder) }; + return Err(error); + } + let handles = [Owned(folder), Owned(stop), Owned(done)]; + let thread = std::thread::Builder::new() + .name("snowbound-watch".into()) + .spawn(move || { + let [folder, stop, done] = handles.map(|handle| handle.0); + watch(folder, stop, done, &root, &changed); + unsafe { + CloseHandle(folder); + CloseHandle(done); + } + })?; + Ok(Self { + stop, + thread: Some(thread), + }) + } + } + + /// Reports the changes below `root`, whose folder is open as `folder`, until `stop`. + fn watch( + folder: HANDLE, + stop: HANDLE, + done: HANDLE, + root: &std::path::Path, + changed: &Changed, + ) { + // 64 KiB, the most a change notification carries over SMB; DWORD-aligned. + let mut buffer = vec![0u32; 16 * 1024]; + let filter = FILE_NOTIFY_CHANGE_FILE_NAME + | FILE_NOTIFY_CHANGE_DIR_NAME + | FILE_NOTIFY_CHANGE_ATTRIBUTES + | FILE_NOTIFY_CHANGE_SIZE + | FILE_NOTIFY_CHANGE_LAST_WRITE; + loop { + let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; + overlapped.hEvent = done; + let asked = unsafe { + ReadDirectoryChangesW( + folder, + buffer.as_mut_ptr().cast(), + (buffer.len() * 4) as u32, + 1, + filter, + std::ptr::null_mut(), + &mut overlapped, + None, + ) + }; + if asked == 0 { + return; + } + let woken = unsafe { WaitForMultipleObjects(2, [done, stop].as_ptr(), 0, INFINITE) }; + let mut read = 0; + let finished = woken == WAIT_OBJECT_0 + && unsafe { GetOverlappedResult(folder, &overlapped, &mut read, 0) } != 0; + if !finished { + // The request must end before its buffer does. + unsafe { + CancelIoEx(folder, &overlapped); + GetOverlappedResult(folder, &overlapped, &mut read, 1); + } + return; + } + // Too many changes for the buffer: something below the root changed. + if read == 0 { + changed(vec![root.to_owned()]); + continue; + } + let bytes: &[u8] = + unsafe { std::slice::from_raw_parts(buffer.as_ptr().cast(), read as usize) }; + let mut paths = Vec::new(); + let mut at = 0; + loop { + let entry: FILE_NOTIFY_INFORMATION = + unsafe { std::ptr::read_unaligned(bytes[at..].as_ptr().cast()) }; + let start = at + std::mem::offset_of!(FILE_NOTIFY_INFORMATION, FileName); + let end = (start + entry.FileNameLength as usize).min(bytes.len()); + let name: Vec = bytes[start..end] + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + paths.push(root.join(String::from_utf16_lossy(&name))); + if entry.NextEntryOffset == 0 { + break; + } + at += entry.NextEntryOffset as usize; + if at >= bytes.len() { + break; + } + } + changed(paths); + } + } + + impl Drop for Stream { + fn drop(&mut self) { + unsafe { SetEvent(self.stop) }; + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + unsafe { CloseHandle(self.stop) }; + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs new file mode 100644 index 0000000000000000000000000000000000000000..8580a477902d3429c7352fe8fd6cae3a5efade1b --- /dev/null +++ b/crates/snowbound/src/windows.rs @@ -0,0 +1,1428 @@ +//! Windows 7 to 11. Where the desktop composes windows, the toolbar's row is the title bar: +//! over Aero glass on 7 and Mica on 11, beside the system's own caption buttons, and on 8 +//! and 10 with caption buttons drawn as theirs, over 10's acrylic. Dialogs are the +//! system's, passwords live in the Credential Manager, and dates follow the user's locale. +//! Anything newer than Windows 7 is looked up at run time, so one executable starts on all +//! of them. + +use canvas::date::DateField; +use std::{ + ffi::c_void, + path::PathBuf, + sync::{ + OnceLock, + atomic::{AtomicBool, AtomicIsize, AtomicU32, Ordering}, + }, + time::Duration, +}; +use ui::{Flags, Spec, Ui, px}; +use windows_sys::Win32::{ + Foundation::{FILETIME, HWND, LPARAM, LRESULT, POINT, RECT, SYSTEMTIME, WPARAM}, + Graphics::{Dwm, Gdi}, + System::LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW}, + UI::{Controls::MARGINS, WindowsAndMessaging as wm}, +}; +use windows_sys::core::BOOL; +use winit::{ + error::EventLoopError, + event_loop::{EventLoop, EventLoopProxy}, + platform::windows::{IconExtWindows, WindowAttributesExtWindows}, + raw_window_handle::{HasWindowHandle, RawWindowHandle}, + window::{Icon, Theme, Window, WindowAttributes}, +}; + +/// The title bar's leading margin. +pub const LEADING: f32 = 8.0; +/// The margin past the caption buttons, which the row's last gap already makes; the +/// buttons slide over it to the window's corner. +pub const TRAILING: f32 = GAP; +const GAP: f32 = 6.0; +/// A drawn caption button's width, as Windows 10 draws its own. +const CAPTION_BUTTON: f32 = 46.0; +/// How far inside the window's edges a press resizes it, at 96 dpi. +const EDGE: f32 = 8.0; + +static QUIT: OnceLock> = OnceLock::new(); +/// The window, which dialogs belong to. +static WINDOW: AtomicIsize = AtomicIsize::new(0); +/// The window procedure the frame's own passes the rest to. +static PREVIOUS: AtomicIsize = AtomicIsize::new(0); +/// Whether the desktop composes windows, which Windows 7's basic and classic themes don't. +static COMPOSED: AtomicBool = AtomicBool::new(false); +/// The window's scale factor, as `f32` bits, for hit-testing outside a frame. +static SCALE: AtomicU32 = AtomicU32::new(0x3f80_0000); +/// The caption button under the pointer, by its hit-test code: as the system reports it +/// over Mica, where the system runs them, and as the row saw it for the close button, +/// whose glyph turns white on red. +static CAPTION_HOVERED: AtomicU32 = AtomicU32::new(0); + +/// The Windows release: its major and minor version and its build. +fn version() -> (u32, u32, u32) { + static VERSION: OnceLock<(u32, u32, u32)> = OnceLock::new(); + *VERSION.get_or_init(|| { + // GetVersionEx answers as the manifest allows; ntdll's RtlGetVersion never lies. + #[repr(C)] + struct Info { + size: u32, + major: u32, + minor: u32, + build: u32, + platform: u32, + service_pack: [u16; 128], + } + let mut info = Info { + size: size_of::() as u32, + major: 6, + minor: 1, + build: 7601, + platform: 0, + service_pack: [0; 128], + }; + if let Some(get) = + function:: i32>("ntdll.dll", c"RtlGetVersion") + { + unsafe { get(&mut info) }; + } + (info.major, info.minor, info.build) + }) +} + +/// Windows 11, whose windows have rounded corners and Mica. +fn eleven() -> bool { + version().0 >= 10 && version().2 >= 22000 +} + +/// Who draws the caption buttons. +#[derive(Clone, Copy, PartialEq)] +enum Caption { + /// The system, in its own title bar: Windows 7 while the desktop doesn't compose. + System, + /// The system, over the glass or Mica the row shows through: Windows 7 composing, 11. + Glass, + /// The row, as the system draws them: Windows 8 and 10, whose frames are opaque. + Drawn, +} + +fn caption() -> Caption { + if eleven() || (version() < (6, 2, 0) && COMPOSED.load(Ordering::Relaxed)) { + Caption::Glass + } else if version() < (6, 2, 0) { + Caption::System + } else { + Caption::Drawn + } +} + +/// Function `name` of `library`, of signature `F`, where this Windows has it. +fn function(library: &str, name: &std::ffi::CStr) -> Option { + let library = wide(library); + unsafe { + let mut module = GetModuleHandleW(library.as_ptr()); + if module.is_null() { + module = LoadLibraryW(library.as_ptr()); + } + let address = GetProcAddress(module, name.as_ptr().cast())?; + Some(std::mem::transmute_copy(&address)) + } +} + +/// `text` as a NUL-terminated UTF-16 string. +fn wide(text: impl AsRef) -> Vec { + use std::os::windows::ffi::OsStrExt; + text.as_ref().encode_wide().chain([0]).collect() +} + +/// The UTF-16 string at `text` up to its first NUL. +fn narrow(text: &[u16]) -> String { + let end = text + .iter() + .position(|&unit| unit == 0) + .unwrap_or(text.len()); + String::from_utf16_lossy(&text[..end]) +} + +fn hwnd(window: &Window) -> HWND { + match window.window_handle().expect("Live window").as_raw() { + RawWindowHandle::Win32(handle) => handle.hwnd.get() as HWND, + _ => unreachable!(), + } +} + +/// The window dialogs belong to, once there is one. +fn owner() -> HWND { + WINDOW.load(Ordering::Relaxed) as HWND +} + +fn composed() -> bool { + let mut on: BOOL = 0; + unsafe { Dwm::DwmIsCompositionEnabled(&mut on) >= 0 && on != 0 } +} + +pub fn event_loop(_headless: bool) -> Result, EventLoopError> { + let event_loop = EventLoop::with_user_event().build()?; + QUIT.set(event_loop.create_proxy()) + .expect("Only one application event loop is created"); + COMPOSED.store(composed(), Ordering::Relaxed); + Ok(event_loop) +} + +/// The executable's icon, and where the row draws the caption buttons, no system frame: +/// the desktop draws the window's shadow. +pub fn window_attributes() -> WindowAttributes { + let icon = |size| Icon::from_resource(1, Some(winit::dpi::PhysicalSize::new(size, size))); + Window::default_attributes() + .with_window_icon(icon(16).ok()) + .with_taskbar_icon(icon(32).ok()) + .with_decorations(caption() != Caption::Drawn) + .with_undecorated_shadow(true) +} + +/// Takes the window's frame messages ahead of winit's: over glass or Mica, the caption +/// leaves the non-client area for the row, beside the system's caption buttons; with drawn +/// buttons, the window's edges resize it. +pub fn install_title_bar(window: &Window) { + let hwnd = hwnd(window); + WINDOW.store(hwnd as isize, Ordering::Relaxed); + SCALE.store((window.scale_factor() as f32).to_bits(), Ordering::Relaxed); + let procedure = frame_procedure as *const () as isize; + PREVIOUS.store( + unsafe { wm::SetWindowLongPtrW(hwnd, wm::GWLP_WNDPROC, procedure) }, + Ordering::Relaxed, + ); + reframe(hwnd); +} + +/// Has the system measure the window's frame again. +fn reframe(hwnd: HWND) { + let flags = wm::SWP_FRAMECHANGED | wm::SWP_NOMOVE | wm::SWP_NOSIZE | wm::SWP_NOZORDER; + unsafe { wm::SetWindowPos(hwnd, std::ptr::null_mut(), 0, 0, 0, 0, flags) }; +} + +fn set_attribute(hwnd: HWND, attribute: u32, value: &T) -> bool { + unsafe { + Dwm::DwmSetWindowAttribute( + hwnd, + attribute, + (value as *const T).cast(), + size_of::() as u32, + ) >= 0 + } +} + +/// The system's frame under the whole window, which the row and the rest of the chrome +/// show through. +fn extend_frame(hwnd: HWND) { + let sheet = MARGINS { + cxLeftWidth: -1, + cxRightWidth: -1, + cyTopHeight: -1, + cyBottomHeight: -1, + }; + unsafe { Dwm::DwmExtendFrameIntoClientArea(hwnd, &sheet) }; +} + +unsafe extern "system" fn frame_procedure( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + let previous = |message, wparam, lparam| unsafe { + let procedure: wm::WNDPROC = std::mem::transmute(PREVIOUS.load(Ordering::Relaxed)); + wm::CallWindowProcW(procedure, hwnd, message, wparam, lparam) + }; + match message { + // Accent colours and transparency effects reach the window only as settings. + wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => { + if let Some(proxy) = QUIT.get() { + let _ = proxy.send_event(crate::UserEvent::Appearance); + } + } + wm::WM_DWMCOMPOSITIONCHANGED => { + COMPOSED.store(composed(), Ordering::Relaxed); + reframe(hwnd); + } + _ => {} + } + let result = match caption() { + Caption::System => None, + Caption::Glass => glass_frame(hwnd, message, wparam, lparam, &previous), + Caption::Drawn => drawn_frame(hwnd, message, wparam, lparam, &previous), + }; + result.unwrap_or_else(|| previous(message, wparam, lparam)) +} + +type Procedure<'a> = &'a dyn Fn(u32, WPARAM, LPARAM) -> LRESULT; + +/// The frame over glass or Mica: the system's frame keeps its sides and bottom, the caption +/// goes, and the system draws and hit-tests its caption buttons over the row. +fn glass_frame( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + previous: Procedure<'_>, +) -> Option { + if eleven() { + light_caption(hwnd, message, wparam); + } + let mut result = 0; + if unsafe { Dwm::DwmDefWindowProc(hwnd, message, wparam, lparam, &mut result) } != 0 { + return Some(result); + } + let maximized = unsafe { wm::IsZoomed(hwnd) } != 0; + match message { + wm::WM_NCCALCSIZE if wparam != 0 => { + let params = unsafe { &mut *(lparam as *mut wm::NCCALCSIZE_PARAMS) }; + let top = params.rgrc[0].top; + let result = previous(message, wparam, lparam); + // A maximized window's frame lies past the screen's edge. + params.rgrc[0].top = top + if maximized { frame_size() } else { 0 }; + Some(result) + } + wm::WM_NCHITTEST => { + let hit = previous(message, wparam, lparam); + let top = client_point(hwnd, lparam).is_some_and(|point| point.y < frame_size()); + Some(if hit == wm::HTCLIENT as LRESULT && top && !maximized { + wm::HTTOP as LRESULT + } else { + hit + }) + } + wm::WM_ACTIVATE | wm::WM_DWMCOMPOSITIONCHANGED => { + extend_frame(hwnd); + None + } + _ => None, + } +} + +/// Follows the pointer over the system's caption buttons, which the row draws on Windows 11. +fn light_caption(hwnd: HWND, message: u32, wparam: WPARAM) { + use windows_sys::Win32::UI::Input::KeyboardAndMouse as input; + let over = match message { + wm::WM_NCMOUSEMOVE => match wparam as u32 { + hit @ (wm::HTMINBUTTON | wm::HTMAXBUTTON | wm::HTCLOSE) => hit, + _ => 0, + }, + wm::WM_NCMOUSELEAVE => 0, + _ => return, + }; + if CAPTION_HOVERED.swap(over, Ordering::Relaxed) == over { + return; + } + if over != 0 { + let mut track = input::TRACKMOUSEEVENT { + cbSize: size_of::() as u32, + dwFlags: input::TME_LEAVE | input::TME_NONCLIENT, + hwndTrack: hwnd, + dwHoverTime: 0, + }; + unsafe { input::TrackMouseEvent(&mut track) }; + } + unsafe { Gdi::InvalidateRect(hwnd, std::ptr::null(), 0) }; +} + +/// The system frame's thickness at the top, where the caption went. +fn frame_size() -> i32 { + unsafe { wm::GetSystemMetrics(wm::SM_CYFRAME) + wm::GetSystemMetrics(wm::SM_CXPADDEDBORDER) } +} + +/// The screen point in a hit-test's `lparam` in the window's client coordinates. +fn client_point(hwnd: HWND, lparam: LPARAM) -> Option { + let mut point = POINT { + x: (lparam & 0xffff) as i16 as i32, + y: ((lparam >> 16) & 0xffff) as i16 as i32, + }; + (unsafe { Gdi::ScreenToClient(hwnd, &mut point) } != 0).then_some(point) +} + +/// The frame with drawn caption buttons, where winit has made the whole window its client +/// area: its edges resize it. +fn drawn_frame( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + previous: Procedure<'_>, +) -> Option { + match message { + // -1 keeps the system from painting a caption the window no longer has. + wm::WM_NCACTIVATE => Some(previous(message, wparam, -1)), + wm::WM_NCHITTEST => { + let point = client_point(hwnd, lparam)?; + let style = unsafe { wm::GetWindowLongW(hwnd, wm::GWL_STYLE) } as u32; + // Maximized or full screen, the window has no edges. + if unsafe { wm::IsZoomed(hwnd) } != 0 || style & wm::WS_CAPTION != wm::WS_CAPTION { + return None; + } + let mut client = RECT::default(); + unsafe { wm::GetClientRect(hwnd, &mut client) }; + let edge = (EDGE * f32::from_bits(SCALE.load(Ordering::Relaxed))).round() as i32; + let hit = match [ + point.y < edge, + point.y >= client.bottom - edge, + point.x < edge, + point.x >= client.right - edge, + ] { + [true, _, true, _] => wm::HTTOPLEFT, + [true, _, _, true] => wm::HTTOPRIGHT, + [_, true, true, _] => wm::HTBOTTOMLEFT, + [_, true, _, true] => wm::HTBOTTOMRIGHT, + [true, ..] => wm::HTTOP, + [_, true, ..] => wm::HTBOTTOM, + [.., true, _] => wm::HTLEFT, + [.., true] => wm::HTRIGHT, + _ => return None, + }; + Some(hit as LRESULT) + } + // Windows 10's acrylic lags a window being dragged or resized; plain blur doesn't. + wm::WM_ENTERSIZEMOVE if ACRYLIC.load(Ordering::Relaxed) => { + accent(hwnd, Accent::Blur); + None + } + wm::WM_EXITSIZEMOVE if ACRYLIC.load(Ordering::Relaxed) => { + accent(hwnd, Accent::Acrylic(ACRYLIC_TINT.load(Ordering::Relaxed))); + None + } + _ => None, + } +} + +/// Where the row is not the title bar: Windows 7 with its basic or classic theme, whose +/// desktop draws no glass and whose title bars the system draws. +pub fn system_titlebar(_: &Window) -> bool { + caption() == Caption::System +} + +/// Windows 11 rounds a window's corners unless it is maximized; the desktop clips them. +pub fn corner_radius(window: &Window) -> f32 { + if eleven() && !window.is_maximized() && window.fullscreen().is_none() { + 8.0 + } else { + 0.0 + } +} + +pub fn cuts_corners() -> bool { + false +} + +/// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur, +/// and the tint it shows it with. +static ACRYLIC: AtomicBool = AtomicBool::new(false); +static ACRYLIC_TINT: AtomicU32 = AtomicU32::new(0); + +enum Accent { + Blur, + /// Acrylic tinted with an `0xAABBGGRR` colour. + Acrylic(u32), +} + +/// Windows 10's accent under the window, through the undocumented +/// SetWindowCompositionAttribute that its own shell uses. +fn accent(hwnd: HWND, accent: Accent) -> bool { + #[repr(C)] + struct Policy { + state: u32, + flags: u32, + tint: u32, + animation: u32, + } + #[repr(C)] + struct Data { + attribute: u32, + data: *mut c_void, + size: usize, + } + type Set = unsafe extern "system" fn(HWND, *mut Data) -> BOOL; + let Some(set) = function::("user32.dll", c"SetWindowCompositionAttribute") else { + return false; + }; + // ACCENT_ENABLE_BLURBEHIND and ACCENT_ENABLE_ACRYLICBLURBEHIND; flag 2 takes the tint. + let mut policy = match accent { + Accent::Blur => Policy { + state: 3, + flags: 0, + tint: 0, + animation: 0, + }, + Accent::Acrylic(tint) => Policy { + state: 4, + flags: 2, + tint, + animation: 0, + }, + }; + // WCA_ACCENT_POLICY. + let mut data = Data { + attribute: 19, + data: (&mut policy as *mut Policy).cast(), + size: size_of::(), + }; + unsafe { set(hwnd, &mut data) != 0 } +} + +/// Whether Personalization's transparency effects are on, as acrylic and Mica need. +fn transparency() -> bool { + registry_dword( + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + "EnableTransparency", + ) != Some(0) +} + +/// Lays the system's material under the window, which shows through the app's transparent +/// pixels: Aero glass on Windows 7 while the desktop composes, acrylic on Windows 10 (blur +/// before its April 2018 update) and Mica on 11. Windows 8 has none. +pub fn install_backdrop(window: &Window) -> bool { + let hwnd = hwnd(window); + let (major, _, build) = version(); + match caption() { + Caption::System => false, + Caption::Glass if !eleven() => { + extend_frame(hwnd); + // Blurring behind the whole window has the desktop take its pixels' alpha. + let blur = Dwm::DWM_BLURBEHIND { + dwFlags: Dwm::DWM_BB_ENABLE, + fEnable: 1, + hRgnBlur: std::ptr::null_mut(), + fTransitionOnMaximized: 0, + }; + unsafe { Dwm::DwmEnableBlurBehindWindow(hwnd, &blur) }; + true + } + _ if major < 10 || !transparency() => false, + Caption::Glass => { + extend_frame(hwnd); + if build >= 22621 { + // DWMWA_SYSTEMBACKDROP_TYPE, DWMSBT_MAINWINDOW. + set_attribute(hwnd, 38, &2u32) + } else { + // Windows 11's first release names Mica by an undocumented attribute. + set_attribute(hwnd, 1029, &1u32) + } + } + Caption::Drawn if build >= 17134 => { + // The shell's own acrylic tints: Windows 10's light and dark flyouts. + let dark = appearance(window) == Theme::Dark; + let tint = if dark { 0xcc20_2020 } else { 0xccf3_f3f3 }; + ACRYLIC_TINT.store(tint, Ordering::Relaxed); + let shown = accent(hwnd, Accent::Acrylic(tint)); + ACRYLIC.store(shown, Ordering::Relaxed); + shown + } + Caption::Drawn => accent(hwnd, Accent::Blur), + } +} + +/// The title bar's fills, focused and not, where no material lies under it: Windows 10 +/// and 11's, or the accent colour where Personalization shows it on title bars. +pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { + if version().0 < 10 { + return None; + } + let dwm = r"Software\Microsoft\Windows\DWM"; + if registry_dword(dwm, "ColorPrevalence") == Some(1) + && let Some(color) = registry_dword(dwm, "AccentColor") + { + let [red, green, blue, _] = color.to_le_bytes(); + let inactive = if appearance == Theme::Dark { + 0x2b + } else { + 0xff + }; + return Some([ + draw::srgb(red, green, blue), + draw::srgb(inactive, inactive, inactive), + ]); + } + Some(match appearance { + Theme::Dark => [draw::srgb(0x20, 0x20, 0x20), draw::srgb(0x2b, 0x2b, 0x2b)], + Theme::Light => [draw::srgb(0xff, 0xff, 0xff); 2], + }) +} + +/// None: the kit's own menus. +pub fn menu(_: Theme) -> Option { + None +} + +const MINIMIZE: &[&str] = &[include_str!("../assets/windows/caption-minimize.svg")]; +const MAXIMIZE: &[&str] = &[include_str!("../assets/windows/caption-maximize.svg")]; +const RESTORE: &[&str] = &[include_str!("../assets/windows/caption-restore.svg")]; +const CLOSE: &[&str] = &[include_str!("../assets/windows/caption-close.svg")]; + +/// The caption buttons at the row's trailing end, flush with the window's corner. Over glass +/// and Mica the system hit-tests and runs its own, which brings Windows 11's snap layouts: +/// 7 draws them over the glass and the row leaves them room, and 11 doesn't draw them over +/// the DirectComposition surface, so the row draws them as 11 does, lit as the system +/// reports the pointer. On 8 and 10 the row draws and runs them as 10 does. +pub fn window_controls(ui: &mut Ui, window: &Window) { + let scale = window.scale_factor() as f32; + SCALE.store(scale.to_bits(), Ordering::Relaxed); + let system = caption() == Caption::Glass; + let mut bounds = RECT::default(); + // DWMWA_CAPTION_BUTTON_BOUNDS. + let found = system + && unsafe { + Dwm::DwmGetWindowAttribute( + hwnd(window), + 5, + (&mut bounds as *mut RECT).cast(), + size_of::() as u32, + ) + } >= 0; + let size = if found { + [bounds.right - bounds.left, bounds.bottom - bounds.top].map(|side| side as f32 / scale) + } else { + [3.0 * CAPTION_BUTTON, crate::TITLE] + }; + let offset = [GAP, -(crate::TITLE - ui::shell::TOOL) / 2.0]; + if system && !eleven() { + let size = [px(size[0]), px(1.0)]; + ui.leaf( + "caption", + Spec { + size, + offset: [GAP, 0.0], + ..Spec::default() + }, + ); + return; + } + ui.open( + "caption", + Spec { + size: [px(size[0]), px(size[1])], + offset, + ..Spec::default() + }, + ); + let text = ui.theme.text; + // Windows 11's subtle fill and red, and Windows 10's grey and red. + let (hover, red) = if eleven() { + ( + [text[0], text[1], text[2], 0.06], + draw::srgb(0xc4, 0x2b, 0x1c), + ) + } else { + ( + [text[0], text[1], text[2], 0.1], + draw::srgb(0xe8, 0x11, 0x23), + ) + }; + let lit = CAPTION_HOVERED.load(Ordering::Relaxed); + let maximized = window.is_maximized(); + let buttons = [ + ("minimize", "Minimize", MINIMIZE, wm::HTMINBUTTON), + ( + "maximize", + if maximized { "Restore" } else { "Maximize" }, + if maximized { RESTORE } else { MAXIMIZE }, + wm::HTMAXBUTTON, + ), + ("close", "Close", CLOSE, wm::HTCLOSE), + ]; + for (id, name, icon, hit) in buttons { + let fill = if hit == wm::HTCLOSE { red } else { hover }; + let spec = Spec { + size: [px(size[0] / 3.0), px(size[1])], + icon: Some(icon), + color: Some(if hit == wm::HTCLOSE && lit == hit { + [1.0; 4] + } else { + text + }), + center: true, + ..Spec::default() + }; + if system { + let fill = Some(if lit == hit { fill } else { [0.0; 4] }); + ui.leaf(id, Spec { fill, ..spec }); + continue; + } + let response = ui.leaf( + id, + Spec { + flags: Flags::CLICKABLE, + hover_fill: Some(fill), + role: Some(accesskit::Role::Button), + ..spec + }, + ); + let over = if response.hovered { hit } else { 0 }; + if hit == wm::HTCLOSE && CAPTION_HOVERED.swap(over, Ordering::Relaxed) != over { + window.request_redraw(); + } + if let Some(node) = ui.access(ui.id(id)) { + node.set_label(name); + } + if response.clicked { + match id { + "minimize" => window.set_minimized(true), + "maximize" => zoom(window), + _ => { + if let Some(proxy) = QUIT.get() { + let _ = proxy.send_event(crate::UserEvent::Quit); + } + } + } + } + } + ui.close(); +} + +/// The frame's edges take resizing presses. +pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option { + None +} + +pub fn zoom(window: &Window) { + window.set_maximized(!window.is_maximized()); +} + +/// Winit delivers typed text with its key events on Windows. +pub fn install_text_input(_: &Window) {} + +pub fn move_cursor() -> winit::window::CursorIcon { + winit::window::CursorIcon::Move +} + +/// No system border lies under the chrome. +pub fn cover_border_line(_: &mut Ui, _: f32) {} + +pub fn with_pool(run: impl FnOnce() -> R) -> R { + run() +} + +/// The interface keeps its own font, which fontique finds as Segoe UI. +pub fn system_interface(_: &mut Ui) {} + +/// Resizing takes the window's edges. +pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} + +/// Options opens from the sidebar's footer and Ctrl+Comma; OneNote has a ribbon, not a +/// menu bar, and the toolbar and the command palette stand in for it. +pub fn install_menu() {} + +pub fn update_menu(_: impl FnOnce() -> Vec) {} + +pub fn update_tag_menu(_: &[canvas::editor::NoteTag]) {} + +pub fn clear_marked_text(_: &Window) {} + +#[cfg(feature = "wgpu")] +pub fn configure_presentation(_: &wgpu::Surface<'_>) {} + +#[cfg(feature = "wgpu")] +pub fn commit_presentation(_: &Window) {} + +/// winit reports no pen pressure here, so every stroke keeps its pen's width. +pub fn pen_pressure() -> Option { + None +} + +pub fn double_click_interval() -> Duration { + let millis = unsafe { windows_sys::Win32::UI::Input::KeyboardAndMouse::GetDoubleClickTime() }; + Duration::from_millis(u64::from(millis)) +} + +/// The apps' colour mode from Personalization, which Windows 10 added; light before it. +pub fn appearance(window: &Window) -> Theme { + window.theme().unwrap_or(Theme::Light) +} + +pub struct Clipboard(arboard::Clipboard); + +impl Clipboard { + pub fn new(_: &Window) -> Result { + arboard::Clipboard::new().map(Self) + } + + pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> { + self.0.set_text(text) + } + + pub fn get_text(&mut self) -> Result { + self.0.get_text() + } +} + +/// The DWORD `value` under `key` in the user's registry hive. +fn registry_dword(key: &str, value: &str) -> Option { + use windows_sys::Win32::System::Registry; + let mut data = 0u32; + let mut size = size_of::() as u32; + let result = unsafe { + Registry::RegGetValueW( + Registry::HKEY_CURRENT_USER, + wide(key).as_ptr(), + wide(value).as_ptr(), + Registry::RRF_RT_REG_DWORD, + std::ptr::null_mut(), + (&mut data as *mut u32).cast(), + &mut size, + ) + }; + (result == 0).then_some(data) +} + +/// Windows names no document of a window's. +pub fn represent(_: &Window, _: Option<&std::path::Path>) {} + +pub const SHOW_FILE: &str = "Show in Explorer"; + +/// Opens an Explorer window with `file` selected. +pub fn show_file(file: &std::path::Path) { + use std::os::windows::process::CommandExt; + let mut argument = std::ffi::OsString::from("/select,\""); + argument.push(file); + argument.push("\""); + if let Err(error) = std::process::Command::new("explorer.exe") + .raw_arg(argument) + .spawn() + { + eprintln!("Cannot show {}: {error}", file.display()); + } +} + +/// Opens `target`, a folder, a file or a link's URL, with the shell's handler for it. +pub fn reveal(target: impl AsRef) { + let target = target.as_ref(); + let opened = unsafe { + windows_sys::Win32::UI::Shell::ShellExecuteW( + owner(), + wide("open").as_ptr(), + wide(target).as_ptr(), + std::ptr::null(), + std::ptr::null(), + wm::SW_SHOWNORMAL, + ) + }; + // ShellExecute's success is a pseudo-handle above 32. + if opened as isize <= 32 { + eprintln!("Cannot open {}", target.display()); + } +} + +/// Opens a copy of an attachment with the application registered for it. +pub fn open_file(path: &std::path::Path) { + reveal(path); +} + +/// Opens Character Map, whose picks the user copies in. +pub fn character_palette() { + if let Err(error) = std::process::Command::new("charmap.exe").spawn() { + eprintln!("Cannot open Character Map: {error}"); + } +} + +/// No shell icon lookup yet; the page draws a blank page for the file. +pub fn file_icon(_: &std::path::Path) -> Option> { + None +} + +/// winit reports drops without a position; the file goes to the caret. +pub fn drop_point(_: &Window) -> Option<[f32; 2]> { + None +} + +pub fn cache_dir() -> Option { + Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Snowbound")) +} + +pub fn settings_dir() -> Option { + Some(PathBuf::from(std::env::var_os("APPDATA")?).join("Snowbound")) +} + +/// None: Windows reaches a share by its UNC path with the share modes and byte-range +/// locks OneNote takes, so a notebook there opens as a folder, as OneNote opens it. +/// Snowbound's own client serves Open Notebook from Server…. +pub fn smb_mount(_: &std::path::Path) -> Option { + None +} + +/// The Credential Manager's name for `mount`'s server, as Snowbound keeps it. +fn credential_target(mount: &crate::library::Mount) -> Vec { + wide(format!("Snowbound/smb/{}", mount.host())) +} + +/// The password the Credential Manager keeps for `mount`'s server, or what the user types +/// into the system's sign-in dialog when it keeps none. +pub fn smb_login(mount: &crate::library::Mount) -> Result { + use windows_sys::Win32::Security::Credentials as cred; + let target = credential_target(mount); + let mut found: *mut cred::CREDENTIALW = std::ptr::null_mut(); + if unsafe { cred::CredReadW(target.as_ptr(), cred::CRED_TYPE_GENERIC, 0, &mut found) } != 0 { + let login = unsafe { + let found = &*found; + let secret = std::slice::from_raw_parts( + found.CredentialBlob.cast::(), + found.CredentialBlobSize as usize / 2, + ); + let user = if found.UserName.is_null() { + String::new() + } else { + let length = (0..).take_while(|&at| *found.UserName.add(at) != 0).count(); + String::from_utf16_lossy(std::slice::from_raw_parts(found.UserName, length)) + }; + (user, String::from_utf16_lossy(secret)) + }; + unsafe { cred::CredFree(found.cast()) }; + let (user, password) = login; + let user = mount + .user + .clone() + .filter(|user| !user.is_empty()) + .unwrap_or(user); + return Ok(crate::library::Login { + user, + password, + domain: mount.domain.clone(), + }); + } + let caption = wide("Snowbound"); + let message = wide(format!("Sign in to {}", mount.server)); + let info = cred::CREDUI_INFOW { + cbSize: size_of::() as u32, + hwndParent: owner(), + pszMessageText: message.as_ptr(), + pszCaptionText: caption.as_ptr(), + hbmBanner: std::ptr::null_mut(), + }; + let mut user = [0u16; cred::CREDUI_MAX_USERNAME_LENGTH as usize + 1]; + for (slot, unit) in user + .iter_mut() + .zip(mount.user.clone().unwrap_or_default().encode_utf16()) + { + *slot = unit; + } + let mut password = [0u16; 257]; + let mut save: BOOL = 0; + let server = wide(&mount.server); + let result = unsafe { + cred::CredUIPromptForCredentialsW( + &info, + server.as_ptr(), + std::ptr::null_mut(), + 0, + user.as_mut_ptr(), + user.len() as u32, + password.as_mut_ptr(), + password.len() as u32, + &mut save, + cred::CREDUI_FLAGS_GENERIC_CREDENTIALS + | cred::CREDUI_FLAGS_DO_NOT_PERSIST + | cred::CREDUI_FLAGS_ALWAYS_SHOW_UI, + ) + }; + let login = crate::library::Login { + user: narrow(&user), + password: narrow(&password), + domain: mount.domain.clone(), + }; + password.fill(0); + match result { + 0 => Ok(login), + _ => Err("Signing in was canceled".to_owned()), + } +} + +/// What the sign-in offers for keeping a password, which the Credential Manager keeps. +pub fn remember_label() -> Option<&'static str> { + Some("Remember my credentials") +} + +/// Keeps `login`'s password for `mount`'s server in the Credential Manager, where +/// `smb_login` looks for it. +pub fn save_login( + mount: &crate::library::Mount, + login: &crate::library::Login, +) -> Result<(), String> { + use windows_sys::Win32::Security::Credentials as cred; + let target = credential_target(mount); + let mut user = wide(&login.user); + let secret: Vec = login.password.encode_utf16().collect(); + let credential = cred::CREDENTIALW { + Flags: 0, + Type: cred::CRED_TYPE_GENERIC, + TargetName: target.as_ptr().cast_mut(), + Comment: std::ptr::null_mut(), + LastWritten: FILETIME::default(), + CredentialBlobSize: (secret.len() * 2) as u32, + CredentialBlob: secret.as_ptr().cast::().cast_mut(), + Persist: cred::CRED_PERSIST_LOCAL_MACHINE, + AttributeCount: 0, + Attributes: std::ptr::null_mut(), + TargetAlias: std::ptr::null_mut(), + UserName: user.as_mut_ptr(), + }; + if unsafe { cred::CredWriteW(&credential, 0) } != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error().to_string()) + } +} + +/// The account's display name, as Office takes its user name, or its login where the +/// directory has none. +pub fn user_name() -> String { + use windows_sys::Win32::Security::Authentication::Identity::{GetUserNameExW, NameDisplay}; + let mut name = [0u16; 256]; + let mut length = name.len() as u32; + if unsafe { GetUserNameExW(NameDisplay, name.as_mut_ptr(), &mut length) } { + let name = narrow(&name); + if !name.trim().is_empty() { + return name; + } + } + std::env::var("USERNAME").unwrap_or_default() +} + +/// The keyboard layout's language, as OneNote tags pasted text. +pub fn input_language() -> String { + use windows_sys::Win32::{ + Globalization::LCIDToLocaleName, UI::Input::KeyboardAndMouse::GetKeyboardLayout, + }; + let layout = unsafe { GetKeyboardLayout(0) } as usize; + let language = (layout & 0xffff) as u32; + let mut name = [0u16; 85]; + let length = unsafe { LCIDToLocaleName(language, name.as_mut_ptr(), name.len() as i32, 0) }; + if length > 0 { + narrow(&name) + } else { + String::new() + } +} + +/// FILETIME as local time. +fn local_time(filetime: u64) -> SYSTEMTIME { + use windows_sys::Win32::System::Time::{FileTimeToSystemTime, SystemTimeToTzSpecificLocalTime}; + let time = FILETIME { + dwLowDateTime: filetime as u32, + dwHighDateTime: (filetime >> 32) as u32, + }; + let [mut universal, mut local] = [SYSTEMTIME::default(); 2]; + unsafe { + FileTimeToSystemTime(&time, &mut universal); + SystemTimeToTzSpecificLocalTime(std::ptr::null(), &universal, &mut local); + } + local +} + +/// Local time as FILETIME. +fn filetime(local: &SYSTEMTIME) -> Option { + use windows_sys::Win32::System::Time::{SystemTimeToFileTime, TzSpecificLocalTimeToSystemTime}; + let mut universal = SYSTEMTIME::default(); + let mut time = FILETIME::default(); + unsafe { + (TzSpecificLocalTimeToSystemTime(std::ptr::null(), local, &mut universal) != 0 + && SystemTimeToFileTime(&universal, &mut time) != 0) + .then(|| u64::from(time.dwHighDateTime) << 32 | u64::from(time.dwLowDateTime)) + } +} + +/// `time` in the user's `flags` date format. +fn date_format(time: &SYSTEMTIME, flags: u32) -> String { + use windows_sys::Win32::Globalization::{GetDateFormatW, LOCALE_USER_DEFAULT}; + let mut text = [0u16; 128]; + let length = unsafe { + GetDateFormatW( + LOCALE_USER_DEFAULT, + flags, + time, + std::ptr::null(), + text.as_mut_ptr(), + text.len() as i32, + ) + }; + narrow(&text[..length.max(0) as usize]) +} + +/// `time` in the user's time format without seconds. +fn time_format(time: &SYSTEMTIME) -> String { + use windows_sys::Win32::Globalization::{GetTimeFormatW, LOCALE_USER_DEFAULT, TIME_NOSECONDS}; + let mut text = [0u16; 64]; + let length = unsafe { + GetTimeFormatW( + LOCALE_USER_DEFAULT, + TIME_NOSECONDS, + time, + std::ptr::null(), + text.as_mut_ptr(), + text.len() as i32, + ) + }; + narrow(&text[..length.max(0) as usize]) +} + +/// How far local time is ahead of UTC at Unix time `unix`, in seconds. +pub fn utc_offset(unix: i64) -> i64 { + let ticks = (unix + 11_644_473_600).max(0) as u64 * 10_000_000; + filetime(&local_time(ticks)).map_or(0, |local| (ticks as i64 - local as i64) / -10_000_000) +} + +/// FILETIME as the locale's short date, as OneNote labels a conflict page. +pub fn short_date(filetime: u64) -> String { + date_format( + &local_time(filetime), + windows_sys::Win32::Globalization::DATE_SHORTDATE, + ) +} + +fn date_labels(time: &SYSTEMTIME) -> [String; 2] { + [ + date_format(time, windows_sys::Win32::Globalization::DATE_LONGDATE), + time_format(time), + ] +} + +/// FILETIME as a new page's title shows it: the long date and the short time, which is +/// how OneNote 2010 writes them from the same settings. +pub fn date_text(filetime: u64) -> [String; 2] { + date_labels(&local_time(filetime)) +} + +/// Asks for the page's date or time with the system's date and time picker. +pub fn edit_date( + timestamp: u64, + field: DateField, + title: &str, +) -> Result, &'static str> { + let before = local_time(timestamp); + let Some(chosen) = picker::pick(&before, field, title) else { + return Ok(None); + }; + let mut time = before; + match field { + DateField::Date => { + (time.wYear, time.wMonth, time.wDay) = (chosen.wYear, chosen.wMonth, chosen.wDay); + } + DateField::Time => { + (time.wHour, time.wMinute) = (chosen.wHour, chosen.wMinute); + } + } + let updated = filetime(&time).ok_or(crate::DATE_OUT_OF_RANGE)?; + // FILETIME's sub-second ticks stay as they were. + let updated = updated / 10_000_000 * 10_000_000 + timestamp % 10_000_000; + Ok(Some((updated, date_labels(&local_time(updated))))) +} + +/// Asks for a notebook's table of contents or a section file, titled `title`. +pub fn pick_notebook(title: &str) -> Option { + pick( + title, + &[("OneNote notebooks and sections", "*.onetoc2;*.one")], + None, + false, + ) +} + +/// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. +pub fn pick_file(title: &str, types: &[&str]) -> Option { + let patterns = types + .iter() + .map(|kind| format!("*.{kind}")) + .collect::>() + .join(";"); + let filters = if types.is_empty() { + vec![("All files", "*.*")] + } else { + vec![("Supported files", patterns.as_str()), ("All files", "*.*")] + }; + pick(title, &filters, None, false) +} + +/// Asks where to put something named `name` by default; the dialog names its own button. +pub fn pick_new( + title: &str, + name: &str, + _action: &str, + folder: Option<&std::path::Path>, +) -> Option { + pick(title, &[("All files", "*.*")], Some((name, folder)), true) +} + +/// The common file dialog: an open dialog over `filters` (name and `;`-separated +/// patterns), or with `new`, a save dialog suggesting a name in a folder. +fn pick( + title: &str, + filters: &[(&str, &str)], + new: Option<(&str, Option<&std::path::Path>)>, + save: bool, +) -> Option { + use windows_sys::Win32::UI::Controls::Dialogs as dialogs; + let mut filter: Vec = Vec::new(); + for (name, patterns) in filters { + filter.extend(name.encode_utf16().chain([0])); + filter.extend(patterns.encode_utf16().chain([0])); + } + filter.push(0); + let mut file = vec![0u16; 32 * 1024]; + if let Some((name, _)) = new { + for (slot, unit) in file.iter_mut().zip(name.encode_utf16()) { + *slot = unit; + } + } + let folder = new.and_then(|(_, folder)| folder).map(wide); + let title = wide(title); + let mut dialog = dialogs::OPENFILENAMEW { + lStructSize: size_of::() as u32, + hwndOwner: owner(), + lpstrFilter: filter.as_ptr(), + lpstrFile: file.as_mut_ptr(), + nMaxFile: file.len() as u32, + lpstrTitle: title.as_ptr(), + lpstrInitialDir: folder + .as_ref() + .map_or(std::ptr::null(), |folder| folder.as_ptr()), + Flags: dialogs::OFN_EXPLORER + | dialogs::OFN_NOCHANGEDIR + | if save { + dialogs::OFN_OVERWRITEPROMPT + } else { + dialogs::OFN_FILEMUSTEXIST + }, + ..unsafe { std::mem::zeroed() } + }; + let chosen = unsafe { + if save { + dialogs::GetSaveFileNameW(&mut dialog) + } else { + dialogs::GetOpenFileNameW(&mut dialog) + } + }; + if chosen == 0 { + // Zero where the user cancelled. + let error = unsafe { dialogs::CommDlgExtendedError() }; + if error != 0 { + eprintln!("The file dialog failed: {error:#x}"); + } + return None; + } + Some(PathBuf::from(narrow(&file))) +} + +/// Tells the user something they asked for could not be done: `message`, then what to do. +pub fn alert(message: &str, detail: &str) { + let text = wide(format!("{message}\n\n{detail}")); + let caption = wide("Snowbound"); + let style = wm::MB_OK | wm::MB_ICONWARNING; + unsafe { wm::MessageBoxW(owner(), text.as_ptr(), caption.as_ptr(), style) }; +} + +/// Asks whether to go ahead with `action`, offering `cancel` too: a task dialog with those +/// buttons where the common controls have one, as from Windows Vista, else OK and Cancel. +pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { + use windows_sys::Win32::UI::Controls as controls; + type Indirect = unsafe extern "system" fn( + *const controls::TASKDIALOGCONFIG, + *mut i32, + *mut i32, + *mut BOOL, + ) -> i32; + let [title, instruction, content, cancel_text, action_text] = + ["Snowbound", message, detail, cancel, action].map(wide); + if let Some(indirect) = function::("comctl32.dll", c"TaskDialogIndirect") { + const ACTION: i32 = 100; + let buttons = [ + controls::TASKDIALOG_BUTTON { + nButtonID: ACTION, + pszButtonText: action_text.as_ptr(), + }, + controls::TASKDIALOG_BUTTON { + nButtonID: wm::IDCANCEL, + pszButtonText: cancel_text.as_ptr(), + }, + ]; + let mut config: controls::TASKDIALOGCONFIG = unsafe { std::mem::zeroed() }; + config.cbSize = size_of::() as u32; + config.hwndParent = owner(); + config.dwFlags = controls::TDF_ALLOW_DIALOG_CANCELLATION; + config.pszWindowTitle = title.as_ptr(); + config.pszMainInstruction = instruction.as_ptr(); + config.pszContent = content.as_ptr(); + config.cButtons = buttons.len() as u32; + config.pButtons = buttons.as_ptr(); + config.nDefaultButton = wm::IDCANCEL; + let mut pressed = 0; + let shown = unsafe { + indirect( + &config, + &mut pressed, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if shown >= 0 { + return pressed == ACTION; + } + } + let text = wide(format!("{message}\n\n{detail}")); + let answer = unsafe { + wm::MessageBoxW( + owner(), + text.as_ptr(), + title.as_ptr(), + wm::MB_OKCANCEL | wm::MB_ICONQUESTION | wm::MB_DEFBUTTON2, + ) + }; + answer == wm::IDOK +} + +/// The common controls' date and time picker in a dialog of its own. +mod picker { + use super::{DateField, SYSTEMTIME, owner, wm}; + use std::cell::Cell; + use windows_sys::Win32::{ + Foundation::{HWND, LPARAM, WPARAM}, + UI::Controls as controls, + }; + + const PICKER: i32 = 100; + + thread_local! { + static TIME: Cell = Cell::new(SYSTEMTIME::default()); + } + + /// The dialog, as a template of 16-bit words: its frame, then the picker, OK and Cancel. + fn template(title: &str, style: u32) -> Vec { + let mut words = Vec::new(); + let dword = |words: &mut Vec, value: u32| { + words.extend([value as u16, (value >> 16) as u16]); + }; + let text = |words: &mut Vec, text: &str| { + words.extend(text.encode_utf16().chain([0])); + }; + dword( + &mut words, + wm::DS_MODALFRAME as u32 + | wm::DS_CENTER as u32 + | wm::DS_SETFONT as u32 + | wm::WS_POPUP + | wm::WS_CAPTION + | wm::WS_SYSMENU, + ); + dword(&mut words, 0); + // Three controls in a dialog of 160 by 62 dialog units. + words.extend([3, 0, 0, 160, 62]); + // No menu, the default class, the title, then 9-point Segoe UI. + words.extend([0, 0]); + text(&mut words, title); + words.push(9); + text(&mut words, "Segoe UI"); + let item = |words: &mut Vec, + style: u32, + [x, y, width, height]: [u16; 4], + id: i32, + class: &[u16], + title: &str| { + if words.len() % 2 == 1 { + words.push(0); + } + dword(words, style | wm::WS_CHILD | wm::WS_VISIBLE); + dword(words, 0); + words.extend([x, y, width, height, id as u16]); + words.extend(class); + text(words, title); + words.push(0); + }; + let class: Vec = "SysDateTimePick32".encode_utf16().chain([0]).collect(); + item( + &mut words, + style | wm::WS_TABSTOP, + [7, 7, 146, 14], + PICKER, + &class, + "", + ); + // 0xFFFF 0x0080 is the button class. + item( + &mut words, + wm::BS_DEFPUSHBUTTON as u32 | wm::WS_TABSTOP, + [49, 38, 50, 14], + wm::IDOK, + &[0xffff, 0x0080], + "OK", + ); + item( + &mut words, + wm::BS_PUSHBUTTON as u32 | wm::WS_TABSTOP, + [103, 38, 50, 14], + wm::IDCANCEL, + &[0xffff, 0x0080], + "Cancel", + ); + words + } + + unsafe extern "system" fn procedure( + dialog: HWND, + message: u32, + wparam: WPARAM, + _: LPARAM, + ) -> isize { + let picker = unsafe { wm::GetDlgItem(dialog, PICKER) }; + match message { + wm::WM_INITDIALOG => { + let time = TIME.get(); + unsafe { + wm::SendMessageW( + picker, + controls::DTM_SETSYSTEMTIME, + controls::GDT_VALID as WPARAM, + (&time as *const SYSTEMTIME) as LPARAM, + ) + }; + 1 + } + wm::WM_COMMAND => { + let id = (wparam & 0xffff) as i32; + if id == wm::IDOK { + let mut time = SYSTEMTIME::default(); + unsafe { + wm::SendMessageW( + picker, + controls::DTM_GETSYSTEMTIME, + 0, + (&mut time as *mut SYSTEMTIME) as LPARAM, + ) + }; + TIME.set(time); + } + if id == wm::IDOK || id == wm::IDCANCEL { + unsafe { wm::EndDialog(dialog, id as isize) }; + return 1; + } + 0 + } + _ => 0, + } + } + + /// The date or time the user picks, starting from `time`; none when cancelled. + pub(super) fn pick(time: &SYSTEMTIME, field: DateField, title: &str) -> Option { + let classes = controls::INITCOMMONCONTROLSEX { + dwSize: size_of::() as u32, + dwICC: controls::ICC_DATE_CLASSES, + }; + unsafe { controls::InitCommonControlsEx(&classes) }; + let style = match field { + DateField::Date => controls::DTS_LONGDATEFORMAT, + DateField::Time => controls::DTS_TIMEFORMAT, + }; + TIME.set(*time); + let template = template(title, style); + let chosen = unsafe { + wm::DialogBoxIndirectParamW( + std::ptr::null_mut(), + template.as_ptr().cast(), + owner(), + Some(procedure), + 0, + ) + }; + (chosen == wm::IDOK as isize).then(|| TIME.get()) + } +} diff --git a/crates/snowbound/windows/snowbound.ico b/crates/snowbound/windows/snowbound.ico new file mode 100644 index 0000000000000000000000000000000000000000..84a1b63526a8a9a98a4c2c63e9eb1fbe72db2f1a GIT binary patch literal 87108 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 literal 0 HcmV?d00001 diff --git a/crates/snowbound/windows/snowbound.manifest b/crates/snowbound/windows/snowbound.manifest new file mode 100644 index 0000000000000000000000000000000000000000..681cdf3aab8c4d0d20206b410daeb87bc9f3649c --- /dev/null +++ b/crates/snowbound/windows/snowbound.manifest @@ -0,0 +1,35 @@ + + + + Snowbound + + + + + + + + + + + + + + + + + + true/pm + PerMonitorV2, PerMonitor + true + + + + + + + + + + diff --git a/crates/snowbound/windows/snowbound.rc b/crates/snowbound/windows/snowbound.rc new file mode 100644 index 0000000000000000000000000000000000000000..51d1e85e537723354eaf84a8be2ce4c63e6e764c --- /dev/null +++ b/crates/snowbound/windows/snowbound.rc @@ -0,0 +1,3 @@ +// The executable's icon, which winit gives the window too, and its manifest. +1 ICON "snowbound.ico" +1 24 "snowbound.manifest" diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 36d580383bd90475be4f927340361072318c522b..7587f2a7a17a9e2af37eb2c790c79fa0788bf5c6 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -761,6 +761,10 @@ impl Ui { /// Queues input for the next frame. pub fn event(&mut self, event: Event) { + // A chord the host looks up before the next frame reads the modifiers held now. + if let Event::Modifiers(modifiers) = event { + self.modifiers = modifiers; + } self.queue.push(event); } diff --git a/platform/snow-leopard/probe/src/render.rs b/platform/snow-leopard/probe/src/render.rs index 3a34a038281a508bb4cb96a5d02230b22c971894..0f783e9df325c2a7d55c667d85255963933b959e 100644 --- a/platform/snow-leopard/probe/src/render.rs +++ b/platform/snow-leopard/probe/src/render.rs @@ -10,7 +10,7 @@ fn main() { fn run() { let _context = context::current(); - let mut renderer = draw::Renderer::new().unwrap_or_else(|error| panic!("{error}")); + let mut renderer = draw::Renderer::opengl().unwrap_or_else(|error| panic!("{error}")); let target = draw::Target::new(scene::SIZE).unwrap(); let checker = scene::checker(); let primitives = scene::primitives(&checker); diff --git a/platform/snow-leopard/probe/src/scene.rs b/platform/snow-leopard/probe/src/scene.rs index 7edafc16e304c378376193e93650217846f68e94..49761fbab365c88e946fd6b338dd3bbaf5581cda 100644 --- a/platform/snow-leopard/probe/src/scene.rs +++ b/platform/snow-leopard/probe/src/scene.rs @@ -35,7 +35,7 @@ pub fn primitives(checker: &RasterImage) -> Vec> { } pub fn layer<'a>(primitives: &'a [Primitive<'a>]) -> Layer<'a> { - Layer { scale: 1.0, origin: [0.0; 2], clip: None, backdrop: None, motion: None, primitives } + Layer { scale: 1.0, origin: [0.0; 2], clip: None, backdrop: None, round: None, motion: None, primitives } } /// Lines of interface text through `ui`: black on white like a Cocoa label, the same in diff --git a/platform/snow-leopard/probe/src/text.rs b/platform/snow-leopard/probe/src/text.rs index ecf1496afb3510dbc82219a7be7d7bc0392e248a..5e9ea14e8a1bd6a142f22ced50179f84832a31fe 100644 --- a/platform/snow-leopard/probe/src/text.rs +++ b/platform/snow-leopard/probe/src/text.rs @@ -7,7 +7,7 @@ mod scene; fn main() { objc2::rc::autoreleasepool(|_| { let _context = context::current(); - let mut renderer = draw::Renderer::new().unwrap_or_else(|error| panic!("{error}")); + let mut renderer = draw::Renderer::opengl().unwrap_or_else(|error| panic!("{error}")); let target = draw::Target::new(scene::TEXT_SIZE).unwrap(); let ui = scene::interface(1.0); let layers = ui.layers(); diff --git a/platform/windows/cargo.sh b/platform/windows/cargo.sh index 0949d548b84622424c9eec1721c8f1f82ae17d7f..2fdba63a8771864033b8ec4bb4dd88718a07281b 100755 --- a/platform/windows/cargo.sh +++ b/platform/windows/cargo.sh @@ -19,17 +19,21 @@ x86_64) target=x86_64-win7-windows-gnu toolchain=+nightly # rustc would infer a bare ld from the name `link.sh`; it is a C compiler driver. + # raw-dylib imports (the windows crates) are made by llvm-mingw's dlltool. Cargo + # can't see link.sh's inputs; folding their hash into the flags relinks on change. + runtime=$(cat "$here/link.sh" "$here"/rt/* | shasum | cut -c1-12) set -- -Zbuild-std=std,panic_unwind \ --config "target.$target.linker='$here/link.sh'" \ - --config "target.$target.rustflags=['-C','linker-flavor=gcc']" "$@" + --config "target.$target.rustflags=['-C','linker-flavor=gcc','-C','metadata=rt-$runtime','-C','dlltool=$LLVM_MINGW/bin/x86_64-w64-mingw32-dlltool']" "$@" ;; aarch64) target=aarch64-pc-windows-gnullvm toolchain=+stable rustup target add --toolchain stable "$target" >/dev/null - # crt-static links libunwind in rather than beside the executable. + # crt-static links libunwind in rather than beside the executable. clang ignores + # rustc's -no-pie. set -- --config "target.$target.linker='$LLVM_MINGW/bin/aarch64-w64-mingw32-clang'" \ - --config "target.$target.rustflags=['-C','target-feature=+crt-static']" "$@" + --config "target.$target.rustflags=['-C','target-feature=+crt-static','-C','link-arg=-Wno-unused-command-line-argument']" "$@" ;; *) echo "usage: $0 x86_64|aarch64 COMMAND ARGS..." >&2 diff --git a/platform/windows/imports.py b/platform/windows/imports.py new file mode 100755 index 0000000000000000000000000000000000000000..df9ea5441f62e14213600848ba2c1831bb21363a --- /dev/null +++ b/platform/windows/imports.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Lists what an executable imports that Windows 7 lacks, which stops it loading there: +`imports.py EXE DLLS`, DLLS a folder of Windows 7's System32 DLLs (copied from the lab VM), +reading both with llvm-mingw's llvm-readobj.""" +import os +from pathlib import Path +import re +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[2] +READOBJ = Path(os.environ.get('LLVM_MINGW', ROOT / 'target/windows/llvm-mingw')) / 'bin/llvm-readobj' + + +def readobj(flag, path): + return subprocess.run([READOBJ, flag, path], capture_output=True, text=True, check=True).stdout + + +def imports(executable): + """Each imported DLL, lowercased, with the names imported from it.""" + found, dll = {}, None + for line in readobj('--coff-imports', executable).splitlines(): + if match := re.match(r'\s*Name: (\S+)', line): + dll = found.setdefault(match[1].lower(), set()) + elif (match := re.match(r'\s*Symbol: (\S+)', line)) and dll is not None: + dll.add(match[1]) + return found + + +def main(): + executable, folder = map(Path, sys.argv[1:3]) + dlls = {path.name.lower(): path for path in folder.iterdir()} + missing = [] + for dll, names in sorted(imports(executable).items()): + if dll not in dlls: + missing.append(f'{dll} (no such DLL): {", ".join(sorted(names))}') + continue + exported = set(re.findall(r'Name: (\S+)', readobj('--coff-exports', dlls[dll]))) + missing += [f'{dll}!{name}' for name in sorted(names - exported)] + print('\n'.join(missing) or 'Every import exists on Windows 7.') + sys.exit(1 if missing else 0) + + +if __name__ == '__main__': + main() diff --git a/platform/windows/link.sh b/platform/windows/link.sh index 78e43c640f845d28c9f2deeee5cae28ffe16eac7..6756b081fb9fdf4f258f5675e27c725231303567 100755 --- a/platform/windows/link.sh +++ b/platform/windows/link.sh @@ -1,7 +1,10 @@ #!/bin/sh # The linker for x86_64-win7-windows-gnu: llvm-mingw's clang, which links compiler-rt -# itself, with LLVM's libunwind (static) answering for libgcc's unwinder. +# itself, with LLVM's libunwind (static) answering for libgcc's unwinder, and rt/shims.c +# standing in for the imports Windows 7 lacks. set -eu +here=$(cd "$(dirname "$0")" && pwd) +clang=$LLVM_MINGW/bin/x86_64-w64-mingw32-clang for arg do shift case $arg in @@ -10,4 +13,7 @@ for arg do *) set -- "$@" "$arg" ;; esac done -exec "$LLVM_MINGW/bin/x86_64-w64-mingw32-clang" "$@" +work=$(mktemp -d "${TMPDIR:-/tmp}/windows-link.XXXXXX") +trap 'rm -rf "$work"' EXIT +"$clang" -O2 -Wall -c "$here/rt/shims.c" -o "$work/shims.o" +"$clang" "$work/shims.o" "$@" diff --git a/platform/windows/rt/shims.c b/platform/windows/rt/shims.c new file mode 100644 index 0000000000000000000000000000000000000000..8949334ec6568648feee14c540430f83f710666a --- /dev/null +++ b/platform/windows/rt/shims.c @@ -0,0 +1,58 @@ +// Functions the windows crates and arboard import from DLLs Windows 7 lacks, defined here +// under their own names and as the `__imp_` pointers an import library would supply, so the +// linker takes these and the executable never names those DLLs. +#include +#include + +typedef long HRESULT; +typedef int BOOL; +#define S_OK ((HRESULT)0) +#define S_FALSE ((HRESULT)1) +#define E_INVALIDARG ((HRESULT)0x80070057L) + +__declspec(dllimport) void *__stdcall LoadLibraryA(const char *name); +__declspec(dllimport) void *__stdcall GetProcAddress(void *module, const char *name); + +// combase.dll (Windows 8): Windows 7's ole32.dll exports the same two. +static void *ole32(const char *name) { + return GetProcAddress(LoadLibraryA("ole32.dll"), name); +} + +HRESULT __stdcall CoCreateFreeThreadedMarshaler(void *outer, void **marshaler) { + static HRESULT(__stdcall * real)(void *, void **); + if (!real) real = (HRESULT(__stdcall *)(void *, void **))ole32("CoCreateFreeThreadedMarshaler"); + return real(outer, marshaler); +} + +void __stdcall CoTaskMemFree(void *memory) { + static void(__stdcall * real)(void *); + if (!real) real = (void(__stdcall *)(void *))ole32("CoTaskMemFree"); + real(memory); +} + +// api-ms-win-core-winrt-error-l1-1-0.dll (Windows 8): nothing records an origin. +BOOL __stdcall RoOriginateErrorW(HRESULT error, unsigned length, const wchar_t *message) { + (void)error, (void)length, (void)message; + return 0; +} + +// api-ms-win-core-path-l1-1-0.dll (Windows 8): `\\?\C:\x` becomes `C:\x` and +// `\\?\UNC\server\x` becomes `\\server\x`; S_FALSE where there was no prefix. +HRESULT __stdcall PathCchStripPrefix(wchar_t *path, size_t length) { + if (!path) return E_INVALIDARG; + if (wcsncmp(path, L"\\\\?\\UNC\\", 8) == 0) { + if (length < 8) return E_INVALIDARG; + memmove(path + 2, path + 8, (wcslen(path + 8) + 1) * sizeof(wchar_t)); + return S_OK; + } + if (wcsncmp(path, L"\\\\?\\", 4) == 0 && path[4] && path[5] == L':') { + memmove(path, path + 4, (wcslen(path + 4) + 1) * sizeof(wchar_t)); + return S_OK; + } + return S_FALSE; +} + +void *__imp_CoCreateFreeThreadedMarshaler = CoCreateFreeThreadedMarshaler; +void *__imp_CoTaskMemFree = CoTaskMemFree; +void *__imp_RoOriginateErrorW = RoOriginateErrorW; +void *__imp_PathCchStripPrefix = PathCchStripPrefix; diff --git a/platform/windows/toolchain.sh b/platform/windows/toolchain.sh new file mode 100755 index 0000000000000000000000000000000000000000..3f33d40b0dbf3cb7da5d4b31e2fa75ea17b7e69a --- /dev/null +++ b/platform/windows/toolchain.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Fetches llvm-mingw into target/windows/llvm-mingw: clang, lld and the MinGW-w64 sysroots +# for x86_64 and aarch64, built for msvcrt.dll, which Windows 7 has without updates, where +# the UCRT needs KB2999226. llvm-mingw publishes msvcrt builds only for Linux and Windows +# hosts, so on macOS its UCRT toolchain takes the msvcrt build's target files. +set -eu +here=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$here/../.." && pwd) +release=20260922 +out=$root/target/windows/llvm-mingw +stage=$root/target/windows/llvm-mingw.partial +base=https://github.com/mstorsjo/llvm-mingw/releases/download/$release +msvcrt=llvm-mingw-$release-msvcrt-ubuntu-22.04-x86_64 +[ ! -e "$out" ] || { echo "$out exists" >&2; exit 1; } +rm -rf "${stage:?}" +mkdir -p "$stage" +curl -fsSL "$base/$msvcrt.tar.xz" | tar -xJ -C "$stage" +if [ "$(uname -s)" = Darwin ]; then + host=llvm-mingw-$release-ucrt-macos-universal + curl -fsSL "$base/$host.tar.xz" | tar -xJ -C "$stage" + clang=$(ls "$stage/$host/lib/clang") + for part in x86_64-w64-mingw32 aarch64-w64-mingw32 generic-w64-mingw32 \ + "lib/clang/$clang/lib/windows"; do + rm -rf "${stage:?}/$host/$part" + mv "$stage/$msvcrt/$part" "$stage/$host/$part" + done +else + host=$msvcrt +fi +# Only x86_64 and aarch64 are built. +rm -rf "${stage:?}/$host/i686-w64-mingw32" "${stage:?}/$host/armv7-w64-mingw32" +find "$stage/$host/bin" \( -name 'i686-*' -o -name 'armv7-*' -o -name '*uwp*' \) -delete +mv "$stage/$host" "$out" +rm -rf "${stage:?}" +echo "$out" diff --git a/tools/RELEASE.md b/tools/RELEASE.md index 72c8eaff06b490ed87ee39b768be28dcad809596..f4fd2a1639ee71eca6b9ea24be493f0a2d1c881b 100644 --- a/tools/RELEASE.md +++ b/tools/RELEASE.md @@ -18,7 +18,7 @@ development builds, which never update themselves. ## The published folder ```text -latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": ..., "linux-x86_64": ..., "linux-aarch64": ...} +latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": ..., "linux-x86_64": ..., "windows-x86_64": ..., ...} 2026-09-29.r10/ build.json version, commit, and per platform: file, size, sha256, signature build.json.sig ed25519 signature of build.json, hex @@ -26,6 +26,8 @@ latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": Snowbound-2026-09-29-r10-macos-10.6.zip snowbound-2026-09-29-r10-linux-x86_64 the executable itself snowbound-2026-09-29-r10-linux-aarch64 + snowbound-2026-09-29-r10-windows-x86_64.exe Windows 7 SP1 to 11 + snowbound-2026-09-29-r10-windows-aarch64.exe Windows 11 on Arm ``` A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into @@ -80,7 +82,7 @@ quarantine and open directly. ## Publishing ```sh -python3 tools/release.py # all four platforms +python3 tools/release.py # all six platforms python3 tools/release.py --ad-hoc # the macOS app without Developer ID python3 tools/release.py --dry-run # the same into a new temporary folder python3 tools/release.py --platforms macos-aarch64 linux-x86_64 @@ -89,14 +91,17 @@ python3 tools/release.py --platforms macos-aarch64 linux-x86_64 The script refuses to run unless the working copy matches `main` (a dry run only warns), runs Clippy (workspace, and `snowbound` without default features) and the workspace tests, builds each platform with -`tools/canvas/build_macos.py` (`--snow-leopard` for 10.6) and -`crates/snowbound/linux/package.sh` (taking only its executables), then checks +`tools/canvas/build_macos.py` (`--snow-leopard` for 10.6), +`crates/snowbound/linux/package.sh` (taking only its executables) and +`platform/windows/cargo.sh`, then checks the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and publishes as above. Run again for the same commit, it only brings `latest.json` up to date; a different commit that derives the same version is refused. The 10.6 build needs the SDK and nightly toolchain `platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the -cross linker against glibc 2.31. All four build from an Apple silicon Mac. +cross linker against glibc 2.31; the Windows builds need llvm-mingw, which +`platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for +x86_64. All six build from an Apple silicon Mac. ## In the app @@ -108,11 +113,14 @@ palette elsewhere) checks at once and reports what it found. A check reads downloads the archive for this platform, verifying size, SHA-256 and signature. It stages the update beside the install, so the swap is a rename: the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the -executable into `.snowbound.update` next to it on Linux. The sync status icon gets a dot, and its popup offers +executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on +Windows). The sync status icon gets a dot, and its popup offers Build Folder and Restart to Update. Restart to Update quits the app and starts the old executable with -`--finish-update`, which waits for the app to exit, renames the new bundle (or -executable) over the old one, and opens it. Where the install can't be +`--finish-update`, which waits for the app to exit, renames the old bundle (or +executable) aside and the new one into its place, and opens it. Windows renames an +executable while it runs, as this one does, but won't delete it: the old one waits beside +the new as `.snowbound.exe.old` until the next update removes it. Where the install can't be written, and on Mac OS X 10.6, the popup and the check name the build and open its folder to download instead. diff --git a/tools/release.py b/tools/release.py index 135281cabd2d013cd9694bd360c9d5f71fb046ca..9b118b960e089804d9ac9e23b08f920d77c7ddad 100755 --- a/tools/release.py +++ b/tools/release.py @@ -17,7 +17,10 @@ PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') URL = 'https://file.paperclover.net/shr/snowbound/' KEY = Path.home() / '.config/snowbound/release-key' ZONE = ZoneInfo('America/Los_Angeles') -PLATFORMS = ['macos-aarch64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64'] +PLATFORMS = ['macos-aarch64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64', 'windows-x86_64', + 'windows-aarch64'] +# Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm. +WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'} CHECKS = [ ['cargo', 'clippy', '--workspace', '--all-targets', '--all-features', '--', '-D', 'warnings'], ['cargo', 'clippy', '-p', 'snowbound', '--no-default-features', '--', '-D', 'warnings'], @@ -128,6 +131,17 @@ def build_linux(architectures): return {f'linux-{arch}': ROOT / f'target/{arch}-unknown-linux-gnu/release/snowbound' for arch in architectures} +def build_windows(architectures): + """The executables, each all of Snowbound for its architecture, one running on every + Windows it supports.""" + built = {} + for arch in architectures: + run(['sh', ROOT / 'platform/windows/cargo.sh', arch, 'build', '--release', '-p', 'snowbound'], + env={**os.environ, 'CARGO_PROFILE_RELEASE_STRIP': 'symbols'}) + built[f'windows-{arch}'] = ROOT / f'target/windows/{WINDOWS[arch]}/release/snowbound.exe' + return built + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--dry-run', action='store_true', @@ -178,6 +192,9 @@ def main(): linux = [platform.removeprefix('linux-') for platform in args.platforms if platform.startswith('linux')] if linux: built |= build_linux(linux) + windows = [platform.removeprefix('windows-') for platform in args.platforms if platform.startswith('windows')] + if windows: + built |= build_windows(windows) clean(args.dry_run, 'after the build') files = {} for platform, source in built.items(): diff --git a/tools/w7/README.md b/tools/w7/README.md index 272251504d22b23aab131f225a57f29bb3621ac0..472278a3f4c070ed70faa66109c86b878b5aa601 100644 --- a/tools/w7/README.md +++ b/tools/w7/README.md @@ -96,6 +96,37 @@ The control agent starts minimized after login. Its taskbar button remains available for diagnosis without covering OneNote or changing screenshot-based test coordinates. +## Windows 10 and 11 + +`win10` (22H2 x64, emulated like Windows 7, so slow) and `win11` (24H2 arm64, +hardware-virtualized) are further bases for clones. They install unattended and +headless from Microsoft's Media Creation Tool catalog: Pro from the +volume-license image, which installs without a product key and stays +unactivated, with local administrator `one` / `one`. Setup's first logon runs +[unattend/lab-setup.cmd](unattend/lab-setup.cmd), which installs the agent like +the Windows 7 build and restarts into it. + +```sh +./windows_media.py win11 # -> $ONE_VM_HOME/media/win11.iso (brew: wimlib xorriso) +./vm.py install --base win11 # headless; add --display to watch +./mcp_win7.py --target win11-build health # answers once the build reaches its desktop +./vm.py poweroff win11-build +./vm.py seal --base win11 +./vm.py up w11 --base win11 --wait # clones work as above; --base is kept per clone +./mcp_win7.py --target w11 shot +``` + +A Windows 11 install reaches its agent in about 10 minutes and a new clone in +one; Windows 10 takes about 55 minutes and 7, so pass `--timeout 900` to its +`up --wait`. + +Screens are 1024x768, the largest mode edk2 offers the arm64 guest's framebuffer. +The arm64 guest runs x64 programs, including the agent, through Windows' own +emulation. Windows on Arm has no inbox driver for any NIC QEMU emulates, so +`windows_media.py win11` also keeps Red Hat's signed NetKVM driver from the +virtio-win disc. Transparency is on and DWM's `ForceEffectMode` is set, so Mica +and Acrylic render on the emulated display adapter. + ## Abrupt-stop recovery tests `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. ## Gotchas +- `-cpu qemu64` lacks SSSE3, which Mesa's llvmpipe (software OpenGL for Snowbound on a clone) + needs: `ONE_VM_CPU=Nehalem ./vm.py up NAME --wait` picks another model. - Keep Windows display scaling at 100%. Anything else and the screenshot coordinates no longer match where clicks land. - The listener must run in the interactive logged-in session — as a scheduled diff --git a/tools/w7/mcp_win7.py b/tools/w7/mcp_win7.py index 90ce900e6343a70a9cbf76a376bf4d094256661a..58fa5826aca370c02b0f814c8cf5611864313f53 100755 --- a/tools/w7/mcp_win7.py +++ b/tools/w7/mcp_win7.py @@ -13,6 +13,7 @@ import urllib.error import urllib.request from env import ROOT, setting +from vm import BASES DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "local") TARGETS_PATH = Path(setting("WIN7_TARGETS_FILE") or Path(setting("ONE_VM_HOME") or ROOT / "lab-unset") / "targets.json").expanduser() @@ -25,6 +26,8 @@ class Win7Error(Exception): def resolve_target(name): targets = {"local": {"base": "http://127.0.0.1:18777"}} + targets.update({base + "-build": {"base": "http://127.0.0.1:%d" % port} + for base, (_stem, port) in BASES.items()}) if TARGETS_PATH.exists(): try: configured = json.loads(TARGETS_PATH.read_text()) @@ -288,15 +291,17 @@ TOOLS += [ { "name": "win7_vm_up", "description": ( - "Create a named Windows 7 clone when absent, then boot it. Creation settings " - "are ignored for an existing clone. Set wait to return only when its " - "authenticated desktop agent reports the expected hostname." + "Create a named Windows clone when absent, then boot it. base picks Windows 7 " + "(x64, OneNote 2010), 10 (x64, emulated and slow) or 11 (arm64, native speed). " + "Creation settings are ignored for an existing clone. Set wait to return only " + "when its authenticated desktop agent reports the expected hostname." ), "inputSchema": { "type": "object", "properties": { "name": {"type": "string", "description": "Unique 1-11 character lowercase VM name."}, + "base": {"type": "string", "enum": sorted(BASES), "default": "win7"}, "hostname": {"type": "string", "description": "Optional Windows hostname."}, "cpus": {"type": "integer", "default": 2, "minimum": 1, "maximum": 16}, "memory_mb": {"type": "integer", "default": 4096, @@ -375,7 +380,7 @@ def vm_tool(name, args): argv = ["status"] + ([args["name"]] if args.get("name") else []) elif verb == "up": argv = ["up", args["name"]] - for key, option in (("hostname", "--hostname"), ("cpus", "--cpus"), + for key, option in (("base", "--base"), ("hostname", "--hostname"), ("cpus", "--cpus"), ("memory_mb", "--memory"), ("port", "--port")): if args.get(key) is not None: argv += [option, str(args[key])] diff --git a/tools/w7/payload/bootstrap.cmd b/tools/w7/payload/bootstrap.cmd index 6f717700aad2d4db98ea90775f6e9fe701673cc3..f7e935f3f663a7ecfeaa5041840925e3f63d26f0 100644 --- a/tools/w7/payload/bootstrap.cmd +++ b/tools/w7/payload/bootstrap.cmd @@ -1,22 +1,22 @@ -@echo off -setlocal EnableExtensions -set "ONEVM_HOSTNAME=" -for %%D in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist "%%D:\onenote-vm.ini" for /f "usebackq tokens=1,* delims==" %%A in ("%%D:\onenote-vm.ini") do ( - if /i "%%A"=="HOSTNAME" set "ONEVM_HOSTNAME=%%B" - if /i "%%A"=="TOKEN" set "WIN7_TOKEN=%%B" -) -if not defined ONEVM_HOSTNAME goto agent -if /i "%COMPUTERNAME%"=="%ONEVM_HOSTNAME%" goto agent -rem Windows 11 ships without wmic; Windows 7 PowerShell lacks Rename-Computer. -where wmic >nul 2>&1 || goto rename_powershell -wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1 -find "ReturnValue = 0;" "%~dp0bootstrap.log" >nul || exit /b 1 -goto restart -:rename_powershell -powershell -NoProfile -Command "Rename-Computer -NewName '%ONEVM_HOSTNAME%' -Force -ErrorAction Stop" >"%~dp0bootstrap.log" 2>&1 || exit /b 1 -:restart -shutdown /r /t 0 -exit /b - -:agent -call "%~dp0run.bat" +@echo off +setlocal EnableExtensions +set "ONEVM_HOSTNAME=" +for %%D in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist "%%D:\onenote-vm.ini" for /f "usebackq tokens=1,* delims==" %%A in ("%%D:\onenote-vm.ini") do ( + if /i "%%A"=="HOSTNAME" set "ONEVM_HOSTNAME=%%B" + if /i "%%A"=="TOKEN" set "WIN7_TOKEN=%%B" +) +if not defined ONEVM_HOSTNAME goto agent +if /i "%COMPUTERNAME%"=="%ONEVM_HOSTNAME%" goto agent +rem Windows 11 ships without wmic; Windows 7 PowerShell lacks Rename-Computer. +where wmic >nul 2>&1 || goto rename_powershell +wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1 +find "ReturnValue = 0;" "%~dp0bootstrap.log" >nul || exit /b 1 +goto restart +:rename_powershell +powershell -NoProfile -Command "Rename-Computer -NewName '%ONEVM_HOSTNAME%' -Force -ErrorAction Stop" >"%~dp0bootstrap.log" 2>&1 || exit /b 1 +:restart +shutdown /r /t 0 +exit /b + +:agent +call "%~dp0run.bat" diff --git a/tools/w7/test_vm.py b/tools/w7/test_vm.py index f461e843979ede522a47ac490dbef5dc9da2f6ef..59a26737d2827f868bf7a127fa70c0fe8030a9a2 100644 --- a/tools/w7/test_vm.py +++ b/tools/w7/test_vm.py @@ -12,8 +12,7 @@ import vm class VmLifecycleTest(unittest.TestCase): def setUp(self): self.original = {name: getattr(vm, name) for name in ( - "VM_HOME", "IMAGES", "MEDIA", "INSTANCES", "RUN", "DISK", - "BASE_DISK", "BASE_MANIFEST", "AGENT_ISO", "TARGETS", + "VM_HOME", "IMAGES", "MEDIA", "INSTANCES", "RUN", "AGENT_ISO", "TARGETS", )} self.temporary = tempfile.TemporaryDirectory(prefix="one-vm-test-") home = Path(self.temporary.name) @@ -22,17 +21,16 @@ class VmLifecycleTest(unittest.TestCase): vm.MEDIA = home / "media" vm.INSTANCES = home / "instances" vm.RUN = home / "run" - vm.DISK = vm.IMAGES / "win7-office-build.qcow2" - vm.BASE_DISK = vm.IMAGES / "win7-office-base.qcow2" - vm.BASE_MANIFEST = vm.IMAGES / "win7-office-base.json" vm.AGENT_ISO = vm.MEDIA / "win7-agent.iso" vm.TARGETS = home / "targets.json" vm.IMAGES.mkdir(parents=True) - subprocess.run([ - vm.qemu("qemu-img"), "create", "-q", "-f", "qcow2", str(vm.DISK), "64M" - ], check=True) - with contextlib.redirect_stdout(io.StringIO()): - vm.seal() + for base in ("win7", "win11"): + subprocess.run([ + vm.qemu("qemu-img"), "create", "-q", "-f", "qcow2", + str(vm.build_disk(base)), "64M", + ], check=True) + with contextlib.redirect_stdout(io.StringIO()): + vm.seal(base) def tearDown(self): self.temporary.cleanup() @@ -66,6 +64,18 @@ class VmLifecycleTest(unittest.TestCase): def test_reserved_target_name_is_rejected(self): with self.assertRaisesRegex(SystemExit, "reserved"): vm.create_instance("local") + with self.assertRaisesRegex(SystemExit, "reserved"): + vm.create_instance("win11-build") + + def test_clone_records_its_base(self): + with contextlib.redirect_stdout(io.StringIO()): + vm.create_instance("arm", base="win11") + self.assertEqual(json.loads(vm.instance_path("arm").read_text())["base"], "win11") + backing = json.loads(subprocess.check_output([ + vm.qemu("qemu-img"), "info", "--output=json", + str(vm.IMAGES / "instances" / "arm.qcow2"), + ]))["backing-filename"] + self.assertEqual(backing, str(vm.base_disk("win11"))) if __name__ == "__main__": diff --git a/tools/w7/unattend/lab-setup.cmd b/tools/w7/unattend/lab-setup.cmd index d5ba5be7b092f98c37d8602191e9eb6789dbc267..f04051494f4ca3555b0001cff26ab8359aa5335e 100644 --- a/tools/w7/unattend/lab-setup.cmd +++ b/tools/w7/unattend/lab-setup.cmd @@ -1,24 +1,28 @@ -@echo off -rem First logon of a Windows 10/11 lab build: install the agent and quiet the desktop. -set "AGENT=" -for %%D in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist "%%D:\agent.py" set "AGENT=%%D:" -if not defined AGENT exit /b 1 -xcopy /e /i /y /q "%AGENT%\" C:\win7-agent\ || exit /b 1 -attrib -r /s /d "C:\win7-agent\*" -netsh advfirewall firewall add rule name="win7-agent" dir=in action=allow protocol=TCP localport=8777 -net accounts /maxpwage:unlimited -set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon -reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d 1 /f -reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d one /f -reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d one /f -reg delete "%WINLOGON%" /v AutoLogonCount /f -powercfg /change monitor-timeout-ac 0 -powercfg /change standby-timeout-ac 0 -powercfg /hibernate off -reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f -reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoUpdate /t REG_DWORD /d 1 /f -reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f -reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v EnableTransparency /t REG_DWORD /d 1 /f -reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f -call C:\win7-agent\install-autostart.cmd -shutdown /r /t 5 +@echo off +rem First logon of a Windows 10/11 lab build: install the agent and quiet the desktop. +set "AGENT=" +for %%D in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist "%%D:\agent.py" set "AGENT=%%D:" +if not defined AGENT exit /b 1 +if exist "%~dp0netkvm\netkvm.inf" pnputil /add-driver "%~dp0netkvm\netkvm.inf" /install +xcopy /e /i /y /q "%AGENT%\" C:\win7-agent\ || exit /b 1 +attrib -r /s /d "C:\win7-agent\*" +netsh advfirewall firewall add rule name="win7-agent" dir=in action=allow protocol=TCP localport=8777 +net accounts /maxpwage:unlimited +set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon +reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d 1 /f +reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d one /f +reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d one /f +reg delete "%WINLOGON%" /v AutoLogonCount /f +powercfg /change monitor-timeout-ac 0 +powercfg /change standby-timeout-ac 0 +powercfg /hibernate off +reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f +reg add "HKLM\SYSTEM\CurrentControlSet\Control\Network\NewNetworkWindowOff" /f +reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoUpdate /t REG_DWORD /d 1 /f +reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f +reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v EnableTransparency /t REG_DWORD /d 1 /f +rem DWM leaves blur off on the emulated display adapter unless forced. +reg add "HKLM\SOFTWARE\Microsoft\Windows\Dwm" /v ForceEffectMode /t REG_DWORD /d 2 /f +reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f +call C:\win7-agent\install-autostart.cmd +shutdown /r /t 5 diff --git a/tools/w7/unattend/startup.nsh b/tools/w7/unattend/startup.nsh new file mode 100644 index 0000000000000000000000000000000000000000..46bf715adca972977bb72442827e64e6483aeaea --- /dev/null +++ b/tools/w7/unattend/startup.nsh @@ -0,0 +1,7 @@ +@echo -off +# edk2 on arm64 lists no boot option for the USB installer disc, so its shell starts it. +for %m in fs0 fs1 fs2 fs3 fs4 fs5 + if exist %m:\efi\boot\bootaa64.efi then + %m:\efi\boot\bootaa64.efi + endif +endfor diff --git a/tools/w7/vm.py b/tools/w7/vm.py index 0ab58c82b1b8f63070afd1dbd2005af489ad02e3..f121bfeb5832ba1e606ce078d164270d63fa4a7b 100755 --- a/tools/w7/vm.py +++ b/tools/w7/vm.py @@ -10,6 +10,7 @@ import re import secrets import shutil import socket +import struct import subprocess import tempfile import time @@ -30,12 +31,18 @@ IMAGES = VM_HOME / "images" MEDIA = VM_HOME / "media" INSTANCES = VM_HOME / "instances" RUN = VM_HOME / "run" -DISK = IMAGES / "win7-office-build.qcow2" -BASE_DISK = IMAGES / "win7-office-base.qcow2" -BASE_MANIFEST = IMAGES / "win7-office-base.json" AGENT_ISO = MEDIA / "win7-agent.iso" TARGETS = VM_HOME / "targets.json" +# Image stem and build-VM control port. Windows 7 is installed by hand from licensed +# media; Windows 10 (x64) and 11 (arm64) install unattended from windows_media.py ISOs. +BASES = { + "win7": ("win7-office", 18777), + "win10": ("win10", 18774), + "win11": ("win11", 18775), +} BUILD = "win7-build" +UNATTEND = Path(__file__).with_name("unattend") +FIRMWARE = Path("/opt/homebrew/share/qemu") NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,9}[a-z0-9])?") HOSTNAME = re.compile(r"[A-Z0-9](?:[A-Z0-9-]{0,13}[A-Z0-9])?") @@ -56,6 +63,18 @@ def require_vm_home(): raise SystemExit("VM volume is not mounted: %s" % volume) +def build_disk(base): + return IMAGES / ("%s-build.qcow2" % BASES[base][0]) + + +def base_disk(base): + return IMAGES / ("%s-base.qcow2" % BASES[base][0]) + + +def base_manifest(base): + return IMAGES / ("%s-base.json" % BASES[base][0]) + + def runtime(name): root = RUN / name return root, root / "qmp.sock", root / "qemu.pid", root / "qemu.log", root / "screen.png" @@ -91,7 +110,72 @@ def qmp(name, command, arguments=None): raise SystemExit(response["error"]["desc"]) -def launch(name, disk, port, mac, cpus, memory_mb, display, drives): +def uefi_vars(path, template, width=1024, height=768): + """Write an edk2 variable store whose PlatformConfig sets the GOP resolution. + + Windows keeps the firmware's framebuffer mode, and 1024x768 is the largest mode + edk2 offers for ramfb on arm64.""" + image = bytearray(template.read_bytes()) + offset = struct.unpack_from("