diff --git a/crates/tinycomputer-browser/src/convert/convert_tests/session_tests.rs b/crates/tinycomputer-browser/src/convert/convert_tests/session_tests.rs index 5113ec06..910c5524 100644 --- a/crates/tinycomputer-browser/src/convert/convert_tests/session_tests.rs +++ b/crates/tinycomputer-browser/src/convert/convert_tests/session_tests.rs @@ -1,7 +1,9 @@ //! Tests for the launch a session starts with. use serde_json::json; -use tinycomputer_bus::browser::{SessionOptions, Viewport}; +use tinycomputer_bus::browser::{ + Screen, SessionOptions, UserAgentBrand, UserAgentMetadata, Viewport, +}; use crate::convert::{launch, viewport}; @@ -46,3 +48,105 @@ fn every_session_option_reaches_its_launch_field() { json!({"action": "viewport", "width": 800, "height": 600, "deviceScaleFactor": 1.0, "mobile": false}) ); } + +#[test] +fn every_identity_option_reaches_its_launch_field() { + let options = SessionOptions { + timezone: Some("Asia/Tokyo".to_owned()), + locale: Some("ja-JP".to_owned()), + accept_language: Some("ja-JP,ja".to_owned()), + user_agent_metadata: Some(UserAgentMetadata { + brands: Some(vec![UserAgentBrand { + brand: "Chromium".to_owned(), + version: "140".to_owned(), + }]), + full_version_list: Some(vec![UserAgentBrand { + brand: "Chromium".to_owned(), + version: "140.0.7339.0".to_owned(), + }]), + full_version: None, + platform: "macOS".to_owned(), + platform_version: "14.5.0".to_owned(), + architecture: "arm".to_owned(), + model: String::new(), + mobile: false, + bitness: Some("64".to_owned()), + wow64: Some(false), + }), + screen: Some(Screen { + width: 1920, + height: 1080, + device_scale_factor: 2.0, + }), + init_scripts: vec!["window.marker = 1;".to_owned()], + stealth: true, + ..SessionOptions::default() + }; + assert_eq!( + launch(&options), + json!({ + "action": "launch", + "headless": true, + "args": [], + "timezone": "Asia/Tokyo", + "locale": "ja-JP", + "acceptLanguage": "ja-JP,ja", + "userAgentMetadata": { + "brands": [{"brand": "Chromium", "version": "140"}], + "fullVersionList": [{"brand": "Chromium", "version": "140.0.7339.0"}], + "platform": "macOS", + "platformVersion": "14.5.0", + "architecture": "arm", + "model": "", + "mobile": false, + "bitness": "64", + "wow64": false, + }, + "screen": {"width": 1920, "height": 1080, "deviceScaleFactor": 2.0}, + "initScriptSources": ["window.marker = 1;"], + "stealth": true, + }) + ); +} + +#[test] +fn a_session_that_asks_for_no_identity_sends_none() { + // `stealth: false` and no init scripts are the defaults: none is sent, so + // an engine that predates the fields never sees them. + let command = launch(&SessionOptions::default()); + let sent = command.as_object().expect("an object"); + for key in [ + "timezone", + "locale", + "acceptLanguage", + "userAgentMetadata", + "screen", + "initScriptSources", + "stealth", + ] { + assert!(!sent.contains_key(key), "{key} was sent"); + } +} + +#[test] +fn a_screen_sets_the_pixel_ratio_the_viewport_would_otherwise_replace() { + // The viewport is applied after the launch and replaces its pixel ratio, + // so a session that asked for a 2x screen would report 1x. + let options = SessionOptions { + screen: Some(Screen { + width: 1920, + height: 1080, + device_scale_factor: 2.0, + }), + ..SessionOptions::default() + }; + assert_eq!( + viewport(&options), + json!({"action": "viewport", "width": 1280, "height": 800, "deviceScaleFactor": 2.0, "mobile": false}) + ); + assert_eq!( + viewport(&SessionOptions::default())["deviceScaleFactor"], + 1.0, + "without a screen the viewport's own ratio stands" + ); +} diff --git a/crates/tinycomputer-browser/src/convert/session.rs b/crates/tinycomputer-browser/src/convert/session.rs index d3f1ce13..db2157d5 100644 --- a/crates/tinycomputer-browser/src/convert/session.rs +++ b/crates/tinycomputer-browser/src/convert/session.rs @@ -6,7 +6,7 @@ //! (`crate::origins`). use serde_json::{Value, json}; -use tinycomputer_bus::browser::SessionOptions; +use tinycomputer_bus::browser::{SessionOptions, UserAgentBrand, UserAgentMetadata}; /// The explicit `launch` every session starts with. /// @@ -26,23 +26,83 @@ pub(crate) fn launch(options: &SessionOptions) -> Value { ("userAgent", options.user_agent.as_ref()), ("profile", options.user_data_dir.as_ref()), ("downloadPath", options.download_dir.as_ref()), + ("timezone", options.timezone.as_ref()), + ("locale", options.locale.as_ref()), + ("acceptLanguage", options.accept_language.as_ref()), ]; for (key, value) in fields { if let Some(value) = value { command[key] = json!(value); } } + if let Some(metadata) = &options.user_agent_metadata { + command["userAgentMetadata"] = user_agent_metadata(metadata); + } + if let Some(screen) = &options.screen { + command["screen"] = json!({ + "width": screen.width, + "height": screen.height, + "deviceScaleFactor": screen.device_scale_factor, + }); + } + if !options.init_scripts.is_empty() { + command["initScriptSources"] = json!(options.init_scripts); + } + if options.stealth { + command["stealth"] = json!(true); + } command } +/// The client hints in the engine's field names; fields left unset are left +/// out, as the engine reads an absent list differently from an empty one. +fn user_agent_metadata(metadata: &UserAgentMetadata) -> Value { + let brands = |list: &[UserAgentBrand]| -> Value { + list.iter() + .map(|entry| json!({"brand": entry.brand, "version": entry.version})) + .collect() + }; + let mut value = json!({ + "platform": metadata.platform, + "platformVersion": metadata.platform_version, + "architecture": metadata.architecture, + "model": metadata.model, + "mobile": metadata.mobile, + }); + if let Some(list) = &metadata.brands { + value["brands"] = brands(list); + } + if let Some(list) = &metadata.full_version_list { + value["fullVersionList"] = brands(list); + } + if let Some(version) = &metadata.full_version { + value["fullVersion"] = json!(version); + } + if let Some(bitness) = &metadata.bitness { + value["bitness"] = json!(bitness); + } + if let Some(wow64) = metadata.wow64 { + value["wow64"] = json!(wow64); + } + value +} + /// The viewport a session applies straight after launching. +/// +/// It replaces the launch's pixel ratio, so a session with a screen takes the +/// screen's: what `devicePixelRatio` reports is part of that screen. #[must_use] pub(crate) fn viewport(options: &SessionOptions) -> Value { + let scale = options + .screen + .map_or(options.viewport.device_scale_factor, |screen| { + screen.device_scale_factor + }); json!({ "action": "viewport", "width": options.viewport.width, "height": options.viewport.height, - "deviceScaleFactor": options.viewport.device_scale_factor, + "deviceScaleFactor": scale, "mobile": options.viewport.mobile, }) } diff --git a/crates/tinycomputer-browser/src/sessions/sessions_tests.rs b/crates/tinycomputer-browser/src/sessions/sessions_tests.rs index e9275a0f..fe6730fa 100644 --- a/crates/tinycomputer-browser/src/sessions/sessions_tests.rs +++ b/crates/tinycomputer-browser/src/sessions/sessions_tests.rs @@ -19,6 +19,7 @@ mod deep_keys_tests; mod deep_page; mod deep_tests; mod focus_tests; +mod identity_tests; mod lifecycle_tests; mod origins_tests; mod page_tests; diff --git a/crates/tinycomputer-browser/src/sessions/sessions_tests/identity_tests.rs b/crates/tinycomputer-browser/src/sessions/sessions_tests/identity_tests.rs new file mode 100644 index 00000000..d9d32dc2 --- /dev/null +++ b/crates/tinycomputer-browser/src/sessions/sessions_tests/identity_tests.rs @@ -0,0 +1,147 @@ +//! Tests for the identity a session asks its browser to present: the launch +//! command carries it, and a real browser shows it to the page (gated on the +//! `agent-browser` feature and `TINYCOMPUTER_LIVE_BROWSER=1`, since CI has no +//! browser to launch). + +use tinycomputer_bus::browser::{Screen, SessionOptions, UserAgentBrand, UserAgentMetadata}; + +use crate::fake::Fake; +use crate::sessions::Browser; + +use super::scratch; + +fn tokyo() -> SessionOptions { + SessionOptions { + timezone: Some("Asia/Tokyo".to_owned()), + locale: Some("ja-JP".to_owned()), + accept_language: Some("ja-JP,ja".to_owned()), + user_agent: Some( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like \ + Gecko) Chrome/140.0.0.0 Safari/537.36" + .to_owned(), + ), + user_agent_metadata: Some(UserAgentMetadata { + brands: Some(vec![UserAgentBrand { + brand: "Chromium".to_owned(), + version: "140".to_owned(), + }]), + full_version_list: None, + full_version: Some("140.0.0.0".to_owned()), + platform: "macOS".to_owned(), + platform_version: "14.5.0".to_owned(), + architecture: "arm".to_owned(), + model: String::new(), + mobile: false, + bitness: Some("64".to_owned()), + wow64: None, + }), + screen: Some(Screen { + width: 1920, + height: 1080, + device_scale_factor: 2.0, + }), + init_scripts: vec!["window.fromInitScript = 'ran first';".to_owned()], + stealth: true, + ..SessionOptions::default() + } +} + +#[tokio::test] +async fn opening_a_session_hands_the_engine_every_identity_field_in_its_launch() { + let fake = Fake::new(); + let browser = Browser::with_scratch(std::sync::Arc::new(fake.clone()), scratch("identity")); + browser.open_session(tokyo()).await.unwrap(); + + let launch = fake.last("launch"); + assert_eq!(launch["timezone"], "Asia/Tokyo"); + assert_eq!(launch["locale"], "ja-JP"); + assert_eq!(launch["acceptLanguage"], "ja-JP,ja"); + assert_eq!(launch["userAgentMetadata"]["platform"], "macOS"); + assert_eq!(launch["screen"]["width"], 1920); + assert_eq!( + launch["initScriptSources"][0], + "window.fromInitScript = 'ran first';" + ); + assert_eq!(launch["stealth"], true); +} + +/// A page on `localhost`, served to the first few requests. +#[cfg(feature = "agent-browser")] +fn serve_a_page() -> String { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for stream in listener.incoming().take(8) { + let Ok(mut stream) = stream else { continue }; + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let body = "identity

identity

"; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + } + }); + format!("http://localhost:{port}/") +} + +#[cfg(feature = "agent-browser")] +#[tokio::test] +async fn live_a_page_sees_the_identity_its_session_was_opened_with() { + use std::sync::Arc; + + use serde_json::json; + + if std::env::var("TINYCOMPUTER_LIVE_BROWSER").as_deref() != Ok("1") { + return; + } + let browser = Browser::new(Arc::new(crate::AgentBrowser)); + let options = SessionOptions { + executable: std::env::var("TINYCOMPUTER_BROWSER_EXECUTABLE").ok(), + ..tokyo() + }; + let info = browser + .open_session(options) + .await + .expect("a browser launches when live runs are asked for"); + // Init scripts run in a document loaded after launch, not the blank tab, + // and client hints exist only in a secure context, which a page on + // localhost is and a `data:` page is not. + let url = serve_a_page(); + browser + .command(&info.id, json!({"action": "navigate", "url": url})) + .await + .expect("the page loads"); + let read = |expression: &'static str| { + let browser = &browser; + let id = &info.id; + async move { + browser + .command(id, json!({"action": "evaluate", "script": expression})) + .await + .expect("the expression runs")["result"] + .clone() + } + }; + + assert_eq!( + read("Intl.DateTimeFormat().resolvedOptions().timeZone").await, + "Asia/Tokyo" + ); + assert_eq!(read("navigator.language").await, "ja-JP"); + assert_eq!(read("navigator.languages.join(',')").await, "ja-JP,ja"); + assert_eq!(read("navigator.userAgentData.platform").await, "macOS"); + assert_eq!( + read("navigator.userAgentData.brands.some(b => b.brand === 'Chromium' && b.version === '140')") + .await, + true + ); + assert_eq!(read("screen.width").await, 1920); + assert_eq!(read("screen.height").await, 1080); + assert_eq!(read("window.devicePixelRatio").await, 2); + assert_eq!(read("window.fromInitScript").await, "ran first"); + browser.close_session(&info.id).await.unwrap(); +} diff --git a/crates/tinycomputer-bus/src/browser/mod.rs b/crates/tinycomputer-bus/src/browser/mod.rs index 974421e4..600adfe5 100644 --- a/crates/tinycomputer-bus/src/browser/mod.rs +++ b/crates/tinycomputer-bus/src/browser/mod.rs @@ -96,6 +96,9 @@ pub use page::{ EvaluateRequest, NavigateRequest, PageState, PageText, ReadFormat, ReadRequest, WaitUntil, }; pub use profile::{ClaimProfileRequest, ClaimProfileResponse, ProfileClaim, ReleaseProfileRequest}; -pub use session::{SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, Viewport}; +pub use session::{ + Screen, SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, UserAgentBrand, + UserAgentMetadata, Viewport, +}; pub use snapshot::{ElementRef, Snapshot, SnapshotRequest}; pub use tool::{ToolAction, ToolArgError, ToolArgs}; diff --git a/crates/tinycomputer-bus/src/browser/session/mod.rs b/crates/tinycomputer-bus/src/browser/session/mod.rs index 16bae046..2b8c15af 100644 --- a/crates/tinycomputer-bus/src/browser/session/mod.rs +++ b/crates/tinycomputer-bus/src/browser/session/mod.rs @@ -14,7 +14,10 @@ mod types; -pub use types::{SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, Viewport}; +pub use types::{ + Screen, SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, UserAgentBrand, + UserAgentMetadata, Viewport, +}; #[cfg(test)] mod session_tests; diff --git a/crates/tinycomputer-bus/src/browser/session/session_tests.rs b/crates/tinycomputer-bus/src/browser/session/session_tests.rs index 30cff8f7..85122f10 100644 --- a/crates/tinycomputer-bus/src/browser/session/session_tests.rs +++ b/crates/tinycomputer-bus/src/browser/session/session_tests.rs @@ -6,7 +6,10 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use super::{SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, Viewport}; +use super::{ + Screen, SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, UserAgentBrand, + UserAgentMetadata, Viewport, +}; use crate::browser::{Action, NavigateRequest, Target}; use serde_json::json; @@ -59,6 +62,7 @@ fn options_serialize_with_the_documented_field_names() { assert_eq!( keys, [ + "accept_language", "allowed_origins", "args", "default_timeout_ms", @@ -66,13 +70,121 @@ fn options_serialize_with_the_documented_field_names() { "endpoint", "executable", "headless", + "init_scripts", + "locale", + "screen", + "stealth", + "timezone", "user_agent", + "user_agent_metadata", "user_data_dir", "viewport", ] ); } +#[test] +fn a_host_that_predates_the_identity_fields_still_opens_a_session() { + // The wire form of `SessionOptions` before the identity fields: every + // field a host could send, and none of the new ones. + let old: SessionOptions = serde_json::from_value(json!({ + "endpoint": null, + "executable": "/usr/bin/chromium", + "headless": false, + "viewport": {"width": 800, "height": 600, "device_scale_factor": 2.0, "mobile": false}, + "user_agent": "agent", + "user_data_dir": "/profiles/trip", + "download_dir": "/downloads", + "args": ["--lang=en"], + "allowed_origins": ["https://example.com"], + "default_timeout_ms": 5000, + })) + .expect("an old client's options deserialize"); + + assert_eq!(old.timezone, None); + assert_eq!(old.locale, None); + assert_eq!(old.accept_language, None); + assert_eq!(old.user_agent_metadata, None); + assert_eq!(old.screen, None); + assert_eq!(old.init_scripts, Vec::::new()); + assert!(!old.stealth); + assert_eq!(old.user_agent.as_deref(), Some("agent")); +} + +#[test] +fn identity_fields_round_trip_in_snake_case() { + let options = SessionOptions { + timezone: Some("Asia/Tokyo".to_owned()), + locale: Some("ja-JP".to_owned()), + accept_language: Some("ja-JP,ja".to_owned()), + user_agent_metadata: Some(UserAgentMetadata { + brands: Some(vec![UserAgentBrand { + brand: "Chromium".to_owned(), + version: "140".to_owned(), + }]), + full_version_list: None, + full_version: Some("140.0.0.0".to_owned()), + platform: "macOS".to_owned(), + platform_version: "14.5.0".to_owned(), + architecture: "arm".to_owned(), + model: String::new(), + mobile: false, + bitness: Some("64".to_owned()), + wow64: None, + }), + screen: Some(Screen { + width: 1920, + height: 1080, + device_scale_factor: 2.0, + }), + init_scripts: vec!["window.marker = 1;".to_owned()], + stealth: true, + ..SessionOptions::default() + }; + + let encoded = serde_json::to_value(&options).expect("serializes"); + assert_eq!(encoded["timezone"], json!("Asia/Tokyo")); + assert_eq!(encoded["accept_language"], json!("ja-JP,ja")); + assert_eq!( + encoded["user_agent_metadata"], + json!({ + "brands": [{"brand": "Chromium", "version": "140"}], + "full_version": "140.0.0.0", + "platform": "macOS", + "platform_version": "14.5.0", + "architecture": "arm", + "model": "", + "mobile": false, + "bitness": "64", + }) + ); + assert_eq!( + encoded["screen"], + json!({"width": 1920, "height": 1080, "device_scale_factor": 2.0}) + ); + assert_eq!(encoded["init_scripts"], json!(["window.marker = 1;"])); + assert_eq!(encoded["stealth"], json!(true)); + assert_eq!( + serde_json::from_value::(encoded).expect("deserializes"), + options + ); +} + +#[test] +fn a_minimal_metadata_and_screen_fill_their_defaults() { + let options: SessionOptions = serde_json::from_value(json!({ + "user_agent_metadata": {"platform": "Windows"}, + "screen": {"width": 1366, "height": 768}, + })) + .expect("deserializes"); + + let metadata = options.user_agent_metadata.expect("metadata"); + assert_eq!(metadata.platform, "Windows"); + assert_eq!(metadata.platform_version, ""); + assert!(!metadata.mobile); + assert_eq!(options.screen.expect("screen").device_scale_factor, 1.0); +} + #[test] fn viewport_round_trips() { let viewport = Viewport { diff --git a/crates/tinycomputer-bus/src/browser/session/types.rs b/crates/tinycomputer-bus/src/browser/session/types.rs index aae61952..b4cece4c 100644 --- a/crates/tinycomputer-bus/src/browser/session/types.rs +++ b/crates/tinycomputer-bus/src/browser/session/types.rs @@ -101,6 +101,75 @@ impl Default for Viewport { } } +/// One entry of a client-hint brand list. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserAgentBrand { + /// The brand, such as `Chromium`. + pub brand: String, + /// Its version, major only in `brands` and full in `full_version_list`. + pub version: String, +} + +/// The client hints a browser reports beside its `User-Agent`: the +/// `sec-ch-ua*` request headers and `navigator.userAgentData`. +/// +/// A browser whose user agent is overridden without these drops them, which a +/// site can tell from a real browser of the same user agent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserAgentMetadata { + /// Brands for `sec-ch-ua` and `navigator.userAgentData.brands`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brands: Option>, + /// Brands with their full versions, for `sec-ch-ua-full-version-list`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub full_version_list: Option>, + /// The browser's full version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub full_version: Option, + /// The operating system, such as `macOS`, `Windows` or `Linux`. + pub platform: String, + /// The operating system's version. + #[serde(default)] + pub platform_version: String, + /// The CPU architecture, such as `x86` or `arm`. + #[serde(default)] + pub architecture: String, + /// The device model; empty on a desktop. + #[serde(default)] + pub model: String, + /// Whether the browser reports a mobile device. + #[serde(default)] + pub mobile: bool, + /// The CPU's word size, such as `64`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bitness: Option, + /// Whether a 32-bit browser runs on 64-bit Windows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wow64: Option, +} + +/// What `screen.width`, `screen.height` and `devicePixelRatio` report. +/// +/// The page's [`Viewport`] keeps its own size: this is the monitor behind it. +/// A session that sets a screen takes its pixel ratio for the page too, in +/// place of the viewport's. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Screen { + /// Width in CSS pixels. + pub width: u32, + /// Height in CSS pixels. + pub height: u32, + /// Device pixel ratio. Defaults to `1.0`. + #[serde(default = "Screen::default_scale")] + pub device_scale_factor: f64, +} + +impl Screen { + fn default_scale() -> f64 { + 1.0 + } +} + /// How to obtain the browser a session drives. /// /// Every field is optional with a documented default, so the common case — @@ -156,6 +225,32 @@ pub struct SessionOptions { /// Default deadline in milliseconds for operations that do not carry their /// own. Defaults to 30 seconds. pub default_timeout_ms: u64, + /// IANA timezone the page reports, such as `Asia/Tokyo`. Defaults to the + /// host's own. + /// + /// Applies to a browser the module launches, not one it attaches to. + pub timezone: Option, + /// BCP 47 locale the page reports, such as `ja-JP`. Defaults to the + /// browser's own. Applies to a launched browser only. + pub locale: Option, + /// Languages for the `Accept-Language` request header, comma-separated + /// without weights (`ja-JP,ja`). The header does not follow `locale`, so + /// a host that sets one usually sets both. Applies to a launched browser + /// only. + pub accept_language: Option, + /// Client hints to go with `user_agent`. Applies to a launched browser + /// only. + pub user_agent_metadata: Option, + /// What the page sees as its screen. Applies to a launched browser only. + pub screen: Option, + /// Source code run in every page, and every frame, before the page's own + /// scripts. Applies to a launched browser only. + pub init_scripts: Vec, + /// Keep the browser from announcing that an automation tool drives it: + /// the protocol call that does, `Runtime.enable`, is never made. A + /// session that asks for it gets no console or page-error capture. + /// Defaults to `false`. Applies to a launched browser only. + pub stealth: bool, } impl Default for SessionOptions { @@ -171,6 +266,13 @@ impl Default for SessionOptions { args: Vec::new(), allowed_origins: Vec::new(), default_timeout_ms: 30_000, + timezone: None, + locale: None, + accept_language: None, + user_agent_metadata: None, + screen: None, + init_scripts: Vec::new(), + stealth: false, } } } diff --git a/crates/tinycomputer-bus/src/version/mod.rs b/crates/tinycomputer-bus/src/version/mod.rs index ad675d3e..df0cfe38 100644 --- a/crates/tinycomputer-bus/src/version/mod.rs +++ b/crates/tinycomputer-bus/src/version/mod.rs @@ -8,7 +8,7 @@ //! release workflow bumps, which tracks the shipped artifact. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (2, 19); +pub const CONTRACT_VERSION: (u32, u32) = (2, 20); /// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module /// reporting `module`. @@ -22,7 +22,7 @@ pub const CONTRACT_VERSION: (u32, u32) = (2, 19); /// ``` /// # use tinycomputer_bus::{is_compatible, CONTRACT_VERSION}; /// assert!(is_compatible(CONTRACT_VERSION)); -/// assert!(is_compatible((2, 19))); +/// assert!(is_compatible((2, 20))); /// assert!(!is_compatible((2, 11))); /// assert!(!is_compatible((1, 8))); /// ``` diff --git a/crates/tinycomputer-bus/src/version/version_tests.rs b/crates/tinycomputer-bus/src/version/version_tests.rs index 1be61cc7..f3d7b52b 100644 --- a/crates/tinycomputer-bus/src/version/version_tests.rs +++ b/crates/tinycomputer-bus/src/version/version_tests.rs @@ -6,7 +6,7 @@ use super::{CONTRACT_VERSION, binds, is_compatible}; #[test] fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (2, 19)); + assert_eq!(CONTRACT_VERSION, (2, 20)); } #[test] @@ -16,7 +16,7 @@ fn the_contract_binds_to_itself() { #[test] fn a_newer_minor_on_the_module_side_binds() { - assert!(is_compatible((2, 19))); + assert!(is_compatible((2, 20))); assert!(is_compatible((2, 97))); // 2.12 adds replayable GlobeRead and joined terminal GlobeShutdown. // A 2.11 module does not implement those members. diff --git a/crates/tinycomputer/tests/public_api_tests.rs b/crates/tinycomputer/tests/public_api_tests.rs index 5856950e..e13461a8 100644 --- a/crates/tinycomputer/tests/public_api_tests.rs +++ b/crates/tinycomputer/tests/public_api_tests.rs @@ -34,7 +34,7 @@ fn the_crate_re_exports_the_contract_rather_than_redefining_it() { #[test] fn the_shipped_contract_binds_to_itself() { assert!(is_compatible(CONTRACT_VERSION)); - assert_eq!(CONTRACT_VERSION, (2, 19)); + assert_eq!(CONTRACT_VERSION, (2, 20)); } #[test] diff --git a/docs/crates/tinycomputer-bus/browser.md b/docs/crates/tinycomputer-bus/browser.md index 2d14e49c..c158c20e 100644 --- a/docs/crates/tinycomputer-bus/browser.md +++ b/docs/crates/tinycomputer-bus/browser.md @@ -68,6 +68,17 @@ field has a documented default so the common case is | `download_dir` | created by the module, never removed | must be an absolute path | | `allowed_origins` | none (any origin) | pages only: a navigation outside the list is refused before any request is made, and a page a session is taken to outside it is left and reported; `*` admits any public host; the files a page loads are never checked | | `default_timeout_ms` | `30_000` | for operations that do not carry their own deadline | +| `timezone` | browser's own | IANA timezone the page reports (`Asia/Tokyo`) | +| `locale` | browser's own | BCP 47 locale the page reports (`ja-JP`) | +| `accept_language` | browser's own | the `Accept-Language` header (`ja-JP,ja`), which does not follow `locale` | +| `user_agent_metadata` | none | client hints (`sec-ch-ua*`, `navigator.userAgentData`) to go with `user_agent`: without them a browser with an overridden user agent sends none | +| `screen` | browser's own | what `screen.width`, `screen.height` and `devicePixelRatio` report; its `device_scale_factor` replaces the viewport's | +| `init_scripts` | none | source run in every page and frame before the page's own scripts | +| `stealth` | `false` | never makes the protocol call (`Runtime.enable`) that tells a page a tool is attached; a stealth session captures no console output or page errors | + +The identity fields (`timezone` to `stealth`) are additive: a client that does +not send them gets the defaults above. They apply to a browser the module +launches, not to one it attaches to with `endpoint`. `SessionInfo` is what `OpenSession` and `ListSessions` report back: `id`, the actual `ws://` `endpoint` the module is driving (so an operator can diff --git a/docs/crates/tinycomputer-bus/versioning.md b/docs/crates/tinycomputer-bus/versioning.md index c6d9ede8..b8229e67 100644 --- a/docs/crates/tinycomputer-bus/versioning.md +++ b/docs/crates/tinycomputer-bus/versioning.md @@ -9,7 +9,7 @@ code, so this page exists mostly to keep them apart. ## `CONTRACT_VERSION`: does this host understand this module ```rust,ignore -pub const CONTRACT_VERSION: (u32, u32) = (2, 19); +pub const CONTRACT_VERSION: (u32, u32) = (2, 20); ``` This describes the *vocabulary*: the member set and the payload shapes, @@ -45,15 +45,15 @@ Two conditions, both necessary: relies on changed shape or disappeared, so a mismatched major is refused outright, in either direction. 2. **The module must be at least as new, minor-wise, as the host.** A host - built against `(2, 19)` expects at least the members and fields that - existed at `(2, 19)`. A module reporting `(2, 11)` might be missing one of - them, so it is rejected. A module reporting `(2, 19)` has everything the + built against `(2, 20)` expects at least the members and fields that + existed at `(2, 20)`. A module reporting `(2, 11)` might be missing one of + them, so it is rejected. A module reporting `(2, 20)` has everything the host expects, plus something newer the host simply does not use yet, so it is accepted. ```rust,ignore -assert!(is_compatible(CONTRACT_VERSION)); // (2, 19), the exact version this crate ships -assert!(is_compatible((2, 19))); // a newer, still-compatible module +assert!(is_compatible(CONTRACT_VERSION)); // (2, 20), the exact version this crate ships +assert!(is_compatible((2, 20))); // a newer, still-compatible module assert!(!is_compatible((2, 11))); // an older module, missing something assert!(!is_compatible((1, 10))); // a different major, all bets off ``` diff --git a/docs/technical/specs/desktop-module-contract.md b/docs/technical/specs/desktop-module-contract.md index df9365af..41774150 100644 --- a/docs/technical/specs/desktop-module-contract.md +++ b/docs/technical/specs/desktop-module-contract.md @@ -88,6 +88,11 @@ classification (`irreversible` or `payment`). The module calls it from serves the interface and fails to answer, turns the approval into a decline, so the task never takes the action. A host that serves nothing leaves the pause as the only gate. A payment checkpoint is never continuable and needs no review. + +Session identity (2.20): `SessionOptions` takes the optional `timezone`, +`locale`, `accept_language`, `user_agent_metadata`, `screen`, `init_scripts` and +`stealth`. A client that sends none gets the defaults; a 2.19 module ignores +them. They apply to a browser the module launches, not one it attaches to. - `TaskReport` takes a `TaskReportRequest` (2.7), `{"id", "trace"}` with `trace` always serialized, rather than a `TaskRef`. A TinyBus client refuses a confidential body holding an object whose only field is a string diff --git a/vendor/agent-browser b/vendor/agent-browser index ebf10fb3..f98d5fef 160000 --- a/vendor/agent-browser +++ b/vendor/agent-browser @@ -1 +1 @@ -Subproject commit ebf10fb3685d3b8ec3728e681d7d53eb7186a5a7 +Subproject commit f98d5fef6a57b6067e6c5710e6f0ea6fb0b3bd47