authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 16:16:48-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 16:22:25-07:00
logd082587137c0d7b762c19654f035b29f61df28cb
tree0e26a9ce164121c8f7d98fc88ccb681ab60ab8dd
parentf3b369e572b4a689a2e3c0337a0ea4be5da31fd6
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

5 files changed, 99 insertions(+), 13 deletions(-)

apps/ios/Snowbound/ICloud.swift+6-1
...@@ -51,6 +51,10 @@ enum ICloud {...@@ -51,6 +51,10 @@ enum ICloud {
51 /// the container.51 /// the container.
52 private(set) static var documents: URL?52 private(set) static var documents: URL?
5353
54 /// Tooling launched the app, naming a `SNOWBOUND_` variable such as `SNOWBOUND_SCRIPT`: it
55 /// reaches no account's iCloud, only the folder `SNOWBOUND_ICLOUD` names.
56 private static let automated = ProcessInfo.processInfo.environment.keys.contains { $0.hasPrefix("SNOWBOUND_") }
57
54 /// Whether iCloud is signed in with iCloud Drive on, container or not.58 /// Whether iCloud is signed in with iCloud Drive on, container or not.
55 static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil }59 static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil }
5660
...@@ -58,6 +62,7 @@ enum ICloud {...@@ -58,6 +62,7 @@ enum ICloud {
58 /// another device adds or removes; call once at launch.62 /// another device adds or removes; call once at launch.
59 static func start() {63 static func start() {
60 sb_set_versions(listVersions, retireVersion)64 sb_set_versions(listVersions, retireVersion)
65 if automated { return lookUp() }
61 NotificationCenter.default.addObserver(66 NotificationCenter.default.addObserver(
62 forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main67 forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main
63 ) { _ in lookUp() }68 ) { _ in lookUp() }
...@@ -84,7 +89,7 @@ enum ICloud {...@@ -84,7 +89,7 @@ enum ICloud {
84 return URL(fileURLWithPath: folder, isDirectory: true)89 return URL(fileURLWithPath: folder, isDirectory: true)
85 }90 }
86 #endif91 #endif
87 guard92 guard !automated,
88 let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)?93 let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)?
89 .appendingPathComponent("Documents", isDirectory: true)94 .appendingPathComponent("Documents", isDirectory: true)
90 else { return nil }95 else { return nil }
crates/snowbound/src/icloud_macos.rs+4-4
...@@ -111,12 +111,12 @@ pub fn look_up(changed: impl Fn() + Send + Sync + 'static) {...@@ -111,12 +111,12 @@ pub fn look_up(changed: impl Fn() + Send + Sync + 'static) {
111}111}
112112
113/// The container's Documents, or for trying the app out without its entitlement, the folder113/// The container's Documents, or for trying the app out without its entitlement, the folder
114/// `SNOWBOUND_ICLOUD_FOLDER` names in its place.114/// `SNOWBOUND_ICLOUD_FOLDER` names in its place; tooling reaches only that one.
115fn container() -> Option<PathBuf> {115fn container() -> Option<PathBuf> {
116 if let Some(folder) = std::env::var_os("SNOWBOUND_ICLOUD_FOLDER") {116 if let Some(folder) = std::env::var_os("SNOWBOUND_ICLOUD_FOLDER") {
117 return Some(folder.into());117 return Some(folder.into());
118 }118 }
119 if !available() {119 if !available() || crate::automated() {
120 return None;120 return None;
121 }121 }
122 let manager = unsafe { NSFileManager::defaultManager() };122 let manager = unsafe { NSFileManager::defaultManager() };
...@@ -127,9 +127,9 @@ fn container() -> Option<PathBuf> {...@@ -127,9 +127,9 @@ fn container() -> Option<PathBuf> {
127 Some(documents)127 Some(documents)
128}128}
129129
130/// The top of iCloud Drive, where it is on.130/// The top of iCloud Drive, where it is on and a person, not tooling, runs the app.
131pub fn drive() -> Option<PathBuf> {131pub fn drive() -> Option<PathBuf> {
132 if !available() {132 if !available() || crate::automated() {
133 return None;133 return None;
134 }134 }
135 let drive = PathBuf::from(std::env::var_os("HOME")?)135 let drive = PathBuf::from(std::env::var_os("HOME")?)
crates/snowbound/src/main.rs+15-1
...@@ -385,6 +385,16 @@ impl From<accesskit_winit::Event> for UserEvent {...@@ -385,6 +385,16 @@ impl From<accesskit_winit::Event> for UserEvent {
385 }385 }
386}386}
387387
388/// Tooling drives the app, with `--screenshot` or `SNOWBOUND_REPLAY`: it reads only the
389/// settings and iCloud folder it is given, never the account's own.
390#[cfg(not(target_arch = "wasm32"))]
391static AUTOMATED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
392
393#[cfg(not(target_arch = "wasm32"))]
394fn automated() -> bool {
395 AUTOMATED.load(Ordering::Relaxed)
396}
397
388/// With `SNOWBOUND_PROFILE` set, prints how long a phase of the frame took since `start`.398/// With `SNOWBOUND_PROFILE` set, prints how long a phase of the frame took since `start`.
389fn lap(phase: &str, start: Instant) {399fn lap(phase: &str, start: Instant) {
390 static PROFILE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();400 static PROFILE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
...@@ -6317,6 +6327,10 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -6317,6 +6327,10 @@ fn launch() -> Result<(), Box<dyn Error>> {
6317 positional.push(arg);6327 positional.push(arg);
6318 }6328 }
6319 }6329 }
6330 AUTOMATED.store(
6331 screenshot.is_some() || std::env::var_os("SNOWBOUND_REPLAY").is_some(),
6332 Ordering::Relaxed,
6333 );
6320 let stored = notebook.is_some() || section.is_some();6334 let stored = notebook.is_some() || section.is_some();
6321 if (editable || stored) && !positional.is_empty()6335 if (editable || stored) && !positional.is_empty()
6322 || notebook.is_some() && (section.is_some() || reference.is_some())6336 || notebook.is_some() && (section.is_some() || reference.is_some())
...@@ -6349,7 +6363,7 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -6349,7 +6363,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
6349 Some(cache) => cache,6363 Some(cache) => cache,
6350 None => platform::cache_dir().ok_or("HOME is not set.")?,6364 None => platform::cache_dir().ok_or("HOME is not set.")?,
6351 };6365 };
6352 let settings_file = settings_file.or_else(settings::default_path);6366 let settings_file = settings_file.or_else(|| settings::default_path().filter(|_| !automated()));
6353 let saved = settings_file6367 let saved = settings_file
6354 .as_deref()6368 .as_deref()
6355 .map(settings::Settings::load)6369 .map(settings::Settings::load)
crates/snowbound/tests/replay.rs+73-6
...@@ -4,7 +4,8 @@...@@ -4,7 +4,8 @@
4use std::path::{Path, PathBuf};4use std::path::{Path, PathBuf};
5use std::process::Command;5use std::process::Command;
66
7/// A scratch folder, deleted when dropped.7/// A scratch folder, deleted when dropped. Its replays run with a home folder of its own, its
8/// `settings.json` unless removed, and its `icloud` folder, where made, standing in for iCloud's.
8struct Scratch(PathBuf);9struct Scratch(PathBuf);
910
10impl Scratch {11impl Scratch {
...@@ -12,6 +13,7 @@ impl Scratch {...@@ -12,6 +13,7 @@ impl Scratch {
12 let path = std::env::temp_dir().join(format!("snowbound-{name}-{}", std::process::id()));13 let path = std::env::temp_dir().join(format!("snowbound-{name}-{}", std::process::id()));
13 let _ = std::fs::remove_dir_all(&path);14 let _ = std::fs::remove_dir_all(&path);
14 std::fs::create_dir_all(path.join("notebook")).unwrap();15 std::fs::create_dir_all(path.join("notebook")).unwrap();
16 std::fs::create_dir_all(path.join("home")).unwrap();
15 std::fs::write(17 std::fs::write(
16 path.join("settings.json"),18 path.join("settings.json"),
17 r#"{"user_name": "Snowbound Test"}"#,19 r#"{"user_name": "Snowbound Test"}"#,
...@@ -52,15 +54,21 @@ fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<Str...@@ -52,15 +54,21 @@ fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<Str
52 }54 }
53 script += "quit\n";55 script += "quit\n";
54 std::fs::write(dir.join("script"), script).unwrap();56 std::fs::write(dir.join("script"), script).unwrap();
55 let status = Command::new(env!("CARGO_BIN_EXE_snowbound"))57 let mut command = Command::new(env!("CARGO_BIN_EXE_snowbound"));
58 if dir.join("icloud").is_dir() {
59 command.env("SNOWBOUND_ICLOUD_FOLDER", dir.join("icloud"));
60 }
61 command
62 .env("HOME", dir.join("home"))
56 .env("SNOWBOUND_REPLAY", dir.join("script"))63 .env("SNOWBOUND_REPLAY", dir.join("script"))
57 .arg("--notebook")64 .arg("--notebook")
58 .arg(dir.join("notebook"))65 .arg(dir.join("notebook"))
59 .args(["--cache".as_ref(), dir.join("cache").as_os_str()])66 .args(["--cache".as_ref(), dir.join("cache").as_os_str()])
60 .args(["--settings".as_ref(), dir.join("settings.json").as_os_str()])67 .args(["--screenshot".as_ref(), dir.join("shot").as_os_str()]);
61 .args(["--screenshot".as_ref(), dir.join("shot").as_os_str()])68 if dir.join("settings.json").exists() {
62 .status()69 command.args(["--settings".as_ref(), dir.join("settings.json").as_os_str()]);
63 .unwrap();70 }
71 let status = command.status().unwrap();
64 assert!(status.success(), "the replay ended with {status}");72 assert!(status.success(), "the replay ended with {status}");
65 trees73 trees
66 .iter()74 .iter()
...@@ -434,3 +442,62 @@ fn a_launch_returns_to_the_page_each_notebook_was_left_on() {...@@ -434,3 +442,62 @@ fn a_launch_returns_to_the_page_each_notebook_was_left_on() {
434 "{other}"442 "{other}"
435 );443 );
436}444}
445
446/// The names of the rows that fold in `tree`'s sidebar: its notebooks, where none holds a
447/// section group.
448fn notebooks(tree: &str) -> Vec<&str> {
449 let lines: Vec<&str> = tree.lines().map(str::trim_start).collect();
450 lines
451 .windows(2)
452 .filter(|pair| {
453 pair[0].starts_with("TreeItem ")
454 && ["Button \"Collapse\"", "Button \"Expand\""]
455 .iter()
456 .any(|fold| pair[1].starts_with(fold))
457 })
458 .filter_map(|pair| pair[0].split('"').nth(1))
459 .collect()
460}
461
462/// Copies the notebook at `source` to `folder`.
463fn copy_notebook(source: &Path, folder: &Path) {
464 std::fs::create_dir_all(folder).unwrap();
465 for entry in std::fs::read_dir(source).unwrap() {
466 let entry = entry.unwrap();
467 std::fs::copy(entry.path(), folder.join(entry.file_name())).unwrap();
468 }
469}
470
471/// Tooling never reaches the account's own notebooks: not those its settings list, nor
472/// iCloud's beyond the folder it names.
473#[test]
474fn a_replay_opens_only_the_notebooks_and_icloud_folder_it_is_given() {
475 let scratch = Scratch::new("isolated");
476 let notebook =
477 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
478 let account = scratch.0.join("Account");
479 copy_notebook(&notebook, &account);
480 let settings = scratch
481 .0
482 .join("home/Library/Application Support/Snowbound/settings.json");
483 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
484 let listed = serde_json::json!({"sidebar": true, "notebooks": [account]});
485 std::fs::write(&settings, listed.to_string()).unwrap();
486 std::fs::remove_file(scratch.0.join("settings.json")).unwrap();
487 let sidebar = [
488 "modifiers command",
489 "key \\",
490 "modifiers",
491 "settle",
492 "accessibility tree",
493 ];
494 let [tree] = replay(&scratch, Some(&notebook), &sidebar)
495 .try_into()
496 .unwrap();
497 assert_eq!(notebooks(&tree), ["notebook"], "{tree}");
498 copy_notebook(&notebook, &scratch.0.join("icloud/Cloudy"));
499 let [tree] = replay(&scratch, None, &sidebar).try_into().unwrap();
500 let mut listed = notebooks(&tree);
501 listed.sort_unstable();
502 assert_eq!(listed, ["Cloudy", "notebook"], "{tree}");
503}
tools/canvas/README.md+1-1
...@@ -56,7 +56,7 @@ As in OneNote 2010, Notebook Recycle Bin (File menu, a notebook's context menu,...@@ -56,7 +56,7 @@ As in OneNote 2010, Notebook Recycle Bin (File menu, a notebook's context menu,
5656
57The `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.57The `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.
5858
59A 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"}`.59A 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.
6060
61```sh61```sh
62cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one62cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one