diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9d36b20..9971e099 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -371,6 +371,8 @@ jobs: - uses: actions/checkout@v5 with: repository: pathscale/ps-observability + # Protocol/driver from PR #21, validated with this engine candidate. + ref: d4f6d70fec8cb0673c4a73c448983aff336ee84c path: .ps-observability # The host moved. `qa-inspect-host` lived in ps-observability and was # deleted there in "refactor: delete qa-inspect-host, so there is one @@ -378,7 +380,15 @@ jobs: - uses: actions/checkout@v5 with: repository: pathscale/chuzz + # Shared document actions and headless build gate from PR #45. + ref: 4ad2d73e5179c6568f3bd7f8f55594e8957c01ef path: .chuzz + - uses: actions/checkout@v5 + with: + repository: pathscale/tauri-runtime-blitz + # Resolve the host workspace before the 0.4.0 registry release. + ref: 5b392a6f0a919fc9e664c4b505c8f661e29f0c5a + path: .tauri-runtime-blitz # ps-qa from the ps-observability checkout, rather than `cargo install # ps-qa`. The harness and the fixtures move together, and installing it # while building the host would make this job depend on a release of the @@ -388,5 +398,6 @@ jobs: env: PS_OBSERVABILITY: .ps-observability CHUZZ: .chuzz + TAURI_RUNTIME_BLITZ: .tauri-runtime-blitz QA_BUILD_PS_QA: "1" run: tests/qa/run.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e1ca9f07..5de0166f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -104,8 +104,13 @@ jobs: # # The job reported success throughout, because it published exactly # the list it was given. + # `ps-blitz-debug-control` sits before `ps-blitz-script`, which takes + # it behind `debug-control`. It carries its own 0.3.x version rather + # than the engine's, so on an ordinary engine release the version + # check below finds it already on crates.io and skips it. crates='ps-blitz-traits ps-stylo-taffy ps-blitz-dom ps-blitz-platform-api ps-blitz-dom-api ps-blitz-html ps-blitz-net ps-blitz-paint + ps-blitz-debug-control ps-blitz-script ps-blitz-shell ps-blitz-wasm ps-blitz ps-dioxus-native-dom ps-dioxus-native' diff --git a/Cargo.toml b/Cargo.toml index 498ff402..a09d6f46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "packages/blitz-traits", "packages/blitz-dom", "packages/blitz-dom-api", + "packages/blitz-debug-control", "packages/blitz-platform-api", "packages/dom-abi", "packages/blitz-wasm", @@ -35,11 +36,13 @@ members = [ # for wasm32-unknown-unknown and has its own target directory, so # `cargo test -p blitz-wasm` can shell out to build it without deadlocking on # this workspace's own target lock. See packages/blitz-wasm/ABI.md. -exclude = ["sites", "packages/blitz-wasm/guest"] +# CI checks the coordinated runtime stack out below this repository. Keep those +# independent workspaces out of Cargo's automatic path-dependency membership. +exclude = ["sites", "packages/blitz-wasm/guest", ".ps-observability", ".chuzz", ".tauri-runtime-blitz"] resolver = "2" [workspace.package] -version = "0.4.7" +version = "0.4.8" license = "MIT OR Apache-2.0" homepage = "https://github.com/pathscale/ps-blitz" repository = "https://github.com/pathscale/ps-blitz" @@ -105,7 +108,7 @@ boa_engine = { package = "ps-boa-engine", version = "^1.0.3" } boa_runtime = { package = "ps-boa-runtime", version = "^1.0.3" } boa_gc = { package = "ps-boa-gc", version = "^1.0.3" } base64 = "^0.22" -blitz-debug-control = { package = "ps-blitz-debug-control", version = "^0.3.5" } +blitz-debug-control = { package = "ps-blitz-debug-control", version = "^0.3.8", path = "./packages/blitz-debug-control" } getrandom = "^0.4" serde_json = "^1" cssparser = { version = "^0.37.0" } diff --git a/packages/blitz-debug-control/Cargo.toml b/packages/blitz-debug-control/Cargo.toml new file mode 100644 index 00000000..ea33d2e6 --- /dev/null +++ b/packages/blitz-debug-control/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "ps-blitz-debug-control" +description = "Debug-only WebDriver-style control transport for Blitz" +documentation = "https://docs.rs/ps-blitz-debug-control" +readme = "README.md" +# Its own line rather than the workspace version. This crate versions the +# loopback debug transport, which changes on its own schedule and is nowhere +# near the engine's 0.4 line; inheriting would republish it on every engine +# release and claim a compatibility break it did not make. +version = "0.3.8" +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish = true +keywords = ["debugging", "testing", "blitz", "webdriver", "ui"] +categories = ["development-tools::debugging", "development-tools::testing"] +edition.workspace = true +rust-version.workspace = true + +[lib] +name = "blitz_debug_control" + +[dependencies] +getrandom.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true diff --git a/packages/blitz-debug-control/LICENSE-APACHE b/packages/blitz-debug-control/LICENSE-APACHE new file mode 100644 index 00000000..1b5ec8b7 --- /dev/null +++ b/packages/blitz-debug-control/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/packages/blitz-debug-control/LICENSE-MIT b/packages/blitz-debug-control/LICENSE-MIT new file mode 100644 index 00000000..31aa7938 --- /dev/null +++ b/packages/blitz-debug-control/LICENSE-MIT @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/packages/blitz-debug-control/README.md b/packages/blitz-debug-control/README.md new file mode 100644 index 00000000..7017ccd2 --- /dev/null +++ b/packages/blitz-debug-control/README.md @@ -0,0 +1,13 @@ +# ps-blitz-debug-control + +A debug-only, loopback HTTP control plane for Blitz renderer embedders. + +It follows the useful WebDriver shape: `/status`, authenticated session +creation, session-scoped commands, and WebDriver error bodies. It is not a +complete WebDriver implementation. Requests require `Content-Length`, connections close +after one response, and Blitz commands are forwarded to the renderer thread. + +Enabling the server gives the same OS user debugger-level control, including +arbitrary document JavaScript when the embedder exposes that command. Use it +only in diagnostics builds. Discovery tokens are random, descriptor files are +owner-only on Unix, and the listener rejects non-loopback addresses. diff --git a/packages/blitz-debug-control/src/lib.rs b/packages/blitz-debug-control/src/lib.rs new file mode 100644 index 00000000..a96a71f2 --- /dev/null +++ b/packages/blitz-debug-control/src/lib.rs @@ -0,0 +1,974 @@ +//! Debug-only, loopback WebDriver transport for Blitz renderers. +//! +//! The server owns networking and session authentication. Renderer commands +//! cross a serialized channel and must be executed by the UI/runtime thread. +//! +//! # Security boundary +//! +//! Enabling this server grants the holder of its per-process discovery token +//! debugger-level control, including arbitrary JavaScript execution in the +//! document. It deliberately has the same trust posture as a browser remote +//! debugging port: loopback-only transport, an unpredictable token, and a +//! descriptor created with owner-only permissions on Unix. It is not a sandbox +//! or an authorization boundary against another process running as the same +//! OS user and able to read that user's files. Production builds should leave +//! the feature and its environment variables disabled. + +use std::fs::{self, OpenOptions}; +use std::io::{self, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError}; +use std::sync::{Arc, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use serde_json::{Value, json}; + +const PROTOCOL_VERSION: u32 = 1; +const MAX_REQUEST_BYTES: usize = 1024 * 1024; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_PENDING_COMMANDS: usize = 64; +const MAX_CONNECTIONS: usize = 64; + +#[derive(Default)] +struct ActiveSession(AtomicU64); + +impl ActiveSession { + fn create(&self) -> io::Result> { + let value = loop { + let mut bytes = [0_u8; 8]; + getrandom::fill(&mut bytes).map_err(io::Error::other)?; + let value = u64::from_ne_bytes(bytes); + if value != 0 { + break value; + } + }; + match self + .0 + .compare_exchange(0, value, Ordering::AcqRel, Ordering::Acquire) + { + Ok(_) => Ok(Some(format!("{value:016x}"))), + Err(_) => Ok(None), + } + } + + fn matches(&self, supplied: &str) -> bool { + u64::from_str_radix(supplied, 16) + .ok() + .is_some_and(|value| value != 0 && self.0.load(Ordering::Acquire) == value) + } + + fn clear(&self) { + self.0.store(0, Ordering::Release); + } +} + +/// Configuration for a loopback debug-control server. +#[derive(Debug, Clone)] +pub struct ServerConfig { + /// Loopback address to bind. Port zero asks the OS to choose a free port. + pub bind_address: SocketAddr, + /// Atomically written once the server is accepting connections. + pub descriptor_path: PathBuf, + /// Git revision or build identifier for the renderer. + pub renderer_revision: String, +} + +/// Wakes whichever thread services requests, so it does not have to poll for +/// them. +/// +/// The server cannot know how to wake its embedder, and the embedder's event +/// loop does not exist yet when the server starts, so the callback is installed +/// later. A `OnceLock` rather than a lock because it is written exactly once +/// and read on the path of every request. +/// +/// Without one installed the embedder must poll, which is what the Blitz shell +/// used to do on a 10ms timer: 100 wakeups a second while idle, up to 10ms of +/// latency on every command, and enough of both to show up in any measurement +/// taken with the driver attached. +#[derive(Clone, Default)] +pub struct ServiceWaker(Arc>>); + +impl ServiceWaker { + /// Install the wake callback. Later calls are ignored. + pub fn set(&self, wake: impl Fn() + Send + Sync + 'static) { + let _ = self.0.set(Box::new(wake)); + } + + fn wake(&self) { + if let Some(wake) = self.0.get() { + wake(); + } + } +} + +impl std::fmt::Debug for ServiceWaker { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ServiceWaker") + .field("installed", &self.0.get().is_some()) + .finish() + } +} + +/// A command forwarded from the HTTP server to the renderer thread. +#[derive(Debug)] +pub struct ControlRequest { + pub method: String, + pub path: String, + pub body: Value, + reply: SyncSender, +} + +impl ControlRequest { + /// Complete this request. Failure means the client already disconnected. + pub fn respond(self, response: ControlResponse) -> Result<(), ControlResponse> { + self.reply.send(response).map_err(|error| error.0) + } +} + +/// A renderer response represented using W3C WebDriver success/error values. +#[derive(Debug)] +pub enum ControlResponse { + Success(Value), + Error { + error: String, + message: String, + stacktrace: String, + }, +} + +impl ControlResponse { + pub fn unsupported(message: impl Into) -> Self { + Self::Error { + error: "unsupported operation".into(), + message: message.into(), + stacktrace: String::new(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Descriptor<'a> { + pid: u32, + address: String, + token: &'a str, + protocol_version: u32, + renderer: &'static str, + renderer_revision: &'a str, +} + +/// Running server. Dropping it shuts down the listener and removes discovery. +pub struct DebugServer { + address: SocketAddr, + token: String, + descriptor_path: PathBuf, + shutdown: Arc, + waker: ServiceWaker, + thread: Option>, +} + +impl DebugServer { + /// Bind a loopback port, write the descriptor, and start the server thread. + pub fn start(config: ServerConfig) -> io::Result<(Self, Receiver)> { + if !config.bind_address.ip().is_loopback() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "debug control must bind to a loopback address", + )); + } + let listener = TcpListener::bind(config.bind_address)?; + let address = listener.local_addr()?; + let token = random_hex(32)?; + // Enough room for a driver burst, but never an unbounded owner of parsed + // request bodies while the renderer is unavailable. + let (command_tx, command_rx) = mpsc::sync_channel(MAX_PENDING_COMMANDS); + let shutdown = Arc::new(AtomicBool::new(false)); + let waker = ServiceWaker::default(); + + let thread_shutdown = Arc::clone(&shutdown); + let thread_token = token.clone(); + let thread_waker = waker.clone(); + let thread = thread::Builder::new() + .name("blitz-debug-control".into()) + .spawn(move || { + server_loop( + listener, + &thread_token, + command_tx, + &thread_waker, + thread_shutdown, + ) + })?; + + if let Err(error) = write_descriptor(&config, address, &token) { + shutdown.store(true, Ordering::Release); + let _ = TcpStream::connect(address); + let _ = thread.join(); + return Err(error); + } + + Ok(( + Self { + address, + token, + descriptor_path: config.descriptor_path, + shutdown, + waker, + thread: Some(thread), + }, + command_rx, + )) + } + + /// Handle for telling the server how to wake the thread that services + /// requests. Nothing wakes until a callback is installed. + pub fn waker(&self) -> ServiceWaker { + self.waker.clone() + } + + pub fn address(&self) -> SocketAddr { + self.address + } + + pub fn token(&self) -> &str { + &self.token + } + + pub fn shutdown(mut self) { + self.stop(); + } + + fn stop(&mut self) { + self.shutdown.store(true, Ordering::Release); + let _ = TcpStream::connect(self.address); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + let _ = fs::remove_file(&self.descriptor_path); + } +} + +impl Drop for DebugServer { + fn drop(&mut self) { + self.stop(); + } +} + +fn random_hex(byte_len: usize) -> io::Result { + let mut bytes = vec![0; byte_len]; + getrandom::fill(&mut bytes).map_err(io::Error::other)?; + let mut output = String::with_capacity(byte_len * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(output, "{byte:02x}").unwrap(); + } + Ok(output) +} + +fn write_descriptor(config: &ServerConfig, address: SocketAddr, token: &str) -> io::Result<()> { + let descriptor = Descriptor { + pid: std::process::id(), + address: address.to_string(), + token, + protocol_version: PROTOCOL_VERSION, + renderer: "blitz", + renderer_revision: &config.renderer_revision, + }; + let bytes = serde_json::to_vec_pretty(&descriptor).map_err(io::Error::other)?; + if let Some(parent) = config.descriptor_path.parent() { + fs::create_dir_all(parent)?; + } + let temporary = config + .descriptor_path + .with_extension(format!("tmp-{}", random_hex(8)?)); + let result = (|| { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temporary)?; + file.write_all(&bytes)?; + file.sync_all()?; + fs::rename(&temporary, &config.descriptor_path) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +fn server_loop( + listener: TcpListener, + token: &str, + command_tx: SyncSender, + waker: &ServiceWaker, + shutdown: Arc, +) { + let active_session = Arc::new(ActiveSession::default()); + let active_connections = Arc::new(AtomicUsize::new(0)); + let token: Arc = Arc::from(token); + for connection in listener.incoming() { + if shutdown.load(Ordering::Acquire) { + break; + } + match connection { + Ok(mut stream) => { + if active_connections.fetch_add(1, Ordering::AcqRel) >= MAX_CONNECTIONS { + active_connections.fetch_sub(1, Ordering::AcqRel); + let _ = write_response( + &mut stream, + webdriver_error("unknown error", "debug-control connection limit reached"), + ); + continue; + } + let token = Arc::clone(&token); + let active_session = Arc::clone(&active_session); + let thread_connections = Arc::clone(&active_connections); + let command_tx = command_tx.clone(); + let waker = waker.clone(); + let spawned = thread::Builder::new() + .name("blitz-debug-connection".into()) + .spawn(move || { + let _permit = ConnectionPermit(thread_connections); + let _ = stream.set_write_timeout(Some(COMMAND_TIMEOUT)); + let response = match read_request(&mut stream) { + Ok(request) => { + route(request, &token, &active_session, &command_tx, &waker) + } + Err(error) => webdriver_error("invalid argument", error.to_string()), + }; + let _ = write_response(&mut stream, response); + }); + if spawned.is_err() { + active_connections.fetch_sub(1, Ordering::AcqRel); + } + } + Err(_) if shutdown.load(Ordering::Acquire) => break, + Err(_) => continue, + } + } +} + +struct ConnectionPermit(Arc); + +impl Drop for ConnectionPermit { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +#[derive(Debug)] +struct HttpRequest { + method: String, + path: String, + body: Value, +} + +fn read_request(stream: &mut TcpStream) -> io::Result { + read_request_within(stream, COMMAND_TIMEOUT) +} + +fn read_request_within(stream: &mut TcpStream, within: Duration) -> io::Result { + let deadline = Instant::now() + within; + let mut bytes = Vec::with_capacity(4096); + let mut scan_from = 0; + let header_end = loop { + if bytes.len() >= MAX_REQUEST_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "request is too large", + )); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new(io::ErrorKind::TimedOut, "request timed out")); + } + stream.set_read_timeout(Some(remaining))?; + let mut chunk = [0; 4096]; + let allowed = (MAX_REQUEST_BYTES - bytes.len()).min(chunk.len()); + let count = stream.read(&mut chunk[..allowed])?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "connection closed before headers", + )); + } + let prior_len = bytes.len(); + bytes.extend_from_slice(&chunk[..count]); + scan_from = scan_from.min(prior_len.saturating_sub(3)); + if let Some(index) = bytes[scan_from..] + .windows(4) + .position(|window| window == b"\r\n\r\n") + { + break scan_from + index + 4; + } + scan_from = bytes.len().saturating_sub(3); + }; + + let headers = std::str::from_utf8(&bytes[..header_end]) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let mut lines = headers.split("\r\n"); + let mut request_line = lines.next().unwrap_or_default().split_whitespace(); + let method = request_line.next().unwrap_or_default().to_string(); + let path = request_line.next().unwrap_or_default().to_string(); + if method.is_empty() || path.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid request line", + )); + } + let header_fields: Vec<_> = lines.filter_map(|line| line.split_once(':')).collect(); + let content_length = header_fields + .iter() + .copied() + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().parse::()) + .transpose() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))? + .unwrap_or(0); + if header_fields.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("transfer-encoding") + && !value.trim().eq_ignore_ascii_case("identity") + }) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "transfer-encoding is unsupported; send Content-Length", + )); + } + let body_end = header_end + .checked_add(content_length) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "request size overflow"))?; + if body_end > MAX_REQUEST_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "request is too large", + )); + } + while bytes.len() < body_end { + let deadline_remaining = deadline.saturating_duration_since(Instant::now()); + if deadline_remaining.is_zero() { + return Err(io::Error::new(io::ErrorKind::TimedOut, "request timed out")); + } + stream.set_read_timeout(Some(deadline_remaining))?; + let remaining = body_end - bytes.len(); + let mut chunk = vec![0; remaining.min(4096)]; + let count = stream.read(&mut chunk)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "connection closed before body", + )); + } + bytes.extend_from_slice(&chunk[..count]); + } + let body = if content_length == 0 { + Value::Null + } else { + serde_json::from_slice(&bytes[header_end..body_end]) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))? + }; + Ok(HttpRequest { method, path, body }) +} + +fn route( + request: HttpRequest, + token: &str, + active_session: &ActiveSession, + command_tx: &SyncSender, + waker: &ServiceWaker, +) -> Value { + if request.method == "GET" && request.path == "/status" { + return json!({"value": { + "ready": true, + "message": "Blitz debug control is ready", + "protocolVersion": PROTOCOL_VERSION, + }}); + } + + if request.method == "POST" && request.path == "/session" { + let supplied_token = request + .body + .pointer("/capabilities/alwaysMatch/blitz:token") + .and_then(Value::as_str); + if !token_matches(supplied_token, token) { + return webdriver_error("invalid argument", "invalid blitz:token capability"); + } + let session_id = match active_session.create() { + Ok(Some(value)) => value, + Ok(None) => { + return webdriver_error("session not created", "only one session is supported"); + } + Err(error) => return webdriver_error("unknown error", error.to_string()), + }; + return json!({"value": { + "sessionId": session_id, + "capabilities": { + "browserName": "blitz", + "blitz:protocolVersion": PROTOCOL_VERSION, + } + }}); + } + + let Some((session_id, command_path)) = session_path(&request.path) else { + return webdriver_error("unknown command", "unknown debug-control route"); + }; + if !active_session.matches(session_id) { + return webdriver_error("invalid session id", "session is not active"); + } + if request.method == "DELETE" && command_path.is_empty() { + active_session.clear(); + return json!({"value": null}); + } + + let (reply_tx, reply_rx) = mpsc::sync_channel(1); + let control_request = ControlRequest { + method: request.method, + path: command_path.to_string(), + body: request.body, + reply: reply_tx, + }; + match command_tx.try_send(control_request) { + Ok(()) => {} + Err(TrySendError::Full(_)) => { + return webdriver_error("unknown error", "renderer command queue is full"); + } + Err(TrySendError::Disconnected(_)) => { + return webdriver_error("unknown error", "renderer command channel is closed"); + } + } + // Queue first, then wake: the embedder must find the request already there + // when it comes round, or the wake is spent on an empty queue. + waker.wake(); + match reply_rx.recv_timeout(COMMAND_TIMEOUT) { + Ok(ControlResponse::Success(value)) => json!({"value": value}), + Ok(ControlResponse::Error { + error, + message, + stacktrace, + }) => json!({"value": { + "error": error, + "message": message, + "stacktrace": stacktrace, + }}), + Err(RecvTimeoutError::Timeout) => webdriver_error("timeout", "renderer command timed out"), + Err(RecvTimeoutError::Disconnected) => { + webdriver_error("unknown error", "renderer response channel is closed") + } + } +} + +/// Compare an attacker-controlled capability with the fixed-size discovery +/// token without leaking how many prefix bytes matched. +fn token_matches(supplied: Option<&str>, expected: &str) -> bool { + let supplied = supplied.unwrap_or_default().as_bytes(); + let expected = expected.as_bytes(); + let mut difference = supplied.len() ^ expected.len(); + for (index, expected_byte) in expected.iter().enumerate() { + difference |= usize::from(*expected_byte ^ supplied.get(index).copied().unwrap_or(0)); + } + difference == 0 +} + +fn session_path(path: &str) -> Option<(&str, &str)> { + let remainder = path.strip_prefix("/session/")?; + let (session_id, command) = remainder.split_once('/').unwrap_or((remainder, "")); + Some((session_id, command)) +} + +fn webdriver_error(error: &str, message: impl Into) -> Value { + json!({"value": { + "error": error, + "message": message.into(), + "stacktrace": "", + }}) +} + +fn write_response(stream: &mut TcpStream, body: Value) -> io::Result<()> { + let bytes = serde_json::to_vec(&body).map_err(io::Error::other)?; + let status = match body.pointer("/value/error").and_then(Value::as_str) { + None => "200 OK", + Some("unknown command" | "invalid session id") => "404 Not Found", + Some("timeout" | "unknown error") => "500 Internal Server Error", + Some(_) => "400 Bad Request", + }; + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + bytes.len() + )?; + stream.write_all(&bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn token_comparison_rejects_prefixes_suffixes_and_missing_values() { + assert!(token_matches(Some("012345"), "012345")); + assert!(!token_matches(Some("01234x"), "012345")); + assert!(!token_matches(Some("0123456"), "012345")); + assert!(!token_matches(Some("01234"), "012345")); + assert!(!token_matches(None, "012345")); + } + + fn descriptor_path() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("blitz-debug-{nonce}.json")) + } + + fn request(address: SocketAddr, method: &str, path: &str, body: Value) -> Value { + let body = if body.is_null() { + Vec::new() + } else { + serde_json::to_vec(&body).unwrap() + }; + let mut stream = TcpStream::connect(address).unwrap(); + write!( + stream, + "{method} {path} HTTP/1.1\r\nHost: {address}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .unwrap(); + stream.write_all(&body).unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + let body_start = response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap() + + 4; + serde_json::from_slice(&response[body_start..]).unwrap() + } + + fn raw_request(address: SocketAddr, request: &str) -> Value { + let mut stream = TcpStream::connect(address).unwrap(); + stream.write_all(request.as_bytes()).unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + let body_start = response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap() + + 4; + serde_json::from_slice(&response[body_start..]).unwrap() + } + + fn create_session(address: SocketAddr, token: &str) -> String { + request( + address, + "POST", + "/session", + json!({"capabilities": {"alwaysMatch": {"blitz:token": token}}}), + )["value"]["sessionId"] + .as_str() + .unwrap() + .to_string() + } + + #[test] + fn status_auth_session_command_and_reconnect() { + let descriptor = descriptor_path(); + let (server, commands) = DebugServer::start(ServerConfig { + bind_address: (std::net::Ipv4Addr::LOCALHOST, 0).into(), + descriptor_path: descriptor.clone(), + renderer_revision: "test-revision".into(), + }) + .unwrap(); + + let status = request(server.address(), "GET", "/status", Value::Null); + assert_eq!(status["value"]["ready"], true); + assert!(descriptor.exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&descriptor).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + let rejected = request( + server.address(), + "POST", + "/session", + json!({"capabilities": {"alwaysMatch": {"blitz:token": "wrong"}}}), + ); + assert_eq!(rejected["value"]["error"], "invalid argument"); + + let session = create_session(server.address(), server.token()); + let duplicate = request( + server.address(), + "POST", + "/session", + json!({"capabilities": {"alwaysMatch": {"blitz:token": server.token()}}}), + ); + assert_eq!(duplicate["value"]["error"], "session not created"); + assert_eq!( + request( + server.address(), + "GET", + "/session/not-the-session/blitz/getDomSnapshot", + Value::Null, + )["value"]["error"], + "invalid session id" + ); + assert_eq!( + request(server.address(), "GET", "/not-a-route", Value::Null)["value"]["error"], + "unknown command" + ); + let address = server.address(); + let command_path = format!("/session/{session}/blitz/getDomSnapshot"); + let client = thread::spawn(move || request(address, "GET", &command_path, Value::Null)); + let command = commands.recv_timeout(COMMAND_TIMEOUT).unwrap(); + assert_eq!(command.method, "GET"); + assert_eq!(command.path, "blitz/getDomSnapshot"); + command + .respond(ControlResponse::Success(json!({"documentRevision": 7}))) + .unwrap(); + let response = client.join().unwrap(); + assert_eq!(response["value"]["documentRevision"], 7); + + let deleted = request( + server.address(), + "DELETE", + &format!("/session/{session}"), + Value::Null, + ); + assert!(deleted["value"].is_null()); + let second_session = create_session(server.address(), server.token()); + assert_ne!(second_session, session); + + server.shutdown(); + assert!(!descriptor.exists()); + } + + #[test] + fn rejects_non_loopback_bind_address() { + let result = DebugServer::start(ServerConfig { + bind_address: ([0, 0, 0, 0], 0).into(), + descriptor_path: descriptor_path(), + renderer_revision: "test-revision".into(), + }); + assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::InvalidInput)); + } + + #[test] + fn overflowing_content_length_is_rejected_without_killing_the_server() { + let (server, _commands) = DebugServer::start(ServerConfig { + bind_address: (std::net::Ipv4Addr::LOCALHOST, 0).into(), + descriptor_path: descriptor_path(), + renderer_revision: "test-revision".into(), + }) + .unwrap(); + + let rejected = raw_request( + server.address(), + &format!( + "POST /session HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + usize::MAX + ), + ); + assert_eq!(rejected["value"]["error"], "invalid argument"); + let malformed_length = raw_request( + server.address(), + "POST /session HTTP/1.1\r\nHost: localhost\r\nContent-Length: nope\r\nConnection: close\r\n\r\n", + ); + assert_eq!(malformed_length["value"]["error"], "invalid argument"); + let malformed_json = raw_request( + server.address(), + "POST /session HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1\r\nConnection: close\r\n\r\n{", + ); + assert_eq!(malformed_json["value"]["error"], "invalid argument"); + assert_eq!( + request(server.address(), "GET", "/status", Value::Null)["value"]["ready"], + true + ); + server.shutdown(); + } + + #[test] + fn a_slow_client_does_not_block_other_connections_and_has_an_absolute_deadline() { + let (server, _commands) = DebugServer::start(ServerConfig { + bind_address: (std::net::Ipv4Addr::LOCALHOST, 0).into(), + descriptor_path: descriptor_path(), + renderer_revision: "test-revision".into(), + }) + .unwrap(); + let mut slow = TcpStream::connect(server.address()).unwrap(); + slow.write_all(b"G").unwrap(); + let started = Instant::now(); + assert_eq!( + request(server.address(), "GET", "/status", Value::Null)["value"]["ready"], + true + ); + assert!(started.elapsed() < Duration::from_millis(500)); + + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + let address = listener.local_addr().unwrap(); + let reader = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + read_request_within(&mut stream, Duration::from_millis(40)).unwrap_err() + }); + let mut drip = TcpStream::connect(address).unwrap(); + drip.write_all(b"G").unwrap(); + let error = reader.join().unwrap(); + assert!(matches!( + error.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + )); + server.shutdown(); + } + + fn snapshot_request() -> HttpRequest { + HttpRequest { + method: "GET".into(), + path: "/session/0000000000000001/blitz/getDomSnapshot".into(), + body: Value::Null, + } + } + + /* + * The wake has to arrive after the request is queued, never before: a wake + * delivered to an empty queue is spent, and the embedder has no timer to + * fall back on any more. Draining inside the callback is exactly what the + * event loop does when it comes round, so servicing here proves the + * ordering rather than asserting it indirectly. + */ + #[test] + fn wakes_the_servicer_with_the_request_already_queued() { + let (command_tx, command_rx) = mpsc::sync_channel::(4); + let waker = ServiceWaker::default(); + let queue = Mutex::new(command_rx); + waker.set(move || { + let queued = queue.lock().unwrap().try_recv().unwrap(); + assert_eq!(queued.path, "blitz/getDomSnapshot"); + queued + .respond(ControlResponse::Success(json!({"serviced": true}))) + .unwrap(); + }); + + let active_session = ActiveSession(AtomicU64::new(1)); + let response = route( + snapshot_request(), + "token", + &active_session, + &command_tx, + &waker, + ); + + assert_eq!(response["value"]["serviced"], true); + } + + /* + * `sync_channel(1)` refused this outright with "renderer command queue is + * full". One unserviced request is normal, not a fault: it happens whenever + * a command arrives before the embedder has a document to run it against. + */ + #[test] + fn a_second_request_queues_behind_an_unserviced_one() { + let (command_tx, command_rx) = mpsc::sync_channel::(4); + let (reply_tx, _reply_rx) = mpsc::sync_channel(1); + command_tx + .send(ControlRequest { + method: "GET".into(), + path: "occupied".into(), + body: Value::Null, + reply: reply_tx, + }) + .unwrap(); + + let waker = ServiceWaker::default(); + let queue = Mutex::new(command_rx); + waker.set(move || { + let queue = queue.lock().unwrap(); + assert_eq!(queue.try_recv().unwrap().path, "occupied"); + queue + .try_recv() + .unwrap() + .respond(ControlResponse::Success(json!({"serviced": true}))) + .unwrap(); + }); + + let active_session = ActiveSession(AtomicU64::new(1)); + let response = route( + snapshot_request(), + "token", + &active_session, + &command_tx, + &waker, + ); + + assert_eq!(response["value"]["serviced"], true); + } + + #[test] + fn a_saturated_renderer_queue_fails_without_growing() { + let (command_tx, _command_rx) = mpsc::sync_channel::(1); + let (reply_tx, _reply_rx) = mpsc::sync_channel(1); + command_tx + .send(ControlRequest { + method: "GET".into(), + path: "occupied".into(), + body: Value::Null, + reply: reply_tx, + }) + .unwrap(); + + let response = route( + snapshot_request(), + "token", + &ActiveSession(AtomicU64::new(1)), + &command_tx, + &ServiceWaker::default(), + ); + + assert_eq!(response["value"]["error"], "unknown error"); + assert_eq!( + response["value"]["message"], + "renderer command queue is full" + ); + } + + /// Nothing installs a waker until the embedder is up, and requests that + /// arrive in that window must still be queued rather than dropped. + #[test] + fn queues_requests_while_no_waker_is_installed() { + let (command_tx, command_rx) = mpsc::sync_channel::(4); + let waker = ServiceWaker::default(); + let active_session = ActiveSession(AtomicU64::new(1)); + + let sender = command_tx.clone(); + let servicer = thread::spawn(move || { + let queued = command_rx.recv_timeout(COMMAND_TIMEOUT).unwrap(); + drop(sender); + queued + .respond(ControlResponse::Success(json!({"serviced": true}))) + .unwrap(); + }); + + let response = route( + snapshot_request(), + "token", + &active_session, + &command_tx, + &waker, + ); + + servicer.join().unwrap(); + assert_eq!(response["value"]["serviced"], true); + } +} diff --git a/packages/blitz-dom/src/accessibility.rs b/packages/blitz-dom/src/accessibility.rs index cbd5aefc..5a6f34dc 100644 --- a/packages/blitz-dom/src/accessibility.rs +++ b/packages/blitz-dom/src/accessibility.rs @@ -1,6 +1,167 @@ +//! The role an element has, and the AccessKit tree built out of those roles. + +use crate::node::ElementData; use crate::{BaseDocument, Node as BlitzDomNode, local_name}; use accesskit::{Node as AccessKitNode, NodeId, Role, Tree, TreeId, TreeUpdate}; +/// An attribute by local name, for the names that are not static atoms. +/// +/// `ElementData::attr` takes something comparable to a `LocalName`, which the +/// `local_name!` macro supplies for the names html5ever interns. `aria-label` +/// and `aria-labelledby` are not among them, so they are matched by string. +fn attr<'a>(element: &'a ElementData, name: &str) -> Option<&'a str> { + element + .attrs() + .iter() + .find(|attribute| attribute.name.local.as_ref() == name) + .map(|attribute| attribute.value.as_ref()) +} + +/// The role an element has by virtue of being that element, per +/// . +/// +/// # Why this is public +/// +/// There were two of these. This one built the AccessKit tree a screen reader +/// reads; a second copy in `tauri-runtime-blitz`'s `agent.rs` built the +/// semantic tree an agent and the QA harness read, and the two answered +/// differently about the same document. `` was the case that showed it: +/// correct here as a column or row header, reported as a plain `cell` there, +/// so a check that wanted "the Version column" had nothing to ask for while +/// every header was spelled the same as the data beneath it. +/// +/// Neither copy was wrong on purpose. They were written months apart against +/// the same specification, and nothing could compare them: this table was +/// private, and the other lived in a different repository. Exporting it is what +/// makes a single answer possible, and the control surface in +/// `blitz-control-protocol` now derives its own role names from this function +/// rather than from a table of its own. +/// +/// # What it does not do +/// +/// An author's explicit `role` attribute is not consulted. That is an override +/// applied on top of the implicit role, and the two consumers apply it +/// differently: the control surface passes the author's string through +/// verbatim, while this tree would need a full ARIA-name-to-[`Role`] table it +/// does not have. Naming it here would decide that question for both. +pub fn implicit_role(element: &ElementData) -> Role { + let name = element.name.local.as_ref(); + match name { + // Document structure + "article" => Role::Article, + "aside" => Role::Complementary, + "footer" => Role::Footer, + "header" => Role::Header, + "main" => Role::Main, + "nav" => Role::Navigation, + "search" => Role::Search, + // A named section is a landmark; an unnamed one is a wrapper. + // + // HTML-AAM maps `
` to `region` when it has an accessible name + // and leaves it generic otherwise. Both halves matter. A named section + // is how a page says "this part is the connection settings", and + // without the rule it arrives indistinguishable from the `
`s + // around it; promoting the unnamed ones would put a landmark around + // every block on a page that reaches for `
` as a synonym for + // `
`. + // + // Attributes only. The accessible name is not computed here, and + // computing it would walk the section's whole subtree for every element + // in the document. `aria-labelledby` is included so an author who names + // a section that way still gets the landmark; resolving the reference + // to the name itself is the caller's job. + "section" => { + if ["aria-label", "aria-labelledby", "title"] + .iter() + .any(|name| attr(element, name).is_some_and(|value| !value.trim().is_empty())) + { + Role::Region + } else { + Role::Section + } + } + "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => Role::Heading, + "p" => Role::Paragraph, + "blockquote" => Role::Blockquote, + "figure" => Role::Figure, + "figcaption" | "caption" => Role::Caption, + "hr" => Role::Splitter, + + // Grouping + "ul" | "ol" | "menu" => Role::List, + "li" => Role::ListItem, + "dl" => Role::DescriptionList, + "dt" => Role::Term, + "dd" => Role::Definition, + "dialog" => Role::Dialog, + "fieldset" => Role::Group, + "form" => Role::Form, + "div" => Role::GenericContainer, + + // Tables + "table" => Role::Table, + "thead" | "tbody" | "tfoot" => Role::RowGroup, + "tr" => Role::Row, + "td" => Role::Cell, + // A header cell is not a cell. `scope` decides which kind; without one + // this is a column header, which is the `` row case. + "th" => match element.attr(local_name!("scope")) { + Some("row") | Some("rowgroup") => Role::RowHeader, + _ => Role::ColumnHeader, + }, + + // Interactive + // An is only a link when it has an href. + "a" => match element.attr(local_name!("href")) { + Some(_) => Role::Link, + None => Role::GenericContainer, + }, + "button" => Role::Button, + "label" => Role::Label, + "legend" => Role::Label, + "select" => match element.attr(local_name!("multiple")) { + Some(_) => Role::ListBox, + None => Role::ComboBox, + }, + "option" => Role::ListBoxOption, + "textarea" => Role::MultilineTextInput, + "progress" => Role::ProgressIndicator, + "meter" => Role::Meter, + "output" => Role::Status, + "summary" => Role::DisclosureTriangle, + + // Inline semantics + "code" => Role::Code, + "em" => Role::Emphasis, + "strong" => Role::Strong, + "mark" => Role::Mark, + "time" => Role::Time, + "img" => Role::Image, + "iframe" => Role::Iframe, + + "input" => { + let ty = element.attr(local_name!("type")).unwrap_or("text"); + match ty { + "button" | "submit" | "reset" => Role::Button, + "checkbox" => Role::CheckBox, + "color" => Role::ColorWell, + "date" => Role::DateInput, + "datetime-local" => Role::DateTimeInput, + "email" => Role::EmailInput, + "number" => Role::NumberInput, + "password" => Role::PasswordInput, + "radio" => Role::RadioButton, + "range" => Role::Slider, + "search" => Role::SearchInput, + "tel" => Role::PhoneNumberInput, + "time" => Role::TimeInput, + _ => Role::TextInput, + } + } + _ => Role::Unknown, + } +} + impl BaseDocument { pub fn build_accessibility_tree(&self) -> TreeUpdate { let mut nodes = std::collections::HashMap::new(); @@ -43,100 +204,18 @@ impl BaseDocument { if node.parent.is_none() { builder.set_role(Role::Window) } else if let Some(element_data) = node.element_data() { - let name = element_data.name.local.to_string(); - - // - let role = match &*name { - // Document structure - "article" => Role::Article, - "aside" => Role::Complementary, - "footer" => Role::Footer, - "header" => Role::Header, - "main" => Role::Main, - "nav" => Role::Navigation, - "search" => Role::Search, - "section" => Role::Section, - "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => Role::Heading, - "p" => Role::Paragraph, - "blockquote" => Role::Blockquote, - "figure" => Role::Figure, - "figcaption" | "caption" => Role::Caption, - "hr" => Role::Splitter, - - // Grouping - "ul" | "ol" | "menu" => Role::List, - "li" => Role::ListItem, - "dl" => Role::DescriptionList, - "dt" => Role::Term, - "dd" => Role::Definition, - "dialog" => Role::Dialog, - "fieldset" => Role::Group, - "form" => Role::Form, - "div" => Role::GenericContainer, - - // Tables - "table" => Role::Table, - "thead" | "tbody" | "tfoot" => Role::RowGroup, - "tr" => Role::Row, - "td" => Role::Cell, - "th" => match element_data.attr(local_name!("scope")) { - Some("row") | Some("rowgroup") => Role::RowHeader, - _ => Role::ColumnHeader, - }, - - // Interactive - // An is only a link when it has an href. - "a" => match element_data.attr(local_name!("href")) { - Some(_) => Role::Link, - None => Role::GenericContainer, - }, - "button" => Role::Button, - "label" => Role::Label, - "legend" => Role::Label, - "select" => match element_data.attr(local_name!("multiple")) { - Some(_) => Role::ListBox, - None => Role::ComboBox, - }, - "option" => Role::ListBoxOption, - "textarea" => Role::MultilineTextInput, - "progress" => Role::ProgressIndicator, - "meter" => Role::Meter, - "output" => Role::Status, - "summary" => Role::DisclosureTriangle, - - // Inline semantics - "code" => Role::Code, - "em" => Role::Emphasis, - "strong" => Role::Strong, - "mark" => Role::Mark, - "time" => Role::Time, - "img" => Role::Image, - "iframe" => Role::Iframe, - - "input" => { - let ty = element_data.attr(local_name!("type")).unwrap_or("text"); - match ty { - "button" | "submit" | "reset" => Role::Button, - "checkbox" => Role::CheckBox, - "color" => Role::ColorWell, - "date" => Role::DateInput, - "datetime-local" => Role::DateTimeInput, - "email" => Role::EmailInput, - "number" => Role::NumberInput, - "password" => Role::PasswordInput, - "radio" => Role::RadioButton, - "range" => Role::Slider, - "search" => Role::SearchInput, - "tel" => Role::PhoneNumberInput, - "time" => Role::TimeInput, - _ => Role::TextInput, - } + builder.set_role(implicit_role(element_data)); + // Roles alone do not expose a picker's current value or its options. + // Read live selectedness so keyboard and script changes reach the tree. + match element_data.name.local.as_ref() { + "select" => builder.set_value(self.select_label(node.id)), + "option" => { + builder.set_label(self.option_label(node.id)); + builder.set_selected(self.option_is_selected(node.id)); } - _ => Role::Unknown, - }; - - builder.set_role(role); - builder.set_html_tag(name); + _ => {} + } + builder.set_html_tag(element_data.name.local.to_string()); } else if node.is_text_node() { builder.set_role(Role::TextRun); builder.set_value(node.text_content()); @@ -148,3 +227,103 @@ impl BaseDocument { (id, builder) } } + +#[cfg(test)] +mod tests { + use markup5ever::{QualName, ns}; + + use crate::node::Attribute; + + use super::*; + + /// One element, built directly. + /// + /// `blitz-dom` holds the tree but does not parse HTML, so a fixture here + /// cannot be a string of markup. The role rules read a tag name and a + /// handful of attributes and nothing else, which is exactly what this + /// supplies. + fn element(tag: &str, attributes: &[(&str, &str)]) -> ElementData { + ElementData::new( + QualName::new(None, ns!(html), tag.into()), + attributes + .iter() + .map(|(name, value)| Attribute { + name: QualName::new(None, ns!(), (*name).into()), + value: (*value).into(), + }) + .collect(), + ) + } + + fn role_of(tag: &str, attributes: &[(&str, &str)]) -> Role { + implicit_role(&element(tag, attributes)) + } + + /// The case that showed there were two tables. + /// + /// A header cell reported as a plain cell is not a cosmetic difference: it + /// is the difference between a document that says which column a value + /// belongs to and one that does not. + /// The case that showed there were two tables. + /// + /// A header cell reported as a plain cell is not a cosmetic difference: it + /// is the difference between a document that says which column a value + /// belongs to and one that does not. + #[test] + fn a_header_cell_is_a_header_and_scope_says_which_kind() { + assert_eq!(role_of("th", &[]), Role::ColumnHeader); + assert_eq!(role_of("th", &[("scope", "col")]), Role::ColumnHeader); + assert_eq!(role_of("th", &[("scope", "row")]), Role::RowHeader); + assert_eq!(role_of("th", &[("scope", "rowgroup")]), Role::RowHeader); + assert_eq!(role_of("td", &[]), Role::Cell); + } + + #[test] + fn a_named_section_is_a_landmark_and_an_unnamed_one_is_not() { + assert_eq!( + role_of("section", &[("aria-label", "connection settings")]), + Role::Region + ); + assert_eq!( + role_of("section", &[("aria-labelledby", "heading")]), + Role::Region + ); + assert_eq!(role_of("section", &[("title", "notes")]), Role::Region); + assert_eq!(role_of("section", &[]), Role::Section); + assert_eq!( + role_of("section", &[("aria-label", " ")]), + Role::Section, + "whitespace is not a name" + ); + } + + #[test] + fn an_anchor_is_a_link_only_when_it_goes_somewhere() { + assert_eq!(role_of("a", &[("href", "/x")]), Role::Link); + assert_eq!(role_of("a", &[]), Role::GenericContainer); + } + + #[test] + fn an_input_takes_its_role_from_its_type() { + assert_eq!(role_of("input", &[]), Role::TextInput); + assert_eq!(role_of("input", &[("type", "checkbox")]), Role::CheckBox); + assert_eq!(role_of("input", &[("type", "radio")]), Role::RadioButton); + assert_eq!(role_of("input", &[("type", "range")]), Role::Slider); + assert_eq!(role_of("input", &[("type", "submit")]), Role::Button); + assert_eq!( + role_of("input", &[("type", "password")]), + Role::PasswordInput + ); + assert_eq!( + role_of("input", &[("type", "not-a-type")]), + Role::TextInput, + "an unknown input type still edits text" + ); + } + + #[test] + fn a_multiple_select_is_a_list_box() { + assert_eq!(role_of("select", &[]), Role::ComboBox); + assert_eq!(role_of("select", &[("multiple", "")]), Role::ListBox); + } +} diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 13d26253..5cfb750e 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -255,6 +255,41 @@ pub struct BaseDocument { /// paints beneath every background between them and disappears. pub(crate) hoisted_fixed_parents: HashMap, + /// Every `position: fixed` node whose containing block is the viewport, + /// which is every one of them except those under a transformed ancestor. + /// + /// Collected by the walk that hoists them, and used by + /// `resolve_fixed_positions` to hold them still while the page scrolls. + pub(crate) fixed_nodes: Vec, + + /// The viewport scroll currently baked into those nodes' locations. + /// + /// One value for the whole document rather than one per node: the pin is + /// the same displacement for every fixed box, because they all share the + /// viewport as their containing block. Reset by `resolve_layout`, which + /// rewrites the locations it was added to. + pub(crate) fixed_scroll_offset: crate::Point, + + /// Every `position: sticky` node in the document, in tree order. + /// + /// Collected by the same walk that hoists fixed nodes, because both need + /// one pre-order pass over the box tree and a second one would cost the + /// same on every frame of every document, sticky or not. Tree order matters: + /// a sticky box inside another sticky box is adjusted on top of its + /// ancestor's adjustment, so the ancestor has to be settled first. + pub(crate) sticky_nodes: Vec, + + /// For each sticky node, the offset currently baked into its + /// `final_layout().location`. + /// + /// The adjustment is written into the box itself so that paint, hit testing + /// and `absolute_position` cannot disagree about where the box is. That + /// makes the pass non-idempotent unless it can recover the flow position it + /// started from, which is what this records. Cleared by `resolve_layout`, + /// which rewrites every location from taffy and so discards the offsets + /// along with them. + pub(crate) sticky_offsets: HashMap>, + /// Stacking contexts holding a hoisted child that an ancestor clips. /// /// Collected while flushing styles so that `resolve_hoisted_clips` visits @@ -529,6 +564,10 @@ impl BaseDocument { let mut doc = Self { hoisted_fixed_parents: HashMap::new(), + fixed_nodes: Vec::new(), + fixed_scroll_offset: crate::Point::ZERO, + sticky_nodes: Vec::new(), + sticky_offsets: HashMap::new(), hoisted_clip_hosts: Vec::new(), id, tx, @@ -2589,7 +2628,27 @@ impl BaseDocument { /// Scroll a node by given x and y /// Will bubble scrolling up to parent node once it can no longer scroll further /// If we're already at the root node, bubbles scrolling up to the viewport + /// + /// A `position: sticky` box is held against the edge of the scrollport it + /// lives in, so the boxes have to be re-adjusted here rather than only in + /// `resolve`: a wheel event does not necessarily produce a style and layout + /// pass, and a header that only unstuck on the next restyle is a header + /// that visibly lags the scroll. pub fn scroll_node_by_has_changed( + &mut self, + node_id: NodeId, + x: f64, + y: f64, + dispatch_event: F, + ) -> bool { + let has_changed = self.scroll_node_by_inner(node_id, x, y, dispatch_event); + if has_changed { + self.resolve_sticky_positions(); + } + has_changed + } + + fn scroll_node_by_inner( &mut self, node_id: NodeId, x: f64, @@ -2653,7 +2712,7 @@ impl BaseDocument { if bubble_x != 0.0 || bubble_y != 0.0 { let bubbled = if let Some(parent) = parent { - self.scroll_node_by_has_changed(parent, bubble_x, bubble_y, dispatch_event) + self.scroll_node_by_inner(parent, bubble_x, bubble_y, dispatch_event) } else { self.scroll_viewport_by_has_changed(bubble_x, bubble_y) }; @@ -2743,7 +2802,7 @@ impl BaseDocument { if bubble_x != 0.0 || bubble_y != 0.0 { if let Some(parent) = parent { - return self.scroll_node_by_has_changed(parent, bubble_x, bubble_y, dispatch_event) + return self.scroll_node_by_inner(parent, bubble_x, bubble_y, dispatch_event) | has_changed; } else { return self.scroll_viewport_by_has_changed(bubble_x, bubble_y) | has_changed; @@ -2783,7 +2842,16 @@ impl BaseDocument { self.viewport_scroll.y = f64::max(0.0, f64::min(new_scroll.1, content_height - window_height)); - self.viewport_scroll != initial + let has_changed = self.viewport_scroll != initial; + if has_changed { + // The viewport is the scrollport a page-level sticky box is held + // against, and the containing block a fixed box is pinned to, so + // both move with this and not with the next relayout. See + // `resolve_sticky_positions` and `resolve_fixed_positions`. + self.resolve_sticky_positions(); + self.resolve_fixed_positions(); + } + has_changed } pub fn scroll_by( @@ -2988,6 +3056,10 @@ impl BaseDocument { // Non-atomic inline elements have no layout box of their own: return // the union of their per-line-box fragment rects. if let Some(rects) = self.inline_fragment_rects(node_id) { + let rects: Vec<_> = rects + .into_iter() + .map(|rect| self.transformed_client_rect(node_id, rect)) + .collect(); let x0 = rects.iter().map(|r| r.x).fold(f64::INFINITY, f64::min); let y0 = rects.iter().map(|r| r.y).fold(f64::INFINITY, f64::min); let x1 = rects @@ -3018,12 +3090,56 @@ impl BaseDocument { } let pos = node.absolute_position(0.0, 0.0); - Some(BoundingRect { - x: pos.x as f64 - self.viewport_scroll.x, - y: pos.y as f64 - self.viewport_scroll.y, - width: node.unrounded_layout().size.width as f64, - height: node.unrounded_layout().size.height as f64, - }) + Some(self.transformed_client_rect( + node_id, + BoundingRect { + x: pos.x as f64 - self.viewport_scroll.x, + y: pos.y as f64 - self.viewport_scroll.y, + width: node.unrounded_layout().size.width as f64, + height: node.unrounded_layout().size.height as f64, + }, + )) + } + + /// Map the layout rectangle through the same two-dimensional transforms as paint. + /// Layout coordinates deliberately exclude transforms; CSSOM client rectangles do not. + fn transformed_client_rect(&self, node_id: NodeId, rect: BoundingRect) -> BoundingRect { + let mut matrix = kurbo::Affine::IDENTITY; + let mut current = self.get_node(node_id); + let scale = self.viewport.scale_f64(); + while let Some(node) = current { + if matches!( + node.data, + NodeData::Element(_) | NodeData::AnonymousBlock(_) | NodeData::Document(_) + ) && let Some(transform) = *node.transform() + { + let [a, b, c, d, e, f] = transform.as_coeffs(); + let css_transform = kurbo::Affine::new([a, b, c, d, e / scale, f / scale]); + let origin = node.absolute_position(0.0, 0.0); + let translate = kurbo::Affine::translate(( + origin.x as f64 - self.viewport_scroll.x, + origin.y as f64 - self.viewport_scroll.y, + )); + matrix = translate * css_transform * translate.inverse() * matrix; + } + current = node + .layout_parent + .get() + .or(node.parent) + .and_then(|id| self.get_node(id)); + } + let transformed = matrix.transform_rect_bbox(kurbo::Rect::new( + rect.x, + rect.y, + rect.x + rect.width, + rect.y + rect.height, + )); + BoundingRect { + x: transformed.x0, + y: transformed.y0, + width: transformed.width(), + height: transformed.height(), + } } /// Computes the sizes and positions of the `Node`'s box fragments relative to the @@ -3032,7 +3148,10 @@ impl BaseDocument { /// spans within an inline root's text layout) return one rect per line box. pub fn node_client_rects(&self, node_id: NodeId) -> Vec { match self.inline_fragment_rects(node_id) { - Some(rects) => rects, + Some(rects) => rects + .into_iter() + .map(|rect| self.transformed_client_rect(node_id, rect)) + .collect(), None => self.get_client_bounding_rect(node_id).into_iter().collect(), } } @@ -3146,15 +3265,19 @@ root={:?} root_right={root_right:.1} root_w={:.1} lines={} layout_scale={:.2} vp let node = self.get_node(node_id)?; - // Only non-atomic inline elements lack their own layout box: they are - // flattened into the containing inline root's text layout as style spans. - if !node.is_element() || node.flags.is_inline_root() { - return None; - } - let display = node.primary_styles()?.clone_display(); - if !(display.outside() == DisplayOutside::Inline && display.inside() == DisplayInside::Flow) - { - return None; + // Text and non-atomic inline elements live in the inline root's glyph + // runs rather than owning layout boxes. + let is_text = node.is_text_node(); + if !is_text { + if !node.is_element() || node.flags.is_inline_root() { + return None; + } + let display = node.primary_styles()?.clone_display(); + if !(display.outside() == DisplayOutside::Inline + && display.inside() == DisplayInside::Flow) + { + return None; + } } let inline_root = node.inline_root_ancestor()?; @@ -3206,7 +3329,13 @@ root={:?} root_right={root_right:.1} root_w={:.1} lines={} layout_scale={:.2} vp for item in line.items() { match item { PositionedLayoutItem::GlyphRun(glyph_run) => { - if !is_in_target(glyph_run.style().brush.id) { + let brush = glyph_run.style().brush; + let matches = if is_text { + brush.text_node == Some(node_id) + } else { + is_in_target(brush.id) + }; + if !matches { continue; } let x0 = glyph_run.offset() as f64; @@ -3221,7 +3350,7 @@ root={:?} root_right={root_right:.1} root_w={:.1} lines={} layout_scale={:.2} vp add(x0, y0, x1, y1); } PositionedLayoutItem::InlineBox(inline_box) => { - if !is_in_target(NodeId::from_u64(inline_box.id)) { + if is_text || !is_in_target(NodeId::from_u64(inline_box.id)) { continue; } let x0 = inline_box.x as f64; diff --git a/packages/blitz-dom/src/events/keyboard.rs b/packages/blitz-dom/src/events/keyboard.rs index fd6dae68..f1bdcda0 100644 --- a/packages/blitz-dom/src/events/keyboard.rs +++ b/packages/blitz-dom/src/events/keyboard.rs @@ -86,6 +86,25 @@ pub(crate) fn handle_key_or_input_event( return; } + // A focussed select owns the arrows, Home and End: they move the + // selection. Checked before the mutable borrow below, because deciding + // where a select's selection goes means reading its options, which are + // other nodes. + // + // There was no keyboard activation for anything but a text input, so a + // picker could be focussed and then not driven at all. A harness has no + // other way in: worktables.dev's schema designer selects its column + // type by keyboard or not at all. + if doc + .get_node(node_id) + .is_some_and(|node| node.data.is_element_with_tag_name(&local_name!("select"))) + { + if let KeyboardOrTextInputEvent::KeyPress(key_event) = &event { + handle_select_keypress(doc, node_id, key_event, dispatch_event); + } + return; + } + let node = &mut doc.nodes[node_id]; let Some(element_data) = node.element_data_mut() else { return; @@ -116,6 +135,49 @@ pub(crate) fn handle_key_or_input_event( } } +/// Move a focussed select's selection with the arrows, Home and End. +/// +/// A select commits immediately, the way a checkbox does and unlike a text +/// control: the keystroke *is* the commit, so `input` fires here and +/// blitz-script synthesises the `change` that follows it. Waiting for blur, as +/// a text field does, would leave a picker that never reports what was chosen. +fn handle_select_keypress( + doc: &mut BaseDocument, + node_id: NodeId, + event: &BlitzKeyEvent, + mut dispatch_event: F, +) { + if !event.state.is_pressed() { + return; + } + + let target_index = match &event.key { + Key::ArrowDown => doc.select_index_step(node_id, true), + Key::ArrowUp => doc.select_index_step(node_id, false), + Key::Home => doc.select_index_edge(node_id, false), + Key::End => doc.select_index_edge(node_id, true), + _ => return, + }; + let Some(index) = target_index else { + // Already at the end of the list, or nothing selectable. Silently + // doing nothing is right: a browser does not wrap around. + return; + }; + + if !doc.set_select_selected_index(node_id, index) { + return; + } + // The selectedness is what `:checked` and the accessibility tree read, so + // the restyle has to be asked for or the change is invisible. + doc.snapshot_node(node_id); + + let value = doc.select_value(node_id); + dispatch_event(DomEvent::new( + node_id, + DomEventData::Input(BlitzInputEvent { value }), + )); +} + impl BaseDocument { pub(crate) fn apply_generated_text_input_event( &mut self, diff --git a/packages/blitz-dom/src/events/mod.rs b/packages/blitz-dom/src/events/mod.rs index b1f0409d..d4c279ff 100644 --- a/packages/blitz-dom/src/events/mod.rs +++ b/packages/blitz-dom/src/events/mod.rs @@ -371,7 +371,14 @@ fn scroll_key_is_claimed_by(doc: &BaseDocument, node_id: NodeId, key: &Key) -> b } doc.get_node(node_id) .and_then(|node| node.element_data()) - .is_some_and(|element| element.text_input_data().is_some()) + .is_some_and(|element| { + element.text_input_data().is_some() + // A focussed select moves its selection with the arrows, Home + // and End. Without claiming them here the branch above scrolls + // the page and *returns*, so the keyboard handler never runs + // and a select could not be driven by keyboard at all. + || element.select_data().is_some() + }) } /// The scroll a key asks for, in CSS pixels, or `None` if it asks for none. diff --git a/packages/blitz-dom/src/events/pointer.rs b/packages/blitz-dom/src/events/pointer.rs index 09d63e03..4bd3fd50 100644 --- a/packages/blitz-dom/src/events/pointer.rs +++ b/packages/blitz-dom/src/events/pointer.rs @@ -638,6 +638,12 @@ pub(crate) fn checkable_activation_target(doc: &BaseDocument, target: NodeId) -> return Some(node_id); } local_name!("label") => return None, + // A select is not checkable and has an activation behaviour of its + // own, so the walk stops here for the same reason it stops at a + // text input: `handle_click` claims the press, and the two have to + // agree about where the walk ends or a checkbox wrapping a select + // would toggle without ever seeing a click. + local_name!("select") => return None, _ => {} } @@ -716,6 +722,27 @@ pub(crate) fn handle_click( break 'matched true; } + // A press on a select focuses it. Nothing else yet: there is no + // popup to open. + // + // Without an arm of its own the walk fell through to the + // no-match tail below, which calls `clear_focus()`. Clicking a + // select therefore actively *unfocused* the page, and since the + // keyboard handler is gated on focus, the arrows could not + // drive a select the user had just pressed. `handle_pointerdown` + // is no help either: it classifies a select as + // `ClickTarget::SelectableText` and only the `TextInput` arm + // there generates focus events. + local_name!("select") => { + generate_focus_events( + doc, + &mut |doc| { + doc.set_focus_to(node_id); + }, + dispatch_event, + ); + break 'matched true; + } // Activating the first of a
element toggles // the details' `open` attribute (expand/collapse). local_name!("summary") => { diff --git a/packages/blitz-dom/src/form.rs b/packages/blitz-dom/src/form.rs index 86f5a66f..e0e09202 100644 --- a/packages/blitz-dom/src/form.rs +++ b/packages/blitz-dom/src/form.rs @@ -267,11 +267,29 @@ fn construct_entry_list(doc: &BaseDocument, form_id: NodeId, submitter_id: NodeI continue; }; - // TODO: If the field element is a select element, - // then for each option element in the select element's - // list of options whose selectedness is true and that is not disabled, - // create an entry with name and the value of the option element, - // and append it to entry list. + // If the field element is a select element, then for each option + // element in the select element's list of options whose selectedness is + // true and that is not disabled, create an entry with name and the + // value of the option element, and append it to entry list. + // + // Without this a select fell through to the generic tail below and + // submitted its own literal `value` attribute, which a select does not + // have. Every form containing a picker posted the wrong body. + if element.name.local == local_name!("select") { + let options = doc.select_options(control_id); + for (index, option_id) in options.iter().enumerate() { + let is_selected = element + .select_data() + .map(|data| data.is_selected(index)) + // Before layout construction has run there is no live + // state, and the content attribute is all there is. + .unwrap_or_else(|| doc.option_is_selected(*option_id)); + if is_selected && !doc.option_is_disabled(*option_id) { + create_entry(name, doc.option_value(*option_id).as_str().into()); + } + } + continue; + } // Otherwise, if the field element is an input element whose type attribute is in the Checkbox state or the Radio Button state, then: if element.name.local == local_name!("input") diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index 101f0d08..1f1dee88 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -501,6 +501,14 @@ pub(crate) fn collect_layout_children( } } + // A select has no in-flow content of its own: `option { display: none }` + // in the user-agent sheet sees to that, so returning here costs nothing + // and keeps the options out of the box the control occupies. + if tag_name == "select" { + create_select(doc, container_node_id); + return; + } + #[cfg(feature = "svg")] if matches!(tag_name, "svg") { // Serialised rather than `outer_html`, so that symbols referenced @@ -1118,6 +1126,24 @@ fn create_checkbox_input(doc: &mut BaseDocument, input_element_id: NodeId) { } } +fn create_select(doc: &mut BaseDocument, select_element_id: NodeId) { + // Read before the node is borrowed mutably: the seed comes from the + // options, which are other nodes. + let initial = doc.initial_select_data(select_element_id); + let option_count = initial.len(); + + let node = &mut doc.nodes[select_element_id]; + let element = &mut node.data.downcast_element_mut().unwrap(); + match element.special_data { + // Construction runs again on every resolve. Re-seeding would put the + // control back to its parsed state on the next frame, so a selection + // made by the user or by script would survive exactly until anything + // else on the page changed. Only the option count is refreshed. + SpecialElementData::Select(ref mut data) => data.resize(option_count), + _ => element.special_data = SpecialElementData::Select(initial), + } +} + /// Find and return the "layout_children" (inline boxes) for an inline layout /// without actually constructing the layout. This allows us to defer the expensive /// construction of the Parley layout (which invokes text shaping) to a paralell phase. @@ -1505,8 +1531,12 @@ pub(crate) fn build_inline_layout_into( }; } NodeData::Text(data) => { - // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); - // dbg!(&data.content); + // Keep the styling element for painting, and retain the text + // node's identity so inspection can recover its actual glyph bounds. + builder.push_style_modification_span(&[StyleProperty::Brush(TextBrush { + id: parent_id, + text_node: Some(node_id), + })]); // TODO: optimize case transforms to be non-allocating match parent_text_transform { @@ -1520,6 +1550,7 @@ pub(crate) fn build_inline_layout_into( builder.push_text(&data.content); } } + builder.pop_style_span(); } NodeData::Comment { .. } | NodeData::ShadowRoot(_) => { // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); diff --git a/packages/blitz-dom/src/layout/mod.rs b/packages/blitz-dom/src/layout/mod.rs index 56bf5097..49dc1236 100644 --- a/packages/blitz-dom/src/layout/mod.rs +++ b/packages/blitz-dom/src/layout/mod.rs @@ -338,6 +338,15 @@ impl BaseDocument { /// sheet sees to that, and nothing replaces it. Without a measure of its own it /// laid out at zero and no site's country picker, currency picker or language /// picker had a box to press. +/// +/// Still a character count rather than shaped text, because the options never +/// reach layout and so are never shaped. What it counts is now the option's +/// *label*, which is the string the control actually shows: a `label` +/// attribute overrides the element's text, and the text is collapsed the way it +/// would be rendered. Counting `text_content().trim()` measured the hidden text +/// of a labelled option, and `trim()` only strips the ends, so a label whose own +/// words were split across source lines counted the newline and the indentation +/// before the next word and came out that much too wide. fn select_metrics_of( doc: &BaseDocument, node_id: blitz_traits::node_id::NodeId, @@ -348,14 +357,10 @@ fn select_metrics_of( return None; } - let widest = crate::traversal::TreeTraverser::new_with_root(doc, node_id) - .filter_map(|descendant_id| doc.nodes.get(descendant_id)) - .filter(|descendant| { - descendant - .data - .is_element_with_tag_name(&local_name!("option")) - }) - .map(|option| option.text_content().trim().chars().count()) + let widest = doc + .select_options(node_id) + .into_iter() + .map(|option_id| doc.option_label(option_id).chars().count()) .max() .unwrap_or(0); diff --git a/packages/blitz-dom/src/lib.rs b/packages/blitz-dom/src/lib.rs index 6b83f9c8..4e08cf0e 100644 --- a/packages/blitz-dom/src/lib.rs +++ b/packages/blitz-dom/src/lib.rs @@ -53,6 +53,7 @@ mod mutator; pub mod paint_damage; mod query_selector; mod resolve; +mod select; mod selection; #[cfg(feature = "shadow-dom")] mod shadow; @@ -73,7 +74,16 @@ pub mod net; pub mod util; #[cfg(feature = "accessibility")] -mod accessibility; +pub mod accessibility; +/// The role vocabulary [`accessibility::implicit_role`] answers in. +/// +/// Re-exported rather than left to the caller to depend on: a second copy of +/// `accesskit` in the graph makes its `Role` a different type from this one, +/// and the error that produces names the same enum on both sides of a +/// mismatch. Anything consuming the role rules gets the vocabulary from the +/// crate that owns them. +#[cfg(feature = "accessibility")] +pub use accesskit; #[cfg(feature = "custom-widget")] pub use crate::node::Widget; @@ -98,7 +108,7 @@ pub use markup5ever::{ namespace_prefix, namespace_url, ns, }; pub use mutator::DocumentMutator; -pub use node::{Attribute, DocumentData, ElementData, Node, NodeData, TextNodeData}; +pub use node::{Attribute, DocumentData, ElementData, Node, NodeData, SelectData, TextNodeData}; pub use paint_damage::PaintDamage; // Re-exported because `PaintDamage` takes and returns `kurbo::Rect` across the // crate boundary. A consumer that pulls kurbo in itself and lands on a diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index 4b6ef2c0..84dc62fd 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -497,6 +497,49 @@ impl DocumentMutator<'_> { || (tag, attr) == tag_and_attr!("iframe", "srcdoc") { self.load_iframe(node_id); + } else if (tag, attr) == tag_and_attr!("option", "selected") { + // `selected` is an HTML boolean attribute: present means selected, + // whatever the value reads. The same trap `checked` fell into, where + // `selected="false"` selected the option. + // + // Selectedness lives on the owning select once that has been + // constructed, and construction is idempotent, so writing the + // attribute alone would land nowhere anything reads. Before + // construction the attribute is the only carrier and seeds the + // state on the next resolve, which is why this is allowed to do + // nothing at all. + self.set_option_selected_state(node_id, true); + } + } + + /// Push an option's selectedness into the owning select's live state, if + /// that state exists yet. + fn set_option_selected_state(&mut self, option_id: NodeId, selected: bool) { + let Some(select_id) = self.doc.option_owner_select(option_id) else { + return; + }; + let Some(index) = self + .doc + .select_options(select_id) + .iter() + .position(|id| *id == option_id) + else { + return; + }; + let changed = if selected { + self.doc.set_select_selected_index(select_id, index) + } else { + self.doc + .get_node_mut(select_id) + .and_then(|node| node.data.downcast_element_mut()) + .and_then(|el| el.select_data_mut()) + .is_some_and(|data| data.set_selected(index, false)) + }; + if changed { + // `option:checked` is matched from this state, so the restyle has + // to be asked for here or the change is invisible to CSS. + self.doc.snapshot_node(option_id); + self.doc.snapshot_node(select_id); } } @@ -1392,6 +1435,7 @@ impl<'doc> DocumentMutator<'doc> { SpecialElementData::TableRoot(_) => {} SpecialElementData::TextInput(_) => {} SpecialElementData::CheckboxInput(_) => {} + SpecialElementData::Select(_) => {} #[cfg(feature = "file-input")] SpecialElementData::FileInput(_) => {} SpecialElementData::None => {} diff --git a/packages/blitz-dom/src/node/element.rs b/packages/blitz-dom/src/node/element.rs index 15075fb8..49061f72 100644 --- a/packages/blitz-dom/src/node/element.rs +++ b/packages/blitz-dom/src/node/element.rs @@ -32,7 +32,7 @@ use super::stylo_data::StyloData; use super::{Attribute, Attributes}; use crate::Document; use crate::layout::table::TableContext; -use crate::node::{TextBrush, TextInputData, TextLayout}; +use crate::node::{SelectData, TextBrush, TextInputData, TextLayout}; #[cfg(feature = "shadow-dom")] use super::custom_element::CustomElementData; @@ -383,6 +383,7 @@ pub enum SpecialElementType { TableRoot, TextInput, CheckboxInput, + Select, #[cfg(feature = "file-input")] FileInput, #[default] @@ -412,6 +413,8 @@ pub enum SpecialElementData { TextInput(TextInputData), /// Checkbox checked state CheckboxInput(bool), + /// A \ element's selectedness and open state + Select(SelectData), /// Selected files #[cfg(feature = "file-input")] FileInput(FileData), @@ -434,6 +437,7 @@ impl Clone for SpecialElementData { Self::TableRoot(data) => Self::TableRoot(data.clone()), Self::TextInput(data) => Self::TextInput(data.clone()), Self::CheckboxInput(data) => Self::CheckboxInput(*data), + Self::Select(data) => Self::Select(data.clone()), #[cfg(feature = "file-input")] Self::FileInput(data) => Self::FileInput(data.clone()), Self::None => Self::None, @@ -682,6 +686,27 @@ impl ElementData { } } + /// The live state of a `` element. + +/// The selectedness of a `` can +/// have any number of options selected at once, and a single index would make +/// the multiple case unrepresentable rather than merely unsupported. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SelectData { + selected: Vec, + open: bool, +} + +impl SelectData { + /// Seed the state with one entry per option, in the select's list order. + pub fn new(selected: Vec) -> Self { + Self { + selected, + open: false, + } + } + + /// Grow or shrink to `len` options, keeping the selectedness of the options + /// that are still there. + /// + /// Layout construction runs again on every resolve, so this is the only + /// place a script-added `