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

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

- One executable per architecture: x86_64 built with llvm-mingw against msvcrt for Windows 7 SP1, aarch64 for Windows 11 on Arm; a small shim answers the few Windows 8+ imports. - draw builds wgpu and OpenGL 2.1 side by side on Windows and picks D3D12 where it exists, OpenGL elsewhere. - The title bar follows the system: Mica with system caption hit-testing on 11, a drawn caption over Acrylic on 8 and 10, the system frame on 7. - Section files open with OneNote's share modes and one-byte locks, so Snowbound and OneNote 2010 edit one notebook side by side, locally and over UNC shares. - Dialogs, Credential Manager, ReadDirectoryChangesW, MCI audio, spelling, icon and manifest; releases publish Windows builds that update themselves. - Also: the Windows 10 and 11 lab VMs, Bonjour share names, redrawn sync icons with a mount-fallback warning, symlinked mount detection, and fixes for modifier timing, tied edit clocks, and folder syncing on Windows. Assisted-by: claude-opus-5.5

58 files changed, 3727 insertions(+), 411 deletions(-)

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