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 @@...@@ -198,6 +198,7 @@
198 isa = XCBuildConfiguration;198 isa = XCBuildConfiguration;
199 buildSettings = {199 buildSettings = {
200 ARCHS = arm64;200 ARCHS = arm64;
201 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
201 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;202 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
202 CURRENT_PROJECT_VERSION = 1;203 CURRENT_PROJECT_VERSION = 1;
203 GENERATE_INFOPLIST_FILE = YES;204 GENERATE_INFOPLIST_FILE = YES;
...@@ -234,6 +235,7 @@...@@ -234,6 +235,7 @@
234 isa = XCBuildConfiguration;235 isa = XCBuildConfiguration;
235 buildSettings = {236 buildSettings = {
236 ARCHS = arm64;237 ARCHS = arm64;
238 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
237 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;239 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
238 CURRENT_PROJECT_VERSION = 1;240 CURRENT_PROJECT_VERSION = 1;
239 GENERATE_INFOPLIST_FILE = YES;241 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 {...@@ -28,6 +28,13 @@ struct Pen {
28 let width: Float28 let width: Float
29 let highlighter: Bool29 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
31 /// The pens under a section of tab colour `section`, a COLORREF: its accent, then38 /// The pens under a section of tab colour `section`, a COLORREF: its accent, then
32 /// OneNote 2010's favourites, as the desktop's gallery.39 /// OneNote 2010's favourites, as the desktop's gallery.
33 static func gallery(_ section: UInt32) -> [Pen] {40 static func gallery(_ section: UInt32) -> [Pen] {
...@@ -56,7 +63,7 @@ final class InkGesture: UIGestureRecognizer {...@@ -56,7 +63,7 @@ final class InkGesture: UIGestureRecognizer {
56 private var tracked: UITouch?63 private var tracked: UITouch?
5764
58 private func sample(_ touch: UITouch) -> InkSample {65 private func sample(_ touch: UITouch) -> InkSample {
59 let pressure = touch.type == .pencil ? Float(touch.force / touch.maximumPossibleForce) : -166 let pressure = touch.type == .pencil && Pen.pressure ? Float(touch.force / touch.maximumPossibleForce) : -1
60 return (touch.preciseLocation(in: view), pressure)67 return (touch.preciseLocation(in: view), pressure)
61 }68 }
6269
...@@ -275,7 +282,14 @@ final class InkPicker: UIView {...@@ -275,7 +282,14 @@ final class InkPicker: UIView {
275 action.state = other == width ? .on : .off282 action.state = other == width ? .on : .off
276 return action283 return action
277 }284 }
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 ]
279 }293 }
280294
281 private static func name(_ color: UIColor?) -> String { color?.accessibilityName.capitalized ?? "Black" }295 private static func name(_ color: UIColor?) -> String { color?.accessibilityName.capitalized ?? "Black" }
arc/canvas.md+4-1
...@@ -161,7 +161,10 @@ themes....@@ -161,7 +161,10 @@ themes.
161A pen that reports pressure (a tablet on macOS, the Apple Pencil) draws and stores it as161A pen that reports pressure (a tablet on macOS, the Apple Pencil) draws and stores it as
162OneNote 2010 does with pressure sensitivity on: each point's width is the pen's times162OneNote 2010 does with pressure sensitivity on: each point's width is the pen's times
1630.25 plus 1.5 times the pressure, and the stroke keeps NormalPressure beside X and Y1630.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
166## Tables and selections across them169## 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:...@@ -28,8 +28,9 @@ What it shows, and `crates/onestore/tests/page_ink.rs` pins:
2828
29Snowbound reads pressure the same way and draws it as OneNote does. A stroke drawn with a pen29Snowbound reads pressure the same way and draws it as OneNote does. A stroke drawn with a pen
30that reports pressure (a tablet on macOS, the Apple Pencil) stores X, Y and NormalPressure30that 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 a31from 0 to 1023 without IgnorePressure, as OneNote does with the option on; a mouse's,
32finger's stroke stays as OneNote's mouse ink.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
34`candidate` is `pressure_ink_is_written_as_onenote_keeps_it_and_survives_edits` in35`candidate` is `pressure_ink_is_written_as_onenote_keeps_it_and_survives_edits` in
35`page_ink.rs` (`ONESTORE_INK_PRESSURE_EXPORT`): the native file with the first drawing's36`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};...@@ -3,7 +3,6 @@ use std::io::{self, ErrorKind};
3#[cfg(any(unix, windows))]3#[cfg(any(unix, windows))]
4use std::{4use std::{
5 fs::File,5 fs::File,
6 io::Read,
7 path::Path,6 path::Path,
8 sync::{Mutex, MutexGuard},7 sync::{Mutex, MutexGuard},
9};8};
...@@ -33,11 +32,14 @@ impl FileIo {...@@ -33,11 +32,14 @@ impl FileIo {
33 {32 {
34 use std::os::unix::fs::OpenOptionsExt;33 use std::os::unix::fs::OpenOptionsExt;
35 // SMB can lose exclusion when separate opens race with flock. On smbfs these are34 // 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.35 // share modes, taken as OneNote takes them: a writer's shared lock denies only
37 let lock = if write {36 // other writers, and a reader takes none, as `stable` sees past a commit.
38 nix::libc::O_EXLOCK37 let smb = nix::sys::statfs::statfs(path.as_ref())
39 } else {38 .is_ok_and(|fs| fs.filesystem_type_name() == "smbfs");
40 nix::libc::O_SHLOCK39 let lock = match (write, smb) {
40 (true, false) => nix::libc::O_EXLOCK,
41 (false, true) => 0,
42 _ => nix::libc::O_SHLOCK,
41 };43 };
42 options.custom_flags(lock | nix::libc::O_NONBLOCK);44 options.custom_flags(lock | nix::libc::O_NONBLOCK);
43 }45 }
...@@ -105,8 +107,9 @@ pub fn place_file(path: impl AsRef<Path>, ancestor: [u8; 16], name: &str) -> io:...@@ -105,8 +107,9 @@ pub fn place_file(path: impl AsRef<Path>, ancestor: [u8; 16], name: &str) -> io:
105 released107 released
106}108}
107109
108/// Reads a snapshot excluding writers, as commits exclude everyone (macOS shares it with110/// Reads a snapshot, excluding writers as their commits exclude it, except on an SMB mount,
109/// other readers). Native writers can expose incomplete graphs to unlocked filesystem reads.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`.
110#[cfg(any(unix, windows))]113#[cfg(any(unix, windows))]
111pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {114pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
112 read_file_limited(path, usize::MAX)115 read_file_limited(path, usize::MAX)
...@@ -116,21 +119,56 @@ pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {...@@ -116,21 +119,56 @@ pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
116/// A size failure returns `FileTooLarge` without a partial snapshot.119/// A size failure returns `FileTooLarge` without a partial snapshot.
117#[cfg(any(unix, windows))]120#[cfg(any(unix, windows))]
118pub fn read_file_limited(path: impl AsRef<Path>, limit: usize) -> io::Result<Vec<u8>> {121pub 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);
122 let mut io = FileIo::open(path, false)?;122 let mut io = FileIo::open(path, false)?;
123 let mut bytes = Vec::new();123 let result = stable(|offset, output| io.read_at(offset, output), limit);
124 let result = (&mut io.file).take(count).read_to_end(&mut bytes);
125 let released = io.release();124 let released = io.release();
126 result?;125 let bytes = result?;
127 released?;126 released?;
128 if bytes.len() > limit {
129 return Err(ErrorKind::FileTooLarge.into());
130 }
131 Ok(bytes)127 Ok(bytes)
132}128}
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
134#[cfg(any(unix, windows))]172#[cfg(any(unix, windows))]
135impl CommitIo for FileIo {173impl CommitIo for FileIo {
136 fn read_at(&mut self, offset: u64, bytes: &mut [u8]) -> io::Result<usize> {174 fn read_at(&mut self, offset: u64, bytes: &mut [u8]) -> io::Result<usize> {
...@@ -434,3 +472,60 @@ impl Transaction {...@@ -434,3 +472,60 @@ impl Transaction {
434 io.finish(result)472 io.finish(result)
435 }473 }
436}474}
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 @@...@@ -1,4 +1,13 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<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"/>2 <defs>
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"/>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"/>
4</svg>13</svg>
crates/snowbound/assets/icons/sync-done.svg+10-2
...@@ -1,4 +1,12 @@...@@ -1,4 +1,12 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<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"/>2 <defs>
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"/>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"/>
4</svg>12</svg>
crates/snowbound/assets/icons/sync-error.svg+10-2
...@@ -1,4 +1,12 @@...@@ -1,4 +1,12 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<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"/>2 <defs>
3 <path d="M8 6.5V9.25M8 11.25V11.3" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round"/>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"/>
4</svg>12</svg>
crates/snowbound/assets/icons/sync-offline.svg+8-2
...@@ -1,4 +1,10 @@...@@ -1,4 +1,10 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<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"/>2 <defs>
3 <path d="M2.75 2.75L13.25 13.25" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>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"/>
4</svg>10</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");...@@ -69,6 +69,7 @@ pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy");
69pub const SYNC_DONE: &[&str] = art!("icons/sync-done");69pub const SYNC_DONE: &[&str] = art!("icons/sync-done");
70pub const SYNC_ERROR: &[&str] = art!("icons/sync-error");70pub const SYNC_ERROR: &[&str] = art!("icons/sync-error");
71pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline");71pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline");
72pub const SYNC_WARNING: &[&str] = art!("icons/sync-warning");
7273
73pub const ERASER: &[&str] = art!("icons/eraser");74pub const ERASER: &[&str] = art!("icons/eraser");
74pub const LASSO: &[&str] = art!("icons/lasso");75pub const LASSO: &[&str] = art!("icons/lasso");
crates/snowbound/src/library.rs+34-1
...@@ -157,8 +157,17 @@ impl Mount {...@@ -157,8 +157,17 @@ impl Mount {
157 }157 }
158 }158 }
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.
161 pub fn endpoint(&self) -> String {162 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 }
162 if self.host() == self.server {171 if self.host() == self.server {
163 format!("{}:445", self.server)172 format!("{}:445", self.server)
164 } else {173 } else {
...@@ -176,6 +185,14 @@ impl Mount {...@@ -176,6 +185,14 @@ impl Mount {
176 }185 }
177}186}
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
179/// `text` with `%XX` escapes decoded.196/// `text` with `%XX` escapes decoded.
180fn decode(text: &str) -> String {197fn decode(text: &str) -> String {
181 let bytes = text.as_bytes();198 let bytes = text.as_bytes();
...@@ -1050,6 +1067,22 @@ mod tests {...@@ -1050,6 +1067,22 @@ mod tests {
1050 assert_eq!(Mount::parse("/dev/disk1", "", ""), None);1067 assert_eq!(Mount::parse("/dev/disk1", "", ""), None);
1051 }1068 }
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
1053 #[test]1086 #[test]
1054 fn chosen_paths_open_their_notebook_or_section() {1087 fn chosen_paths_open_their_notebook_or_section() {
1055 let root = std::env::temp_dir().join(format!("snowbound-locate-{}", std::process::id()));1088 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> {...@@ -344,6 +344,88 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {
344 crate::library::Mount::parse(&text(&mount.f_mntfromname), &within.to_string_lossy(), "")344 crate::library::Mount::parse(&text(&mount.f_mntfromname), &within.to_string_lossy(), "")
345}345}
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
347#[link(name = "Security", kind = "framework")]429#[link(name = "Security", kind = "framework")]
348unsafe extern "C" {430unsafe extern "C" {
349 fn SecKeychainFindInternetPassword(431 fn SecKeychainFindInternetPassword(
...@@ -1294,4 +1376,14 @@ mod tests {...@@ -1294,4 +1376,14 @@ mod tests {
1294 let _ = std::fs::remove_file(&path);1376 let _ = std::fs::remove_file(&path);
1295 assert_eq!(read.unwrap().password, "second");1377 assert_eq!(read.unwrap().password, "second");
1296 }1378 }
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 }
1297}1389}
crates/snowbound/src/main.rs+4-1
...@@ -614,6 +614,8 @@ struct State {...@@ -614,6 +614,8 @@ struct State {
614 /// The system's spell checker, where it has one.614 /// The system's spell checker, where it has one.
615 spelling: Option<canvas::spelling::Spelling>,615 spelling: Option<canvas::spelling::Spelling>,
616 hide_spelling: bool,616 hide_spelling: bool,
617 /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure.
618 pen_pressure: bool,
617 /// The word the Spelling pane shows.619 /// The word the Spelling pane shows.
618 correction: Option<canvas::interaction::Correction>,620 correction: Option<canvas::interaction::Correction>,
619 /// The strip's fill with the window focused and not, continuing the system's title bar.621 /// The strip's fill with the window focused and not, continuing the system's title bar.
...@@ -980,6 +982,7 @@ impl State {...@@ -980,6 +982,7 @@ impl State {
980 page_grafted: false,982 page_grafted: false,
981 spelling,983 spelling,
982 hide_spelling: stored.hide_spelling,984 hide_spelling: stored.hide_spelling,
985 pen_pressure: !stored.ignore_pen_pressure,
983 correction: None,986 correction: None,
984 };987 };
985 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.988 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.
...@@ -2534,7 +2537,7 @@ impl State {...@@ -2534,7 +2537,7 @@ impl State {
2534 let response = match event {2537 let response = match event {
2535 ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?,2538 ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?,
2536 ui::Event::Pressure(pressure) => {2539 ui::Event::Pressure(pressure) => {
2537 self.view.set_pressure(pressure);2540 self.view.set_pressure(pressure.filter(|_| self.pen_pressure));
2538 continue;2541 continue;
2539 }2542 }
2540 ui::Event::PointerLeft => self.view.pointer_left(),2543 ui::Event::PointerLeft => self.view.pointer_left(),
crates/snowbound/src/options.rs+19-1
...@@ -23,13 +23,15 @@ enum Page {...@@ -23,13 +23,15 @@ enum Page {
23 General,23 General,
24 Display,24 Display,
25 SaveBackup,25 SaveBackup,
26 Advanced,
26}27}
2728
28impl Page {29impl Page {
29 const ALL: [(Page, &str); 3] = [30 const ALL: [(Page, &str); 4] = [
30 (Page::General, "General"),31 (Page::General, "General"),
31 (Page::Display, "Display"),32 (Page::Display, "Display"),
32 (Page::SaveBackup, "Save & Backup"),33 (Page::SaveBackup, "Save & Backup"),
34 (Page::Advanced, "Advanced"),
33 ];35 ];
34}36}
3537
...@@ -40,6 +42,7 @@ pub struct Options {...@@ -40,6 +42,7 @@ pub struct Options {
40 color_scheme: ColorScheme,42 color_scheme: ColorScheme,
41 light_pages: bool,43 light_pages: bool,
42 automatic_updates: bool,44 automatic_updates: bool,
45 pen_pressure: bool,
43}46}
4447
45fn id() -> Id {48fn id() -> Id {
...@@ -62,6 +65,7 @@ impl State {...@@ -62,6 +65,7 @@ impl State {
62 color_scheme: self.color_scheme,65 color_scheme: self.color_scheme,
63 light_pages: self.light_pages,66 light_pages: self.light_pages,
64 automatic_updates: self.updates.automatic(),67 automatic_updates: self.updates.automatic(),
68 pen_pressure: self.pen_pressure,
65 });69 });
66 self.ui.open_popup(id());70 self.ui.open_popup(id());
67 self.ui.set_focus(Some(user_name()));71 self.ui.set_focus(Some(user_name()));
...@@ -252,6 +256,19 @@ impl State {...@@ -252,6 +256,19 @@ impl State {
252 options.light_pages = dark;256 options.light_pages = dark;
253 }257 }
254 }258 }
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 }
255 Page::SaveBackup => {272 Page::SaveBackup => {
256 heading(ui, &theme, "Cache file location");273 heading(ui, &theme, "Cache file location");
257 let cache = &self.cache;274 let cache = &self.cache;
...@@ -304,6 +321,7 @@ impl State {...@@ -304,6 +321,7 @@ impl State {
304 self.author = name.to_owned();321 self.author = name.to_owned();
305 self.color_scheme = options.color_scheme;322 self.color_scheme = options.color_scheme;
306 self.light_pages = options.light_pages;323 self.light_pages = options.light_pages;
324 self.pen_pressure = options.pen_pressure;
307 self.updates.set_automatic(options.automatic_updates);325 self.updates.set_automatic(options.automatic_updates);
308 self.follow_color_scheme();326 self.follow_color_scheme();
309 self.save_settings();327 self.save_settings();
crates/snowbound/src/server.rs+31-9
...@@ -43,6 +43,9 @@ pub struct Connect {...@@ -43,6 +43,9 @@ pub struct Connect {
43 asked: u64,43 asked: u64,
44 /// A notebook listed as open that could not sign in, which opens once it lists.44 /// A notebook listed as open that could not sign in, which opens once it lists.
45 reopen: bool,45 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>,
46 replies: (mpsc::Sender<Reply>, mpsc::Receiver<Reply>),49 replies: (mpsc::Sender<Reply>, mpsc::Receiver<Reply>),
47}50}
4851
...@@ -207,6 +210,7 @@ impl Connect {...@@ -207,6 +210,7 @@ impl Connect {
207 status: Status::Idle,210 status: Status::Idle,
208 asked: 0,211 asked: 0,
209 reopen: false,212 reopen: false,
213 mounted: None,
210 replies: mpsc::channel(),214 replies: mpsc::channel(),
211 }215 }
212 }216 }
...@@ -357,12 +361,20 @@ fn domain_field() -> Id {...@@ -357,12 +361,20 @@ fn domain_field() -> Id {
357361
358impl State {362impl State {
359 /// Opens the dialog; on `location`, a notebook opened from its server that couldn't sign363 /// 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.
361 pub(crate) fn open_server(&mut self, location: Option<&str>) {366 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 };
362 let mut connect = Connect::new(373 let mut connect = Connect::new(
363 location.unwrap_or_default().to_owned(),374 address.unwrap_or_default(),
364 platform::remember_label().map(|_| false),375 platform::remember_label().map(|_| false),
365 );376 );
377 connect.mounted = mounted.map(str::to_owned);
366 let mut request = None;378 let mut request = None;
367 if location.is_some() {379 if location.is_some() {
368 connect.reopen = true;380 connect.reopen = true;
...@@ -409,15 +421,25 @@ impl State {...@@ -409,15 +421,25 @@ impl State {
409 });421 });
410 }422 }
411423
412 /// Opens the notebook at `mount` through the embedded client signed in as `login`, and424 /// Opens the notebook at `mount` through the embedded client signed in as `login`, in
413 /// closes the dialog.425 /// place of any reading it through the system's mount, and closes the dialog.
414 fn open_from_server(&mut self, mount: Mount, login: Login) {426 fn open_from_server(&mut self, mount: Mount, login: Login) {
415 self.ui.close_popup(id());427 self.ui.close_popup(id());
416 self.server = None;428 let url = mount.url();
417 let location = mount.url();429 let read =
418 self.open_notebook_with(location, None, move |location, cache| {430 move |location: &str, cache: &Path| Library::on_share(location, mount, login, cache);
419 Library::on_share(location, mount, login, cache)431 match self.server.take().and_then(|connect| connect.mounted) {
420 });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 }
421 }443 }
422444
423 /// Builds the dialog while it is open.445 /// Builds the dialog while it is open.
crates/snowbound/src/settings.rs+5
...@@ -30,6 +30,9 @@ pub struct Settings {...@@ -30,6 +30,9 @@ pub struct Settings {
30 pub tags: Option<Vec<canvas::editor::NoteTag>>,30 pub tags: Option<Vec<canvas::editor::NoteTag>>,
31 /// Checks for updates only when Check for Updates… asks.31 /// Checks for updates only when Check for Updates… asks.
32 pub manual_updates: bool,32 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,
33}36}
3437
35/// What the toolbar's buttons apply from their menus' last picks.38/// What the toolbar's buttons apply from their menus' last picks.
...@@ -140,6 +143,7 @@ impl crate::State {...@@ -140,6 +143,7 @@ impl crate::State {
140 search_scope: self.search.default,143 search_scope: self.search.default,
141 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),144 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),
142 manual_updates: !self.updates.automatic(),145 manual_updates: !self.updates.automatic(),
146 ignore_pen_pressure: !self.pen_pressure,
143 };147 };
144 if let Err(error) = settings.save(path) {148 if let Err(error) = settings.save(path) {
145 eprintln!("Cannot save the settings in {}: {error}", path.display());149 eprintln!("Cannot save the settings in {}: {error}", path.display());
...@@ -199,6 +203,7 @@ mod tests {...@@ -199,6 +203,7 @@ mod tests {
199 art: Some(format!("{}.png", "ab".repeat(32))),203 art: Some(format!("{}.png", "ab".repeat(32))),
200 }]),204 }]),
201 manual_updates: true,205 manual_updates: true,
206 ignore_pen_pressure: true,
202 };207 };
203 settings.save(&path).unwrap();208 settings.save(&path).unwrap();
204 assert_eq!(Settings::load(&path), settings);209 assert_eq!(Settings::load(&path), settings);
crates/snowbound/src/sidebar.rs+12
...@@ -1042,6 +1042,18 @@ impl crate::State {...@@ -1042,6 +1042,18 @@ impl crate::State {
1042 {1042 {
1043 return;1043 return;
1044 }1044 }
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 ) {
1045 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));1057 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));
1046 self.load(move || {1058 self.load(move || {
1047 let library = match open {1059 let library = match open {
crates/snowbound/src/sync.rs+34-13
...@@ -18,8 +18,12 @@ fn button() -> Id {...@@ -18,8 +18,12 @@ fn button() -> Id {
18 Id::ROOT.child("sync-button")18 Id::ROOT.child("sync-button")
19}19}
2020
21/// The status's label and icon, and what the reader can do about an error.21/// The status's label and icon, and what the reader can do about an error; `mounted` where
22fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option<&'static str>) {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>) {
23 if library::offline() {27 if library::offline() {
24 return (28 return (
25 "Working offline",29 "Working offline",
...@@ -53,6 +57,9 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option...@@ -53,6 +57,9 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option
53 SyncState::Unreadable => ("Can’t read this section", art::SYNC_ERROR, None),57 SyncState::Unreadable => ("Can’t read this section", art::SYNC_ERROR, None),
54 SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None),58 SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None),
55 SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None),59 SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None),
60 SyncState::UpToDate if mounted => {
61 ("Using the system’s connection", art::SYNC_WARNING, None)
62 }
56 SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None),63 SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None),
57 }64 }
58}65}
...@@ -137,15 +144,13 @@ fn update_note(update: &update::Status) -> Option<String> {...@@ -137,15 +144,13 @@ fn update_note(update: &update::Status) -> Option<String> {
137/// and a dot on it says a newer build is ready.144/// and a dot on it says a newer build is ready.
138pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) {145pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) {
139 let sync = overall(&sections(session));146 let sync = overall(&sections(session));
140 let strong = ui.popup_open(id()) || sync.error.is_some() && !library::offline();147 let (label, icon, _) = describe(&sync, session.library.notice.is_some());
141 let (label, icon, _) = describe(&sync);
142 ui.open_as(148 ui.open_as(
143 button(),149 button(),
144 Spec {150 Spec {
145 flags: Flags::CLICKABLE,151 flags: Flags::CLICKABLE,
146 size: [px(TOOL), px(TOOL)],152 size: [px(TOOL), px(TOOL)],
147 icon: Some(icon),153 icon: Some(icon),
148 color: Some(if strong { theme.text } else { theme.text_dim }),
149 hover_fill: Some(theme.hover()),154 hover_fill: Some(theme.hover()),
150 radius: 4.0,155 radius: 4.0,
151 center: true,156 center: true,
...@@ -210,7 +215,7 @@ impl State {...@@ -210,7 +215,7 @@ impl State {
210 let offline = library::offline();215 let offline = library::offline();
211 let sections = sections(session);216 let sections = sections(session);
212 let sync = overall(&sections);217 let sync = overall(&sections);
213 let (progress, _, advice) = describe(&sync);218 let (progress, _, advice) = describe(&sync, session.library.notice.is_some());
214 ui.open_as(219 ui.open_as(
215 id(),220 id(),
216 Spec {221 Spec {
...@@ -287,8 +292,23 @@ impl State {...@@ -287,8 +292,23 @@ impl State {
287 if let Some(advice) = advice {292 if let Some(advice) = advice {
288 text(ui, "advice", advice, theme.text, false);293 text(ui, "advice", advice, theme.text, false);
289 }294 }
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 }
290 text(ui, "sections", "Sections", theme.text, true);303 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() {
292 ui.open(312 ui.open(
293 format!("section-{index}"),313 format!("section-{index}"),
294 Spec {314 Spec {
...@@ -308,8 +328,8 @@ impl State {...@@ -308,8 +328,8 @@ impl State {
308 },328 },
309 );329 );
310 let status = match sync.queued {330 let status = match sync.queued {
311 0 => describe(sync).0.to_owned(),331 0 => describe(sync, false).0.to_owned(),
312 queued => format!("{}, {}", describe(sync).0, changes(queued)),332 queued => format!("{}, {}", describe(sync, false).0, changes(queued)),
313 };333 };
314 ui.leaf(334 ui.leaf(
315 "status",335 "status",
...@@ -326,10 +346,6 @@ impl State {...@@ -326,10 +346,6 @@ impl State {
326 text(ui, &part, &error.to_string(), theme.text_dim, false);346 text(ui, &part, &error.to_string(), theme.text_dim, false);
327 }347 }
328 }348 }
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 }
333 let (mut folder, mut restart) = (false, false);349 let (mut folder, mut restart) = (false, false);
334 if let Some(note) = update_note(&update) {350 if let Some(note) = update_note(&update) {
335 text(ui, "update-title", "Snowbound Update", theme.text, true);351 text(ui, "update-title", "Snowbound Update", theme.text, true);
...@@ -414,6 +430,11 @@ impl State {...@@ -414,6 +430,11 @@ impl State {
414 if let Some(file) = file.filter(|_| show) {430 if let Some(file) = file.filter(|_| show) {
415 platform::show_file(&file);431 platform::show_file(&file);
416 }432 }
433 if let Some(location) = sign_in {
434 self.ui.close_popup(id());
435 self.commands
436 .push(crate::Command::OpenFromServer(Some(location)));
437 }
417 match update {438 match update {
418 update::Status::Downloading(version)439 update::Status::Downloading(version)
419 | update::Status::Ready(version, _)440 | 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....@@ -47,9 +47,14 @@ build with the new public half, signed with the old key.
4747
48The macOS app is signed with Clover's Developer ID Application certificate48The macOS app is signed with Clover's Developer ID Application certificate
49(team 9R7DPNW28H), named in `release.py` by its SHA-1 hash, since its name is49(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 gets50the account holder's legal name, which nothing here prints or stores.
51hardened runtime, a secure timestamp, and the microphone and camera51`build_macos.py --sign developer-id` signs it, embedding the Developer ID
52entitlements recording needs. `--ad-hoc` signs ad hoc instead; the 10.6 bundle52provisioning 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
53stays unsigned, as it predates Developer ID. codesign fails with58stays unsigned, as it predates Developer ID. codesign fails with
54`errSecInternalComponent` where it can't ask to use the private key, as from an59`errSecInternalComponent` where it can't ask to use the private key, as from an
55agent's shell; `security set-key-partition-list -S apple-tool:,apple:,codesign:60agent'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...@@ -90,7 +95,8 @@ the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes
90publishes as above. Run again for the same commit, it only brings95publishes as above. Run again for the same commit, it only brings
91`latest.json` up to date; a different commit that derives the same version is96`latest.json` up to date; a different commit that derives the same version is
92refused. The 10.6 build needs the SDK and nightly toolchain97refused. 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
95## In the app101## In the app
96102
tools/canvas/README.md+1-1
...@@ -14,7 +14,7 @@ cargo test -p draw -- --ignored...@@ -14,7 +14,7 @@ cargo test -p draw -- --ignored
14cargo test -p canvas --features gpu gpu:: -- --ignored14cargo test -p canvas --features gpu gpu:: -- --ignored
15```15```
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
19```sh19```sh
20python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id net.paperclover.snowbound.review20python3 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',...@@ -22,9 +22,13 @@ parser.add_argument('--sign-identity', metavar='SHA1',
22 help="A Developer ID Application certificate's SHA-1 hash; found in the keychain otherwise")22 help="A Developer ID Application certificate's SHA-1 hash; found in the keychain otherwise")
23parser.add_argument('--profile', type=Path,23parser.add_argument('--profile', type=Path,
24 help='A Developer ID provisioning profile for the app; found where Xcode keeps them otherwise')24 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')
25args = parser.parse_args()27args = parser.parse_args()
26if args.bundle_id and not args.output:28if args.bundle_id and not args.output:
27 parser.error('Use --bundle-id with --output.')29 parser.error('Use --bundle-id with --output.')
30if args.sign and args.snow_leopard:
31 parser.error('The 10.6 bundle stays unsigned.')
28if args.output and (args.output.suffix != '.app' or args.output.exists()):32if args.output and (args.output.suffix != '.app' or args.output.exists()):
29 parser.error('Choose a new output path ending in .app.')33 parser.error('Choose a new output path ending in .app.')
30root = Path(__file__).resolve().parents[2]34root = Path(__file__).resolve().parents[2]
...@@ -133,8 +137,8 @@ if build:...@@ -133,8 +137,8 @@ if build:
133} | versions | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))137} | versions | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))
134# 10.6 runs the bundle unsigned.138# 10.6 runs the bundle unsigned.
135if not args.snow_leopard:139if not args.snow_leopard:
136 identity = args.sign_identity or developer_id()140 identity = args.sign != 'ad-hoc' and (args.sign_identity or developer_id())
137 profile = args.profile or developer_id_profile()141 profile = args.sign != 'ad-hoc' and (args.profile or developer_id_profile())
138 if identity and profile and (args.bundle_id or BUNDLE_ID) == BUNDLE_ID:142 if identity and profile and (args.bundle_id or BUNDLE_ID) == BUNDLE_ID:
139 shutil.copy2(profile, bundle / 'Contents/embedded.provisionprofile')143 shutil.copy2(profile, bundle / 'Contents/embedded.provisionprofile')
140 with tempfile.TemporaryDirectory() as scratch:144 with tempfile.TemporaryDirectory() as scratch:
...@@ -145,13 +149,18 @@ if not args.snow_leopard:...@@ -145,13 +149,18 @@ if not args.snow_leopard:
145 'com.apple.developer.icloud-services': ['CloudDocuments'],149 'com.apple.developer.icloud-services': ['CloudDocuments'],
146 'com.apple.developer.icloud-container-identifiers': [CONTAINER],150 'com.apple.developer.icloud-container-identifiers': [CONTAINER],
147 'com.apple.developer.ubiquity-container-identifiers': [CONTAINER],151 'com.apple.developer.ubiquity-container-identifiers': [CONTAINER],
152 'com.apple.developer.icloud-container-environment': 'Production',
148 # Hardened runtime's Record Audio and Record Video.153 # Hardened runtime's Record Audio and Record Video.
149 'com.apple.security.device.audio-input': True,154 'com.apple.security.device.audio-input': True,
150 'com.apple.security.device.camera': True,155 'com.apple.security.device.camera': True,
151 }))156 }))
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,
153 '--sign', identity, str(bundle)], check=True)160 '--sign', identity, str(bundle)], check=True)
154 print(f'Signed with the Developer ID of team {TEAM}, with the iCloud container {CONTAINER}.')161 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}.')
155 else:164 else:
156 subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True)165 subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True)
157 subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True)166 subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True)
tools/release.py+15-20
...@@ -6,7 +6,6 @@ import hashlib...@@ -6,7 +6,6 @@ import hashlib
6import json6import json
7import os7import os
8from pathlib import Path8from pathlib import Path
9import plistlib
10import shutil9import shutil
11import subprocess10import subprocess
12import sys11import sys
...@@ -29,11 +28,6 @@ CHECKS = [...@@ -29,11 +28,6 @@ CHECKS = [
29IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E'28IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E'
30# The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}.29# The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}.
31NOTARY = Path('~/.config/snowbound/notary.json').expanduser()30NOTARY = 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
39def derive(release, commits):33def derive(release, commits):
...@@ -109,21 +103,21 @@ def notary():...@@ -109,21 +103,21 @@ def notary():
109103
110104
111def build_mac(platform, folder, developer_id, notarize):105def 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."""
113 bundle = folder / 'Snowbound.app'107 bundle = folder / 'Snowbound.app'
114 run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle]108 if platform == 'macos-10.6':
115 + (['--snow-leopard'] if platform == 'macos-10.6' else []))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])
116 archive = folder / 'archive.zip'115 archive = folder / 'archive.zip'
117 if developer_id and platform != 'macos-10.6':116 if notarize and platform != 'macos-10.6':
118 entitlements = folder / 'entitlements.plist'117 zip_bundle(bundle, archive)
119 entitlements.write_bytes(plistlib.dumps(ENTITLEMENTS))118 run(['xcrun', 'notarytool', 'submit', archive, *notarize, '--wait'])
120 run(['codesign', '--force', '--options', 'runtime', '--timestamp', '--entitlements', entitlements,119 run(['xcrun', 'stapler', 'staple', bundle])
121 '--sign', IDENTITY, bundle])120 archive.unlink()
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()
127 zip_bundle(bundle, archive)121 zip_bundle(bundle, archive)
128 return archive122 return archive
129123
...@@ -208,7 +202,8 @@ def main():...@@ -208,7 +202,8 @@ def main():
208 shutil.rmtree(partial, ignore_errors=True)202 shutil.rmtree(partial, ignore_errors=True)
209 partial.mkdir()203 partial.mkdir()
210 for file in [*files.values(), stage / 'build.json', stage / 'build.json.sig']:204 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)
212 partial.rename(target)207 partial.rename(target)
213 shutil.rmtree(stage)208 shutil.rmtree(stage)
214 print(f'Published {target}')209 print(f'Published {target}')
tools/test_release.py+28
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1from datetime import datetime, timezone1from datetime import datetime, timezone
2from pathlib import Path2from pathlib import Path
3import runpy3import runpy
4import tempfile
4import unittest5import unittest
56
6release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py'))7release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py'))
...@@ -29,6 +30,33 @@ class ReleaseTest(unittest.TestCase):...@@ -29,6 +30,33 @@ class ReleaseTest(unittest.TestCase):
29 'macos-10.6': '2026-09-29-r10'})30 'macos-10.6': '2026-09-29-r10'})
30 self.assertEqual(release['newest'](latest, {'macos-aarch64': {}}, ('2026-09-29', 9)), latest)31 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
33if __name__ == '__main__':61if __name__ == '__main__':
34 unittest.main()62 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...@@ -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
7)7)
8if not defined ONEVM_HOSTNAME goto agent8if not defined ONEVM_HOSTNAME goto agent
9if /i "%COMPUTERNAME%"=="%ONEVM_HOSTNAME%" goto agent9if /i "%COMPUTERNAME%"=="%ONEVM_HOSTNAME%" goto agent
10rem Windows 11 ships without wmic; Windows 7 PowerShell lacks Rename-Computer.
11where wmic >nul 2>&1 || goto rename_powershell
10wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&112wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1
11find "ReturnValue = 0;" "%~dp0bootstrap.log" >nul || exit /b 113find "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
12shutdown /r /t 018shutdown /r /t 0
13exit /b19exit /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()