diff --git a/apps/ios/Snowbound.xcodeproj/project.pbxproj b/apps/ios/Snowbound.xcodeproj/project.pbxproj index 21806b54565e4cbfbb00ff930e63953437c9e447..90582edbc6c67670d0933c2dd8613b9dc9bc7e88 100644 --- a/apps/ios/Snowbound.xcodeproj/project.pbxproj +++ b/apps/ios/Snowbound.xcodeproj/project.pbxproj @@ -198,6 +198,7 @@ isa = XCBuildConfiguration; buildSettings = { ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; @@ -234,6 +235,7 @@ isa = XCBuildConfiguration; buildSettings = { ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; diff --git a/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png b/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d3784cc7e08337046994d459825ad45f6d179ce7 Binary files /dev/null and b/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png differ diff --git a/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-light.png b/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-light.png new file mode 100644 index 0000000000000000000000000000000000000000..50a5781f59066d15ac1c83bba9ac91f78ac31b56 Binary files /dev/null and b/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-light.png differ diff --git a/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png b/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png new file mode 100644 index 0000000000000000000000000000000000000000..c6d897d3b69ad71058082324c0896ad8c5da500d Binary files /dev/null and b/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png differ diff --git a/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..0cca95d9702931ad00d725611814a7409b15da97 --- /dev/null +++ b/apps/ios/Snowbound/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "AppIcon-dark.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "AppIcon-tinted.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Snowbound/Assets.xcassets/Contents.json b/apps/ios/Snowbound/Assets.xcassets/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..73c00596a7fca3f3d4bdd64053b69d86745f9e10 --- /dev/null +++ b/apps/ios/Snowbound/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Snowbound/Ink.swift b/apps/ios/Snowbound/Ink.swift index da9714374545ef9406937d2822af136a25d20952..af8746276b230e51c7e1e506d80c83aa8f72f023 100644 --- a/apps/ios/Snowbound/Ink.swift +++ b/apps/ios/Snowbound/Ink.swift @@ -28,6 +28,13 @@ struct Pen { let width: Float let highlighter: Bool + /// OneNote 2010's "Use pen pressure sensitivity": the Pencil's strokes follow its pressure + /// unless turned off, when they keep their pen's width. + static var pressure: Bool { + get { !UserDefaults.standard.bool(forKey: "ignorePenPressure") } + set { UserDefaults.standard.set(!newValue, forKey: "ignorePenPressure") } + } + /// The pens under a section of tab colour `section`, a COLORREF: its accent, then /// OneNote 2010's favourites, as the desktop's gallery. static func gallery(_ section: UInt32) -> [Pen] { @@ -56,7 +63,7 @@ final class InkGesture: UIGestureRecognizer { private var tracked: UITouch? private func sample(_ touch: UITouch) -> InkSample { - let pressure = touch.type == .pencil ? Float(touch.force / touch.maximumPossibleForce) : -1 + let pressure = touch.type == .pencil && Pen.pressure ? Float(touch.force / touch.maximumPossibleForce) : -1 return (touch.preciseLocation(in: view), pressure) } @@ -275,7 +282,14 @@ final class InkPicker: UIView { action.state = other == width ? .on : .off return action } - return [UIMenu(options: .displayInline, children: swatches), UIMenu(options: .displayInline, children: weights)] + let pressure = UIAction(title: "Use pen pressure sensitivity", image: UIImage(systemName: "hand.draw")) { _ in + Pen.pressure.toggle() + } + pressure.state = Pen.pressure ? .on : .off + return [ + UIMenu(options: .displayInline, children: swatches), UIMenu(options: .displayInline, children: weights), + UIMenu(options: .displayInline, children: [pressure]), + ] } private static func name(_ color: UIColor?) -> String { color?.accessibilityName.capitalized ?? "Black" } diff --git a/arc/canvas.md b/arc/canvas.md index 32c122e2ec6ef4a68d23f648437bec0d57f3eac1..6dffa9a1b2c4c033d282ded5370cbcf43c4d9eca 100644 --- a/arc/canvas.md +++ b/arc/canvas.md @@ -161,7 +161,10 @@ themes. A pen that reports pressure (a tablet on macOS, the Apple Pencil) draws and stores it as OneNote 2010 does with pressure sensitivity on: each point's width is the pen's times 0.25 plus 1.5 times the pressure, and the stroke keeps NormalPressure beside X and Y -(`corpus/ink-pressure`). A mouse or finger draws at the pen's width. +(`corpus/ink-pressure`). A mouse, trackpad or finger draws at the pen's width, and so +does every pen with OneNote's "Use pen pressure sensitivity" turned off (Options > +Advanced on the desktop, the pen's colour menu on iOS; on by default). winit reports no +tablet pressure on Linux. ## Tables and selections across them diff --git a/corpus/ink-pressure/README.md b/corpus/ink-pressure/README.md index 0dbc16ab3ea5484daea1e68cf05281c20612de16..e54f1db3706f15095f6cb387d7fb9ca0366a712d 100644 --- a/corpus/ink-pressure/README.md +++ b/corpus/ink-pressure/README.md @@ -28,8 +28,9 @@ What it shows, and `crates/onestore/tests/page_ink.rs` pins: Snowbound reads pressure the same way and draws it as OneNote does. A stroke drawn with a pen that reports pressure (a tablet on macOS, the Apple Pencil) stores X, Y and NormalPressure -from 0 to 1023 without IgnorePressure, as OneNote does with the option on; a mouse's or a -finger's stroke stays as OneNote's mouse ink. +from 0 to 1023 without IgnorePressure, as OneNote does with the option on; a mouse's, +trackpad's or finger's stroke, or any stroke with Snowbound's own "Use pen pressure +sensitivity" off, stays as OneNote's mouse ink. `candidate` is `pressure_ink_is_written_as_onenote_keeps_it_and_survives_edits` in `page_ink.rs` (`ONESTORE_INK_PRESSURE_EXPORT`): the native file with the first drawing's diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index 90bc2f3be0f141117bbf04c915f0d1a76edcfaa5..182ef5bc650b0e99bb4e5dbf570aa9ce2c91cca8 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -3,7 +3,6 @@ use std::io::{self, ErrorKind}; #[cfg(any(unix, windows))] use std::{ fs::File, - io::Read, path::Path, sync::{Mutex, MutexGuard}, }; @@ -33,11 +32,14 @@ impl FileIo { { use std::os::unix::fs::OpenOptionsExt; // SMB can lose exclusion when separate opens race with flock. On smbfs these are - // share modes: a shared reader denies only writers, so OneNote's readers proceed. - let lock = if write { - nix::libc::O_EXLOCK - } else { - nix::libc::O_SHLOCK + // share modes, taken as OneNote takes them: a writer's shared lock denies only + // other writers, and a reader takes none, as `stable` sees past a commit. + let smb = nix::sys::statfs::statfs(path.as_ref()) + .is_ok_and(|fs| fs.filesystem_type_name() == "smbfs"); + let lock = match (write, smb) { + (true, false) => nix::libc::O_EXLOCK, + (false, true) => 0, + _ => nix::libc::O_SHLOCK, }; options.custom_flags(lock | nix::libc::O_NONBLOCK); } @@ -105,8 +107,9 @@ pub fn place_file(path: impl AsRef, ancestor: [u8; 16], name: &str) -> io: released } -/// Reads a snapshot excluding writers, as commits exclude everyone (macOS shares it with -/// other readers). Native writers can expose incomplete graphs to unlocked filesystem reads. +/// Reads a snapshot, excluding writers as their commits exclude it, except on an SMB mount, +/// where it takes no lock, as OneNote's readers take none (macOS shares it with other readers). +/// A read that meets a commit in progress is read again, then refused as `WouldBlock`. #[cfg(any(unix, windows))] pub fn read_file(path: impl AsRef) -> io::Result> { read_file_limited(path, usize::MAX) @@ -116,21 +119,56 @@ pub fn read_file(path: impl AsRef) -> io::Result> { /// A size failure returns `FileTooLarge` without a partial snapshot. #[cfg(any(unix, windows))] pub fn read_file_limited(path: impl AsRef, limit: usize) -> io::Result> { - let count = u64::try_from(limit) - .map_err(|_| ErrorKind::InvalidInput)? - .saturating_add(1); let mut io = FileIo::open(path, false)?; - let mut bytes = Vec::new(); - let result = (&mut io.file).take(count).read_to_end(&mut bytes); + let result = stable(|offset, output| io.read_at(offset, output), limit); let released = io.release(); - result?; + let bytes = result?; released?; - if bytes.len() > limit { - return Err(ErrorKind::FileTooLarge.into()); - } Ok(bytes) } +/// How many times a read that meets a commit in progress is made before it is refused. +#[cfg(any(unix, windows))] +const TRIES: usize = 3; + +/// The file through `read`, read again where a commit tore it: its header changed while it was +/// read, as commits write the header last, or it ends short of the header's length. +#[cfg(any(unix, windows))] +fn stable( + mut read: impl FnMut(u64, &mut [u8]) -> io::Result, + limit: usize, +) -> io::Result> { + let mut block = vec![0; 1 << 16]; + for _ in 0..TRIES { + let mut bytes = Vec::new(); + loop { + let size = (limit.saturating_add(1) - bytes.len()).min(block.len()); + match read(bytes.len() as u64, &mut block[..size]) { + Ok(0) => break, + Ok(count) if count <= size => bytes.extend_from_slice(&block[..count]), + Ok(_) => return Err(ErrorKind::InvalidData.into()), + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + if bytes.len() > limit { + return Err(ErrorKind::FileTooLarge.into()); + } + } + let mut header = vec![0; bytes.len().min(1024)]; + match crate::snapshot::read_exact(&mut read, 0, &mut header) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::UnexpectedEof => continue, + Err(error) => return Err(error), + } + let whole = crate::Header::parse(&bytes) + .map_or(true, |parsed| parsed.expected_length <= bytes.len() as u64); + if header == bytes[..header.len()] && whole { + return Ok(bytes); + } + } + Err(ErrorKind::WouldBlock.into()) +} + #[cfg(any(unix, windows))] impl CommitIo for FileIo { fn read_at(&mut self, offset: u64, bytes: &mut [u8]) -> io::Result { @@ -434,3 +472,60 @@ impl Transaction { io.finish(result) } } + +#[cfg(all(test, any(unix, windows)))] +mod tests { + use super::*; + + /// Reads `bytes`, changing a header byte on each of the first `changes` rereads of it. + fn reader(bytes: &[u8], mut changes: usize) -> impl FnMut(u64, &mut [u8]) -> io::Result { + let mut bytes = bytes.to_vec(); + let mut reads = 0; + move |offset, output| { + if offset == 0 { + reads += 1; + // Each pass reads the header twice: with the body, then to check it. + if reads % 2 == 0 && changes > 0 { + changes -= 1; + bytes[1000] ^= 1; + } + } + let rest = bytes.get(offset as usize..).unwrap_or_default(); + let count = rest.len().min(output.len()); + output[..count].copy_from_slice(&rest[..count]); + Ok(count) + } + } + + #[test] + fn a_read_torn_by_a_commit_is_read_again_then_refused() { + let section = crate::create_section("Torn.one", "Text", "Fixture").unwrap(); + assert_eq!(stable(reader(§ion, 0), section.len()).unwrap(), section); + let mut changed = section.clone(); + changed[1000] ^= 1; + assert_eq!(stable(reader(§ion, 1), section.len()).unwrap(), changed); + assert_eq!( + stable(reader(§ion, TRIES), section.len()) + .unwrap_err() + .kind(), + ErrorKind::WouldBlock + ); + // Storage shortened ahead of the header that publishes its new length. + let short = §ion[..section.len() - 1]; + assert_eq!( + stable(reader(short, 0), section.len()).unwrap_err().kind(), + ErrorKind::WouldBlock + ); + assert_eq!( + stable(reader(§ion, 0), section.len() - 1) + .unwrap_err() + .kind(), + ErrorKind::FileTooLarge + ); + // Files that are not revision stores read as they are. + assert_eq!( + stable(reader(b"unfinished", 0), 100).unwrap(), + b"unfinished" + ); + } +} diff --git a/crates/snowbound/assets/icons/sync-busy.svg b/crates/snowbound/assets/icons/sync-busy.svg index 2433d77c9c800aabf333ec780ec17ceaed4603fe..1138e7fee47584491d35f0ac43d4078decb82fee 100644 --- a/crates/snowbound/assets/icons/sync-busy.svg +++ b/crates/snowbound/assets/icons/sync-busy.svg @@ -1,4 +1,13 @@ - - + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/sync-done.svg b/crates/snowbound/assets/icons/sync-done.svg index 10b6c9321626d1e709a000831cdfb37ea581f098..56028496cc776eb2382f27a5ca71f491b6791ed9 100644 --- a/crates/snowbound/assets/icons/sync-done.svg +++ b/crates/snowbound/assets/icons/sync-done.svg @@ -1,4 +1,12 @@ - - + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/sync-error.svg b/crates/snowbound/assets/icons/sync-error.svg index 68525b7274cd94fff9287abf752e742e909969e2..190caca0d5ab9e9aef3235f3abf2ce9c434c63bc 100644 --- a/crates/snowbound/assets/icons/sync-error.svg +++ b/crates/snowbound/assets/icons/sync-error.svg @@ -1,4 +1,12 @@ - - + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/sync-offline.svg b/crates/snowbound/assets/icons/sync-offline.svg index 280bce17ea2b25e85f9dd2057c36526648a50782..d3e730f549f9204c3b882cbdb0a4ca5cdfbc58b7 100644 --- a/crates/snowbound/assets/icons/sync-offline.svg +++ b/crates/snowbound/assets/icons/sync-offline.svg @@ -1,4 +1,10 @@ - - + + + + + + + + diff --git a/crates/snowbound/assets/icons/sync-warning.svg b/crates/snowbound/assets/icons/sync-warning.svg new file mode 100644 index 0000000000000000000000000000000000000000..6212307ec427909bf1352f55f09d022855828de5 --- /dev/null +++ b/crates/snowbound/assets/icons/sync-warning.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index 0dd428ba9a06429c0f9c7a6c0dddbc435125becd..afdadaa55d7d741eb992945239f9ab235d62d37c 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -69,6 +69,7 @@ pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy"); pub const SYNC_DONE: &[&str] = art!("icons/sync-done"); pub const SYNC_ERROR: &[&str] = art!("icons/sync-error"); pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline"); +pub const SYNC_WARNING: &[&str] = art!("icons/sync-warning"); pub const ERASER: &[&str] = art!("icons/eraser"); pub const LASSO: &[&str] = art!("icons/lasso"); diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index 3384944d5f847e34747343945f026c8de77b57ae..f423a72c98de66374ab3d0f927a90fc063ca85d8 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -157,8 +157,17 @@ impl Mount { } } - /// Where the embedded client dials: the server, on SMB's port unless it names another. + /// Where the embedded client dials: the server, on SMB's port unless it names another; + /// for a Bonjour service, where it answers now. pub fn endpoint(&self) -> String { + if let Some(instance) = bonjour_instance(&self.server) { + #[cfg(target_os = "macos")] + if let Some(endpoint) = crate::platform::bonjour_endpoint(&instance) { + return endpoint; + } + // Samba and macOS name their service after the host, which mDNS answers for. + return format!("{instance}.local:445"); + } if self.host() == self.server { format!("{}:445", self.server) } else { @@ -176,6 +185,14 @@ impl Mount { } } +/// The Bonjour SMB service `server` names, as the Finder mounts a server it browsed to. +fn bonjour_instance(server: &str) -> Option { + let instance = server + .trim_end_matches('.') + .strip_suffix("._smb._tcp.local")?; + (!instance.is_empty()).then(|| decode(instance)) +} + /// `text` with `%XX` escapes decoded. fn decode(text: &str) -> String { let bytes = text.as_bytes(); @@ -1050,6 +1067,22 @@ mod tests { assert_eq!(Mount::parse("/dev/disk1", "", ""), None); } + /// The Finder's mount keeps the Bonjour name its keychain entry is under; only dialing + /// resolves it. + #[test] + fn bonjour_mounts_keep_their_service_name() { + let mount = Mount::parse("//clo@My%20NAS._smb._tcp.local/agent", "", "").unwrap(); + assert_eq!(mount.host(), "My%20NAS._smb._tcp.local"); + assert_eq!(bonjour_instance(&mount.server).as_deref(), Some("My NAS")); + assert_eq!( + bonjour_instance("zenith._smb._tcp.local.").as_deref(), + Some("zenith") + ); + for server in ["zenith.local", "_smb._tcp.local", "10.0.0.1:445"] { + assert_eq!(bonjour_instance(server), None, "{server}"); + } + } + #[test] fn chosen_paths_open_their_notebook_or_section() { let root = std::env::temp_dir().join(format!("snowbound-locate-{}", std::process::id())); diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 515211118a77ab39cc07c4a42336c8578cd4cab9..5308be98c962c50f4aef34484a500aae3ba3f3a7 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -344,6 +344,88 @@ pub fn smb_mount(path: &std::path::Path) -> Option { crate::library::Mount::parse(&text(&mount.f_mntfromname), &within.to_string_lossy(), "") } +#[allow(non_camel_case_types)] +type DNSServiceResolveReply = extern "C" fn( + service: *mut std::ffi::c_void, + flags: u32, + interface: u32, + error: i32, + name: *const libc::c_char, + host: *const libc::c_char, + port: u16, + txt_length: u16, + txt: *const u8, + context: *mut std::ffi::c_void, +); + +unsafe extern "C" { + fn DNSServiceResolve( + service: *mut *mut std::ffi::c_void, + flags: u32, + interface: u32, + name: *const libc::c_char, + kind: *const libc::c_char, + domain: *const libc::c_char, + reply: DNSServiceResolveReply, + context: *mut std::ffi::c_void, + ) -> i32; + fn DNSServiceRefSockFD(service: *mut std::ffi::c_void) -> i32; + fn DNSServiceProcessResult(service: *mut std::ffi::c_void) -> i32; + fn DNSServiceRefDeallocate(service: *mut std::ffi::c_void); +} + +/// The `host:port` the SMB service Bonjour names `instance` answers at. +pub fn bonjour_endpoint(instance: &str) -> Option { + extern "C" fn resolved( + _: *mut std::ffi::c_void, + _: u32, + _: u32, + error: i32, + _: *const libc::c_char, + host: *const libc::c_char, + port: u16, + _: u16, + _: *const u8, + context: *mut std::ffi::c_void, + ) { + if error != 0 || host.is_null() { + return; + } + let host = unsafe { std::ffi::CStr::from_ptr(host) }.to_string_lossy(); + let found = format!("{}:{}", host.trim_end_matches('.'), u16::from_be(port)); + unsafe { *context.cast::>() = Some(found) }; + } + let name = std::ffi::CString::new(instance).ok()?; + let mut service = std::ptr::null_mut(); + let mut found: Option = None; + let status = unsafe { + DNSServiceResolve( + &mut service, + 0, + 0, + name.as_ptr(), + c"_smb._tcp".as_ptr(), + c"local.".as_ptr(), + resolved, + (&raw mut found).cast(), + ) + }; + if status != 0 { + return None; + } + let mut ready = libc::pollfd { + fd: unsafe { DNSServiceRefSockFD(service) }, + events: libc::POLLIN, + revents: 0, + }; + // As long as the Finder waits to connect. + if unsafe { libc::poll(&mut ready, 1, 5000) } == 1 { + unsafe { DNSServiceProcessResult(service) }; + } + unsafe { DNSServiceRefDeallocate(service) }; + found +} + #[link(name = "Security", kind = "framework")] unsafe extern "C" { fn SecKeychainFindInternetPassword( @@ -1294,4 +1376,14 @@ mod tests { let _ = std::fs::remove_file(&path); assert_eq!(read.unwrap().password, "second"); } + + /// A server the Finder mounted by its Bonjour service resolves to where it answers. + #[test] + #[ignore = "requires SNOWBOUND_TEST_BONJOUR naming an SMB service on this network"] + fn bonjour_services_resolve_to_their_host() { + let instance = std::env::var("SNOWBOUND_TEST_BONJOUR").unwrap(); + let endpoint = super::bonjour_endpoint(&instance).unwrap(); + assert!(endpoint.contains(".local:"), "{endpoint}"); + assert_eq!(super::bonjour_endpoint("snowbound-no-such-service"), None); + } } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index e4d5655c4ffe341ac3de1ce16d8673c47c39661f..d8b68d2c2da4285becde7cbc779185af4e5e4b26 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -614,6 +614,8 @@ struct State { /// The system's spell checker, where it has one. spelling: Option, hide_spelling: bool, + /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure. + pen_pressure: bool, /// The word the Spelling pane shows. correction: Option, /// The strip's fill with the window focused and not, continuing the system's title bar. @@ -980,6 +982,7 @@ impl State { page_grafted: false, spelling, hide_spelling: stored.hide_spelling, + pen_pressure: !stored.ignore_pen_pressure, correction: None, }; // A notebook opened from its server that couldn't sign in asks to, as the Finder does. @@ -2534,7 +2537,7 @@ impl State { let response = match event { ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?, ui::Event::Pressure(pressure) => { - self.view.set_pressure(pressure); + self.view.set_pressure(pressure.filter(|_| self.pen_pressure)); continue; } ui::Event::PointerLeft => self.view.pointer_left(), diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs index 0f5c4a73f59345989034d422d2f3042f486990ac..d23763de50f3993487ab351805672e2a9f923251 100644 --- a/crates/snowbound/src/options.rs +++ b/crates/snowbound/src/options.rs @@ -23,13 +23,15 @@ enum Page { General, Display, SaveBackup, + Advanced, } impl Page { - const ALL: [(Page, &str); 3] = [ + const ALL: [(Page, &str); 4] = [ (Page::General, "General"), (Page::Display, "Display"), (Page::SaveBackup, "Save & Backup"), + (Page::Advanced, "Advanced"), ]; } @@ -40,6 +42,7 @@ pub struct Options { color_scheme: ColorScheme, light_pages: bool, automatic_updates: bool, + pen_pressure: bool, } fn id() -> Id { @@ -62,6 +65,7 @@ impl State { color_scheme: self.color_scheme, light_pages: self.light_pages, automatic_updates: self.updates.automatic(), + pen_pressure: self.pen_pressure, }); self.ui.open_popup(id()); self.ui.set_focus(Some(user_name())); @@ -252,6 +256,19 @@ impl State { options.light_pages = dark; } } + Page::Advanced => { + heading(ui, &theme, "Pen"); + if ui::check_box( + ui, + "pen-pressure", + "Use pen pressure sensitivity", + options.pen_pressure, + ) + .clicked + { + options.pen_pressure = !options.pen_pressure; + } + } Page::SaveBackup => { heading(ui, &theme, "Cache file location"); let cache = &self.cache; @@ -304,6 +321,7 @@ impl State { self.author = name.to_owned(); self.color_scheme = options.color_scheme; self.light_pages = options.light_pages; + self.pen_pressure = options.pen_pressure; self.updates.set_automatic(options.automatic_updates); self.follow_color_scheme(); self.save_settings(); diff --git a/crates/snowbound/src/server.rs b/crates/snowbound/src/server.rs index c538a0c7d3580ddc5704be84382bcd054a2e0579..74fb34544d3c8ce9e0607ca881a987094b98112b 100644 --- a/crates/snowbound/src/server.rs +++ b/crates/snowbound/src/server.rs @@ -43,6 +43,9 @@ pub struct Connect { asked: u64, /// A notebook listed as open that could not sign in, which opens once it lists. reopen: bool, + /// The folder of a notebook read through the system's mount of the share, which moves to + /// the embedded client once it signs in. + mounted: Option, replies: (mpsc::Sender, mpsc::Receiver), } @@ -207,6 +210,7 @@ impl Connect { status: Status::Idle, asked: 0, reopen: false, + mounted: None, replies: mpsc::channel(), } } @@ -357,12 +361,20 @@ fn domain_field() -> Id { impl State { /// Opens the dialog; on `location`, a notebook opened from its server that couldn't sign - /// in, at its sign-in with any password the keychain keeps tried first. + /// in, or read through the system's mount because Snowbound's client couldn't, at its + /// sign-in with any password the keychain keeps tried first. pub(crate) fn open_server(&mut self, location: Option<&str>) { + let mounted = + location.filter(|location| crate::library::server_address(location).is_none()); + let address = match mounted { + Some(folder) => platform::smb_mount(Path::new(folder)).map(|mount| mount.url()), + None => location.map(str::to_owned), + }; let mut connect = Connect::new( - location.unwrap_or_default().to_owned(), + address.unwrap_or_default(), platform::remember_label().map(|_| false), ); + connect.mounted = mounted.map(str::to_owned); let mut request = None; if location.is_some() { connect.reopen = true; @@ -409,15 +421,25 @@ impl State { }); } - /// Opens the notebook at `mount` through the embedded client signed in as `login`, and - /// closes the dialog. + /// Opens the notebook at `mount` through the embedded client signed in as `login`, in + /// place of any reading it through the system's mount, and closes the dialog. fn open_from_server(&mut self, mount: Mount, login: Login) { self.ui.close_popup(id()); - self.server = None; - let location = mount.url(); - self.open_notebook_with(location, None, move |location, cache| { - Library::on_share(location, mount, login, cache) - }); + let url = mount.url(); + let read = + move |location: &str, cache: &Path| Library::on_share(location, mount, login, cache); + match self.server.take().and_then(|connect| connect.mounted) { + // The notebook stays where it was listed and shown, now read by Snowbound's client. + Some(folder) => { + let section = self + .session + .as_ref() + .filter(|session| session.library.location == folder) + .map(|session| session.tabs[session.tab].path.clone()); + self.read_notebook(folder, section, None, read); + } + None => self.open_notebook_with(url, None, read), + } } /// Builds the dialog while it is open. diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index 6716dca5645b1d8b9c710626915622c5b7a02adc..7b96095c7844d128949faaa962f0911f8836c18a 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -30,6 +30,9 @@ pub struct Settings { pub tags: Option>, /// Checks for updates only when Check for Updates… asks. pub manual_updates: bool, + /// Draws a tablet pen's strokes at its width, as OneNote 2010 with "Use pen pressure + /// sensitivity" off. + pub ignore_pen_pressure: bool, } /// What the toolbar's buttons apply from their menus' last picks. @@ -140,6 +143,7 @@ impl crate::State { search_scope: self.search.default, tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()), manual_updates: !self.updates.automatic(), + ignore_pen_pressure: !self.pen_pressure, }; if let Err(error) = settings.save(path) { eprintln!("Cannot save the settings in {}: {error}", path.display()); @@ -199,6 +203,7 @@ mod tests { art: Some(format!("{}.png", "ab".repeat(32))), }]), manual_updates: true, + ignore_pen_pressure: true, }; settings.save(&path).unwrap(); assert_eq!(Settings::load(&path), settings); diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index 9e24cf8686d79e08b2f0880a018a135a81cff7a1..a81d5127a579f80a956427fbfd7adcfd0bb4382c 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -1042,6 +1042,18 @@ impl crate::State { { return; } + self.read_notebook(location, section, open, read); + } + + /// Shows `section`, or the first section, of the notebook at `location`, `open` or read + /// with `read`, in place of the notebook listed there. + pub(crate) fn read_notebook( + &mut self, + location: String, + section: Option, + open: Option>, + read: impl FnOnce(&str, &std::path::Path) -> Result + Send + 'static, + ) { let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone())); self.load(move || { let library = match open { diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index 762c43df10e37e310fdfb1ba49d51b13a709df00..7cf725dc5c0763ebe7c20ea747d520b678f24ff0 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -18,8 +18,12 @@ fn button() -> Id { Id::ROOT.child("sync-button") } -/// The status's label and icon, and what the reader can do about an error. -fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option<&'static str>) { +/// The status's label and icon, and what the reader can do about an error; `mounted` where +/// the notebook syncs through the system's mount because Snowbound's client couldn't sign in. +fn describe( + sync: &SyncStatus, + mounted: bool, +) -> (&'static str, &'static [&'static str], Option<&'static str>) { if library::offline() { return ( "Working offline", @@ -53,6 +57,9 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option SyncState::Unreadable => ("Can’t read this section", art::SYNC_ERROR, None), SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None), SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None), + SyncState::UpToDate if mounted => { + ("Using the system’s connection", art::SYNC_WARNING, None) + } SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None), } } @@ -137,15 +144,13 @@ fn update_note(update: &update::Status) -> Option { /// and a dot on it says a newer build is ready. pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) { let sync = overall(§ions(session)); - let strong = ui.popup_open(id()) || sync.error.is_some() && !library::offline(); - let (label, icon, _) = describe(&sync); + let (label, icon, _) = describe(&sync, session.library.notice.is_some()); ui.open_as( button(), Spec { flags: Flags::CLICKABLE, size: [px(TOOL), px(TOOL)], icon: Some(icon), - color: Some(if strong { theme.text } else { theme.text_dim }), hover_fill: Some(theme.hover()), radius: 4.0, center: true, @@ -210,7 +215,7 @@ impl State { let offline = library::offline(); let sections = sections(session); let sync = overall(§ions); - let (progress, _, advice) = describe(&sync); + let (progress, _, advice) = describe(&sync, session.library.notice.is_some()); ui.open_as( id(), Spec { @@ -287,8 +292,23 @@ impl State { if let Some(advice) = advice { text(ui, "advice", advice, theme.text, false); } + let mut sign_in = None; + if let Some(notice) = &session.library.notice { + let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}"); + text(ui, "notice", ¬ice, theme.text_dim, false); + if ui::button(ui, "sign-in", "Sign In\u{2026}").clicked { + sign_in = Some(session.library.location.clone()); + } + } text(ui, "sections", "Sections", theme.text, true); - for (index, (path, sync)) in sections.iter().enumerate() { + // OneNote's sync dialog leaves out the recycle bin, unless something is wrong there. + let listed = sections.iter().filter(|(path, sync)| { + sync.error.is_some() + || !path + .rsplit_once('/') + .is_some_and(|(folder, _)| library::recycle_bin(folder)) + }); + for (index, (path, sync)) in listed.enumerate() { ui.open( format!("section-{index}"), Spec { @@ -308,8 +328,8 @@ impl State { }, ); let status = match sync.queued { - 0 => describe(sync).0.to_owned(), - queued => format!("{}, {}", describe(sync).0, changes(queued)), + 0 => describe(sync, false).0.to_owned(), + queued => format!("{}, {}", describe(sync, false).0, changes(queued)), }; ui.leaf( "status", @@ -326,10 +346,6 @@ impl State { text(ui, &part, &error.to_string(), theme.text_dim, false); } } - if let Some(notice) = &session.library.notice { - let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}"); - text(ui, "notice", ¬ice, theme.text_dim, false); - } let (mut folder, mut restart) = (false, false); if let Some(note) = update_note(&update) { text(ui, "update-title", "Snowbound Update", theme.text, true); @@ -414,6 +430,11 @@ impl State { if let Some(file) = file.filter(|_| show) { platform::show_file(&file); } + if let Some(location) = sign_in { + self.ui.close_popup(id()); + self.commands + .push(crate::Command::OpenFromServer(Some(location))); + } match update { update::Status::Downloading(version) | update::Status::Ready(version, _) diff --git a/platform/windows/cargo.sh b/platform/windows/cargo.sh new file mode 100755 index 0000000000000000000000000000000000000000..0949d548b84622424c9eec1721c8f1f82ae17d7f --- /dev/null +++ b/platform/windows/cargo.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# cargo for Windows from macOS or Linux: `cargo.sh ARCH COMMAND ARGS...`. +# x86_64 Windows 7 SP1 to 11: nightly's tier-3 x86_64-win7-windows-gnu, std built here +# aarch64 Windows 11 on Arm: aarch64-pc-windows-gnullvm +# Both link with llvm-mingw (`toolchain.sh`) against msvcrt.dll, which every Windows has. +set -eu +here=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$here/../.." && pwd) +LLVM_MINGW=${LLVM_MINGW:-$root/target/windows/llvm-mingw} +export LLVM_MINGW +[ -x "$LLVM_MINGW/bin/clang" ] || { + echo "No llvm-mingw at $LLVM_MINGW; run $here/toolchain.sh" >&2 + exit 1 +} +arch=$1 command=$2 +shift 2 +case $arch in +x86_64) + target=x86_64-win7-windows-gnu + toolchain=+nightly + # rustc would infer a bare ld from the name `link.sh`; it is a C compiler driver. + set -- -Zbuild-std=std,panic_unwind \ + --config "target.$target.linker='$here/link.sh'" \ + --config "target.$target.rustflags=['-C','linker-flavor=gcc']" "$@" + ;; +aarch64) + target=aarch64-pc-windows-gnullvm + toolchain=+stable + rustup target add --toolchain stable "$target" >/dev/null + # crt-static links libunwind in rather than beside the executable. + set -- --config "target.$target.linker='$LLVM_MINGW/bin/aarch64-w64-mingw32-clang'" \ + --config "target.$target.rustflags=['-C','target-feature=+crt-static']" "$@" + ;; +*) + echo "usage: $0 x86_64|aarch64 COMMAND ARGS..." >&2 + exit 2 + ;; +esac +variable=$(echo "$target" | tr - _) +# cc-rs (bundled SQLite, ring) compiles with the same clang and archives with its llvm-ar. +env "CC_$variable=$LLVM_MINGW/bin/$arch-w64-mingw32-clang" \ + "AR_$variable=$LLVM_MINGW/bin/llvm-ar" \ + CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$root/target/windows}" \ + cargo "$toolchain" "$command" --target "$target" "$@" diff --git a/platform/windows/link.sh b/platform/windows/link.sh new file mode 100755 index 0000000000000000000000000000000000000000..78e43c640f845d28c9f2deeee5cae28ffe16eac7 --- /dev/null +++ b/platform/windows/link.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# The linker for x86_64-win7-windows-gnu: llvm-mingw's clang, which links compiler-rt +# itself, with LLVM's libunwind (static) answering for libgcc's unwinder. +set -eu +for arg do + shift + case $arg in + -lgcc_eh | -lgcc_s) set -- "$@" -l:libunwind.a ;; + -lgcc) ;; + *) set -- "$@" "$arg" ;; + esac +done +exec "$LLVM_MINGW/bin/x86_64-w64-mingw32-clang" "$@" diff --git a/tools/RELEASE.md b/tools/RELEASE.md index ab0570868ea1d2debb676702472f7437232e46ee..72c8eaff06b490ed87ee39b768be28dcad809596 100644 --- a/tools/RELEASE.md +++ b/tools/RELEASE.md @@ -47,9 +47,14 @@ build with the new public half, signed with the old key. The macOS app is signed with Clover's Developer ID Application certificate (team 9R7DPNW28H), named in `release.py` by its SHA-1 hash, since its name is -the account holder's legal name, which nothing here prints or stores. It gets -hardened runtime, a secure timestamp, and the microphone and camera -entitlements recording needs. `--ad-hoc` signs ad hoc instead; the 10.6 bundle +the account holder's legal name, which nothing here prints or stores. +`build_macos.py --sign developer-id` signs it, embedding the Developer ID +provisioning profile for `net.paperclover.snowbound` ("Snowbound Developer ID", +found where Xcode keeps profiles) as `Contents/embedded.provisionprofile`, with +hardened runtime, a secure timestamp, the production iCloud container +`iCloud.net.paperclover.snowbound` that Use iCloud Drive needs, and the +microphone and camera entitlements recording needs. It fails rather than fall +back to ad hoc. `--ad-hoc` signs ad hoc instead, without iCloud; the 10.6 bundle stays unsigned, as it predates Developer ID. codesign fails with `errSecInternalComponent` where it can't ask to use the private key, as from an 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 publishes as above. Run again for the same commit, it only brings `latest.json` up to date; a different commit that derives the same version is refused. The 10.6 build needs the SDK and nightly toolchain -`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`. +`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the +cross linker against glibc 2.31. All four build from an Apple silicon Mac. ## In the app diff --git a/tools/canvas/README.md b/tools/canvas/README.md index db1e9476ba4ed167539df3b90a398dad21c2d32e..e1fabbec57a201a96f3880df9e060057ce498eba 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -14,7 +14,7 @@ cargo test -p draw -- --ignored cargo test -p canvas --features gpu gpu:: -- --ignored ``` -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: +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: ```sh python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id net.paperclover.snowbound.review diff --git a/tools/canvas/build_macos.py b/tools/canvas/build_macos.py index 00a004625e512668579d25c3cff112829ce05b5f..c5a929946d4634ecfb0c96206144f9be46157839 100644 --- a/tools/canvas/build_macos.py +++ b/tools/canvas/build_macos.py @@ -22,9 +22,13 @@ parser.add_argument('--sign-identity', metavar='SHA1', help="A Developer ID Application certificate's SHA-1 hash; found in the keychain otherwise") parser.add_argument('--profile', type=Path, help='A Developer ID provisioning profile for the app; found where Xcode keeps them otherwise') +parser.add_argument('--sign', choices=['developer-id', 'ad-hoc'], + help='Require Developer ID with the iCloud container, or sign ad hoc; Developer ID where available otherwise') args = parser.parse_args() if args.bundle_id and not args.output: parser.error('Use --bundle-id with --output.') +if args.sign and args.snow_leopard: + parser.error('The 10.6 bundle stays unsigned.') if args.output and (args.output.suffix != '.app' or args.output.exists()): parser.error('Choose a new output path ending in .app.') root = Path(__file__).resolve().parents[2] @@ -133,8 +137,8 @@ if build: } | versions | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {}))) # 10.6 runs the bundle unsigned. if not args.snow_leopard: - identity = args.sign_identity or developer_id() - profile = args.profile or developer_id_profile() + identity = args.sign != 'ad-hoc' and (args.sign_identity or developer_id()) + profile = args.sign != 'ad-hoc' and (args.profile or developer_id_profile()) if identity and profile and (args.bundle_id or BUNDLE_ID) == BUNDLE_ID: shutil.copy2(profile, bundle / 'Contents/embedded.provisionprofile') with tempfile.TemporaryDirectory() as scratch: @@ -145,13 +149,18 @@ if not args.snow_leopard: 'com.apple.developer.icloud-services': ['CloudDocuments'], 'com.apple.developer.icloud-container-identifiers': [CONTAINER], 'com.apple.developer.ubiquity-container-identifiers': [CONTAINER], + 'com.apple.developer.icloud-container-environment': 'Production', # Hardened runtime's Record Audio and Record Video. 'com.apple.security.device.audio-input': True, 'com.apple.security.device.camera': True, })) - subprocess.run(['codesign', '--force', '--options', 'runtime', '--entitlements', entitlements, + # A release fails where the timestamp server can't be reached, as notarization needs it. + timestamp = ['--timestamp'] if args.sign == 'developer-id' else [] + subprocess.run(['codesign', '--force', '--options', 'runtime', *timestamp, '--entitlements', entitlements, '--sign', identity, str(bundle)], check=True) print(f'Signed with the Developer ID of team {TEAM}, with the iCloud container {CONTAINER}.') + elif args.sign == 'developer-id': + raise SystemExit(f'No Developer ID Application identity of team {TEAM} and profile for {BUNDLE_ID} with {CONTAINER}.') else: subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True) subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True) diff --git a/tools/release.py b/tools/release.py index b4a501940a9be571892836a4f9fe9bb5fa2ef9c1..135281cabd2d013cd9694bd360c9d5f71fb046ca 100755 --- a/tools/release.py +++ b/tools/release.py @@ -6,7 +6,6 @@ import hashlib import json import os from pathlib import Path -import plistlib import shutil import subprocess import sys @@ -29,11 +28,6 @@ CHECKS = [ IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E' # The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}. NOTARY = Path('~/.config/snowbound/notary.json').expanduser() -# What hardened runtime needs for Record Audio and Record Video. -ENTITLEMENTS = { - 'com.apple.security.device.audio-input': True, - 'com.apple.security.device.camera': True, -} def derive(release, commits): @@ -109,21 +103,21 @@ def notary(): def build_mac(platform, folder, developer_id, notarize): - """The zipped app; 10.6's stays unsigned, as it predates Developer ID.""" + """The zipped app, which build_macos.py signs; 10.6's stays unsigned, as it predates Developer ID.""" bundle = folder / 'Snowbound.app' - run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle] - + (['--snow-leopard'] if platform == 'macos-10.6' else [])) + if platform == 'macos-10.6': + signing = ['--snow-leopard'] + elif developer_id: + signing = ['--sign', 'developer-id', '--sign-identity', IDENTITY] + else: + signing = ['--sign', 'ad-hoc'] + run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, *signing]) archive = folder / 'archive.zip' - if developer_id and platform != 'macos-10.6': - entitlements = folder / 'entitlements.plist' - entitlements.write_bytes(plistlib.dumps(ENTITLEMENTS)) - run(['codesign', '--force', '--options', 'runtime', '--timestamp', '--entitlements', entitlements, - '--sign', IDENTITY, bundle]) - if notarize: - zip_bundle(bundle, archive) - run(['xcrun', 'notarytool', 'submit', archive, *notarize, '--wait']) - run(['xcrun', 'stapler', 'staple', bundle]) - archive.unlink() + if notarize and platform != 'macos-10.6': + zip_bundle(bundle, archive) + run(['xcrun', 'notarytool', 'submit', archive, *notarize, '--wait']) + run(['xcrun', 'stapler', 'staple', bundle]) + archive.unlink() zip_bundle(bundle, archive) return archive @@ -208,7 +202,8 @@ def main(): shutil.rmtree(partial, ignore_errors=True) partial.mkdir() for file in [*files.values(), stage / 'build.json', stage / 'build.json.sig']: - shutil.copyfile(file, partial / file.name) + # copy() keeps the Linux executables executable for anyone running them off the share. + shutil.copy(file, partial / file.name) partial.rename(target) shutil.rmtree(stage) print(f'Published {target}') diff --git a/tools/test_release.py b/tools/test_release.py index 81f098891da42e66f8467b5a3c42d1424365400b..607dcfe1e59996d1c54388b67cf73a1c1f503eaa 100644 --- a/tools/test_release.py +++ b/tools/test_release.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone from pathlib import Path import runpy +import tempfile import unittest release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py')) @@ -29,6 +30,33 @@ class ReleaseTest(unittest.TestCase): 'macos-10.6': '2026-09-29-r10'}) self.assertEqual(release['newest'](latest, {'macos-aarch64': {}}, ('2026-09-29', 9)), latest) + def test_build_macos_signs_each_mac_app(self): + build_mac, scope = release['build_mac'], release['build_mac'].__globals__ + commands = [] + + def record(command, **_): + commands.append(list(map(str, command))) + if command[0] == 'ditto': + Path(command[-1]).touch() + + run, scope['run'] = scope['run'], record + try: + def signing(platform, developer_id, notarize): + commands.clear() + with tempfile.TemporaryDirectory() as stage: + build_mac(platform, Path(stage), developer_id, notarize) + build = commands[0] + return (build[build.index(f'{stage}/Snowbound.app') + 1:], + [command[1] for command in commands[1:]]) + + self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']), + (['--sign', 'developer-id', '--sign-identity', release['IDENTITY']], + ['-c', 'notarytool', 'stapler', '-c'])) + self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc'], ['-c'])) + self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c'])) + finally: + scope['run'] = run + if __name__ == '__main__': unittest.main() diff --git a/tools/w7/payload/bootstrap.cmd b/tools/w7/payload/bootstrap.cmd index 987ef8d2f7b8071b767ad482e56f8509ae501ea2..6f717700aad2d4db98ea90775f6e9fe701673cc3 100644 --- a/tools/w7/payload/bootstrap.cmd +++ b/tools/w7/payload/bootstrap.cmd @@ -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 ) if not defined ONEVM_HOSTNAME goto agent if /i "%COMPUTERNAME%"=="%ONEVM_HOSTNAME%" goto agent +rem Windows 11 ships without wmic; Windows 7 PowerShell lacks Rename-Computer. +where wmic >nul 2>&1 || goto rename_powershell wmic computersystem where name="%COMPUTERNAME%" call rename name="%ONEVM_HOSTNAME%" >"%~dp0bootstrap.log" 2>&1 find "ReturnValue = 0;" "%~dp0bootstrap.log" >nul || exit /b 1 +goto restart +:rename_powershell +powershell -NoProfile -Command "Rename-Computer -NewName '%ONEVM_HOSTNAME%' -Force -ErrorAction Stop" >"%~dp0bootstrap.log" 2>&1 || exit /b 1 +:restart shutdown /r /t 0 exit /b diff --git a/tools/w7/unattend/autounattend.xml b/tools/w7/unattend/autounattend.xml new file mode 100644 index 0000000000000000000000000000000000000000..aaf2d1bce36c50059bac76c4ef28364161998dd6 --- /dev/null +++ b/tools/w7/unattend/autounattend.xml @@ -0,0 +1,97 @@ + + + + + en-US + en-US + en-US + en-US + en-US + + + + + 1reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f + 2reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f + 3reg add HKLM\SYSTEM\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 1 /f + + + + 0 + true + + 1EFI300 + 2MSR16 + 3Primarytrue + + + 11FAT32 + 22 + 33NTFSC + + + + + + + /IMAGE/INDEX1 + 03 + + + + true + one + Snowbound lab + + + + + + {hostname} + UTC + + + + 1reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE /v BypassNRO /t REG_DWORD /d 1 /f + + + + + + en-US + en-US + en-US + en-US + + + + true + true + true + true + 3 + + + + + one + Administrators + onetrue</PlainText></Password> + </LocalAccount> + </LocalAccounts> + </UserAccounts> + <AutoLogon> + <Enabled>true</Enabled> + <Username>one</Username> + <Password><Value>one</Value><PlainText>true</PlainText></Password> + <LogonCount>9999999</LogonCount> + </AutoLogon> + <FirstLogonCommands> + <SynchronousCommand wcm:action="add"> + <Order>1</Order> + <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> + </SynchronousCommand> + </FirstLogonCommands> + </component> + </settings> +</unattend> diff --git a/tools/w7/unattend/lab-setup.cmd b/tools/w7/unattend/lab-setup.cmd new file mode 100644 index 0000000000000000000000000000000000000000..d5ba5be7b092f98c37d8602191e9eb6789dbc267 --- /dev/null +++ b/tools/w7/unattend/lab-setup.cmd @@ -0,0 +1,24 @@ +@echo off +rem First logon of a Windows 10/11 lab build: install the agent and quiet the desktop. +set "AGENT=" +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:" +if not defined AGENT exit /b 1 +xcopy /e /i /y /q "%AGENT%\" C:\win7-agent\ || exit /b 1 +attrib -r /s /d "C:\win7-agent\*" +netsh advfirewall firewall add rule name="win7-agent" dir=in action=allow protocol=TCP localport=8777 +net accounts /maxpwage:unlimited +set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon +reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d 1 /f +reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d one /f +reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d one /f +reg delete "%WINLOGON%" /v AutoLogonCount /f +powercfg /change monitor-timeout-ac 0 +powercfg /change standby-timeout-ac 0 +powercfg /hibernate off +reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f +reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoUpdate /t REG_DWORD /d 1 /f +reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f +reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v EnableTransparency /t REG_DWORD /d 1 /f +reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f +call C:\win7-agent\install-autostart.cmd +shutdown /r /t 5 diff --git a/tools/w7/windows_media.py b/tools/w7/windows_media.py new file mode 100755 index 0000000000000000000000000000000000000000..76bed157ef6f4e8b583c5c72e0c910612b803567 --- /dev/null +++ b/tools/w7/windows_media.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Build Windows 10/11 installation ISOs from Microsoft's Media Creation Tool catalog.""" + +import argparse +import hashlib +from pathlib import Path +import shutil +import subprocess +import tempfile +import urllib.request +import xml.etree.ElementTree as ET + +from env import require + +# The catalogs the Media Creation Tool itself downloads. +CATALOGS = { + "win10": ("https://go.microsoft.com/fwlink/?LinkId=841361", "x64"), + "win11": ("https://go.microsoft.com/fwlink/?linkid=2156292", "ARM64"), +} + + +def tool(name): + path = shutil.which(name) or "/opt/homebrew/bin/" + name + if not Path(path).is_file(): + raise SystemExit("Install with: /opt/homebrew/bin/brew install wimlib xorriso") + return path + + +def catalog_entry(base, work): + url, arch = CATALOGS[base] + cab = work / "products.cab" + urllib.request.urlretrieve(url, cab) + subprocess.run(["bsdtar", "-xf", str(cab), "-C", str(work), "products.xml"], check=True) + for entry in ET.parse(work / "products.xml").getroot().iter("File"): + field = lambda key: entry.findtext(key) or "" + if (field("LanguageCode") == "en-us" and field("Architecture") == arch + and "CLIENTCONSUMER_RET" in field("FileName")): + return field("FilePath"), field("Sha1").lower() + raise SystemExit("The %s catalog has no en-us %s consumer image" % (base, arch)) + + +def download(url, sha1, path): + digest = hashlib.sha1() + with urllib.request.urlopen(url) as response, path.open("wb") as output: + while chunk := response.read(8 * 1024 * 1024): + output.write(chunk) + digest.update(chunk) + if digest.hexdigest() != sha1: + raise SystemExit("Download failed SHA-1 verification: %s" % url) + + +def pro_index(esd): + info = subprocess.check_output([tool("wimlib-imagex"), "info", str(esd)], text=True) + index = None + for line in info.splitlines(): + key, _, value = line.partition(":") + if key.strip() == "Index": + index = value.strip() + elif key.strip() == "Edition ID" and value.strip() == "Professional": + return index + raise SystemExit("No Professional edition in %s" % esd) + + +def build(base): + media = Path(require("ONE_VM_HOME")).expanduser() / "media" + iso = media / ("%s.iso" % base) + if iso.exists(): + raise SystemExit("Move the existing ISO first: %s" % iso) + media.mkdir(parents=True, exist_ok=True) + wim = tool("wimlib-imagex") + with tempfile.TemporaryDirectory(prefix="one-media-", dir=media) as temporary: + work = Path(temporary) + url, sha1 = catalog_entry(base, work) + esd = work / "image.esd" + print("Downloading %s" % url, flush=True) + download(url, sha1, esd) + tree = work / "iso" + sources = tree / "sources" + subprocess.run([wim, "apply", str(esd), "1", str(tree)], check=True) + subprocess.run([wim, "export", str(esd), "2", str(sources / "boot.wim"), + "--compress=LZX"], check=True) + subprocess.run([wim, "export", str(esd), "3", str(sources / "boot.wim"), + "--boot"], check=True) + # Solid LZMS keeps install.esd under the 4 GiB ISO 9660 file limit. + subprocess.run([wim, "export", str(esd), pro_index(esd), + str(sources / "install.esd"), "--compress=LZMS", "--solid"], + check=True) + esd.unlink() + partial = work / "out.iso" + # The no-prompt loader boots unattended instead of waiting for a key press. + subprocess.run([ + tool("xorriso"), "-as", "mkisofs", "-quiet", "-iso-level", "3", "-J", + "-joliet-long", "-V", base.upper(), + "-e", "efi/microsoft/boot/efisys_noprompt.bin", "-no-emul-boot", + "-o", str(partial), str(tree), + ], check=True) + partial.replace(iso) + print(iso) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("base", choices=sorted(CATALOGS)) + build(parser.parse_args().base) + + +if __name__ == "__main__": + main()