authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 09:16:38-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 09:40:30-07:00
log7341e1e1c5fa607ec4e4d583e094b552e6e1a996
tree7e28ce9510ef9b841d4d4f541ff6ac36796f8e50
parente860c97095edca027e9a21a94783ad92e2f3305e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: iCloud-signed releases for every platform, pen pressure setting, lock-free mount reads, iOS icon

- Releases publish macOS (Developer ID, notarized, iCloud entitlements and the embedded profile), Mac OS X 10.6, and Linux x86_64 and aarch64. - Use pen pressure sensitivity, on by default, as in OneNote 2010. - Mount reads detect torn reads and take no lock on smbfs; mount writers deny only other writers, as OneNote's do. Sign In moves a mount-read notebook onto Snowbound's SMB client, and Bonjour share names resolve. - The iOS app has its icon. Assisted-by: claude-opus-5.5

35 files changed, 822 insertions(+), 83 deletions(-)

apps/ios/Snowbound.xcodeproj/project.pbxproj+2
......@@ -198,6 +198,7 @@
198198 isa = XCBuildConfiguration;
199199 buildSettings = {
200200 ARCHS = arm64;
201 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
201202 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
202203 CURRENT_PROJECT_VERSION = 1;
203204 GENERATE_INFOPLIST_FILE = YES;
......@@ -234,6 +235,7 @@
234235 isa = XCBuildConfiguration;
235236 buildSettings = {
236237 ARCHS = arm64;
238 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
237239 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
238240 CURRENT_PROJECT_VERSION = 1;
239241 GENERATE_INFOPLIST_FILE = YES;
apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png created
Binary files /dev/null and b/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png differ
apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-light.png created
Binary files /dev/null and b/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-light.png differ
apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png created
Binary files /dev/null and b/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png differ
apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/Contents.json created+38
......@@ -0,0 +1,38 @@
1{
2 "images" : [
3 {
4 "filename" : "AppIcon-light.png",
5 "idiom" : "universal",
6 "platform" : "ios",
7 "size" : "1024x1024"
8 },
9 {
10 "appearances" : [
11 {
12 "appearance" : "luminosity",
13 "value" : "dark"
14 }
15 ],
16 "filename" : "AppIcon-dark.png",
17 "idiom" : "universal",
18 "platform" : "ios",
19 "size" : "1024x1024"
20 },
21 {
22 "appearances" : [
23 {
24 "appearance" : "luminosity",
25 "value" : "tinted"
26 }
27 ],
28 "filename" : "AppIcon-tinted.png",
29 "idiom" : "universal",
30 "platform" : "ios",
31 "size" : "1024x1024"
32 }
33 ],
34 "info" : {
35 "author" : "xcode",
36 "version" : 1
37 }
38}
apps/ios/Snowbound/Assets.xcassets/Contents.json created+6
......@@ -0,0 +1,6 @@
1{
2 "info" : {
3 "author" : "xcode",
4 "version" : 1
5 }
6}
apps/ios/Snowbound/Ink.swift+16-2
......@@ -28,6 +28,13 @@ struct Pen {
2828 let width: Float
2929 let highlighter: Bool
3030
31 /// OneNote 2010's "Use pen pressure sensitivity": the Pencil's strokes follow its pressure
32 /// unless turned off, when they keep their pen's width.
33 static var pressure: Bool {
34 get { !UserDefaults.standard.bool(forKey: "ignorePenPressure") }
35 set { UserDefaults.standard.set(!newValue, forKey: "ignorePenPressure") }
36 }
37
3138 /// The pens under a section of tab colour `section`, a COLORREF: its accent, then
3239 /// OneNote 2010's favourites, as the desktop's gallery.
3340 static func gallery(_ section: UInt32) -> [Pen] {
......@@ -56,7 +63,7 @@ final class InkGesture: UIGestureRecognizer {
5663 private var tracked: UITouch?
5764
5865 private func sample(_ touch: UITouch) -> InkSample {
59 let pressure = touch.type == .pencil ? Float(touch.force / touch.maximumPossibleForce) : -1
66 let pressure = touch.type == .pencil && Pen.pressure ? Float(touch.force / touch.maximumPossibleForce) : -1
6067 return (touch.preciseLocation(in: view), pressure)
6168 }
6269
......@@ -275,7 +282,14 @@ final class InkPicker: UIView {
275282 action.state = other == width ? .on : .off
276283 return action
277284 }
278 return [UIMenu(options: .displayInline, children: swatches), UIMenu(options: .displayInline, children: weights)]
285 let pressure = UIAction(title: "Use pen pressure sensitivity", image: UIImage(systemName: "hand.draw")) { _ in
286 Pen.pressure.toggle()
287 }
288 pressure.state = Pen.pressure ? .on : .off
289 return [
290 UIMenu(options: .displayInline, children: swatches), UIMenu(options: .displayInline, children: weights),
291 UIMenu(options: .displayInline, children: [pressure]),
292 ]
279293 }
280294
281295 private static func name(_ color: UIColor?) -> String { color?.accessibilityName.capitalized ?? "Black" }
arc/canvas.md+4-1
......@@ -161,7 +161,10 @@ themes.
161161A pen that reports pressure (a tablet on macOS, the Apple Pencil) draws and stores it as
162162OneNote 2010 does with pressure sensitivity on: each point's width is the pen's times
1631630.25 plus 1.5 times the pressure, and the stroke keeps NormalPressure beside X and Y
164(`corpus/ink-pressure`). A mouse or finger draws at the pen's width.
164(`corpus/ink-pressure`). A mouse, trackpad or finger draws at the pen's width, and so
165does every pen with OneNote's "Use pen pressure sensitivity" turned off (Options >
166Advanced on the desktop, the pen's colour menu on iOS; on by default). winit reports no
167tablet pressure on Linux.
165168
166169## Tables and selections across them
167170
corpus/ink-pressure/README.md+3-2
......@@ -28,8 +28,9 @@ What it shows, and `crates/onestore/tests/page_ink.rs` pins:
2828
2929Snowbound reads pressure the same way and draws it as OneNote does. A stroke drawn with a pen
3030that reports pressure (a tablet on macOS, the Apple Pencil) stores X, Y and NormalPressure
31from 0 to 1023 without IgnorePressure, as OneNote does with the option on; a mouse's or a
32finger's stroke stays as OneNote's mouse ink.
31from 0 to 1023 without IgnorePressure, as OneNote does with the option on; a mouse's,
32trackpad's or finger's stroke, or any stroke with Snowbound's own "Use pen pressure
33sensitivity" off, stays as OneNote's mouse ink.
3334
3435`candidate` is `pressure_ink_is_written_as_onenote_keeps_it_and_survives_edits` in
3536`page_ink.rs` (`ONESTORE_INK_PRESSURE_EXPORT`): the native file with the first drawing's
crates/onestore/src/commit.rs+112-17
......@@ -3,7 +3,6 @@ use std::io::{self, ErrorKind};
33#[cfg(any(unix, windows))]
44use std::{
55 fs::File,
6 io::Read,
76 path::Path,
87 sync::{Mutex, MutexGuard},
98};
......@@ -33,11 +32,14 @@ impl FileIo {
3332 {
3433 use std::os::unix::fs::OpenOptionsExt;
3534 // SMB can lose exclusion when separate opens race with flock. On smbfs these are
36 // share modes: a shared reader denies only writers, so OneNote's readers proceed.
37 let lock = if write {
38 nix::libc::O_EXLOCK
39 } else {
40 nix::libc::O_SHLOCK
35 // share modes, taken as OneNote takes them: a writer's shared lock denies only
36 // other writers, and a reader takes none, as `stable` sees past a commit.
37 let smb = nix::sys::statfs::statfs(path.as_ref())
38 .is_ok_and(|fs| fs.filesystem_type_name() == "smbfs");
39 let lock = match (write, smb) {
40 (true, false) => nix::libc::O_EXLOCK,
41 (false, true) => 0,
42 _ => nix::libc::O_SHLOCK,
4143 };
4244 options.custom_flags(lock | nix::libc::O_NONBLOCK);
4345 }
......@@ -105,8 +107,9 @@ pub fn place_file(path: impl AsRef<Path>, ancestor: [u8; 16], name: &str) -> io:
105107 released
106108}
107109
108/// Reads a snapshot excluding writers, as commits exclude everyone (macOS shares it with
109/// other readers). Native writers can expose incomplete graphs to unlocked filesystem reads.
110/// Reads a snapshot, excluding writers as their commits exclude it, except on an SMB mount,
111/// where it takes no lock, as OneNote's readers take none (macOS shares it with other readers).
112/// A read that meets a commit in progress is read again, then refused as `WouldBlock`.
110113#[cfg(any(unix, windows))]
111114pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
112115 read_file_limited(path, usize::MAX)
......@@ -116,21 +119,56 @@ pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
116119/// A size failure returns `FileTooLarge` without a partial snapshot.
117120#[cfg(any(unix, windows))]
118121pub fn read_file_limited(path: impl AsRef<Path>, limit: usize) -> io::Result<Vec<u8>> {
119 let count = u64::try_from(limit)
120 .map_err(|_| ErrorKind::InvalidInput)?
121 .saturating_add(1);
122122 let mut io = FileIo::open(path, false)?;
123 let mut bytes = Vec::new();
124 let result = (&mut io.file).take(count).read_to_end(&mut bytes);
123 let result = stable(|offset, output| io.read_at(offset, output), limit);
125124 let released = io.release();
126 result?;
125 let bytes = result?;
127126 released?;
128 if bytes.len() > limit {
129 return Err(ErrorKind::FileTooLarge.into());
130 }
131127 Ok(bytes)
132128}
133129
130/// How many times a read that meets a commit in progress is made before it is refused.
131#[cfg(any(unix, windows))]
132const TRIES: usize = 3;
133
134/// The file through `read`, read again where a commit tore it: its header changed while it was
135/// read, as commits write the header last, or it ends short of the header's length.
136#[cfg(any(unix, windows))]
137fn stable(
138 mut read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,
139 limit: usize,
140) -> io::Result<Vec<u8>> {
141 let mut block = vec![0; 1 << 16];
142 for _ in 0..TRIES {
143 let mut bytes = Vec::new();
144 loop {
145 let size = (limit.saturating_add(1) - bytes.len()).min(block.len());
146 match read(bytes.len() as u64, &mut block[..size]) {
147 Ok(0) => break,
148 Ok(count) if count <= size => bytes.extend_from_slice(&block[..count]),
149 Ok(_) => return Err(ErrorKind::InvalidData.into()),
150 Err(error) if error.kind() == ErrorKind::Interrupted => {}
151 Err(error) => return Err(error),
152 }
153 if bytes.len() > limit {
154 return Err(ErrorKind::FileTooLarge.into());
155 }
156 }
157 let mut header = vec![0; bytes.len().min(1024)];
158 match crate::snapshot::read_exact(&mut read, 0, &mut header) {
159 Ok(()) => {}
160 Err(error) if error.kind() == ErrorKind::UnexpectedEof => continue,
161 Err(error) => return Err(error),
162 }
163 let whole = crate::Header::parse(&bytes)
164 .map_or(true, |parsed| parsed.expected_length <= bytes.len() as u64);
165 if header == bytes[..header.len()] && whole {
166 return Ok(bytes);
167 }
168 }
169 Err(ErrorKind::WouldBlock.into())
170}
171
134172#[cfg(any(unix, windows))]
135173impl CommitIo for FileIo {
136174 fn read_at(&mut self, offset: u64, bytes: &mut [u8]) -> io::Result<usize> {
......@@ -434,3 +472,60 @@ impl Transaction {
434472 io.finish(result)
435473 }
436474}
475
476#[cfg(all(test, any(unix, windows)))]
477mod tests {
478 use super::*;
479
480 /// Reads `bytes`, changing a header byte on each of the first `changes` rereads of it.
481 fn reader(bytes: &[u8], mut changes: usize) -> impl FnMut(u64, &mut [u8]) -> io::Result<usize> {
482 let mut bytes = bytes.to_vec();
483 let mut reads = 0;
484 move |offset, output| {
485 if offset == 0 {
486 reads += 1;
487 // Each pass reads the header twice: with the body, then to check it.
488 if reads % 2 == 0 && changes > 0 {
489 changes -= 1;
490 bytes[1000] ^= 1;
491 }
492 }
493 let rest = bytes.get(offset as usize..).unwrap_or_default();
494 let count = rest.len().min(output.len());
495 output[..count].copy_from_slice(&rest[..count]);
496 Ok(count)
497 }
498 }
499
500 #[test]
501 fn a_read_torn_by_a_commit_is_read_again_then_refused() {
502 let section = crate::create_section("Torn.one", "Text", "Fixture").unwrap();
503 assert_eq!(stable(reader(&section, 0), section.len()).unwrap(), section);
504 let mut changed = section.clone();
505 changed[1000] ^= 1;
506 assert_eq!(stable(reader(&section, 1), section.len()).unwrap(), changed);
507 assert_eq!(
508 stable(reader(&section, TRIES), section.len())
509 .unwrap_err()
510 .kind(),
511 ErrorKind::WouldBlock
512 );
513 // Storage shortened ahead of the header that publishes its new length.
514 let short = &section[..section.len() - 1];
515 assert_eq!(
516 stable(reader(short, 0), section.len()).unwrap_err().kind(),
517 ErrorKind::WouldBlock
518 );
519 assert_eq!(
520 stable(reader(&section, 0), section.len() - 1)
521 .unwrap_err()
522 .kind(),
523 ErrorKind::FileTooLarge
524 );
525 // Files that are not revision stores read as they are.
526 assert_eq!(
527 stable(reader(b"unfinished", 0), 100).unwrap(),
528 b"unfinished"
529 );
530 }
531}
crates/snowbound/assets/icons/sync-busy.svg+11-2
......@@ -1,4 +1,13 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M12.6 6.75A4.75 4.75 0 0 0 4.1 5.2M3.4 9.25A4.75 4.75 0 0 0 11.9 10.8" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3 <path d="M3.75 2.75V5.5H6.5M12.25 13.25V10.5H9.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#f4f6fa"/><stop offset="1" stop-color="#aeb8c6"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
5 </defs>
6 <path d="M4 13.25H11.75A3 3 0 0 0 12.2 7.3A4.3 4.3 0 0 0 4 6.4A3.45 3.45 0 0 0 4 13.25Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M4 12.5H11.75A3 3 0 0 0 12.2 6.55A4.3 4.3 0 0 0 4 5.65A3.45 3.45 0 0 0 4 12.5Z" fill="url(#g0)" stroke="#6b7686" stroke-width="0.8" stroke-linejoin="round"/>
8 <path d="M3.1 8.9A2.2 2.2 0 0 1 5.2 6.9M6.2 5.4A3 3 0 0 1 10.6 5.6" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-opacity="0.85"/>
9 <path d="M8 12.25A3.5 3.5 0 1 1 15 12.25A3.5 3.5 0 1 1 8 12.25Z" fill="#000000" fill-opacity="0.16"/>
10 <path class="accent" d="M8 11.5A3.5 3.5 0 1 1 15 11.5A3.5 3.5 0 1 1 8 11.5Z" fill="url(#g1)" stroke="#1560c4" stroke-width="0.8"/>
11 <path d="M13.4 11.5A1.9 1.9 0 1 1 12.45 9.85" fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
12 <path d="M11.95 9.05L13.55 9.6L12.8 10.95Z" fill="#ffffff" stroke="#ffffff" stroke-width="0.4" stroke-linejoin="round"/>
413</svg>
crates/snowbound/assets/icons/sync-done.svg+10-2
......@@ -1,4 +1,12 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4.25 12.25H11.5A2.75 2.75 0 0 0 11.9 6.78A4 4 0 0 0 4.3 5.9A3.2 3.2 0 0 0 4.25 12.25Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/>
3 <path d="M5.9 8.9L7.4 10.4L10.1 7.4" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#f4f6fa"/><stop offset="1" stop-color="#aeb8c6"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#8fe39a"/><stop offset="1" stop-color="#28a745"/></linearGradient>
5 </defs>
6 <path d="M4 13.25H11.75A3 3 0 0 0 12.2 7.3A4.3 4.3 0 0 0 4 6.4A3.45 3.45 0 0 0 4 13.25Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M4 12.5H11.75A3 3 0 0 0 12.2 6.55A4.3 4.3 0 0 0 4 5.65A3.45 3.45 0 0 0 4 12.5Z" fill="url(#g0)" stroke="#6b7686" stroke-width="0.8" stroke-linejoin="round"/>
8 <path d="M3.1 8.9A2.2 2.2 0 0 1 5.2 6.9M6.2 5.4A3 3 0 0 1 10.6 5.6" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-opacity="0.85"/>
9 <path d="M8 12.25A3.5 3.5 0 1 1 15 12.25A3.5 3.5 0 1 1 8 12.25Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M8 11.5A3.5 3.5 0 1 1 15 11.5A3.5 3.5 0 1 1 8 11.5Z" fill="url(#g1)" stroke="#1b7a33" stroke-width="0.8"/>
11 <path d="M9.75 11.5L11 12.75L13.25 10.25" fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.1"/>
412</svg>
crates/snowbound/assets/icons/sync-error.svg+10-2
......@@ -1,4 +1,12 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 2.25L14 12.75H2Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/>
3 <path d="M8 6.5V9.25M8 11.25V11.3" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round"/>
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#f4f6fa"/><stop offset="1" stop-color="#aeb8c6"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#ff8f80"/><stop offset="1" stop-color="#f03b30"/></linearGradient>
5 </defs>
6 <path d="M4 13.25H11.75A3 3 0 0 0 12.2 7.3A4.3 4.3 0 0 0 4 6.4A3.45 3.45 0 0 0 4 13.25Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M4 12.5H11.75A3 3 0 0 0 12.2 6.55A4.3 4.3 0 0 0 4 5.65A3.45 3.45 0 0 0 4 12.5Z" fill="url(#g0)" stroke="#6b7686" stroke-width="0.8" stroke-linejoin="round"/>
8 <path d="M3.1 8.9A2.2 2.2 0 0 1 5.2 6.9M6.2 5.4A3 3 0 0 1 10.6 5.6" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-opacity="0.85"/>
9 <path d="M8 12.25A3.5 3.5 0 1 1 15 12.25A3.5 3.5 0 1 1 8 12.25Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M8 11.5A3.5 3.5 0 1 1 15 11.5A3.5 3.5 0 1 1 8 11.5Z" fill="url(#g1)" stroke="#c42418" stroke-width="0.8"/>
11 <path d="M11.5 9.6V11.8M11.5 13.35V13.4" fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.3"/>
412</svg>
crates/snowbound/assets/icons/sync-offline.svg+8-2
......@@ -1,4 +1,10 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4.25 12.25H11.5A2.75 2.75 0 0 0 11.9 6.78A4 4 0 0 0 4.3 5.9A3.2 3.2 0 0 0 4.25 12.25Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/>
3 <path d="M2.75 2.75L13.25 13.25" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#e6e9ee"/><stop offset="1" stop-color="#98a2b0"/></linearGradient>
4 </defs>
5 <path d="M4 13.25H11.75A3 3 0 0 0 12.2 7.3A4.3 4.3 0 0 0 4 6.4A3.45 3.45 0 0 0 4 13.25Z" fill="#000000" fill-opacity="0.16"/>
6 <path d="M4 12.5H11.75A3 3 0 0 0 12.2 6.55A4.3 4.3 0 0 0 4 5.65A3.45 3.45 0 0 0 4 12.5Z" fill="url(#g0)" stroke="#6b7686" stroke-width="0.8" stroke-linejoin="round"/>
7 <path d="M3.1 8.9A2.2 2.2 0 0 1 5.2 6.9M6.2 5.4A3 3 0 0 1 10.6 5.6" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-opacity="0.85"/>
8 <path d="M4.2 4.2L12.3 12.3" fill="none" stroke="#f4f6fa" stroke-width="2.6" stroke-linecap="round"/>
9 <path d="M2.5 2.5L13.5 13.5" fill="none" stroke="#4a5462" stroke-width="1.3" stroke-linecap="round"/>
410</svg>
crates/snowbound/assets/icons/sync-warning.svg created+12
......@@ -0,0 +1,12 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#f4f6fa"/><stop offset="1" stop-color="#aeb8c6"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#ffe27a"/><stop offset="1" stop-color="#f5a31a"/></linearGradient>
5 </defs>
6 <path d="M4 13.25H11.75A3 3 0 0 0 12.2 7.3A4.3 4.3 0 0 0 4 6.4A3.45 3.45 0 0 0 4 13.25Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M4 12.5H11.75A3 3 0 0 0 12.2 6.55A4.3 4.3 0 0 0 4 5.65A3.45 3.45 0 0 0 4 12.5Z" fill="url(#g0)" stroke="#6b7686" stroke-width="0.8" stroke-linejoin="round"/>
8 <path d="M3.1 8.9A2.2 2.2 0 0 1 5.2 6.9M6.2 5.4A3 3 0 0 1 10.6 5.6" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-opacity="0.85"/>
9 <path d="M11.5 8.05L15.4 15.25H7.6Z" fill="#000000" fill-opacity="0.16" stroke="#000000" stroke-opacity="0.16" stroke-width="0.8" stroke-linejoin="round"/>
10 <path d="M11.5 7.3L15.4 14.5H7.6Z" fill="url(#g1)" stroke="#b86e00" stroke-width="0.8" stroke-linejoin="round"/>
11 <path d="M11.5 10.1V12.1M11.5 13.5V13.55" fill="none" stroke="#4a3000" stroke-width="1.2" stroke-linecap="round"/>
12</svg>
crates/snowbound/src/art.rs+1
......@@ -69,6 +69,7 @@ pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy");
6969pub const SYNC_DONE: &[&str] = art!("icons/sync-done");
7070pub const SYNC_ERROR: &[&str] = art!("icons/sync-error");
7171pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline");
72pub const SYNC_WARNING: &[&str] = art!("icons/sync-warning");
7273
7374pub const ERASER: &[&str] = art!("icons/eraser");
7475pub const LASSO: &[&str] = art!("icons/lasso");
crates/snowbound/src/library.rs+34-1
......@@ -157,8 +157,17 @@ impl Mount {
157157 }
158158 }
159159
160 /// Where the embedded client dials: the server, on SMB's port unless it names another.
160 /// Where the embedded client dials: the server, on SMB's port unless it names another;
161 /// for a Bonjour service, where it answers now.
161162 pub fn endpoint(&self) -> String {
163 if let Some(instance) = bonjour_instance(&self.server) {
164 #[cfg(target_os = "macos")]
165 if let Some(endpoint) = crate::platform::bonjour_endpoint(&instance) {
166 return endpoint;
167 }
168 // Samba and macOS name their service after the host, which mDNS answers for.
169 return format!("{instance}.local:445");
170 }
162171 if self.host() == self.server {
163172 format!("{}:445", self.server)
164173 } else {
......@@ -176,6 +185,14 @@ impl Mount {
176185 }
177186}
178187
188/// The Bonjour SMB service `server` names, as the Finder mounts a server it browsed to.
189fn bonjour_instance(server: &str) -> Option<String> {
190 let instance = server
191 .trim_end_matches('.')
192 .strip_suffix("._smb._tcp.local")?;
193 (!instance.is_empty()).then(|| decode(instance))
194}
195
179196/// `text` with `%XX` escapes decoded.
180197fn decode(text: &str) -> String {
181198 let bytes = text.as_bytes();
......@@ -1050,6 +1067,22 @@ mod tests {
10501067 assert_eq!(Mount::parse("/dev/disk1", "", ""), None);
10511068 }
10521069
1070 /// The Finder's mount keeps the Bonjour name its keychain entry is under; only dialing
1071 /// resolves it.
1072 #[test]
1073 fn bonjour_mounts_keep_their_service_name() {
1074 let mount = Mount::parse("//clo@My%20NAS._smb._tcp.local/agent", "", "").unwrap();
1075 assert_eq!(mount.host(), "My%20NAS._smb._tcp.local");
1076 assert_eq!(bonjour_instance(&mount.server).as_deref(), Some("My NAS"));
1077 assert_eq!(
1078 bonjour_instance("zenith._smb._tcp.local.").as_deref(),
1079 Some("zenith")
1080 );
1081 for server in ["zenith.local", "_smb._tcp.local", "10.0.0.1:445"] {
1082 assert_eq!(bonjour_instance(server), None, "{server}");
1083 }
1084 }
1085
10531086 #[test]
10541087 fn chosen_paths_open_their_notebook_or_section() {
10551088 let root = std::env::temp_dir().join(format!("snowbound-locate-{}", std::process::id()));
crates/snowbound/src/macos.rs+92
......@@ -344,6 +344,88 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {
344344 crate::library::Mount::parse(&text(&mount.f_mntfromname), &within.to_string_lossy(), "")
345345}
346346
347#[allow(non_camel_case_types)]
348type DNSServiceResolveReply = extern "C" fn(
349 service: *mut std::ffi::c_void,
350 flags: u32,
351 interface: u32,
352 error: i32,
353 name: *const libc::c_char,
354 host: *const libc::c_char,
355 port: u16,
356 txt_length: u16,
357 txt: *const u8,
358 context: *mut std::ffi::c_void,
359);
360
361unsafe extern "C" {
362 fn DNSServiceResolve(
363 service: *mut *mut std::ffi::c_void,
364 flags: u32,
365 interface: u32,
366 name: *const libc::c_char,
367 kind: *const libc::c_char,
368 domain: *const libc::c_char,
369 reply: DNSServiceResolveReply,
370 context: *mut std::ffi::c_void,
371 ) -> i32;
372 fn DNSServiceRefSockFD(service: *mut std::ffi::c_void) -> i32;
373 fn DNSServiceProcessResult(service: *mut std::ffi::c_void) -> i32;
374 fn DNSServiceRefDeallocate(service: *mut std::ffi::c_void);
375}
376
377/// The `host:port` the SMB service Bonjour names `instance` answers at.
378pub fn bonjour_endpoint(instance: &str) -> Option<String> {
379 extern "C" fn resolved(
380 _: *mut std::ffi::c_void,
381 _: u32,
382 _: u32,
383 error: i32,
384 _: *const libc::c_char,
385 host: *const libc::c_char,
386 port: u16,
387 _: u16,
388 _: *const u8,
389 context: *mut std::ffi::c_void,
390 ) {
391 if error != 0 || host.is_null() {
392 return;
393 }
394 let host = unsafe { std::ffi::CStr::from_ptr(host) }.to_string_lossy();
395 let found = format!("{}:{}", host.trim_end_matches('.'), u16::from_be(port));
396 unsafe { *context.cast::<Option<String>>() = Some(found) };
397 }
398 let name = std::ffi::CString::new(instance).ok()?;
399 let mut service = std::ptr::null_mut();
400 let mut found: Option<String> = None;
401 let status = unsafe {
402 DNSServiceResolve(
403 &mut service,
404 0,
405 0,
406 name.as_ptr(),
407 c"_smb._tcp".as_ptr(),
408 c"local.".as_ptr(),
409 resolved,
410 (&raw mut found).cast(),
411 )
412 };
413 if status != 0 {
414 return None;
415 }
416 let mut ready = libc::pollfd {
417 fd: unsafe { DNSServiceRefSockFD(service) },
418 events: libc::POLLIN,
419 revents: 0,
420 };
421 // As long as the Finder waits to connect.
422 if unsafe { libc::poll(&mut ready, 1, 5000) } == 1 {
423 unsafe { DNSServiceProcessResult(service) };
424 }
425 unsafe { DNSServiceRefDeallocate(service) };
426 found
427}
428
347429#[link(name = "Security", kind = "framework")]
348430unsafe extern "C" {
349431 fn SecKeychainFindInternetPassword(
......@@ -1294,4 +1376,14 @@ mod tests {
12941376 let _ = std::fs::remove_file(&path);
12951377 assert_eq!(read.unwrap().password, "second");
12961378 }
1379
1380 /// A server the Finder mounted by its Bonjour service resolves to where it answers.
1381 #[test]
1382 #[ignore = "requires SNOWBOUND_TEST_BONJOUR naming an SMB service on this network"]
1383 fn bonjour_services_resolve_to_their_host() {
1384 let instance = std::env::var("SNOWBOUND_TEST_BONJOUR").unwrap();
1385 let endpoint = super::bonjour_endpoint(&instance).unwrap();
1386 assert!(endpoint.contains(".local:"), "{endpoint}");
1387 assert_eq!(super::bonjour_endpoint("snowbound-no-such-service"), None);
1388 }
12971389}
crates/snowbound/src/main.rs+4-1
......@@ -614,6 +614,8 @@ struct State {
614614 /// The system's spell checker, where it has one.
615615 spelling: Option<canvas::spelling::Spelling>,
616616 hide_spelling: bool,
617 /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure.
618 pen_pressure: bool,
617619 /// The word the Spelling pane shows.
618620 correction: Option<canvas::interaction::Correction>,
619621 /// The strip's fill with the window focused and not, continuing the system's title bar.
......@@ -980,6 +982,7 @@ impl State {
980982 page_grafted: false,
981983 spelling,
982984 hide_spelling: stored.hide_spelling,
985 pen_pressure: !stored.ignore_pen_pressure,
983986 correction: None,
984987 };
985988 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.
......@@ -2534,7 +2537,7 @@ impl State {
25342537 let response = match event {
25352538 ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?,
25362539 ui::Event::Pressure(pressure) => {
2537 self.view.set_pressure(pressure);
2540 self.view.set_pressure(pressure.filter(|_| self.pen_pressure));
25382541 continue;
25392542 }
25402543 ui::Event::PointerLeft => self.view.pointer_left(),
crates/snowbound/src/options.rs+19-1
......@@ -23,13 +23,15 @@ enum Page {
2323 General,
2424 Display,
2525 SaveBackup,
26 Advanced,
2627}
2728
2829impl Page {
29 const ALL: [(Page, &str); 3] = [
30 const ALL: [(Page, &str); 4] = [
3031 (Page::General, "General"),
3132 (Page::Display, "Display"),
3233 (Page::SaveBackup, "Save & Backup"),
34 (Page::Advanced, "Advanced"),
3335 ];
3436}
3537
......@@ -40,6 +42,7 @@ pub struct Options {
4042 color_scheme: ColorScheme,
4143 light_pages: bool,
4244 automatic_updates: bool,
45 pen_pressure: bool,
4346}
4447
4548fn id() -> Id {
......@@ -62,6 +65,7 @@ impl State {
6265 color_scheme: self.color_scheme,
6366 light_pages: self.light_pages,
6467 automatic_updates: self.updates.automatic(),
68 pen_pressure: self.pen_pressure,
6569 });
6670 self.ui.open_popup(id());
6771 self.ui.set_focus(Some(user_name()));
......@@ -252,6 +256,19 @@ impl State {
252256 options.light_pages = dark;
253257 }
254258 }
259 Page::Advanced => {
260 heading(ui, &theme, "Pen");
261 if ui::check_box(
262 ui,
263 "pen-pressure",
264 "Use pen pressure sensitivity",
265 options.pen_pressure,
266 )
267 .clicked
268 {
269 options.pen_pressure = !options.pen_pressure;
270 }
271 }
255272 Page::SaveBackup => {
256273 heading(ui, &theme, "Cache file location");
257274 let cache = &self.cache;
......@@ -304,6 +321,7 @@ impl State {
304321 self.author = name.to_owned();
305322 self.color_scheme = options.color_scheme;
306323 self.light_pages = options.light_pages;
324 self.pen_pressure = options.pen_pressure;
307325 self.updates.set_automatic(options.automatic_updates);
308326 self.follow_color_scheme();
309327 self.save_settings();
crates/snowbound/src/server.rs+31-9
......@@ -43,6 +43,9 @@ pub struct Connect {
4343 asked: u64,
4444 /// A notebook listed as open that could not sign in, which opens once it lists.
4545 reopen: bool,
46 /// The folder of a notebook read through the system's mount of the share, which moves to
47 /// the embedded client once it signs in.
48 mounted: Option<String>,
4649 replies: (mpsc::Sender<Reply>, mpsc::Receiver<Reply>),
4750}
4851
......@@ -207,6 +210,7 @@ impl Connect {
207210 status: Status::Idle,
208211 asked: 0,
209212 reopen: false,
213 mounted: None,
210214 replies: mpsc::channel(),
211215 }
212216 }
......@@ -357,12 +361,20 @@ fn domain_field() -> Id {
357361
358362impl State {
359363 /// Opens the dialog; on `location`, a notebook opened from its server that couldn't sign
360 /// in, at its sign-in with any password the keychain keeps tried first.
364 /// in, or read through the system's mount because Snowbound's client couldn't, at its
365 /// sign-in with any password the keychain keeps tried first.
361366 pub(crate) fn open_server(&mut self, location: Option<&str>) {
367 let mounted =
368 location.filter(|location| crate::library::server_address(location).is_none());
369 let address = match mounted {
370 Some(folder) => platform::smb_mount(Path::new(folder)).map(|mount| mount.url()),
371 None => location.map(str::to_owned),
372 };
362373 let mut connect = Connect::new(
363 location.unwrap_or_default().to_owned(),
374 address.unwrap_or_default(),
364375 platform::remember_label().map(|_| false),
365376 );
377 connect.mounted = mounted.map(str::to_owned);
366378 let mut request = None;
367379 if location.is_some() {
368380 connect.reopen = true;
......@@ -409,15 +421,25 @@ impl State {
409421 });
410422 }
411423
412 /// Opens the notebook at `mount` through the embedded client signed in as `login`, and
413 /// closes the dialog.
424 /// Opens the notebook at `mount` through the embedded client signed in as `login`, in
425 /// place of any reading it through the system's mount, and closes the dialog.
414426 fn open_from_server(&mut self, mount: Mount, login: Login) {
415427 self.ui.close_popup(id());
416 self.server = None;
417 let location = mount.url();
418 self.open_notebook_with(location, None, move |location, cache| {
419 Library::on_share(location, mount, login, cache)
420 });
428 let url = mount.url();
429 let read =
430 move |location: &str, cache: &Path| Library::on_share(location, mount, login, cache);
431 match self.server.take().and_then(|connect| connect.mounted) {
432 // The notebook stays where it was listed and shown, now read by Snowbound's client.
433 Some(folder) => {
434 let section = self
435 .session
436 .as_ref()
437 .filter(|session| session.library.location == folder)
438 .map(|session| session.tabs[session.tab].path.clone());
439 self.read_notebook(folder, section, None, read);
440 }
441 None => self.open_notebook_with(url, None, read),
442 }
421443 }
422444
423445 /// Builds the dialog while it is open.
crates/snowbound/src/settings.rs+5
......@@ -30,6 +30,9 @@ pub struct Settings {
3030 pub tags: Option<Vec<canvas::editor::NoteTag>>,
3131 /// Checks for updates only when Check for Updates… asks.
3232 pub manual_updates: bool,
33 /// Draws a tablet pen's strokes at its width, as OneNote 2010 with "Use pen pressure
34 /// sensitivity" off.
35 pub ignore_pen_pressure: bool,
3336}
3437
3538/// What the toolbar's buttons apply from their menus' last picks.
......@@ -140,6 +143,7 @@ impl crate::State {
140143 search_scope: self.search.default,
141144 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),
142145 manual_updates: !self.updates.automatic(),
146 ignore_pen_pressure: !self.pen_pressure,
143147 };
144148 if let Err(error) = settings.save(path) {
145149 eprintln!("Cannot save the settings in {}: {error}", path.display());
......@@ -199,6 +203,7 @@ mod tests {
199203 art: Some(format!("{}.png", "ab".repeat(32))),
200204 }]),
201205 manual_updates: true,
206 ignore_pen_pressure: true,
202207 };
203208 settings.save(&path).unwrap();
204209 assert_eq!(Settings::load(&path), settings);
crates/snowbound/src/sidebar.rs+12
......@@ -1042,6 +1042,18 @@ impl crate::State {
10421042 {
10431043 return;
10441044 }
1045 self.read_notebook(location, section, open, read);
1046 }
1047
1048 /// Shows `section`, or the first section, of the notebook at `location`, `open` or read
1049 /// with `read`, in place of the notebook listed there.
1050 pub(crate) fn read_notebook(
1051 &mut self,
1052 location: String,
1053 section: Option<String>,
1054 open: Option<Arc<Library>>,
1055 read: impl FnOnce(&str, &std::path::Path) -> Result<Library, String> + Send + 'static,
1056 ) {
10451057 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));
10461058 self.load(move || {
10471059 let library = match open {
crates/snowbound/src/sync.rs+34-13
......@@ -18,8 +18,12 @@ fn button() -> Id {
1818 Id::ROOT.child("sync-button")
1919}
2020
21/// The status's label and icon, and what the reader can do about an error.
22fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option<&'static str>) {
21/// The status's label and icon, and what the reader can do about an error; `mounted` where
22/// the notebook syncs through the system's mount because Snowbound's client couldn't sign in.
23fn describe(
24 sync: &SyncStatus,
25 mounted: bool,
26) -> (&'static str, &'static [&'static str], Option<&'static str>) {
2327 if library::offline() {
2428 return (
2529 "Working offline",
......@@ -53,6 +57,9 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option
5357 SyncState::Unreadable => ("Can’t read this section", art::SYNC_ERROR, None),
5458 SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None),
5559 SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None),
60 SyncState::UpToDate if mounted => {
61 ("Using the system’s connection", art::SYNC_WARNING, None)
62 }
5663 SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None),
5764 }
5865}
......@@ -137,15 +144,13 @@ fn update_note(update: &update::Status) -> Option<String> {
137144/// and a dot on it says a newer build is ready.
138145pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) {
139146 let sync = overall(&sections(session));
140 let strong = ui.popup_open(id()) || sync.error.is_some() && !library::offline();
141 let (label, icon, _) = describe(&sync);
147 let (label, icon, _) = describe(&sync, session.library.notice.is_some());
142148 ui.open_as(
143149 button(),
144150 Spec {
145151 flags: Flags::CLICKABLE,
146152 size: [px(TOOL), px(TOOL)],
147153 icon: Some(icon),
148 color: Some(if strong { theme.text } else { theme.text_dim }),
149154 hover_fill: Some(theme.hover()),
150155 radius: 4.0,
151156 center: true,
......@@ -210,7 +215,7 @@ impl State {
210215 let offline = library::offline();
211216 let sections = sections(session);
212217 let sync = overall(&sections);
213 let (progress, _, advice) = describe(&sync);
218 let (progress, _, advice) = describe(&sync, session.library.notice.is_some());
214219 ui.open_as(
215220 id(),
216221 Spec {
......@@ -287,8 +292,23 @@ impl State {
287292 if let Some(advice) = advice {
288293 text(ui, "advice", advice, theme.text, false);
289294 }
295 let mut sign_in = None;
296 if let Some(notice) = &session.library.notice {
297 let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}");
298 text(ui, "notice", &notice, theme.text_dim, false);
299 if ui::button(ui, "sign-in", "Sign In\u{2026}").clicked {
300 sign_in = Some(session.library.location.clone());
301 }
302 }
290303 text(ui, "sections", "Sections", theme.text, true);
291 for (index, (path, sync)) in sections.iter().enumerate() {
304 // OneNote's sync dialog leaves out the recycle bin, unless something is wrong there.
305 let listed = sections.iter().filter(|(path, sync)| {
306 sync.error.is_some()
307 || !path
308 .rsplit_once('/')
309 .is_some_and(|(folder, _)| library::recycle_bin(folder))
310 });
311 for (index, (path, sync)) in listed.enumerate() {
292312 ui.open(
293313 format!("section-{index}"),
294314 Spec {
......@@ -308,8 +328,8 @@ impl State {
308328 },
309329 );
310330 let status = match sync.queued {
311 0 => describe(sync).0.to_owned(),
312 queued => format!("{}, {}", describe(sync).0, changes(queued)),
331 0 => describe(sync, false).0.to_owned(),
332 queued => format!("{}, {}", describe(sync, false).0, changes(queued)),
313333 };
314334 ui.leaf(
315335 "status",
......@@ -326,10 +346,6 @@ impl State {
326346 text(ui, &part, &error.to_string(), theme.text_dim, false);
327347 }
328348 }
329 if let Some(notice) = &session.library.notice {
330 let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}");
331 text(ui, "notice", &notice, theme.text_dim, false);
332 }
333349 let (mut folder, mut restart) = (false, false);
334350 if let Some(note) = update_note(&update) {
335351 text(ui, "update-title", "Snowbound Update", theme.text, true);
......@@ -414,6 +430,11 @@ impl State {
414430 if let Some(file) = file.filter(|_| show) {
415431 platform::show_file(&file);
416432 }
433 if let Some(location) = sign_in {
434 self.ui.close_popup(id());
435 self.commands
436 .push(crate::Command::OpenFromServer(Some(location)));
437 }
417438 match update {
418439 update::Status::Downloading(version)
419440 | update::Status::Ready(version, _)
platform/windows/cargo.sh created+44
......@@ -0,0 +1,44 @@
1#!/bin/sh
2# cargo for Windows from macOS or Linux: `cargo.sh ARCH COMMAND ARGS...`.
3# x86_64 Windows 7 SP1 to 11: nightly's tier-3 x86_64-win7-windows-gnu, std built here
4# aarch64 Windows 11 on Arm: aarch64-pc-windows-gnullvm
5# Both link with llvm-mingw (`toolchain.sh`) against msvcrt.dll, which every Windows has.
6set -eu
7here=$(cd "$(dirname "$0")" && pwd)
8root=$(cd "$here/../.." && pwd)
9LLVM_MINGW=${LLVM_MINGW:-$root/target/windows/llvm-mingw}
10export LLVM_MINGW
11[ -x "$LLVM_MINGW/bin/clang" ] || {
12 echo "No llvm-mingw at $LLVM_MINGW; run $here/toolchain.sh" >&2
13 exit 1
14}
15arch=$1 command=$2
16shift 2
17case $arch in
18x86_64)
19 target=x86_64-win7-windows-gnu
20 toolchain=+nightly
21 # rustc would infer a bare ld from the name `link.sh`; it is a C compiler driver.
22 set -- -Zbuild-std=std,panic_unwind \
23 --config "target.$target.linker='$here/link.sh'" \
24 --config "target.$target.rustflags=['-C','linker-flavor=gcc']" "$@"
25 ;;
26aarch64)
27 target=aarch64-pc-windows-gnullvm
28 toolchain=+stable
29 rustup target add --toolchain stable "$target" >/dev/null
30 # crt-static links libunwind in rather than beside the executable.
31 set -- --config "target.$target.linker='$LLVM_MINGW/bin/aarch64-w64-mingw32-clang'" \
32 --config "target.$target.rustflags=['-C','target-feature=+crt-static']" "$@"
33 ;;
34*)
35 echo "usage: $0 x86_64|aarch64 COMMAND ARGS..." >&2
36 exit 2
37 ;;
38esac
39variable=$(echo "$target" | tr - _)
40# cc-rs (bundled SQLite, ring) compiles with the same clang and archives with its llvm-ar.
41env "CC_$variable=$LLVM_MINGW/bin/$arch-w64-mingw32-clang" \
42 "AR_$variable=$LLVM_MINGW/bin/llvm-ar" \
43 CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$root/target/windows}" \
44 cargo "$toolchain" "$command" --target "$target" "$@"
platform/windows/link.sh created+13
......@@ -0,0 +1,13 @@
1#!/bin/sh
2# The linker for x86_64-win7-windows-gnu: llvm-mingw's clang, which links compiler-rt
3# itself, with LLVM's libunwind (static) answering for libgcc's unwinder.
4set -eu
5for arg do
6 shift
7 case $arg in
8 -lgcc_eh | -lgcc_s) set -- "$@" -l:libunwind.a ;;
9 -lgcc) ;;
10 *) set -- "$@" "$arg" ;;
11 esac
12done
13exec "$LLVM_MINGW/bin/x86_64-w64-mingw32-clang" "$@"
tools/RELEASE.md+10-4
......@@ -47,9 +47,14 @@ build with the new public half, signed with the old key.
4747
4848The macOS app is signed with Clover's Developer ID Application certificate
4949(team 9R7DPNW28H), named in `release.py` by its SHA-1 hash, since its name is
50the account holder's legal name, which nothing here prints or stores. It gets
51hardened runtime, a secure timestamp, and the microphone and camera
52entitlements recording needs. `--ad-hoc` signs ad hoc instead; the 10.6 bundle
50the account holder's legal name, which nothing here prints or stores.
51`build_macos.py --sign developer-id` signs it, embedding the Developer ID
52provisioning profile for `net.paperclover.snowbound` ("Snowbound Developer ID",
53found where Xcode keeps profiles) as `Contents/embedded.provisionprofile`, with
54hardened runtime, a secure timestamp, the production iCloud container
55`iCloud.net.paperclover.snowbound` that Use iCloud Drive needs, and the
56microphone and camera entitlements recording needs. It fails rather than fall
57back to ad hoc. `--ad-hoc` signs ad hoc instead, without iCloud; the 10.6 bundle
5358stays unsigned, as it predates Developer ID. codesign fails with
5459`errSecInternalComponent` where it can't ask to use the private key, as from an
5560agent's shell; `security set-key-partition-list -S apple-tool:,apple:,codesign:
......@@ -90,7 +95,8 @@ the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes
9095publishes as above. Run again for the same commit, it only brings
9196`latest.json` up to date; a different commit that derives the same version is
9297refused. The 10.6 build needs the SDK and nightly toolchain
93`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`.
98`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.
94100
95101## In the app
96102
tools/canvas/README.md+1-1
......@@ -14,7 +14,7 @@ cargo test -p draw -- --ignored
1414cargo test -p canvas --features gpu gpu:: -- --ignored
1515```
1616
17The builder signs and verifies the local bundle: with team `9R7DPNW28H`'s Developer ID Application identity (found in the keychain by team, chosen by SHA-1, or `--sign-identity SHA1`) and a Developer ID provisioning profile for `net.paperclover.snowbound` naming `iCloud.net.paperclover.snowbound` (found in Xcode's profile folders, or `--profile PATH`), with hardened runtime and the iCloud container, which Use iCloud Drive needs; without both, ad hoc. To preserve an existing app during review, provide a new bundle path and a distinct identifier together:
17The builder signs and verifies the local bundle: with team `9R7DPNW28H`'s Developer ID Application identity (found in the keychain by team, chosen by SHA-1, or `--sign-identity SHA1`) and a Developer ID provisioning profile for `net.paperclover.snowbound` naming `iCloud.net.paperclover.snowbound` (found in Xcode's profile folders, or `--profile PATH`), with hardened runtime and the iCloud container, which Use iCloud Drive needs; without both, ad hoc. `--sign developer-id` fails instead of falling back, and `--sign ad-hoc` skips Developer ID. To preserve an existing app during review, provide a new bundle path and a distinct identifier together:
1818
1919```sh
2020python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id net.paperclover.snowbound.review
tools/canvas/build_macos.py+12-3
......@@ -22,9 +22,13 @@ parser.add_argument('--sign-identity', metavar='SHA1',
2222 help="A Developer ID Application certificate's SHA-1 hash; found in the keychain otherwise")
2323parser.add_argument('--profile', type=Path,
2424 help='A Developer ID provisioning profile for the app; found where Xcode keeps them otherwise')
25parser.add_argument('--sign', choices=['developer-id', 'ad-hoc'],
26 help='Require Developer ID with the iCloud container, or sign ad hoc; Developer ID where available otherwise')
2527args = parser.parse_args()
2628if args.bundle_id and not args.output:
2729 parser.error('Use --bundle-id with --output.')
30if args.sign and args.snow_leopard:
31 parser.error('The 10.6 bundle stays unsigned.')
2832if args.output and (args.output.suffix != '.app' or args.output.exists()):
2933 parser.error('Choose a new output path ending in .app.')
3034root = Path(__file__).resolve().parents[2]
......@@ -133,8 +137,8 @@ if build:
133137} | versions | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))
134138# 10.6 runs the bundle unsigned.
135139if not args.snow_leopard:
136 identity = args.sign_identity or developer_id()
137 profile = args.profile or developer_id_profile()
140 identity = args.sign != 'ad-hoc' and (args.sign_identity or developer_id())
141 profile = args.sign != 'ad-hoc' and (args.profile or developer_id_profile())
138142 if identity and profile and (args.bundle_id or BUNDLE_ID) == BUNDLE_ID:
139143 shutil.copy2(profile, bundle / 'Contents/embedded.provisionprofile')
140144 with tempfile.TemporaryDirectory() as scratch:
......@@ -145,13 +149,18 @@ if not args.snow_leopard:
145149 'com.apple.developer.icloud-services': ['CloudDocuments'],
146150 'com.apple.developer.icloud-container-identifiers': [CONTAINER],
147151 'com.apple.developer.ubiquity-container-identifiers': [CONTAINER],
152 'com.apple.developer.icloud-container-environment': 'Production',
148153 # Hardened runtime's Record Audio and Record Video.
149154 'com.apple.security.device.audio-input': True,
150155 'com.apple.security.device.camera': True,
151156 }))
152 subprocess.run(['codesign', '--force', '--options', 'runtime', '--entitlements', entitlements,
157 # A release fails where the timestamp server can't be reached, as notarization needs it.
158 timestamp = ['--timestamp'] if args.sign == 'developer-id' else []
159 subprocess.run(['codesign', '--force', '--options', 'runtime', *timestamp, '--entitlements', entitlements,
153160 '--sign', identity, str(bundle)], check=True)
154161 print(f'Signed with the Developer ID of team {TEAM}, with the iCloud container {CONTAINER}.')
162 elif args.sign == 'developer-id':
163 raise SystemExit(f'No Developer ID Application identity of team {TEAM} and profile for {BUNDLE_ID} with {CONTAINER}.')
155164 else:
156165 subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True)
157166 subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True)
tools/release.py+15-20
......@@ -6,7 +6,6 @@ import hashlib
66import json
77import os
88from pathlib import Path
9import plistlib
109import shutil
1110import subprocess
1211import sys
......@@ -29,11 +28,6 @@ CHECKS = [
2928IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E'
3029# The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}.
3130NOTARY = Path('~/.config/snowbound/notary.json').expanduser()
32# What hardened runtime needs for Record Audio and Record Video.
33ENTITLEMENTS = {
34 'com.apple.security.device.audio-input': True,
35 'com.apple.security.device.camera': True,
36}
3731
3832
3933def derive(release, commits):
......@@ -109,21 +103,21 @@ def notary():
109103
110104
111105def build_mac(platform, folder, developer_id, notarize):
112 """The zipped app; 10.6's stays unsigned, as it predates Developer ID."""
106 """The zipped app, which build_macos.py signs; 10.6's stays unsigned, as it predates Developer ID."""
113107 bundle = folder / 'Snowbound.app'
114 run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle]
115 + (['--snow-leopard'] if platform == 'macos-10.6' else []))
108 if platform == 'macos-10.6':
109 signing = ['--snow-leopard']
110 elif developer_id:
111 signing = ['--sign', 'developer-id', '--sign-identity', IDENTITY]
112 else:
113 signing = ['--sign', 'ad-hoc']
114 run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, *signing])
116115 archive = folder / 'archive.zip'
117 if developer_id and platform != 'macos-10.6':
118 entitlements = folder / 'entitlements.plist'
119 entitlements.write_bytes(plistlib.dumps(ENTITLEMENTS))
120 run(['codesign', '--force', '--options', 'runtime', '--timestamp', '--entitlements', entitlements,
121 '--sign', IDENTITY, bundle])
122 if notarize:
123 zip_bundle(bundle, archive)
124 run(['xcrun', 'notarytool', 'submit', archive, *notarize, '--wait'])
125 run(['xcrun', 'stapler', 'staple', bundle])
126 archive.unlink()
116 if notarize and platform != 'macos-10.6':
117 zip_bundle(bundle, archive)
118 run(['xcrun', 'notarytool', 'submit', archive, *notarize, '--wait'])
119 run(['xcrun', 'stapler', 'staple', bundle])
120 archive.unlink()
127121 zip_bundle(bundle, archive)
128122 return archive
129123
......@@ -208,7 +202,8 @@ def main():
208202 shutil.rmtree(partial, ignore_errors=True)
209203 partial.mkdir()
210204 for file in [*files.values(), stage / 'build.json', stage / 'build.json.sig']:
211 shutil.copyfile(file, partial / file.name)
205 # copy() keeps the Linux executables executable for anyone running them off the share.
206 shutil.copy(file, partial / file.name)
212207 partial.rename(target)
213208 shutil.rmtree(stage)
214209 print(f'Published {target}')
tools/test_release.py+28
......@@ -1,6 +1,7 @@
11from datetime import datetime, timezone
22from pathlib import Path
33import runpy
4import tempfile
45import unittest
56
67release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py'))
......@@ -29,6 +30,33 @@ class ReleaseTest(unittest.TestCase):
2930 'macos-10.6': '2026-09-29-r10'})
3031 self.assertEqual(release['newest'](latest, {'macos-aarch64': {}}, ('2026-09-29', 9)), latest)
3132
33 def test_build_macos_signs_each_mac_app(self):
34 build_mac, scope = release['build_mac'], release['build_mac'].__globals__
35 commands = []
36
37 def record(command, **_):
38 commands.append(list(map(str, command)))
39 if command[0] == 'ditto':
40 Path(command[-1]).touch()
41
42 run, scope['run'] = scope['run'], record
43 try:
44 def signing(platform, developer_id, notarize):
45 commands.clear()
46 with tempfile.TemporaryDirectory() as stage:
47 build_mac(platform, Path(stage), developer_id, notarize)
48 build = commands[0]
49 return (build[build.index(f'{stage}/Snowbound.app') + 1:],
50 [command[1] for command in commands[1:]])
51
52 self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']),
53 (['--sign', 'developer-id', '--sign-identity', release['IDENTITY']],
54 ['-c', 'notarytool', 'stapler', '-c']))
55 self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc'], ['-c']))
56 self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c']))
57 finally:
58 scope['run'] = run
59
3260
3361if __name__ == '__main__':
3462 unittest.main()
tools/w7/payload/bootstrap.cmd+6
......@@ -7,8 +7,14 @@ for %%D in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist "%%D:\one
77)
88if not defined ONEVM_HOSTNAME goto agent
99if /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
1012wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1
1113find "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
1218shutdown /r /t 0
1319exit /b
1420
tools/w7/unattend/autounattend.xml created+97
......@@ -0,0 +1,97 @@
1<?xml version="1.0" encoding="utf-8"?>
2<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
3 <settings pass="windowsPE">
4 <component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="{arch}" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
5 <SetupUILanguage><UILanguage>en-US</UILanguage></SetupUILanguage>
6 <InputLocale>en-US</InputLocale>
7 <SystemLocale>en-US</SystemLocale>
8 <UILanguage>en-US</UILanguage>
9 <UserLocale>en-US</UserLocale>
10 </component>
11 <component name="Microsoft-Windows-Setup" processorArchitecture="{arch}" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
12 <!-- The lab has no TPM or Secure Boot; Windows 10 ignores these keys. -->
13 <RunSynchronous>
14 <RunSynchronousCommand wcm:action="add"><Order>1</Order><Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
15 <RunSynchronousCommand wcm:action="add"><Order>2</Order><Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
16 <RunSynchronousCommand wcm:action="add"><Order>3</Order><Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
17 </RunSynchronous>
18 <DiskConfiguration>
19 <Disk wcm:action="add">
20 <DiskID>0</DiskID>
21 <WillWipeDisk>true</WillWipeDisk>
22 <CreatePartitions>
23 <CreatePartition wcm:action="add"><Order>1</Order><Type>EFI</Type><Size>300</Size></CreatePartition>
24 <CreatePartition wcm:action="add"><Order>2</Order><Type>MSR</Type><Size>16</Size></CreatePartition>
25 <CreatePartition wcm:action="add"><Order>3</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition>
26 </CreatePartitions>
27 <ModifyPartitions>
28 <ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Format>FAT32</Format><Label>System</Label></ModifyPartition>
29 <ModifyPartition wcm:action="add"><Order>2</Order><PartitionID>2</PartitionID></ModifyPartition>
30 <ModifyPartition wcm:action="add"><Order>3</Order><PartitionID>3</PartitionID><Format>NTFS</Format><Label>Windows</Label><Letter>C</Letter></ModifyPartition>
31 </ModifyPartitions>
32 </Disk>
33 </DiskConfiguration>
34 <!-- Choosing the image by index is what lets setup continue without a product key. -->
35 <ImageInstall>
36 <OSImage>
37 <InstallFrom><MetaData wcm:action="add"><Key>/IMAGE/INDEX</Key><Value>1</Value></MetaData></InstallFrom>
38 <InstallTo><DiskID>0</DiskID><PartitionID>3</PartitionID></InstallTo>
39 </OSImage>
40 </ImageInstall>
41 <UserData>
42 <AcceptEula>true</AcceptEula>
43 <FullName>one</FullName>
44 <Organization>Snowbound lab</Organization>
45 </UserData>
46 </component>
47 </settings>
48 <settings pass="specialize">
49 <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="{arch}" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
50 <ComputerName>{hostname}</ComputerName>
51 <TimeZone>UTC</TimeZone>
52 </component>
53 <component name="Microsoft-Windows-Deployment" processorArchitecture="{arch}" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
54 <RunSynchronous>
55 <RunSynchronousCommand wcm:action="add"><Order>1</Order><Path>reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE /v BypassNRO /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
56 </RunSynchronous>
57 </component>
58 </settings>
59 <settings pass="oobeSystem">
60 <component name="Microsoft-Windows-International-Core" processorArchitecture="{arch}" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
61 <InputLocale>en-US</InputLocale>
62 <SystemLocale>en-US</SystemLocale>
63 <UILanguage>en-US</UILanguage>
64 <UserLocale>en-US</UserLocale>
65 </component>
66 <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="{arch}" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
67 <OOBE>
68 <HideEULAPage>true</HideEULAPage>
69 <HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
70 <HideOnlineAccountScreens>true</HideOnlineAccountScreens>
71 <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
72 <ProtectYourPC>3</ProtectYourPC>
73 </OOBE>
74 <UserAccounts>
75 <LocalAccounts>
76 <LocalAccount wcm:action="add">
77 <Name>one</Name>
78 <Group>Administrators</Group>
79 <Password><Value>one</Value><PlainText>true</PlainText></Password>
80 </LocalAccount>
81 </LocalAccounts>
82 </UserAccounts>
83 <AutoLogon>
84 <Enabled>true</Enabled>
85 <Username>one</Username>
86 <Password><Value>one</Value><PlainText>true</PlainText></Password>
87 <LogonCount>9999999</LogonCount>
88 </AutoLogon>
89 <FirstLogonCommands>
90 <SynchronousCommand wcm:action="add">
91 <Order>1</Order>
92 <CommandLine>cmd /c for %d in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist %d:\lab-setup.cmd %d:\lab-setup.cmd</CommandLine>
93 </SynchronousCommand>
94 </FirstLogonCommands>
95 </component>
96 </settings>
97</unattend>
tools/w7/unattend/lab-setup.cmd created+24
......@@ -0,0 +1,24 @@
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
tools/w7/windows_media.py created+108
......@@ -0,0 +1,108 @@
1#!/usr/bin/env python3
2"""Build Windows 10/11 installation ISOs from Microsoft's Media Creation Tool catalog."""
3
4import argparse
5import hashlib
6from pathlib import Path
7import shutil
8import subprocess
9import tempfile
10import urllib.request
11import xml.etree.ElementTree as ET
12
13from env import require
14
15# The catalogs the Media Creation Tool itself downloads.
16CATALOGS = {
17 "win10": ("https://go.microsoft.com/fwlink/?LinkId=841361", "x64"),
18 "win11": ("https://go.microsoft.com/fwlink/?linkid=2156292", "ARM64"),
19}
20
21
22def tool(name):
23 path = shutil.which(name) or "/opt/homebrew/bin/" + name
24 if not Path(path).is_file():
25 raise SystemExit("Install with: /opt/homebrew/bin/brew install wimlib xorriso")
26 return path
27
28
29def catalog_entry(base, work):
30 url, arch = CATALOGS[base]
31 cab = work / "products.cab"
32 urllib.request.urlretrieve(url, cab)
33 subprocess.run(["bsdtar", "-xf", str(cab), "-C", str(work), "products.xml"], check=True)
34 for entry in ET.parse(work / "products.xml").getroot().iter("File"):
35 field = lambda key: entry.findtext(key) or ""
36 if (field("LanguageCode") == "en-us" and field("Architecture") == arch
37 and "CLIENTCONSUMER_RET" in field("FileName")):
38 return field("FilePath"), field("Sha1").lower()
39 raise SystemExit("The %s catalog has no en-us %s consumer image" % (base, arch))
40
41
42def download(url, sha1, path):
43 digest = hashlib.sha1()
44 with urllib.request.urlopen(url) as response, path.open("wb") as output:
45 while chunk := response.read(8 * 1024 * 1024):
46 output.write(chunk)
47 digest.update(chunk)
48 if digest.hexdigest() != sha1:
49 raise SystemExit("Download failed SHA-1 verification: %s" % url)
50
51
52def pro_index(esd):
53 info = subprocess.check_output([tool("wimlib-imagex"), "info", str(esd)], text=True)
54 index = None
55 for line in info.splitlines():
56 key, _, value = line.partition(":")
57 if key.strip() == "Index":
58 index = value.strip()
59 elif key.strip() == "Edition ID" and value.strip() == "Professional":
60 return index
61 raise SystemExit("No Professional edition in %s" % esd)
62
63
64def build(base):
65 media = Path(require("ONE_VM_HOME")).expanduser() / "media"
66 iso = media / ("%s.iso" % base)
67 if iso.exists():
68 raise SystemExit("Move the existing ISO first: %s" % iso)
69 media.mkdir(parents=True, exist_ok=True)
70 wim = tool("wimlib-imagex")
71 with tempfile.TemporaryDirectory(prefix="one-media-", dir=media) as temporary:
72 work = Path(temporary)
73 url, sha1 = catalog_entry(base, work)
74 esd = work / "image.esd"
75 print("Downloading %s" % url, flush=True)
76 download(url, sha1, esd)
77 tree = work / "iso"
78 sources = tree / "sources"
79 subprocess.run([wim, "apply", str(esd), "1", str(tree)], check=True)
80 subprocess.run([wim, "export", str(esd), "2", str(sources / "boot.wim"),
81 "--compress=LZX"], check=True)
82 subprocess.run([wim, "export", str(esd), "3", str(sources / "boot.wim"),
83 "--boot"], check=True)
84 # Solid LZMS keeps install.esd under the 4 GiB ISO 9660 file limit.
85 subprocess.run([wim, "export", str(esd), pro_index(esd),
86 str(sources / "install.esd"), "--compress=LZMS", "--solid"],
87 check=True)
88 esd.unlink()
89 partial = work / "out.iso"
90 # The no-prompt loader boots unattended instead of waiting for a key press.
91 subprocess.run([
92 tool("xorriso"), "-as", "mkisofs", "-quiet", "-iso-level", "3", "-J",
93 "-joliet-long", "-V", base.upper(),
94 "-e", "efi/microsoft/boot/efisys_noprompt.bin", "-no-emul-boot",
95 "-o", str(partial), str(tree),
96 ], check=True)
97 partial.replace(iso)
98 print(iso)
99
100
101def main():
102 parser = argparse.ArgumentParser(description=__doc__)
103 parser.add_argument("base", choices=sorted(CATALOGS))
104 build(parser.parse_args().base)
105
106
107if __name__ == "__main__":
108 main()