| author | |
| committer | |
| log | d082587137c0d7b762c19654f035b29f61df28cb |
| tree | 0e26a9ce164121c8f7d98fc88ccb681ab60ab8dd |
| parent | f3b369e572b4a689a2e3c0337a0ea4be5da31fd6 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.55 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? |
| 53 | 53 | ||
| 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 } |
| 56 | 60 | ||
| ... | @@ -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: .main | 67 | 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 | #endif | 91 | #endif |
| 87 | guard | 92 | 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 | } |
| 112 | 112 | ||
| 113 | /// The container's Documents, or for trying the app out without its entitlement, the folder | 113 | /// 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. |
| 115 | fn container() -> Option<PathBuf> { | 115 | fn 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 | } |
| 129 | 129 | ||
| 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. |
| 131 | pub fn drive() -> Option<PathBuf> { | 131 | pub 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 | } |
| 387 | 387 | ||
| 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"))] | ||
| 391 | static AUTOMATED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); | ||
| 392 | |||
| 393 | #[cfg(not(target_arch = "wasm32"))] | ||
| 394 | fn 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`. |
| 389 | fn lap(phase: &str, start: Instant) { | 399 | fn 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_file | 6367 | 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 @@ |
| 4 | use std::path::{Path, PathBuf}; | 4 | use std::path::{Path, PathBuf}; |
| 5 | use std::process::Command; | 5 | use std::process::Command; |
| 6 | 6 | ||
| 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. | ||
| 8 | struct Scratch(PathBuf); | 9 | struct Scratch(PathBuf); |
| 9 | 10 | ||
| 10 | impl Scratch { | 11 | impl 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 | trees | 73 | 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. | ||
| 448 | fn 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`. | ||
| 463 | fn 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] | ||
| 474 | fn 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, |
| 56 | 56 | ||
| 57 | 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. | 57 | 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. |
| 58 | 58 | ||
| 59 | 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"}`. | 59 | 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. |
| 60 | 60 | ||
| 61 | ```sh | 61 | ```sh |
| 62 | cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one | 62 | cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one |