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 {
5151 /// the container.
5252 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
5458 /// Whether iCloud is signed in with iCloud Drive on, container or not.
5559 static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil }
5660
......@@ -58,6 +62,7 @@ enum ICloud {
5862 /// another device adds or removes; call once at launch.
5963 static func start() {
6064 sb_set_versions(listVersions, retireVersion)
65 if automated { return lookUp() }
6166 NotificationCenter.default.addObserver(
6267 forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main
6368 ) { _ in lookUp() }
......@@ -84,7 +89,7 @@ enum ICloud {
8489 return URL(fileURLWithPath: folder, isDirectory: true)
8590 }
8691 #endif
87 guard
92 guard !automated,
8893 let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)?
8994 .appendingPathComponent("Documents", isDirectory: true)
9095 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) {
111111}
112112
113113/// 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.
115115fn container() -> Option<PathBuf> {
116116 if let Some(folder) = std::env::var_os("SNOWBOUND_ICLOUD_FOLDER") {
117117 return Some(folder.into());
118118 }
119 if !available() {
119 if !available() || crate::automated() {
120120 return None;
121121 }
122122 let manager = unsafe { NSFileManager::defaultManager() };
......@@ -127,9 +127,9 @@ fn container() -> Option<PathBuf> {
127127 Some(documents)
128128}
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.
131131pub fn drive() -> Option<PathBuf> {
132 if !available() {
132 if !available() || crate::automated() {
133133 return None;
134134 }
135135 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 {
385385 }
386386}
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
388398/// With `SNOWBOUND_PROFILE` set, prints how long a phase of the frame took since `start`.
389399fn lap(phase: &str, start: Instant) {
390400 static PROFILE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
......@@ -6317,6 +6327,10 @@ fn launch() -> Result<(), Box<dyn Error>> {
63176327 positional.push(arg);
63186328 }
63196329 }
6330 AUTOMATED.store(
6331 screenshot.is_some() || std::env::var_os("SNOWBOUND_REPLAY").is_some(),
6332 Ordering::Relaxed,
6333 );
63206334 let stored = notebook.is_some() || section.is_some();
63216335 if (editable || stored) && !positional.is_empty()
63226336 || notebook.is_some() && (section.is_some() || reference.is_some())
......@@ -6349,7 +6363,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
63496363 Some(cache) => cache,
63506364 None => platform::cache_dir().ok_or("HOME is not set.")?,
63516365 };
6352 let settings_file = settings_file.or_else(settings::default_path);
6366 let settings_file = settings_file.or_else(|| settings::default_path().filter(|_| !automated()));
63536367 let saved = settings_file
63546368 .as_deref()
63556369 .map(settings::Settings::load)
crates/snowbound/tests/replay.rs+73-6
......@@ -4,7 +4,8 @@
44use std::path::{Path, PathBuf};
55use 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.
89struct Scratch(PathBuf);
910
1011impl Scratch {
......@@ -12,6 +13,7 @@ impl Scratch {
1213 let path = std::env::temp_dir().join(format!("snowbound-{name}-{}", std::process::id()));
1314 let _ = std::fs::remove_dir_all(&path);
1415 std::fs::create_dir_all(path.join("notebook")).unwrap();
16 std::fs::create_dir_all(path.join("home")).unwrap();
1517 std::fs::write(
1618 path.join("settings.json"),
1719 r#"{"user_name": "Snowbound Test"}"#,
......@@ -52,15 +54,21 @@ fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<Str
5254 }
5355 script += "quit\n";
5456 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"))
5663 .env("SNOWBOUND_REPLAY", dir.join("script"))
5764 .arg("--notebook")
5865 .arg(dir.join("notebook"))
5966 .args(["--cache".as_ref(), dir.join("cache").as_os_str()])
60 .args(["--settings".as_ref(), dir.join("settings.json").as_os_str()])
61 .args(["--screenshot".as_ref(), dir.join("shot").as_os_str()])
62 .status()
63 .unwrap();
67 .args(["--screenshot".as_ref(), dir.join("shot").as_os_str()]);
68 if dir.join("settings.json").exists() {
69 command.args(["--settings".as_ref(), dir.join("settings.json").as_os_str()]);
70 }
71 let status = command.status().unwrap();
6472 assert!(status.success(), "the replay ended with {status}");
6573 trees
6674 .iter()
......@@ -434,3 +442,62 @@ fn a_launch_returns_to_the_page_each_notebook_was_left_on() {
434442 "{other}"
435443 );
436444}
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,
5656
5757The `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
6161```sh
6262cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one