From d082587137c0d7b762c19654f035b29f61df28cb Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 16:16:48 -0700 Subject: [PATCH] fix: replays, screenshots and scripted iOS launches keep away from the account's settings and iCloud A replay or --screenshot run without --settings starts from no settings, and reaches iCloud only through SNOWBOUND_ICLOUD_FOLDER; iOS launched with a SNOWBOUND_ variable reaches it only through SNOWBOUND_ICLOUD. The replay tests run in a home folder of their own. Assisted-by: claude-opus-5.5 --- apps/ios/Snowbound/ICloud.swift | 7 ++- crates/snowbound/src/icloud_macos.rs | 8 +-- crates/snowbound/src/main.rs | 16 +++++- crates/snowbound/tests/replay.rs | 79 +++++++++++++++++++++++++--- tools/canvas/README.md | 2 +- 5 files changed, 99 insertions(+), 13 deletions(-) diff --git a/apps/ios/Snowbound/ICloud.swift b/apps/ios/Snowbound/ICloud.swift index fbe77e22b30191a421013df06a1fd931acc22bf8..138ba291f91a0c50590ca6fdb577093777fc8dbc 100644 --- a/apps/ios/Snowbound/ICloud.swift +++ b/apps/ios/Snowbound/ICloud.swift @@ -51,6 +51,10 @@ enum ICloud { /// the container. private(set) static var documents: URL? + /// Tooling launched the app, naming a `SNOWBOUND_` variable such as `SNOWBOUND_SCRIPT`: it + /// reaches no account's iCloud, only the folder `SNOWBOUND_ICLOUD` names. + private static let automated = ProcessInfo.processInfo.environment.keys.contains { $0.hasPrefix("SNOWBOUND_") } + /// Whether iCloud is signed in with iCloud Drive on, container or not. static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil } @@ -58,6 +62,7 @@ enum ICloud { /// another device adds or removes; call once at launch. static func start() { sb_set_versions(listVersions, retireVersion) + if automated { return lookUp() } NotificationCenter.default.addObserver( forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main ) { _ in lookUp() } @@ -84,7 +89,7 @@ enum ICloud { return URL(fileURLWithPath: folder, isDirectory: true) } #endif - guard + guard !automated, let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)? .appendingPathComponent("Documents", isDirectory: true) else { return nil } diff --git a/crates/snowbound/src/icloud_macos.rs b/crates/snowbound/src/icloud_macos.rs index abbe1410faf3e911dde64e16e441bf286ba87fed..b330e85db7d47af91b930f9dd3cd4de979072fb4 100644 --- a/crates/snowbound/src/icloud_macos.rs +++ b/crates/snowbound/src/icloud_macos.rs @@ -111,12 +111,12 @@ pub fn look_up(changed: impl Fn() + Send + Sync + 'static) { } /// The container's Documents, or for trying the app out without its entitlement, the folder -/// `SNOWBOUND_ICLOUD_FOLDER` names in its place. +/// `SNOWBOUND_ICLOUD_FOLDER` names in its place; tooling reaches only that one. fn container() -> Option { if let Some(folder) = std::env::var_os("SNOWBOUND_ICLOUD_FOLDER") { return Some(folder.into()); } - if !available() { + if !available() || crate::automated() { return None; } let manager = unsafe { NSFileManager::defaultManager() }; @@ -127,9 +127,9 @@ fn container() -> Option { Some(documents) } -/// The top of iCloud Drive, where it is on. +/// The top of iCloud Drive, where it is on and a person, not tooling, runs the app. pub fn drive() -> Option { - if !available() { + if !available() || crate::automated() { return None; } let drive = PathBuf::from(std::env::var_os("HOME")?) diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 17cacb4f0dc04e53db3fb00e96ef82c44d50d66e..5de8217f40978ccbef2ae5ea47aafd41d16d5c31 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -385,6 +385,16 @@ impl From for UserEvent { } } +/// Tooling drives the app, with `--screenshot` or `SNOWBOUND_REPLAY`: it reads only the +/// settings and iCloud folder it is given, never the account's own. +#[cfg(not(target_arch = "wasm32"))] +static AUTOMATED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +#[cfg(not(target_arch = "wasm32"))] +fn automated() -> bool { + AUTOMATED.load(Ordering::Relaxed) +} + /// With `SNOWBOUND_PROFILE` set, prints how long a phase of the frame took since `start`. fn lap(phase: &str, start: Instant) { static PROFILE: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -6317,6 +6327,10 @@ fn launch() -> Result<(), Box> { positional.push(arg); } } + AUTOMATED.store( + screenshot.is_some() || std::env::var_os("SNOWBOUND_REPLAY").is_some(), + Ordering::Relaxed, + ); let stored = notebook.is_some() || section.is_some(); if (editable || stored) && !positional.is_empty() || notebook.is_some() && (section.is_some() || reference.is_some()) @@ -6349,7 +6363,7 @@ fn launch() -> Result<(), Box> { Some(cache) => cache, None => platform::cache_dir().ok_or("HOME is not set.")?, }; - let settings_file = settings_file.or_else(settings::default_path); + let settings_file = settings_file.or_else(|| settings::default_path().filter(|_| !automated())); let saved = settings_file .as_deref() .map(settings::Settings::load) diff --git a/crates/snowbound/tests/replay.rs b/crates/snowbound/tests/replay.rs index 4029f0c3009722df3736766b41e950d0dbf629ba..b262aca3f4adf27fe38189b70b686c872c49a373 100644 --- a/crates/snowbound/tests/replay.rs +++ b/crates/snowbound/tests/replay.rs @@ -4,7 +4,8 @@ use std::path::{Path, PathBuf}; use std::process::Command; -/// A scratch folder, deleted when dropped. +/// A scratch folder, deleted when dropped. Its replays run with a home folder of its own, its +/// `settings.json` unless removed, and its `icloud` folder, where made, standing in for iCloud's. struct Scratch(PathBuf); impl Scratch { @@ -12,6 +13,7 @@ impl Scratch { let path = std::env::temp_dir().join(format!("snowbound-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&path); std::fs::create_dir_all(path.join("notebook")).unwrap(); + std::fs::create_dir_all(path.join("home")).unwrap(); std::fs::write( path.join("settings.json"), r#"{"user_name": "Snowbound Test"}"#, @@ -52,15 +54,21 @@ fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec Vec<&str> { + let lines: Vec<&str> = tree.lines().map(str::trim_start).collect(); + lines + .windows(2) + .filter(|pair| { + pair[0].starts_with("TreeItem ") + && ["Button \"Collapse\"", "Button \"Expand\""] + .iter() + .any(|fold| pair[1].starts_with(fold)) + }) + .filter_map(|pair| pair[0].split('"').nth(1)) + .collect() +} + +/// Copies the notebook at `source` to `folder`. +fn copy_notebook(source: &Path, folder: &Path) { + std::fs::create_dir_all(folder).unwrap(); + for entry in std::fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + std::fs::copy(entry.path(), folder.join(entry.file_name())).unwrap(); + } +} + +/// Tooling never reaches the account's own notebooks: not those its settings list, nor +/// iCloud's beyond the folder it names. +#[test] +fn a_replay_opens_only_the_notebooks_and_icloud_folder_it_is_given() { + let scratch = Scratch::new("isolated"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); + let account = scratch.0.join("Account"); + copy_notebook(¬ebook, &account); + let settings = scratch + .0 + .join("home/Library/Application Support/Snowbound/settings.json"); + std::fs::create_dir_all(settings.parent().unwrap()).unwrap(); + let listed = serde_json::json!({"sidebar": true, "notebooks": [account]}); + std::fs::write(&settings, listed.to_string()).unwrap(); + std::fs::remove_file(scratch.0.join("settings.json")).unwrap(); + let sidebar = [ + "modifiers command", + "key \\", + "modifiers", + "settle", + "accessibility tree", + ]; + let [tree] = replay(&scratch, Some(¬ebook), &sidebar) + .try_into() + .unwrap(); + assert_eq!(notebooks(&tree), ["notebook"], "{tree}"); + copy_notebook(¬ebook, &scratch.0.join("icloud/Cloudy")); + let [tree] = replay(&scratch, None, &sidebar).try_into().unwrap(); + let mut listed = notebooks(&tree); + listed.sort_unstable(); + assert_eq!(listed, ["Cloudy", "notebook"], "{tree}"); +} diff --git a/tools/canvas/README.md b/tools/canvas/README.md index d13295a5d5992db93c6fa64354b3236bfe5c77f7..67d0bb8a4acab61b925c1593a1de1d5c2f7a6a96 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -56,7 +56,7 @@ As in OneNote 2010, Notebook Recycle Bin (File menu, a notebook's context menu, The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. Its tab is selected at once, and an open that takes over 80 ms shows a page's outline in place of the page leaving, which takes no input. The pages beside the open one, the sections beside the open section and a hovered page or section tab are read ahead on a thread of their own. A notebook keeps the last three sections left or read ahead open, which `Library::open` hands out, and the last 32 pages shown or read ahead keep their scenes, with up to 128 MiB of decoded pictures, so a page shown again is laid out afresh around pictures already drawn. -A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. +A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. A replay or `--screenshot` run never reads the account's own settings or iCloud: without `--settings` it starts from none, and its iCloud folder is only the one `SNOWBOUND_ICLOUD_FOLDER` names. ```sh cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one -- 2.54.0