| 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 | 198 | 			isa = XCBuildConfiguration; |
| 199 | 199 | 			buildSettings = { |
| 200 | 200 | 				ARCHS = arm64; |
| 201 | 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | |
| 201 | 202 | 				CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; |
| 202 | 203 | 				CURRENT_PROJECT_VERSION = 1; |
| 203 | 204 | 				GENERATE_INFOPLIST_FILE = YES; |
| ... | ... | @@ -234,6 +235,7 @@ |
| 234 | 235 | 			isa = XCBuildConfiguration; |
| 235 | 236 | 			buildSettings = { |
| 236 | 237 | 				ARCHS = arm64; |
| 238 | 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | |
| 237 | 239 | 				CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; |
| 238 | 240 | 				CURRENT_PROJECT_VERSION = 1; |
| 239 | 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 | 28 | let width: Float |
| 29 | 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 | 38 | /// The pens under a section of tab colour `section`, a COLORREF: its accent, then |
| 32 | 39 | /// OneNote 2010's favourites, as the desktop's gallery. |
| 33 | 40 | static func gallery(_ section: UInt32) -> [Pen] { |
| ... | ... | @@ -56,7 +63,7 @@ final class InkGesture: UIGestureRecognizer { |
| 56 | 63 | private var tracked: UITouch? |
| 57 | 64 | |
| 58 | 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 | 67 | return (touch.preciseLocation(in: view), pressure) |
| 61 | 68 | } |
| 62 | 69 | |
| ... | ... | @@ -275,7 +282,14 @@ final class InkPicker: UIView { |
| 275 | 282 | action.state = other == width ? .on : .off |
| 276 | 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 | 295 | private static func name(_ color: UIColor?) -> String { color?.accessibilityName.capitalized ?? "Black" } |
arc/canvas.md+4-1| ... | ... | @@ -161,7 +161,10 @@ themes. |
| 161 | 161 | A pen that reports pressure (a tablet on macOS, the Apple Pencil) draws and stores it as |
| 162 | 162 | OneNote 2010 does with pressure sensitivity on: each point's width is the pen's times |
| 163 | 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 | 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 | 28 | |
| 29 | 29 | Snowbound reads pressure the same way and draws it as OneNote does. A stroke drawn with a pen |
| 30 | 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 | |
| 32 | finger's stroke stays as OneNote's mouse ink. | |
| 31 | from 0 to 1023 without IgnorePressure, as OneNote does with the option on; a mouse's, | |
| 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 | 35 | `candidate` is `pressure_ink_is_written_as_onenote_keeps_it_and_survives_edits` in |
| 35 | 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 | 3 | #[cfg(any(unix, windows))] |
| 4 | 4 | use std::{ |
| 5 | 5 | fs::File, |
| 6 | io::Read, | |
| 7 | 6 | path::Path, |
| 8 | 7 | sync::{Mutex, MutexGuard}, |
| 9 | 8 | }; |
| ... | ... | @@ -33,11 +32,14 @@ impl FileIo { |
| 33 | 32 | { |
| 34 | 33 | use std::os::unix::fs::OpenOptionsExt; |
| 35 | 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. | |
| 37 | let lock = if write { | |
| 38 | nix::libc::O_EXLOCK | |
| 39 | } else { | |
| 40 | nix::libc::O_SHLOCK | |
| 35 | // share modes, taken as OneNote takes them: a writer's shared lock denies only | |
| 36 | // other writers, and a reader takes none, as `stable` sees past a commit. | |
| 37 | let smb = nix::sys::statfs::statfs(path.as_ref()) | |
| 38 | .is_ok_and(|fs| fs.filesystem_type_name() == "smbfs"); | |
| 39 | let lock = match (write, smb) { | |
| 40 | (true, false) => nix::libc::O_EXLOCK, | |
| 41 | (false, true) => 0, | |
| 42 | _ => nix::libc::O_SHLOCK, | |
| 41 | 43 | }; |
| 42 | 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 | 107 | released |
| 106 | 108 | } |
| 107 | 109 | |
| 108 | /// Reads a snapshot excluding writers, as commits exclude everyone (macOS shares it with | |
| 109 | /// other readers). Native writers can expose incomplete graphs to unlocked filesystem reads. | |
| 110 | /// Reads a snapshot, excluding writers as their commits exclude it, except on an SMB mount, | |
| 111 | /// where it takes no lock, as OneNote's readers take none (macOS shares it with other readers). | |
| 112 | /// A read that meets a commit in progress is read again, then refused as `WouldBlock`. | |
| 110 | 113 | #[cfg(any(unix, windows))] |
| 111 | 114 | pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> { |
| 112 | 115 | read_file_limited(path, usize::MAX) |
| ... | ... | @@ -116,21 +119,56 @@ pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> { |
| 116 | 119 | /// A size failure returns `FileTooLarge` without a partial snapshot. |
| 117 | 120 | #[cfg(any(unix, windows))] |
| 118 | 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 | 122 | let mut io = FileIo::open(path, false)?; |
| 123 | let mut bytes = Vec::new(); | |
| 124 | let result = (&mut io.file).take(count).read_to_end(&mut bytes); | |
| 123 | let result = stable(|offset, output| io.read_at(offset, output), limit); | |
| 125 | 124 | let released = io.release(); |
| 126 | result?; | |
| 125 | let bytes = result?; | |
| 127 | 126 | released?; |
| 128 | if bytes.len() > limit { | |
| 129 | return Err(ErrorKind::FileTooLarge.into()); | |
| 130 | } | |
| 131 | 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 | 172 | #[cfg(any(unix, windows))] |
| 135 | 173 | impl CommitIo for FileIo { |
| 136 | 174 | fn read_at(&mut self, offset: u64, bytes: &mut [u8]) -> io::Result<usize> { |
| ... | ... | @@ -434,3 +472,60 @@ impl Transaction { |
| 434 | 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 | 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"/> | |
| 3 | <path d="M3.75 2.75V5.5H6.5M12.25 13.25V10.5H9.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/> | |
| 2 | <defs> | |
| 3 | <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#f4f6fa"/><stop offset="1" stop-color="#aeb8c6"/></linearGradient> | |
| 4 | <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient> | |
| 5 | </defs> | |
| 6 | <path d="M4 13.25H11.75A3 3 0 0 0 12.2 7.3A4.3 4.3 0 0 0 4 6.4A3.45 3.45 0 0 0 4 13.25Z" fill="#000000" fill-opacity="0.16"/> | |
| 7 | <path d="M4 12.5H11.75A3 3 0 0 0 12.2 6.55A4.3 4.3 0 0 0 4 5.65A3.45 3.45 0 0 0 4 12.5Z" fill="url(#g0)" stroke="#6b7686" stroke-width="0.8" stroke-linejoin="round"/> | |
| 8 | <path d="M3.1 8.9A2.2 2.2 0 0 1 5.2 6.9M6.2 5.4A3 3 0 0 1 10.6 5.6" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-opacity="0.85"/> | |
| 9 | <path d="M8 12.25A3.5 3.5 0 1 1 15 12.25A3.5 3.5 0 1 1 8 12.25Z" fill="#000000" fill-opacity="0.16"/> | |
| 10 | <path class="accent" d="M8 11.5A3.5 3.5 0 1 1 15 11.5A3.5 3.5 0 1 1 8 11.5Z" fill="url(#g1)" stroke="#1560c4" stroke-width="0.8"/> | |
| 11 | <path d="M13.4 11.5A1.9 1.9 0 1 1 12.45 9.85" fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/> | |
| 12 | <path d="M11.95 9.05L13.55 9.6L12.8 10.95Z" fill="#ffffff" stroke="#ffffff" stroke-width="0.4" stroke-linejoin="round"/> | |
| 4 | 13 | </svg> |
crates/snowbound/assets/icons/sync-done.svg+10-2| ... | ... | @@ -1,4 +1,12 @@ |
| 1 | 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"/> | |
| 3 | <path d="M5.9 8.9L7.4 10.4L10.1 7.4" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/> | |
| 2 | <defs> | |
| 3 | <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#f4f6fa"/><stop offset="1" stop-color="#aeb8c6"/></linearGradient> | |
| 4 | <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#8fe39a"/><stop offset="1" stop-color="#28a745"/></linearGradient> | |
| 5 | </defs> | |
| 6 | <path d="M4 13.25H11.75A3 3 0 0 0 12.2 7.3A4.3 4.3 0 0 0 4 6.4A3.45 3.45 0 0 0 4 13.25Z" fill="#000000" fill-opacity="0.16"/> | |
| 7 | <path d="M4 12.5H11.75A3 3 0 0 0 12.2 6.55A4.3 4.3 0 0 0 4 5.65A3.45 3.45 0 0 0 4 12.5Z" fill="url(#g0)" stroke="#6b7686" stroke-width="0.8" stroke-linejoin="round"/> | |
| 8 | <path d="M3.1 8.9A2.2 2.2 0 0 1 5.2 6.9M6.2 5.4A3 3 0 0 1 10.6 5.6" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-opacity="0.85"/> | |
| 9 | <path d="M8 12.25A3.5 3.5 0 1 1 15 12.25A3.5 3.5 0 1 1 8 12.25Z" fill="#000000" fill-opacity="0.16"/> | |
| 10 | <path d="M8 11.5A3.5 3.5 0 1 1 15 11.5A3.5 3.5 0 1 1 8 11.5Z" fill="url(#g1)" stroke="#1b7a33" stroke-width="0.8"/> | |
| 11 | <path d="M9.75 11.5L11 12.75L13.25 10.25" fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.1"/> | |
| 4 | 12 | </svg> |
crates/snowbound/assets/icons/sync-error.svg+10-2| ... | ... | @@ -1,4 +1,12 @@ |
| 1 | 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"/> | |
| 3 | <path d="M8 6.5V9.25M8 11.25V11.3" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round"/> | |
| 2 | <defs> | |
| 3 | <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#f4f6fa"/><stop offset="1" stop-color="#aeb8c6"/></linearGradient> | |
| 4 | <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#ff8f80"/><stop offset="1" stop-color="#f03b30"/></linearGradient> | |
| 5 | </defs> | |
| 6 | <path d="M4 13.25H11.75A3 3 0 0 0 12.2 7.3A4.3 4.3 0 0 0 4 6.4A3.45 3.45 0 0 0 4 13.25Z" fill="#000000" fill-opacity="0.16"/> | |
| 7 | <path d="M4 12.5H11.75A3 3 0 0 0 12.2 6.55A4.3 4.3 0 0 0 4 5.65A3.45 3.45 0 0 0 4 12.5Z" fill="url(#g0)" stroke="#6b7686" stroke-width="0.8" stroke-linejoin="round"/> | |
| 8 | <path d="M3.1 8.9A2.2 2.2 0 0 1 5.2 6.9M6.2 5.4A3 3 0 0 1 10.6 5.6" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-opacity="0.85"/> | |
| 9 | <path d="M8 12.25A3.5 3.5 0 1 1 15 12.25A3.5 3.5 0 1 1 8 12.25Z" fill="#000000" fill-opacity="0.16"/> | |
| 10 | <path d="M8 11.5A3.5 3.5 0 1 1 15 11.5A3.5 3.5 0 1 1 8 11.5Z" fill="url(#g1)" stroke="#c42418" stroke-width="0.8"/> | |
| 11 | <path d="M11.5 9.6V11.8M11.5 13.35V13.4" fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.3"/> | |
| 4 | 12 | </svg> |
crates/snowbound/assets/icons/sync-offline.svg+8-2| ... | ... | @@ -1,4 +1,10 @@ |
| 1 | 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"/> | |
| 3 | <path d="M2.75 2.75L13.25 13.25" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/> | |
| 2 | <defs> | |
| 3 | <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#e6e9ee"/><stop offset="1" stop-color="#98a2b0"/></linearGradient> | |
| 4 | </defs> | |
| 5 | <path d="M4 13.25H11.75A3 3 0 0 0 12.2 7.3A4.3 4.3 0 0 0 4 6.4A3.45 3.45 0 0 0 4 13.25Z" fill="#000000" fill-opacity="0.16"/> | |
| 6 | <path d="M4 12.5H11.75A3 3 0 0 0 12.2 6.55A4.3 4.3 0 0 0 4 5.65A3.45 3.45 0 0 0 4 12.5Z" fill="url(#g0)" stroke="#6b7686" stroke-width="0.8" stroke-linejoin="round"/> | |
| 7 | <path d="M3.1 8.9A2.2 2.2 0 0 1 5.2 6.9M6.2 5.4A3 3 0 0 1 10.6 5.6" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-opacity="0.85"/> | |
| 8 | <path d="M4.2 4.2L12.3 12.3" fill="none" stroke="#f4f6fa" stroke-width="2.6" stroke-linecap="round"/> | |
| 9 | <path d="M2.5 2.5L13.5 13.5" fill="none" stroke="#4a5462" stroke-width="1.3" stroke-linecap="round"/> | |
| 4 | 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 | 69 | pub const SYNC_DONE: &[&str] = art!("icons/sync-done"); |
| 70 | 70 | pub const SYNC_ERROR: &[&str] = art!("icons/sync-error"); |
| 71 | 71 | pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline"); |
| 72 | pub const SYNC_WARNING: &[&str] = art!("icons/sync-warning"); | |
| 72 | 73 | |
| 73 | 74 | pub const ERASER: &[&str] = art!("icons/eraser"); |
| 74 | 75 | pub const LASSO: &[&str] = art!("icons/lasso"); |
crates/snowbound/src/library.rs+34-1| ... | ... | @@ -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 | 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 | 171 | if self.host() == self.server { |
| 163 | 172 | format!("{}:445", self.server) |
| 164 | 173 | } else { |
| ... | ... | @@ -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 | 196 | /// `text` with `%XX` escapes decoded. |
| 180 | 197 | fn decode(text: &str) -> String { |
| 181 | 198 | let bytes = text.as_bytes(); |
| ... | ... | @@ -1050,6 +1067,22 @@ mod tests { |
| 1050 | 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 | 1086 | #[test] |
| 1054 | 1087 | fn chosen_paths_open_their_notebook_or_section() { |
| 1055 | 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 | 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 | 429 | #[link(name = "Security", kind = "framework")] |
| 348 | 430 | unsafe extern "C" { |
| 349 | 431 | fn SecKeychainFindInternetPassword( |
| ... | ... | @@ -1294,4 +1376,14 @@ mod tests { |
| 1294 | 1376 | let _ = std::fs::remove_file(&path); |
| 1295 | 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 | 614 | /// The system's spell checker, where it has one. |
| 615 | 615 | spelling: Option<canvas::spelling::Spelling>, |
| 616 | 616 | hide_spelling: bool, |
| 617 | /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure. | |
| 618 | pen_pressure: bool, | |
| 617 | 619 | /// The word the Spelling pane shows. |
| 618 | 620 | correction: Option<canvas::interaction::Correction>, |
| 619 | 621 | /// The strip's fill with the window focused and not, continuing the system's title bar. |
| ... | ... | @@ -980,6 +982,7 @@ impl State { |
| 980 | 982 | page_grafted: false, |
| 981 | 983 | spelling, |
| 982 | 984 | hide_spelling: stored.hide_spelling, |
| 985 | pen_pressure: !stored.ignore_pen_pressure, | |
| 983 | 986 | correction: None, |
| 984 | 987 | }; |
| 985 | 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 | 2537 | let response = match event { |
| 2535 | 2538 | ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?, |
| 2536 | 2539 | ui::Event::Pressure(pressure) => { |
| 2537 | self.view.set_pressure(pressure); | |
| 2540 | self.view.set_pressure(pressure.filter(|_| self.pen_pressure)); | |
| 2538 | 2541 | continue; |
| 2539 | 2542 | } |
| 2540 | 2543 | ui::Event::PointerLeft => self.view.pointer_left(), |
crates/snowbound/src/options.rs+19-1| ... | ... | @@ -23,13 +23,15 @@ enum Page { |
| 23 | 23 | General, |
| 24 | 24 | Display, |
| 25 | 25 | SaveBackup, |
| 26 | Advanced, | |
| 26 | 27 | } |
| 27 | 28 | |
| 28 | 29 | impl Page { |
| 29 | const ALL: [(Page, &str); 3] = [ | |
| 30 | const ALL: [(Page, &str); 4] = [ | |
| 30 | 31 | (Page::General, "General"), |
| 31 | 32 | (Page::Display, "Display"), |
| 32 | 33 | (Page::SaveBackup, "Save & Backup"), |
| 34 | (Page::Advanced, "Advanced"), | |
| 33 | 35 | ]; |
| 34 | 36 | } |
| 35 | 37 | |
| ... | ... | @@ -40,6 +42,7 @@ pub struct Options { |
| 40 | 42 | color_scheme: ColorScheme, |
| 41 | 43 | light_pages: bool, |
| 42 | 44 | automatic_updates: bool, |
| 45 | pen_pressure: bool, | |
| 43 | 46 | } |
| 44 | 47 | |
| 45 | 48 | fn id() -> Id { |
| ... | ... | @@ -62,6 +65,7 @@ impl State { |
| 62 | 65 | color_scheme: self.color_scheme, |
| 63 | 66 | light_pages: self.light_pages, |
| 64 | 67 | automatic_updates: self.updates.automatic(), |
| 68 | pen_pressure: self.pen_pressure, | |
| 65 | 69 | }); |
| 66 | 70 | self.ui.open_popup(id()); |
| 67 | 71 | self.ui.set_focus(Some(user_name())); |
| ... | ... | @@ -252,6 +256,19 @@ impl State { |
| 252 | 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 | 272 | Page::SaveBackup => { |
| 256 | 273 | heading(ui, &theme, "Cache file location"); |
| 257 | 274 | let cache = &self.cache; |
| ... | ... | @@ -304,6 +321,7 @@ impl State { |
| 304 | 321 | self.author = name.to_owned(); |
| 305 | 322 | self.color_scheme = options.color_scheme; |
| 306 | 323 | self.light_pages = options.light_pages; |
| 324 | self.pen_pressure = options.pen_pressure; | |
| 307 | 325 | self.updates.set_automatic(options.automatic_updates); |
| 308 | 326 | self.follow_color_scheme(); |
| 309 | 327 | self.save_settings(); |
crates/snowbound/src/server.rs+31-9| ... | ... | @@ -43,6 +43,9 @@ pub struct Connect { |
| 43 | 43 | asked: u64, |
| 44 | 44 | /// A notebook listed as open that could not sign in, which opens once it lists. |
| 45 | 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 | 49 | replies: (mpsc::Sender<Reply>, mpsc::Receiver<Reply>), |
| 47 | 50 | } |
| 48 | 51 | |
| ... | ... | @@ -207,6 +210,7 @@ impl Connect { |
| 207 | 210 | status: Status::Idle, |
| 208 | 211 | asked: 0, |
| 209 | 212 | reopen: false, |
| 213 | mounted: None, | |
| 210 | 214 | replies: mpsc::channel(), |
| 211 | 215 | } |
| 212 | 216 | } |
| ... | ... | @@ -357,12 +361,20 @@ fn domain_field() -> Id { |
| 357 | 361 | |
| 358 | 362 | impl State { |
| 359 | 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 | 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 | 373 | let mut connect = Connect::new( |
| 363 | location.unwrap_or_default().to_owned(), | |
| 374 | address.unwrap_or_default(), | |
| 364 | 375 | platform::remember_label().map(|_| false), |
| 365 | 376 | ); |
| 377 | connect.mounted = mounted.map(str::to_owned); | |
| 366 | 378 | let mut request = None; |
| 367 | 379 | if location.is_some() { |
| 368 | 380 | connect.reopen = true; |
| ... | ... | @@ -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 | |
| 413 | /// closes the dialog. | |
| 424 | /// Opens the notebook at `mount` through the embedded client signed in as `login`, in | |
| 425 | /// place of any reading it through the system's mount, and closes the dialog. | |
| 414 | 426 | fn open_from_server(&mut self, mount: Mount, login: Login) { |
| 415 | 427 | self.ui.close_popup(id()); |
| 416 | self.server = None; | |
| 417 | let location = mount.url(); | |
| 418 | self.open_notebook_with(location, None, move |location, cache| { | |
| 419 | Library::on_share(location, mount, login, cache) | |
| 420 | }); | |
| 428 | let url = mount.url(); | |
| 429 | let read = | |
| 430 | move |location: &str, cache: &Path| Library::on_share(location, mount, login, cache); | |
| 431 | match self.server.take().and_then(|connect| connect.mounted) { | |
| 432 | // The notebook stays where it was listed and shown, now read by Snowbound's client. | |
| 433 | Some(folder) => { | |
| 434 | let section = self | |
| 435 | .session | |
| 436 | .as_ref() | |
| 437 | .filter(|session| session.library.location == folder) | |
| 438 | .map(|session| session.tabs[session.tab].path.clone()); | |
| 439 | self.read_notebook(folder, section, None, read); | |
| 440 | } | |
| 441 | None => self.open_notebook_with(url, None, read), | |
| 442 | } | |
| 421 | 443 | } |
| 422 | 444 | |
| 423 | 445 | /// Builds the dialog while it is open. |
crates/snowbound/src/settings.rs+5| ... | ... | @@ -30,6 +30,9 @@ pub struct Settings { |
| 30 | 30 | pub tags: Option<Vec<canvas::editor::NoteTag>>, |
| 31 | 31 | /// Checks for updates only when Check for Updates… asks. |
| 32 | 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 | 38 | /// What the toolbar's buttons apply from their menus' last picks. |
| ... | ... | @@ -140,6 +143,7 @@ impl crate::State { |
| 140 | 143 | search_scope: self.search.default, |
| 141 | 144 | tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()), |
| 142 | 145 | manual_updates: !self.updates.automatic(), |
| 146 | ignore_pen_pressure: !self.pen_pressure, | |
| 143 | 147 | }; |
| 144 | 148 | if let Err(error) = settings.save(path) { |
| 145 | 149 | eprintln!("Cannot save the settings in {}: {error}", path.display()); |
| ... | ... | @@ -199,6 +203,7 @@ mod tests { |
| 199 | 203 | art: Some(format!("{}.png", "ab".repeat(32))), |
| 200 | 204 | }]), |
| 201 | 205 | manual_updates: true, |
| 206 | ignore_pen_pressure: true, | |
| 202 | 207 | }; |
| 203 | 208 | settings.save(&path).unwrap(); |
| 204 | 209 | assert_eq!(Settings::load(&path), settings); |
crates/snowbound/src/sidebar.rs+12| ... | ... | @@ -1042,6 +1042,18 @@ impl crate::State { |
| 1042 | 1042 | { |
| 1043 | 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 | 1057 | let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone())); |
| 1046 | 1058 | self.load(move || { |
| 1047 | 1059 | let library = match open { |
crates/snowbound/src/sync.rs+34-13| ... | ... | @@ -18,8 +18,12 @@ fn button() -> Id { |
| 18 | 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. | |
| 22 | fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option<&'static str>) { | |
| 21 | /// The status's label and icon, and what the reader can do about an error; `mounted` where | |
| 22 | /// the notebook syncs through the system's mount because Snowbound's client couldn't sign in. | |
| 23 | fn describe( | |
| 24 | sync: &SyncStatus, | |
| 25 | mounted: bool, | |
| 26 | ) -> (&'static str, &'static [&'static str], Option<&'static str>) { | |
| 23 | 27 | if library::offline() { |
| 24 | 28 | return ( |
| 25 | 29 | "Working offline", |
| ... | ... | @@ -53,6 +57,9 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option |
| 53 | 57 | SyncState::Unreadable => ("Can’t read this section", art::SYNC_ERROR, None), |
| 54 | 58 | SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None), |
| 55 | 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 | 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 | 144 | /// and a dot on it says a newer build is ready. |
| 138 | 145 | pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) { |
| 139 | 146 | let sync = overall(&sections(session)); |
| 140 | let strong = ui.popup_open(id()) || sync.error.is_some() && !library::offline(); | |
| 141 | let (label, icon, _) = describe(&sync); | |
| 147 | let (label, icon, _) = describe(&sync, session.library.notice.is_some()); | |
| 142 | 148 | ui.open_as( |
| 143 | 149 | button(), |
| 144 | 150 | Spec { |
| 145 | 151 | flags: Flags::CLICKABLE, |
| 146 | 152 | size: [px(TOOL), px(TOOL)], |
| 147 | 153 | icon: Some(icon), |
| 148 | color: Some(if strong { theme.text } else { theme.text_dim }), | |
| 149 | 154 | hover_fill: Some(theme.hover()), |
| 150 | 155 | radius: 4.0, |
| 151 | 156 | center: true, |
| ... | ... | @@ -210,7 +215,7 @@ impl State { |
| 210 | 215 | let offline = library::offline(); |
| 211 | 216 | let sections = sections(session); |
| 212 | 217 | let sync = overall(&sections); |
| 213 | let (progress, _, advice) = describe(&sync); | |
| 218 | let (progress, _, advice) = describe(&sync, session.library.notice.is_some()); | |
| 214 | 219 | ui.open_as( |
| 215 | 220 | id(), |
| 216 | 221 | Spec { |
| ... | ... | @@ -287,8 +292,23 @@ impl State { |
| 287 | 292 | if let Some(advice) = advice { |
| 288 | 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 | 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 | 312 | ui.open( |
| 293 | 313 | format!("section-{index}"), |
| 294 | 314 | Spec { |
| ... | ... | @@ -308,8 +328,8 @@ impl State { |
| 308 | 328 | }, |
| 309 | 329 | ); |
| 310 | 330 | let status = match sync.queued { |
| 311 | 0 => describe(sync).0.to_owned(), | |
| 312 | queued => format!("{}, {}", describe(sync).0, changes(queued)), | |
| 331 | 0 => describe(sync, false).0.to_owned(), | |
| 332 | queued => format!("{}, {}", describe(sync, false).0, changes(queued)), | |
| 313 | 333 | }; |
| 314 | 334 | ui.leaf( |
| 315 | 335 | "status", |
| ... | ... | @@ -326,10 +346,6 @@ impl State { |
| 326 | 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 | 349 | let (mut folder, mut restart) = (false, false); |
| 334 | 350 | if let Some(note) = update_note(&update) { |
| 335 | 351 | text(ui, "update-title", "Snowbound Update", theme.text, true); |
| ... | ... | @@ -414,6 +430,11 @@ impl State { |
| 414 | 430 | if let Some(file) = file.filter(|_| show) { |
| 415 | 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 | 438 | match update { |
| 418 | 439 | update::Status::Downloading(version) |
| 419 | 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 | 47 | |
| 48 | 48 | The macOS app is signed with Clover's Developer ID Application certificate |
| 49 | 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 | |
| 51 | hardened runtime, a secure timestamp, and the microphone and camera | |
| 52 | entitlements recording needs. `--ad-hoc` signs ad hoc instead; the 10.6 bundle | |
| 50 | the account holder's legal name, which nothing here prints or stores. | |
| 51 | `build_macos.py --sign developer-id` signs it, embedding the Developer ID | |
| 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 | 58 | stays unsigned, as it predates Developer ID. codesign fails with |
| 54 | 59 | `errSecInternalComponent` where it can't ask to use the private key, as from an |
| 55 | 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 | 95 | publishes as above. Run again for the same commit, it only brings |
| 91 | 96 | `latest.json` up to date; a different commit that derives the same version is |
| 92 | 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 | 101 | ## In the app |
| 96 | 102 |
tools/canvas/README.md+1-1| ... | ... | @@ -14,7 +14,7 @@ cargo test -p draw -- --ignored |
| 14 | 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 | 19 | ```sh |
| 20 | 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 | 22 | help="A Developer ID Application certificate's SHA-1 hash; found in the keychain otherwise") |
| 23 | 23 | parser.add_argument('--profile', type=Path, |
| 24 | 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 | 27 | args = parser.parse_args() |
| 26 | 28 | if args.bundle_id and not args.output: |
| 27 | 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 | 32 | if args.output and (args.output.suffix != '.app' or args.output.exists()): |
| 29 | 33 | parser.error('Choose a new output path ending in .app.') |
| 30 | 34 | root = Path(__file__).resolve().parents[2] |
| ... | ... | @@ -133,8 +137,8 @@ if build: |
| 133 | 137 | } | versions | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {}))) |
| 134 | 138 | # 10.6 runs the bundle unsigned. |
| 135 | 139 | if not args.snow_leopard: |
| 136 | identity = args.sign_identity or developer_id() | |
| 137 | profile = args.profile or developer_id_profile() | |
| 140 | identity = args.sign != 'ad-hoc' and (args.sign_identity or developer_id()) | |
| 141 | profile = args.sign != 'ad-hoc' and (args.profile or developer_id_profile()) | |
| 138 | 142 | if identity and profile and (args.bundle_id or BUNDLE_ID) == BUNDLE_ID: |
| 139 | 143 | shutil.copy2(profile, bundle / 'Contents/embedded.provisionprofile') |
| 140 | 144 | with tempfile.TemporaryDirectory() as scratch: |
| ... | ... | @@ -145,13 +149,18 @@ if not args.snow_leopard: |
| 145 | 149 | 'com.apple.developer.icloud-services': ['CloudDocuments'], |
| 146 | 150 | 'com.apple.developer.icloud-container-identifiers': [CONTAINER], |
| 147 | 151 | 'com.apple.developer.ubiquity-container-identifiers': [CONTAINER], |
| 152 | 'com.apple.developer.icloud-container-environment': 'Production', | |
| 148 | 153 | # Hardened runtime's Record Audio and Record Video. |
| 149 | 154 | 'com.apple.security.device.audio-input': True, |
| 150 | 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 | 160 | '--sign', identity, str(bundle)], check=True) |
| 154 | 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 | 164 | else: |
| 156 | 165 | subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True) |
| 157 | 166 | subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True) |
tools/release.py+15-20| ... | ... | @@ -6,7 +6,6 @@ import hashlib |
| 6 | 6 | import json |
| 7 | 7 | import os |
| 8 | 8 | from pathlib import Path |
| 9 | import plistlib | |
| 10 | 9 | import shutil |
| 11 | 10 | import subprocess |
| 12 | 11 | import sys |
| ... | ... | @@ -29,11 +28,6 @@ CHECKS = [ |
| 29 | 28 | IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E' |
| 30 | 29 | # The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}. |
| 31 | 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 | 33 | def derive(release, commits): |
| ... | ... | @@ -109,21 +103,21 @@ def notary(): |
| 109 | 103 | |
| 110 | 104 | |
| 111 | 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 | 107 | bundle = folder / 'Snowbound.app' |
| 114 | run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle] | |
| 115 | + (['--snow-leopard'] if platform == 'macos-10.6' else [])) | |
| 108 | if platform == 'macos-10.6': | |
| 109 | signing = ['--snow-leopard'] | |
| 110 | elif developer_id: | |
| 111 | signing = ['--sign', 'developer-id', '--sign-identity', IDENTITY] | |
| 112 | else: | |
| 113 | signing = ['--sign', 'ad-hoc'] | |
| 114 | run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, *signing]) | |
| 116 | 115 | archive = folder / 'archive.zip' |
| 117 | if developer_id and platform != 'macos-10.6': | |
| 118 | entitlements = folder / 'entitlements.plist' | |
| 119 | entitlements.write_bytes(plistlib.dumps(ENTITLEMENTS)) | |
| 120 | run(['codesign', '--force', '--options', 'runtime', '--timestamp', '--entitlements', entitlements, | |
| 121 | '--sign', IDENTITY, bundle]) | |
| 122 | if notarize: | |
| 123 | zip_bundle(bundle, archive) | |
| 124 | run(['xcrun', 'notarytool', 'submit', archive, *notarize, '--wait']) | |
| 125 | run(['xcrun', 'stapler', 'staple', bundle]) | |
| 126 | archive.unlink() | |
| 116 | if notarize and platform != 'macos-10.6': | |
| 117 | zip_bundle(bundle, archive) | |
| 118 | run(['xcrun', 'notarytool', 'submit', archive, *notarize, '--wait']) | |
| 119 | run(['xcrun', 'stapler', 'staple', bundle]) | |
| 120 | archive.unlink() | |
| 127 | 121 | zip_bundle(bundle, archive) |
| 128 | 122 | return archive |
| 129 | 123 | |
| ... | ... | @@ -208,7 +202,8 @@ def main(): |
| 208 | 202 | shutil.rmtree(partial, ignore_errors=True) |
| 209 | 203 | partial.mkdir() |
| 210 | 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 | 207 | partial.rename(target) |
| 213 | 208 | shutil.rmtree(stage) |
| 214 | 209 | print(f'Published {target}') |
tools/test_release.py+28| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | from datetime import datetime, timezone |
| 2 | 2 | from pathlib import Path |
| 3 | 3 | import runpy |
| 4 | import tempfile | |
| 4 | 5 | import unittest |
| 5 | 6 | |
| 6 | 7 | release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py')) |
| ... | ... | @@ -29,6 +30,33 @@ class ReleaseTest(unittest.TestCase): |
| 29 | 30 | 'macos-10.6': '2026-09-29-r10'}) |
| 30 | 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 | 61 | if __name__ == '__main__': |
| 34 | 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 | 7 | ) |
| 8 | 8 | if not defined ONEVM_HOSTNAME goto agent |
| 9 | 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 | 12 | wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1 |
| 11 | 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 | 18 | shutdown /r /t 0 |
| 13 | 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() |