| author | |
| committer | |
| log | 7341e1e1c5fa607ec4e4d583e094b552e6e1a996 |
| tree | 7e28ce9510ef9b841d4d4f541ff6ac36796f8e50 |
| parent | e860c97095edca027e9a21a94783ad92e2f3305e |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- 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.535 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: Float | 28 | let width: Float |
| 29 | let highlighter: Bool | 29 | let highlighter: Bool |
| 30 | 30 | ||
| 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, then | 38 | /// 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? |
| 57 | 64 | ||
| 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) : -1 | 66 | 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 | } |
| 62 | 69 | ||
| ... | @@ -275,7 +282,14 @@ final class InkPicker: UIView { | ... | @@ -275,7 +282,14 @@ final class InkPicker: UIView { |
| 275 | action.state = other == width ? .on : .off | 282 | action.state = other == width ? .on : .off |
| 276 | return action | 283 | 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 | } |
| 280 | 294 | ||
| 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. |
| 161 | A pen that reports pressure (a tablet on macOS, the Apple Pencil) draws and stores it as | 161 | A pen that reports pressure (a tablet on macOS, the Apple Pencil) draws and stores it as |
| 162 | OneNote 2010 does with pressure sensitivity on: each point's width is the pen's times | 162 | OneNote 2010 does with pressure sensitivity on: each point's width is the pen's times |
| 163 | 0.25 plus 1.5 times the pressure, and the stroke keeps NormalPressure beside X and Y | 163 | 0.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 |
| 165 | does every pen with OneNote's "Use pen pressure sensitivity" turned off (Options > | ||
| 166 | Advanced on the desktop, the pen's colour menu on iOS; on by default). winit reports no | ||
| 167 | tablet pressure on Linux. | ||
| 165 | 168 | ||
| 166 | ## Tables and selections across them | 169 | ## Tables and selections across them |
| 167 | 170 |
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: |
| 28 | 28 | ||
| 29 | Snowbound reads pressure the same way and draws it as OneNote does. A stroke drawn with a pen | 29 | Snowbound reads pressure the same way and draws it as OneNote does. A stroke drawn with a pen |
| 30 | that reports pressure (a tablet on macOS, the Apple Pencil) stores X, Y and NormalPressure | 30 | that reports pressure (a tablet on macOS, the Apple Pencil) stores X, Y and NormalPressure |
| 31 | from 0 to 1023 without IgnorePressure, as OneNote does with the option on; a mouse's or a | 31 | from 0 to 1023 without IgnorePressure, as OneNote does with the option on; a mouse's, |
| 32 | finger's stroke stays as OneNote's mouse ink. | 32 | trackpad's or finger's stroke, or any stroke with Snowbound's own "Use pen pressure |
| 33 | sensitivity" off, stays as OneNote's mouse ink. | ||
| 33 | 34 | ||
| 34 | `candidate` is `pressure_ink_is_written_as_onenote_keeps_it_and_survives_edits` in | 35 | `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's | 36 | `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))] |
| 4 | use std::{ | 4 | use 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 are | 34 | // 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_EXLOCK | 37 | 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_SHLOCK | 39 | 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 | released | 107 | released |
| 106 | } | 108 | } |
| 107 | 109 | ||
| 108 | /// Reads a snapshot excluding writers, as commits exclude everyone (macOS shares it with | 110 | /// 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))] |
| 111 | pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> { | 114 | pub 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))] |
| 118 | pub fn read_file_limited(path: impl AsRef<Path>, limit: usize) -> io::Result<Vec<u8>> { | 121 | pub 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 | } |
| 133 | 129 | ||
| 130 | /// How many times a read that meets a commit in progress is made before it is refused. | ||
| 131 | #[cfg(any(unix, windows))] | ||
| 132 | const 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))] | ||
| 137 | fn 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))] |
| 135 | impl CommitIo for FileIo { | 173 | impl 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)))] | ||
| 477 | mod 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"); |
| 69 | pub const SYNC_DONE: &[&str] = art!("icons/sync-done"); | 69 | pub const SYNC_DONE: &[&str] = art!("icons/sync-done"); |
| 70 | pub const SYNC_ERROR: &[&str] = art!("icons/sync-error"); | 70 | pub const SYNC_ERROR: &[&str] = art!("icons/sync-error"); |
| 71 | pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline"); | 71 | pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline"); |
| 72 | pub const SYNC_WARNING: &[&str] = art!("icons/sync-warning"); | ||
| 72 | 73 | ||
| 73 | pub const ERASER: &[&str] = art!("icons/eraser"); | 74 | pub const ERASER: &[&str] = art!("icons/eraser"); |
| 74 | pub const LASSO: &[&str] = art!("icons/lasso"); | 75 | pub 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 | } |
| 159 | 159 | ||
| 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 | } |
| 178 | 187 | ||
| 188 | /// The Bonjour SMB service `server` names, as the Finder mounts a server it browsed to. | ||
| 189 | fn 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. |
| 180 | fn decode(text: &str) -> String { | 197 | fn 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 | } |
| 1052 | 1069 | ||
| 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 | } |
| 346 | 346 | ||
| 347 | #[allow(non_camel_case_types)] | ||
| 348 | type 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 | |||
| 361 | unsafe 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. | ||
| 378 | pub 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")] |
| 348 | unsafe extern "C" { | 430 | unsafe 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 | } |
| 27 | 28 | ||
| 28 | impl Page { | 29 | impl 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 | } |
| 35 | 37 | ||
| ... | @@ -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 | } |
| 44 | 47 | ||
| 45 | fn id() -> Id { | 48 | fn 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 | } |
| 48 | 51 | ||
| ... | @@ -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 { |
| 357 | 361 | ||
| 358 | impl State { | 362 | impl State { |
| 359 | /// Opens the dialog; on `location`, a notebook opened from its server that couldn't sign | 363 | /// 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 | } |
| 411 | 423 | ||
| 412 | /// Opens the notebook at `mount` through the embedded client signed in as `login`, and | 424 | /// 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 | } |
| 422 | 444 | ||
| 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 | } |
| 34 | 37 | ||
| 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 | } |
| 20 | 20 | ||
| 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 |
| 22 | fn 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. |
| 23 | fn 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. |
| 138 | pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) { | 145 | pub(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. | ||
| 6 | set -eu | ||
| 7 | here=$(cd "$(dirname "$0")" && pwd) | ||
| 8 | root=$(cd "$here/../.." && pwd) | ||
| 9 | LLVM_MINGW=${LLVM_MINGW:-$root/target/windows/llvm-mingw} | ||
| 10 | export 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 | } | ||
| 15 | arch=$1 command=$2 | ||
| 16 | shift 2 | ||
| 17 | case $arch in | ||
| 18 | x86_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 | ;; | ||
| 26 | aarch64) | ||
| 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 | ;; | ||
| 38 | esac | ||
| 39 | variable=$(echo "$target" | tr - _) | ||
| 40 | # cc-rs (bundled SQLite, ring) compiles with the same clang and archives with its llvm-ar. | ||
| 41 | env "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. | ||
| 4 | set -eu | ||
| 5 | for arg do | ||
| 6 | shift | ||
| 7 | case $arg in | ||
| 8 | -lgcc_eh | -lgcc_s) set -- "$@" -l:libunwind.a ;; | ||
| 9 | -lgcc) ;; | ||
| 10 | *) set -- "$@" "$arg" ;; | ||
| 11 | esac | ||
| 12 | done | ||
| 13 | exec "$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. |
| 47 | 47 | ||
| 48 | The macOS app is signed with Clover's Developer ID Application certificate | 48 | The 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 is | 49 | (team 9R7DPNW28H), named in `release.py` by its SHA-1 hash, since its name is |
| 50 | the account holder's legal name, which nothing here prints or stores. It gets | 50 | the account holder's legal name, which nothing here prints or stores. |
| 51 | hardened runtime, a secure timestamp, and the microphone and camera | 51 | `build_macos.py --sign developer-id` signs it, embedding the Developer ID |
| 52 | entitlements recording needs. `--ad-hoc` signs ad hoc instead; the 10.6 bundle | 52 | provisioning profile for `net.paperclover.snowbound` ("Snowbound Developer ID", |
| 53 | found where Xcode keeps profiles) as `Contents/embedded.provisionprofile`, with | ||
| 54 | hardened runtime, a secure timestamp, the production iCloud container | ||
| 55 | `iCloud.net.paperclover.snowbound` that Use iCloud Drive needs, and the | ||
| 56 | microphone and camera entitlements recording needs. It fails rather than fall | ||
| 57 | back to ad hoc. `--ad-hoc` signs ad hoc instead, without iCloud; the 10.6 bundle | ||
| 53 | stays unsigned, as it predates Developer ID. codesign fails with | 58 | stays unsigned, as it predates Developer ID. codesign fails with |
| 54 | `errSecInternalComponent` where it can't ask to use the private key, as from an | 59 | `errSecInternalComponent` where it can't ask to use the private key, as from an |
| 55 | agent's shell; `security set-key-partition-list -S apple-tool:,apple:,codesign: | 60 | agent'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 |
| 90 | publishes as above. Run again for the same commit, it only brings | 95 | publishes 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 is | 96 | `latest.json` up to date; a different commit that derives the same version is |
| 92 | refused. The 10.6 build needs the SDK and nightly toolchain | 97 | refused. 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 |
| 99 | cross linker against glibc 2.31. All four build from an Apple silicon Mac. | ||
| 94 | 100 | ||
| 95 | ## In the app | 101 | ## In the app |
| 96 | 102 |
tools/canvas/README.md+1-1| ... | @@ -14,7 +14,7 @@ cargo test -p draw -- --ignored | ... | @@ -14,7 +14,7 @@ cargo test -p draw -- --ignored |
| 14 | cargo test -p canvas --features gpu gpu:: -- --ignored | 14 | cargo test -p canvas --features gpu gpu:: -- --ignored |
| 15 | ``` | 15 | ``` |
| 16 | 16 | ||
| 17 | The 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: | 17 | The 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: |
| 18 | 18 | ||
| 19 | ```sh | 19 | ```sh |
| 20 | python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id net.paperclover.snowbound.review | 20 | python3 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") |
| 23 | parser.add_argument('--profile', type=Path, | 23 | parser.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') |
| 25 | parser.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') | ||
| 25 | args = parser.parse_args() | 27 | args = parser.parse_args() |
| 26 | if args.bundle_id and not args.output: | 28 | if args.bundle_id and not args.output: |
| 27 | parser.error('Use --bundle-id with --output.') | 29 | parser.error('Use --bundle-id with --output.') |
| 30 | if args.sign and args.snow_leopard: | ||
| 31 | parser.error('The 10.6 bundle stays unsigned.') | ||
| 28 | if args.output and (args.output.suffix != '.app' or args.output.exists()): | 32 | if 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.') |
| 30 | root = Path(__file__).resolve().parents[2] | 34 | root = 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. |
| 135 | if not args.snow_leopard: | 139 | if 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 |
| 6 | import json | 6 | import json |
| 7 | import os | 7 | import os |
| 8 | from pathlib import Path | 8 | from pathlib import Path |
| 9 | import plistlib | ||
| 10 | import shutil | 9 | import shutil |
| 11 | import subprocess | 10 | import subprocess |
| 12 | import sys | 11 | import sys |
| ... | @@ -29,11 +28,6 @@ CHECKS = [ | ... | @@ -29,11 +28,6 @@ CHECKS = [ |
| 29 | IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E' | 28 | IDENTITY = '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}. |
| 31 | NOTARY = Path('~/.config/snowbound/notary.json').expanduser() | 30 | NOTARY = Path('~/.config/snowbound/notary.json').expanduser() |
| 32 | # What hardened runtime needs for Record Audio and Record Video. | ||
| 33 | ENTITLEMENTS = { | ||
| 34 | 'com.apple.security.device.audio-input': True, | ||
| 35 | 'com.apple.security.device.camera': True, | ||
| 36 | } | ||
| 37 | 31 | ||
| 38 | 32 | ||
| 39 | def derive(release, commits): | 33 | def derive(release, commits): |
| ... | @@ -109,21 +103,21 @@ def notary(): | ... | @@ -109,21 +103,21 @@ def notary(): |
| 109 | 103 | ||
| 110 | 104 | ||
| 111 | def build_mac(platform, folder, developer_id, notarize): | 105 | def 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 archive | 122 | return archive |
| 129 | 123 | ||
| ... | @@ -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 @@ |
| 1 | from datetime import datetime, timezone | 1 | from datetime import datetime, timezone |
| 2 | from pathlib import Path | 2 | from pathlib import Path |
| 3 | import runpy | 3 | import runpy |
| 4 | import tempfile | ||
| 4 | import unittest | 5 | import unittest |
| 5 | 6 | ||
| 6 | release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py')) | 7 | release = 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) |
| 31 | 32 | ||
| 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 | |||
| 32 | 60 | ||
| 33 | if __name__ == '__main__': | 61 | if __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 | ) |
| 8 | if not defined ONEVM_HOSTNAME goto agent | 8 | if not defined ONEVM_HOSTNAME goto agent |
| 9 | if /i "%COMPUTERNAME%"=="%ONEVM_HOSTNAME%" goto agent | 9 | if /i "%COMPUTERNAME%"=="%ONEVM_HOSTNAME%" goto agent |
| 10 | rem Windows 11 ships without wmic; Windows 7 PowerShell lacks Rename-Computer. | ||
| 11 | where wmic >nul 2>&1 || goto rename_powershell | ||
| 10 | wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1 | 12 | wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1 |
| 11 | find "ReturnValue = 0;" "%~dp0bootstrap.log" >nul || exit /b 1 | 13 | find "ReturnValue = 0;" "%~dp0bootstrap.log" >nul || exit /b 1 |
| 14 | goto restart | ||
| 15 | :rename_powershell | ||
| 16 | powershell -NoProfile -Command "Rename-Computer -NewName '%ONEVM_HOSTNAME%' -Force -ErrorAction Stop" >"%~dp0bootstrap.log" 2>&1 || exit /b 1 | ||
| 17 | :restart | ||
| 12 | shutdown /r /t 0 | 18 | shutdown /r /t 0 |
| 13 | exit /b | 19 | exit /b |
| 14 | 20 |
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 | ||
| 2 | rem First logon of a Windows 10/11 lab build: install the agent and quiet the desktop. | ||
| 3 | set "AGENT=" | ||
| 4 | for %%D in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist "%%D:\agent.py" set "AGENT=%%D:" | ||
| 5 | if not defined AGENT exit /b 1 | ||
| 6 | xcopy /e /i /y /q "%AGENT%\" C:\win7-agent\ || exit /b 1 | ||
| 7 | attrib -r /s /d "C:\win7-agent\*" | ||
| 8 | netsh advfirewall firewall add rule name="win7-agent" dir=in action=allow protocol=TCP localport=8777 | ||
| 9 | net accounts /maxpwage:unlimited | ||
| 10 | set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon | ||
| 11 | reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d 1 /f | ||
| 12 | reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d one /f | ||
| 13 | reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d one /f | ||
| 14 | reg delete "%WINLOGON%" /v AutoLogonCount /f | ||
| 15 | powercfg /change monitor-timeout-ac 0 | ||
| 16 | powercfg /change standby-timeout-ac 0 | ||
| 17 | powercfg /hibernate off | ||
| 18 | reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f | ||
| 19 | reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoUpdate /t REG_DWORD /d 1 /f | ||
| 20 | reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f | ||
| 21 | reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v EnableTransparency /t REG_DWORD /d 1 /f | ||
| 22 | reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f | ||
| 23 | call C:\win7-agent\install-autostart.cmd | ||
| 24 | shutdown /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 | |||
| 4 | import argparse | ||
| 5 | import hashlib | ||
| 6 | from pathlib import Path | ||
| 7 | import shutil | ||
| 8 | import subprocess | ||
| 9 | import tempfile | ||
| 10 | import urllib.request | ||
| 11 | import xml.etree.ElementTree as ET | ||
| 12 | |||
| 13 | from env import require | ||
| 14 | |||
| 15 | # The catalogs the Media Creation Tool itself downloads. | ||
| 16 | CATALOGS = { | ||
| 17 | "win10": ("https://go.microsoft.com/fwlink/?LinkId=841361", "x64"), | ||
| 18 | "win11": ("https://go.microsoft.com/fwlink/?linkid=2156292", "ARM64"), | ||
| 19 | } | ||
| 20 | |||
| 21 | |||
| 22 | def 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 | |||
| 29 | def 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 | |||
| 42 | def 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 | |||
| 52 | def 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 | |||
| 64 | def 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 | |||
| 101 | def main(): | ||
| 102 | parser = argparse.ArgumentParser(description=__doc__) | ||
| 103 | parser.add_argument("base", choices=sorted(CATALOGS)) | ||
| 104 | build(parser.parse_args().base) | ||
| 105 | |||
| 106 | |||
| 107 | if __name__ == "__main__": | ||
| 108 | main() | ||