From 5364172e48d1a521e678227ff72fe9be87c50069 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 11:40:39 -0700 Subject: [PATCH 1/9] feat(daemon): expose the resolved UI directory Resolve the UI directory before extension installation and publish the same path through DaemonState. Remote extensions can now inspect the exact static root used by the local router during install and startup. Co-Authored-By: Nova (GPT-6) --- crates/hypercolor-daemon/src/daemon.rs | 66 +++++++++++++++++-- crates/hypercolor-daemon/src/startup/mod.rs | 10 +++ .../hypercolor-daemon/src/startup/services.rs | 1 + 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/crates/hypercolor-daemon/src/daemon.rs b/crates/hypercolor-daemon/src/daemon.rs index 9fdd356c1..a09230839 100644 --- a/crates/hypercolor-daemon/src/daemon.rs +++ b/crates/hypercolor-daemon/src/daemon.rs @@ -141,13 +141,11 @@ impl PreparedDaemon { self.options.macos_owner_snapshot, self.options.service_status.take(), )?; + let ui_dir = resolve_ui_dir(self.options.ui_dir.clone()); daemon_state.session_monitors = self.options.session_monitors.take(); - for installer in extension_installers { - installer.install(&mut daemon_state)?; - } + install_extensions(&mut daemon_state, ui_dir.clone(), extension_installers)?; Box::pin(daemon_state.start()).await?; - let ui_dir = resolve_ui_dir(self.options.ui_dir.clone()); let app_state = Arc::new(api::build_state( &daemon_state, macos_daemon_session_attestation.as_ref(), @@ -195,6 +193,18 @@ impl PreparedDaemon { } } +fn install_extensions( + daemon: &mut DaemonState, + ui_dir: Option, + extension_installers: &[&dyn DaemonExtensionInstaller], +) -> Result<()> { + daemon.ui_dir = ui_dir; + for installer in extension_installers { + installer.install(daemon)?; + } + Ok(()) +} + pub trait DaemonExtensionInstaller: Send + Sync { /// Install extension state, API routes, and lifecycle hooks before startup. /// @@ -856,8 +866,9 @@ mod tests { use hypercolor_types::config::{HypercolorConfig, LogLevel, RenderAccelerationMode}; use super::{ - bind_api_listener, bind_api_listener_with_lease, default_env_filter, - notify_api_ready_extensions, resolve_log_level, serve_api_listeners_with_shutdown_timeout, + DaemonExtensionInstaller, bind_api_listener, bind_api_listener_with_lease, + default_env_filter, install_extensions, notify_api_ready_extensions, resolve_log_level, + serve_api_listeners_with_shutdown_timeout, }; use crate::app_state::AppState; use crate::extensions::DaemonLifecycleExtension; @@ -884,6 +895,19 @@ mod tests { calls: Arc>>, } + struct UiDirProbe(Arc>>); + + impl DaemonExtensionInstaller for UiDirProbe { + fn install(&self, daemon: &mut DaemonState) -> anyhow::Result<()> { + *self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + daemon.ui_dir().map(std::path::Path::to_path_buf); + Ok(()) + } + } + #[async_trait::async_trait] impl DaemonLifecycleExtension for ApiReadyProbe { fn name(&self) -> &'static str { @@ -1018,4 +1042,34 @@ mod tests { ["first", "second"] ); } + + #[tokio::test] + async fn extension_installers_observe_the_same_resolved_ui_directory_as_the_router() { + let directory = tempfile::tempdir().expect("daemon test directory should be created"); + let _data_dir = DataDirOverride::install(directory.path().join("data")); + let mut config = default_config(); + config.effect_engine.compositor_acceleration_mode = RenderAccelerationMode::Cpu; + let config_manager = Arc::new(ConfigManager::from_config_unchecked( + directory.path().join("hypercolor.toml"), + config.clone(), + )); + let mut daemon = + DaemonState::initialize(BootConfig::from_config_unchecked(config), config_manager) + .expect("daemon test state should initialize"); + let observed = Arc::new(Mutex::new(None)); + let probe = UiDirProbe(Arc::clone(&observed)); + let ui_dir = directory.path().join("ui"); + + install_extensions(&mut daemon, Some(ui_dir.clone()), &[&probe]) + .expect("extension installation should succeed"); + + assert_eq!(daemon.ui_dir(), Some(ui_dir.as_path())); + assert_eq!( + observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_deref(), + Some(ui_dir.as_path()) + ); + } } diff --git a/crates/hypercolor-daemon/src/startup/mod.rs b/crates/hypercolor-daemon/src/startup/mod.rs index ae0b5efa4..c96a52c9c 100644 --- a/crates/hypercolor-daemon/src/startup/mod.rs +++ b/crates/hypercolor-daemon/src/startup/mod.rs @@ -98,6 +98,10 @@ pub(crate) async fn persist_scene_store_snapshot( /// The domain graph is the primary transport-facing surface. Raw authorities /// stay private when their pointer identity must remain fixed after assembly. pub struct DaemonState { + /// Resolved directory served by the local UI router, when present. + /// Extensions may inspect the same path before their install/start hooks. + pub ui_dir: Option, + /// Complete domain service graph shared by every transport. pub domains: DomainContexts, @@ -295,6 +299,12 @@ pub struct DaemonState { } impl DaemonState { + /// Resolved directory served by the local UI router. + #[must_use] + pub fn ui_dir(&self) -> Option<&std::path::Path> { + self.ui_dir.as_deref() + } + #[doc(hidden)] #[must_use] pub const fn input_manager(&self) -> &InputManager { diff --git a/crates/hypercolor-daemon/src/startup/services.rs b/crates/hypercolor-daemon/src/startup/services.rs index 12dcde4de..49fb82351 100644 --- a/crates/hypercolor-daemon/src/startup/services.rs +++ b/crates/hypercolor-daemon/src/startup/services.rs @@ -802,6 +802,7 @@ impl DaemonState { info!("Device backends registered"); Ok(Self { + ui_dir: None, domains, config_manager, extensions: ExtensionRegistry::default(), From 57820e89e7a6eb0455d6626821ed7be15322610b Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 11:45:35 -0700 Subject: [PATCH 2/9] feat(ui): add the host Remote browser bridge Discover the versioned host bridge at runtime and install streaming HTTP and WebSocket transports alongside native discovery. Remote URL resolution is confined to the selected daemon's /api/v1 namespace, including encoded path traversal checks, while the bridge carries abortable request streams. Co-Authored-By: Nova (GPT-6) --- crates/hypercolor-ui/Cargo.lock | 2 + crates/hypercolor-ui/Cargo.toml | 2 + crates/hypercolor-ui/src/api/client.rs | 24 + crates/hypercolor-ui/src/lib.rs | 17 +- crates/hypercolor-ui/src/remote_bridge.rs | 427 ++++++++++++++++++ .../tests/remote_bridge_tests.rs | 44 ++ 6 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 crates/hypercolor-ui/src/remote_bridge.rs create mode 100644 crates/hypercolor-ui/tests/remote_bridge_tests.rs diff --git a/crates/hypercolor-ui/Cargo.lock b/crates/hypercolor-ui/Cargo.lock index 8e223874d..2eabf15e6 100644 --- a/crates/hypercolor-ui/Cargo.lock +++ b/crates/hypercolor-ui/Cargo.lock @@ -817,6 +817,7 @@ dependencies = [ "bytes", "console_error_panic_hook", "console_log", + "futures-util", "gloo-net 0.7.0", "hypercolor-color", "hypercolor-leptos-ext", @@ -838,6 +839,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", + "wasm-streams", "web-sys", ] diff --git a/crates/hypercolor-ui/Cargo.toml b/crates/hypercolor-ui/Cargo.toml index 7be17b865..f4fc86c1a 100644 --- a/crates/hypercolor-ui/Cargo.toml +++ b/crates/hypercolor-ui/Cargo.toml @@ -22,6 +22,8 @@ gloo-net = { version = "0.7", features = ["http"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" +futures-util = "0.3" +wasm-streams = "0.5" bytes = "1.11" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["raw_value"] } diff --git a/crates/hypercolor-ui/src/api/client.rs b/crates/hypercolor-ui/src/api/client.rs index aa9fceddd..5d3e7b842 100644 --- a/crates/hypercolor-ui/src/api/client.rs +++ b/crates/hypercolor-ui/src/api/client.rs @@ -70,6 +70,7 @@ pub fn install_http_transport( #[derive(Clone, PartialEq, Eq)] struct DaemonTransport { native_app: bool, + remote: bool, base_url: Option, protected_control_credential: Option, } @@ -78,6 +79,7 @@ impl Default for DaemonTransport { fn default() -> Self { Self { native_app: false, + remote: false, base_url: None, protected_control_credential: fragment_dev_control_credential(), } @@ -115,6 +117,11 @@ fn fragment_dev_control_credential() -> Option { impl DaemonTransport { fn resolve_url(&self, url: &str) -> Option { + if self.remote { + return self.base_url.as_ref().and_then(|base| { + crate::remote_bridge::resolve_remote_api_url_from_base(base, url) + }); + } if !url.starts_with('/') { return Some(url.to_owned()); } @@ -348,6 +355,7 @@ pub fn save_api_key(api_key: &str) { pub fn begin_native_daemon_verification() { DAEMON_TRANSPORT.with_borrow_mut(|transport| { transport.native_app = true; + transport.remote = false; transport.base_url = None; transport.protected_control_credential = None; }); @@ -361,6 +369,7 @@ pub fn install_verified_daemon_connection(base_url: &str, credential: Option<&st .filter(|credential| !credential.is_empty()); DAEMON_TRANSPORT.with_borrow_mut(|transport| { transport.native_app = true; + transport.remote = false; transport.base_url = (!base_url.is_empty()).then(|| base_url.to_owned()); transport.protected_control_credential = credential.map(str::to_owned); }); @@ -370,6 +379,19 @@ pub fn install_verified_daemon_connection(base_url: &str, credential: Option<&st pub fn clear_verified_daemon_connection() { DAEMON_TRANSPORT.with_borrow_mut(|transport| { transport.base_url = None; + transport.remote = false; + transport.protected_control_credential = None; + }); +} + +/// Install the host-provided Remote daemon route. Remote mode accepts only +/// `/api/v1` paths and never carries a local bearer credential. +#[cfg(target_arch = "wasm32")] +pub(crate) fn install_remote_daemon_connection(base_url: &str) { + DAEMON_TRANSPORT.with_borrow_mut(|transport| { + transport.native_app = false; + transport.remote = true; + transport.base_url = Some(base_url.trim_end_matches('/').to_owned()); transport.protected_control_credential = None; }); } @@ -890,6 +912,7 @@ mod tests { fn native_transport_routes_relative_urls_and_preserves_absolute_urls() { let transport = DaemonTransport { native_app: true, + remote: false, base_url: Some("http://127.0.0.1:9420".to_owned()), protected_control_credential: None, }; @@ -931,6 +954,7 @@ mod tests { fn verified_credential_precedes_public_key_and_clears_without_persistence() { let transport = DaemonTransport { native_app: true, + remote: false, base_url: None, protected_control_credential: Some("protected".to_owned()), }; diff --git a/crates/hypercolor-ui/src/lib.rs b/crates/hypercolor-ui/src/lib.rs index 333d402de..277baeb94 100644 --- a/crates/hypercolor-ui/src/lib.rs +++ b/crates/hypercolor-ui/src/lib.rs @@ -46,6 +46,7 @@ pub mod preview_telemetry; pub mod render_canvas; pub mod render_presets; pub mod route_ui; +pub mod remote_bridge; pub mod settings_audio_devices; pub mod storage; pub mod style_utils; @@ -97,12 +98,26 @@ fn print_banner() { /// than through context: the erased route defs ([`extensions::UiExtensions::routes`]) /// are `Send` but not `Sync`, so `provide_context` cannot carry them. Nav items /// are plain data and are surfaced through context inside [`app::app_view`]. -pub fn run_with_extensions(ext: UiExtensions) { +#[allow(unused_mut, reason = "Remote WASM discovery replaces the runtime mount")] +pub fn run_with_extensions(mut ext: UiExtensions) { _ = console_log::init_with_level(log::Level::Debug); console_error_panic_hook::set_once(); tauri_bridge::initialize_daemon_transport(); + #[cfg(target_arch = "wasm32")] + let remote = match remote_bridge::initialize() { + Ok(remote) => remote, + Err(_) => return, + }; + #[cfg(target_arch = "wasm32")] + if let Some(remote) = &remote { + ext.mount = remote.mount.clone(); + } print_banner(); mount_to_body(move || app::app_view(ext)); + #[cfg(target_arch = "wasm32")] + if let Some(remote) = remote { + remote.ready(); + } } /// Initialize logging and mount the standalone OSS app. The bin target's whole diff --git a/crates/hypercolor-ui/src/remote_bridge.rs b/crates/hypercolor-ui/src/remote_bridge.rs new file mode 100644 index 000000000..fb404a517 --- /dev/null +++ b/crates/hypercolor-ui/src/remote_bridge.rs @@ -0,0 +1,427 @@ +//! Generic browser bridge for host-served Remote UI builds. +//! +//! The module knows only the JavaScript transport contract. Cloud identity, +//! tickets, encryption, and account policy remain in the embedding loader. + +use crate::route_ui::UiMount; + +pub const CONTRACT_MIN: u32 = 1; +pub const CONTRACT_MAX: u32 = 1; + +/// Resolve a daemon API path inside the host-provided Remote mount. +/// +/// Only relative `/api/v1` routes are accepted. Browser normalization never +/// gets an opportunity to turn encoded traversal or separators into a route +/// outside the daemon bridge. +pub fn resolve_remote_api_url(mount: &str, daemon_id: &str, value: &str) -> Option { + let (path, query) = value.split_once('?').map_or((value, None), |(path, query)| (path, Some(query))); + if !(path == "/api/v1" || path.starts_with("/api/v1/")) + || path.starts_with("//") + || path.contains('\\') + || path.contains('#') + || path.chars().any(char::is_control) + || !safe_path_segments(path) + || query.is_some_and(|query| query.contains('#') || query.chars().any(char::is_control)) + { + return None; + } + uuid::Uuid::parse_str(daemon_id).ok()?; + let expected_mount = format!("/remote/{daemon_id}"); + if mount.trim_end_matches('/') != expected_mount { + return None; + } + let mount = UiMount::new(mount, mount).ok()?; + let suffix = query.map_or_else(String::new, |query| format!("?{query}")); + Some(format!("{}/_d{path}{suffix}", mount.route_base())) +} + +pub(crate) fn resolve_remote_api_url_from_base(base: &str, value: &str) -> Option { + let (path, query) = value.split_once('?').map_or((value, None), |(path, query)| (path, Some(query))); + if !(path == "/api/v1" || path.starts_with("/api/v1/")) + || path.starts_with("//") + || path.contains('\\') + || path.contains('#') + || path.chars().any(char::is_control) + || !safe_path_segments(path) + || query.is_some_and(|query| query.contains('#') || query.chars().any(char::is_control)) + { + return None; + } + Some(format!("{}{value}", base.trim_end_matches('/'))) +} + +fn safe_path_segments(path: &str) -> bool { + path.split('/').all(|segment| { + let Some(decoded) = percent_decode(segment) else { + return false; + }; + decoded != "." && decoded != ".." && !decoded.contains('/') && !decoded.contains('\\') + }) +} + +fn percent_decode(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let high = *bytes.get(index + 1)?; + let low = *bytes.get(index + 2)?; + decoded.push(hex(high)? << 4 | hex(low)?); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).ok() +} + +const fn hex(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(target_arch = "wasm32")] +mod browser { + use std::num::NonZeroUsize; + use std::pin::Pin; + use std::rc::Rc; + use std::task::{Context, Poll}; + + use futures_util::Stream; + use js_sys::{Array, Function, Object, Promise, Reflect, Uint8Array}; + use wasm_bindgen::{JsCast, JsValue, closure::Closure}; + use wasm_bindgen_futures::JsFuture; + + use crate::api::client::{install_http_transport, install_remote_daemon_connection}; + use crate::api::http_transport::{ + HttpBody, HttpBodySource, HttpCancellation, HttpHeader, HttpMethod, HttpRequest, + HttpMultipartField, HttpMultipartSource, HttpRequestBody, HttpResponse, HttpStreamError, + HttpStreamFuture, HttpStreamRequest, HttpStreamResponse, HttpTransport, HttpTransportError, + }; + use crate::ws::transport::{ + WebSocketBinaryFrame, WebSocketConnectRequest, WebSocketConnection, WebSocketEvent, + WebSocketEventHandler, WebSocketMessage, WebSocketTransport, WebSocketTransportError, + install_websocket_transport, + }; + + use super::{ + CONTRACT_MAX, CONTRACT_MIN, UiMount, resolve_remote_api_url, + resolve_remote_api_url_from_base, + }; + + pub struct RemoteBridge { + value: JsValue, + pub mount: UiMount, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct RemoteBridgeError { + pub code: &'static str, + } + + impl RemoteBridge { + pub fn ready(&self) { + let _ = call0(&self.value, "ready"); + } + } + + pub fn initialize() -> Result, RemoteBridgeError> { + let Some(window) = web_sys::window() else { return Ok(None) }; + let Ok(value) = Reflect::get(window.as_ref(), &JsValue::from_str("__HYPERCOLOR_REMOTE__")) else { + return Ok(None); + }; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + let result = initialize_value(value.clone()); + if let Err(code) = &result { + fatal(&value, code); + } + result + .map(Some) + .map_err(|code| RemoteBridgeError { code }) + } + + fn initialize_value(value: JsValue) -> Result { + let contract = get(&value, "contract")?; + let minimum = integer(&contract, "min")?; + let maximum = integer(&contract, "max")?; + let selected = CONTRACT_MAX.min(maximum); + if selected < CONTRACT_MIN || selected < minimum { + return Err("remote_contract_mismatch"); + } + let daemon_id = string(&value, "daemonId")?; + let mount_value = string(&value, "mount")?; + let mount = UiMount::new(&mount_value, &mount_value).map_err(|_| "remote_mount_invalid")?; + let base = resolve_remote_api_url(&mount_value, &daemon_id, "/api/v1") + .ok_or("remote_mount_invalid")? + .trim_end_matches("/api/v1") + .to_owned(); + install_remote_daemon_connection(&base); + let bridge = Rc::new(value.clone()); + install_http_transport(Rc::new(BridgeHttp(Rc::clone(&bridge)))) + .map_err(|_| "remote_http_transport_unavailable")?; + install_websocket_transport(Rc::new(BridgeWebSocket(bridge))) + .map_err(|_| "remote_socket_transport_unavailable")?; + Ok(RemoteBridge { value, mount }) + } + + fn fatal(value: &JsValue, code: &str) { + let _ = method(value, "fatal").and_then(|function| { + function.call1(value, &JsValue::from_str(code)).map_err(|_| "remote_fatal_failed") + }); + } + + struct BridgeHttp(Rc); + + impl HttpTransport for BridgeHttp { + fn send(&self, request: HttpRequest) -> crate::api::http_transport::HttpTransportFuture<'_> { + let bridge = Rc::clone(&self.0); + Box::pin(async move { + let cancellation = HttpCancellation::new(); + let body = match request.body { + HttpRequestBody::Empty => HttpBody::new(Box::new(EmptyBody), cancellation.clone()), + HttpRequestBody::Bytes(bytes) => HttpBody::new(Box::new(BytesBody(Some(bytes))), cancellation.clone()), + HttpRequestBody::Multipart(parts) => { + let boundary = multipart_boundary(&parts); + let fields = parts + .into_iter() + .map(|part| { + let filename = part.file_name.unwrap_or_else(|| "blob".to_owned()); + let content_type = part.content_type.as_deref().unwrap_or_default(); + HttpMultipartField::file( + &part.name, + filename, + content_type, + Box::new(BytesBody(Some(part.body))), + ) + }) + .collect(); + let source = HttpMultipartSource::new(boundary, fields) + .map_err(|error| HttpTransportError { message: error.to_string() })?; + let (body, content_type) = source.into_body(cancellation.clone()); + let mut headers = request.headers; + headers.push(content_type); + let response = request_stream(&bridge, HttpStreamRequest { + method: request.method, + path: request.path, + headers, + body, + }).await.map_err(|error| HttpTransportError { message: error.to_string() })?; + let bytes = collect_body(response.body).await.map_err(|error| HttpTransportError { message: error.to_string() })?; + return Ok(HttpResponse { status: response.status, headers: response.headers, body: bytes }); + } + }; + let response = request_stream(&bridge, HttpStreamRequest { + method: request.method, + path: request.path, + headers: request.headers, + body, + }).await.map_err(|error| HttpTransportError { message: error.to_string() })?; + let bytes = collect_body(response.body).await.map_err(|error| HttpTransportError { message: error.to_string() })?; + Ok(HttpResponse { status: response.status, headers: response.headers, body: bytes }) + }) + } + + fn send_stream(&self, request: HttpStreamRequest) -> HttpStreamFuture<'_> { + let bridge = Rc::clone(&self.0); + let cancellation = request.body.cancellation(); + HttpStreamFuture::new(cancellation, async move { request_stream(&bridge, request).await }) + } + } + + fn multipart_boundary(parts: &[crate::api::http_transport::HttpMultipartPart]) -> String { + for nonce in 0_u64.. { + let candidate = format!("hypercolor-remote-boundary-{nonce:016x}"); + if parts.iter().all(|part| { + !part + .body + .windows(candidate.len()) + .any(|window| window == candidate.as_bytes()) + }) { + return candidate; + } + } + unreachable!("u64 boundary space cannot be exhausted") + } + + async fn request_stream(bridge: &JsValue, request: HttpStreamRequest) -> Result { + if resolve_remote_api_url_from_base("", &request.path).is_none() { + return Err(HttpStreamError::Transport( + "Remote request path is outside /api/v1".to_owned(), + )); + } + let cancellation = request.body.cancellation(); + let response_cancellation = cancellation.clone(); + let controller = web_sys::AbortController::new().map_err(js_transport)?; + let stream = body_stream(request.body); + let init = Object::new(); + set(&init, "method", JsValue::from_str(method_name(request.method)))?; + set(&init, "path", JsValue::from_str(&request.path))?; + set(&init, "headers", header_array(&request.headers).into())?; + set(&init, "body", stream.into())?; + set(&init, "signal", controller.signal().into())?; + let promise = method(bridge, "request").map_err(transport_message)?.call1(bridge, &init).map_err(js_transport)? + .dyn_into::().map_err(|_| HttpStreamError::Transport("Remote request did not return a Promise".to_owned()))?; + let abort = controller.clone(); + wasm_bindgen_futures::spawn_local(async move { + cancellation.cancelled().await; + abort.abort(); + }); + let value = JsFuture::from(promise).await.map_err(js_transport)?; + let status = integer(&value, "status").map_err(transport_message)?; + let headers = parse_headers(get(&value, "headers").map_err(transport_message)?)?; + let raw = get(&value, "body").map_err(transport_message)? + .dyn_into::() + .map_err(|_| HttpStreamError::Transport("Remote response body is not a ReadableStream".to_owned()))?; + let body = HttpBody::new(Box::new(JsBody { stream: Box::pin(wasm_streams::ReadableStream::from_raw(raw).into_stream()) }), response_cancellation); + Ok(HttpStreamResponse { status: u16::try_from(status).map_err(|_| HttpStreamError::Transport("Remote response status is invalid".to_owned()))?, headers, body }) + } + + fn body_stream(body: HttpBody) -> web_sys::ReadableStream { + let stream = futures_util::stream::unfold(body, |mut body| async move { + match body.read_chunk(NonZeroUsize::new(64 * 1024).unwrap()).await { + Ok(Some(bytes)) => Some((Ok(Uint8Array::from(bytes.as_slice()).into()), body)), + Ok(None) => None, + Err(error) => Some((Err(JsValue::from_str(&error.to_string())), body)), + } + }); + wasm_streams::ReadableStream::from_stream(stream).into_raw() + } + + async fn collect_body(mut body: HttpBody) -> Result, HttpStreamError> { + let mut bytes = Vec::new(); + while let Some(chunk) = body.read_chunk(NonZeroUsize::new(64 * 1024).unwrap()).await? { bytes.extend(chunk); } + Ok(bytes) + } + + struct EmptyBody; + impl HttpBodySource for EmptyBody { + fn exact_length(&self) -> Option { Some(0) } + fn poll_chunk(&mut self, _: &mut Context<'_>, _: NonZeroUsize) -> Poll>, HttpStreamError>> { Poll::Ready(Ok(None)) } + fn cancel(&mut self) {} + } + struct BytesBody(Option>); + impl HttpBodySource for BytesBody { + fn exact_length(&self) -> Option { self.0.as_ref().map(|v| v.len() as u64) } + fn poll_chunk(&mut self, _: &mut Context<'_>, maximum: NonZeroUsize) -> Poll>, HttpStreamError>> { + let Some(mut bytes) = self.0.take() else { return Poll::Ready(Ok(None)); }; + if bytes.len() <= maximum.get() { return Poll::Ready(Ok(Some(bytes))); } + let rest = bytes.split_off(maximum.get()); self.0 = Some(rest); Poll::Ready(Ok(Some(bytes))) + } + fn cancel(&mut self) { self.0 = None; } + } + + struct JsBody { stream: Pin>>> } + impl HttpBodySource for JsBody { + fn exact_length(&self) -> Option { None } + fn poll_chunk(&mut self, cx: &mut Context<'_>, maximum: NonZeroUsize) -> Poll>, HttpStreamError>> { + match self.stream.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(value))) => { + let bytes = Uint8Array::new(&value).to_vec(); + if bytes.is_empty() || bytes.len() > maximum.get() { Poll::Ready(Err(HttpStreamError::InvalidChunk)) } else { Poll::Ready(Ok(Some(bytes))) } + } + Poll::Ready(Some(Err(error))) => Poll::Ready(Err(js_transport(error))), + Poll::Ready(None) => Poll::Ready(Ok(None)), + Poll::Pending => Poll::Pending, + } + } + fn cancel(&mut self) {} + } + + struct BridgeWebSocket(Rc); + struct BridgeSocket { value: JsValue, _callbacks: Vec> } + impl WebSocketConnection for BridgeSocket { + fn send(&self, message: WebSocketMessage) -> Result<(), WebSocketTransportError> { + let value = match message { WebSocketMessage::Text(v) => JsValue::from_str(&v), WebSocketMessage::Binary(v) => Uint8Array::from(v.to_vec().as_slice()).into() }; + method(&self.value, "send").and_then(|f| f.call1(&self.value, &value).map(|_| ()).map_err(|_| "Remote socket send failed")).map_err(ws_error) + } + fn close(&self) -> Result<(), WebSocketTransportError> { call0(&self.value, "close").map(|_| ()).map_err(ws_error) } + } + impl WebSocketTransport for BridgeWebSocket { + fn connect(&self, request: WebSocketConnectRequest, events: WebSocketEventHandler) -> Result, WebSocketTransportError> { + if resolve_remote_api_url_from_base("", &request.path).is_none() { + return Err(ws_error("Remote socket path is outside /api/v1")); + } + let socket = method(&self.0, "openSocket").and_then(|f| f.call1(&self.0, &JsValue::from_str(&request.path)).map_err(|_| "Remote openSocket failed")).map_err(ws_error)?; + let mut callbacks = Vec::new(); + for (name, event) in [("onopen", WebSocketEvent::Opened), ("onerror", WebSocketEvent::Error { message: "Remote socket error".to_owned() })] { + let events = Rc::clone(&events); let event = event.clone(); + let callback = Closure::wrap(Box::new(move |_: JsValue| events(event.clone())) as Box); + Reflect::set(&socket, &JsValue::from_str(name), callback.as_ref()).map_err(|_| ws_error("Remote socket callback install failed"))?; callbacks.push(callback); + } + let messages = Rc::clone(&events); + let callback = Closure::wrap(Box::new(move |value: JsValue| { + if let Some(text) = value.as_string() { messages(WebSocketEvent::Message(WebSocketMessage::Text(text))); } + else { messages(WebSocketEvent::Message(WebSocketMessage::Binary(WebSocketBinaryFrame::from_bytes(Uint8Array::new(&value).to_vec())))); } + }) as Box); + Reflect::set(&socket, &JsValue::from_str("onmessage"), callback.as_ref()).map_err(|_| ws_error("Remote socket callback install failed"))?; callbacks.push(callback); + let closes = Rc::clone(&events); + let callback = Closure::wrap(Box::new(move |value: JsValue| closes(WebSocketEvent::Closed { code: Reflect::get(&value, &JsValue::from_str("code")).ok().and_then(|v| v.as_f64()).unwrap_or(1006.0) as u16, reason: Reflect::get(&value, &JsValue::from_str("reason")).ok().and_then(|v| v.as_string()).unwrap_or_default() })) as Box); + Reflect::set(&socket, &JsValue::from_str("onclose"), callback.as_ref()).map_err(|_| ws_error("Remote socket callback install failed"))?; callbacks.push(callback); + Ok(Rc::new(BridgeSocket { value: socket, _callbacks: callbacks })) + } + } + + fn get(value: &JsValue, name: &str) -> Result { Reflect::get(value, &JsValue::from_str(name)).map_err(|_| "remote_contract_invalid") } + fn method(value: &JsValue, name: &str) -> Result { get(value, name)?.dyn_into().map_err(|_| "remote_contract_invalid") } + fn call0(value: &JsValue, name: &str) -> Result { method(value, name)?.call0(value).map_err(|_| "remote_contract_invalid") } + fn string(value: &JsValue, name: &str) -> Result { get(value, name)?.as_string().ok_or("remote_contract_invalid") } + fn integer(value: &JsValue, name: &str) -> Result { let number = get(value, name)?.as_f64().ok_or("remote_contract_invalid")?; if number.fract() == 0.0 && number >= 0.0 && number <= u32::MAX as f64 { Ok(number as u32) } else { Err("remote_contract_invalid") } } + fn set(object: &Object, name: &str, value: JsValue) -> Result<(), HttpStreamError> { Reflect::set(object, &JsValue::from_str(name), &value).map(|_| ()).map_err(js_transport) } + fn method_name(method: HttpMethod) -> &'static str { match method { HttpMethod::Get => "GET", HttpMethod::Head => "HEAD", HttpMethod::Post => "POST", HttpMethod::Put => "PUT", HttpMethod::Patch => "PATCH", HttpMethod::Delete => "DELETE" } } + fn header_array(headers: &[HttpHeader]) -> Array { + let rows = Array::new(); + for header in headers { + let pair = Array::new(); + pair.push(&JsValue::from_str(&header.name)); + pair.push(&JsValue::from_str(&header.value)); + rows.push(&pair); + } + rows + } + fn parse_headers(value: JsValue) -> Result, HttpStreamError> { let rows = Array::from(&value); rows.iter().map(|row| { let pair = Array::from(&row); Ok(HttpHeader { name: pair.get(0).as_string().ok_or_else(|| HttpStreamError::Transport("Remote response header name is invalid".to_owned()))?, value: pair.get(1).as_string().ok_or_else(|| HttpStreamError::Transport("Remote response header value is invalid".to_owned()))? }) }).collect() } + fn js_transport(error: JsValue) -> HttpStreamError { HttpStreamError::Transport(error.as_string().unwrap_or_else(|| "Remote bridge JavaScript error".to_owned())) } + fn transport_message(message: &str) -> HttpStreamError { HttpStreamError::Transport(message.to_owned()) } + fn ws_error(message: &str) -> WebSocketTransportError { WebSocketTransportError { message: message.to_owned() } } +} + +#[cfg(target_arch = "wasm32")] +pub use browser::{RemoteBridge, RemoteBridgeError, initialize}; + +#[cfg(test)] +mod tests { + use super::resolve_remote_api_url; + + const DAEMON: &str = "018f4c36-4a44-7cc9-9f57-0d2e9224d2f1"; + + #[test] + fn rebases_api_routes_under_the_remote_daemon_mount() { + assert_eq!(resolve_remote_api_url(&format!("/remote/{DAEMON}"), DAEMON, "/api/v1/devices?limit=2"), Some(format!("/remote/{DAEMON}/_d/api/v1/devices?limit=2"))); + } + + #[test] + fn refuses_absolute_protocol_relative_and_traversal_routes() { + for path in ["https://evil.test/api/v1", "//evil.test/api/v1", "/api/v1/../admin", "/api/v1/%2e%2e/admin", "/api/v1/%2E%2E/admin", "/api/v1/%2fadmin", "/api/v1/a\\b", "/api/v2/devices"] { + assert_eq!(resolve_remote_api_url(&format!("/remote/{DAEMON}"), DAEMON, path), None, "{path}"); + } + } + + #[test] + fn binds_the_remote_mount_to_the_selected_daemon() { + assert_eq!( + resolve_remote_api_url("/remote/not-the-daemon", DAEMON, "/api/v1/devices"), + None + ); + assert_eq!( + resolve_remote_api_url("/remote/018f4c36-4a44-7cc9-9f57-0d2e9224d2f2", DAEMON, "/api/v1/devices"), + None + ); + } +} diff --git a/crates/hypercolor-ui/tests/remote_bridge_tests.rs b/crates/hypercolor-ui/tests/remote_bridge_tests.rs new file mode 100644 index 000000000..8371adfc3 --- /dev/null +++ b/crates/hypercolor-ui/tests/remote_bridge_tests.rs @@ -0,0 +1,44 @@ +#![cfg(target_arch = "wasm32")] + +use hypercolor_ui::remote_bridge; +use wasm_bindgen::prelude::*; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen(inline_js = r#" +export function installIncompatibleBridge() { + window.__hypercolorRemoteFatal = null; + window.__HYPERCOLOR_REMOTE__ = { + contract: {min: 2, max: 3}, + daemonId: "018f4c36-4a44-7cc9-9f57-0d2e9224d2f1", + mount: "/remote/018f4c36-4a44-7cc9-9f57-0d2e9224d2f1", + fatal(code) { window.__hypercolorRemoteFatal = code; }, + }; +} + +export function recordedFatal() { + return window.__hypercolorRemoteFatal; +} + +export function clearBridge() { + delete window.__HYPERCOLOR_REMOTE__; + delete window.__hypercolorRemoteFatal; +} +"#)] +extern "C" { + #[wasm_bindgen(js_name = installIncompatibleBridge)] + fn install_incompatible_bridge(); + #[wasm_bindgen(js_name = recordedFatal)] + fn recorded_fatal() -> Option; + #[wasm_bindgen(js_name = clearBridge)] + fn clear_bridge(); +} + +#[wasm_bindgen_test] +fn incompatible_contract_calls_fatal_and_refuses_startup() { + install_incompatible_bridge(); + assert!(remote_bridge::initialize().is_err()); + assert_eq!(recorded_fatal().as_deref(), Some("remote_contract_mismatch")); + clear_bridge(); +} From 7f9fe364fab56c0cc5871b470e96587541cc1ee3 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 11:54:31 -0700 Subject: [PATCH 3/9] fix(ui): close Remote bridge transport lifecycles Retire request cancellation watchers at EOF and abort pending exchanges on drop. Split browser chunks to each consumer's requested capacity. Socket drops clear JavaScript callbacks before closing. Startup rejects incomplete or malformed bridge contracts before installing transports. Co-Authored-By: Nova (GPT-6) --- crates/hypercolor-daemon/src/startup/mod.rs | 2 +- crates/hypercolor-ui/src/lib.rs | 7 +- crates/hypercolor-ui/src/remote_bridge.rs | 675 +++++++++++++++--- .../tests/remote_bridge_tests.rs | 24 +- 4 files changed, 609 insertions(+), 99 deletions(-) diff --git a/crates/hypercolor-daemon/src/startup/mod.rs b/crates/hypercolor-daemon/src/startup/mod.rs index c96a52c9c..f516794ff 100644 --- a/crates/hypercolor-daemon/src/startup/mod.rs +++ b/crates/hypercolor-daemon/src/startup/mod.rs @@ -100,7 +100,7 @@ pub(crate) async fn persist_scene_store_snapshot( pub struct DaemonState { /// Resolved directory served by the local UI router, when present. /// Extensions may inspect the same path before their install/start hooks. - pub ui_dir: Option, + pub(crate) ui_dir: Option, /// Complete domain service graph shared by every transport. pub domains: DomainContexts, diff --git a/crates/hypercolor-ui/src/lib.rs b/crates/hypercolor-ui/src/lib.rs index 277baeb94..e7a67e96a 100644 --- a/crates/hypercolor-ui/src/lib.rs +++ b/crates/hypercolor-ui/src/lib.rs @@ -43,10 +43,10 @@ pub mod optimistic_controls; pub mod pages; pub mod preferences; pub mod preview_telemetry; +pub mod remote_bridge; pub mod render_canvas; pub mod render_presets; pub mod route_ui; -pub mod remote_bridge; pub mod settings_audio_devices; pub mod storage; pub mod style_utils; @@ -98,7 +98,10 @@ fn print_banner() { /// than through context: the erased route defs ([`extensions::UiExtensions::routes`]) /// are `Send` but not `Sync`, so `provide_context` cannot carry them. Nav items /// are plain data and are surfaced through context inside [`app::app_view`]. -#[allow(unused_mut, reason = "Remote WASM discovery replaces the runtime mount")] +#[allow( + unused_mut, + reason = "Remote WASM discovery replaces the runtime mount" +)] pub fn run_with_extensions(mut ext: UiExtensions) { _ = console_log::init_with_level(log::Level::Debug); console_error_panic_hook::set_once(); diff --git a/crates/hypercolor-ui/src/remote_bridge.rs b/crates/hypercolor-ui/src/remote_bridge.rs index fb404a517..7d02dd19e 100644 --- a/crates/hypercolor-ui/src/remote_bridge.rs +++ b/crates/hypercolor-ui/src/remote_bridge.rs @@ -14,7 +14,9 @@ pub const CONTRACT_MAX: u32 = 1; /// gets an opportunity to turn encoded traversal or separators into a route /// outside the daemon bridge. pub fn resolve_remote_api_url(mount: &str, daemon_id: &str, value: &str) -> Option { - let (path, query) = value.split_once('?').map_or((value, None), |(path, query)| (path, Some(query))); + let (path, query) = value + .split_once('?') + .map_or((value, None), |(path, query)| (path, Some(query))); if !(path == "/api/v1" || path.starts_with("/api/v1/")) || path.starts_with("//") || path.contains('\\') @@ -36,7 +38,9 @@ pub fn resolve_remote_api_url(mount: &str, daemon_id: &str, value: &str) -> Opti } pub(crate) fn resolve_remote_api_url_from_base(base: &str, value: &str) -> Option { - let (path, query) = value.split_once('?').map_or((value, None), |(path, query)| (path, Some(query))); + let (path, query) = value + .split_once('?') + .map_or((value, None), |(path, query)| (path, Some(query))); if !(path == "/api/v1" || path.starts_with("/api/v1/")) || path.starts_with("//") || path.contains('\\') @@ -93,15 +97,18 @@ mod browser { use std::rc::Rc; use std::task::{Context, Poll}; - use futures_util::Stream; + use futures_util::{ + Stream, + future::{AbortHandle, Abortable}, + }; use js_sys::{Array, Function, Object, Promise, Reflect, Uint8Array}; use wasm_bindgen::{JsCast, JsValue, closure::Closure}; use wasm_bindgen_futures::JsFuture; use crate::api::client::{install_http_transport, install_remote_daemon_connection}; use crate::api::http_transport::{ - HttpBody, HttpBodySource, HttpCancellation, HttpHeader, HttpMethod, HttpRequest, - HttpMultipartField, HttpMultipartSource, HttpRequestBody, HttpResponse, HttpStreamError, + HttpBody, HttpBodySource, HttpCancellation, HttpHeader, HttpMethod, HttpMultipartField, + HttpMultipartSource, HttpRequest, HttpRequestBody, HttpResponse, HttpStreamError, HttpStreamFuture, HttpStreamRequest, HttpStreamResponse, HttpTransport, HttpTransportError, }; use crate::ws::transport::{ @@ -132,8 +139,11 @@ mod browser { } pub fn initialize() -> Result, RemoteBridgeError> { - let Some(window) = web_sys::window() else { return Ok(None) }; - let Ok(value) = Reflect::get(window.as_ref(), &JsValue::from_str("__HYPERCOLOR_REMOTE__")) else { + let Some(window) = web_sys::window() else { + return Ok(None); + }; + let Ok(value) = Reflect::get(window.as_ref(), &JsValue::from_str("__HYPERCOLOR_REMOTE__")) + else { return Ok(None); }; if value.is_null() || value.is_undefined() { @@ -143,15 +153,16 @@ mod browser { if let Err(code) = &result { fatal(&value, code); } - result - .map(Some) - .map_err(|code| RemoteBridgeError { code }) + result.map(Some).map_err(|code| RemoteBridgeError { code }) } fn initialize_value(value: JsValue) -> Result { let contract = get(&value, "contract")?; let minimum = integer(&contract, "min")?; let maximum = integer(&contract, "max")?; + if minimum == 0 || maximum == 0 || minimum > maximum { + return Err("remote_contract_mismatch"); + } let selected = CONTRACT_MAX.min(maximum); if selected < CONTRACT_MIN || selected < minimum { return Err("remote_contract_mismatch"); @@ -163,6 +174,9 @@ mod browser { .ok_or("remote_mount_invalid")? .trim_end_matches("/api/v1") .to_owned(); + for name in ["request", "openSocket", "ready", "fatal"] { + method(&value, name)?; + } install_remote_daemon_connection(&base); let bridge = Rc::new(value.clone()); install_http_transport(Rc::new(BridgeHttp(Rc::clone(&bridge)))) @@ -174,20 +188,29 @@ mod browser { fn fatal(value: &JsValue, code: &str) { let _ = method(value, "fatal").and_then(|function| { - function.call1(value, &JsValue::from_str(code)).map_err(|_| "remote_fatal_failed") + function + .call1(value, &JsValue::from_str(code)) + .map_err(|_| "remote_fatal_failed") }); } struct BridgeHttp(Rc); impl HttpTransport for BridgeHttp { - fn send(&self, request: HttpRequest) -> crate::api::http_transport::HttpTransportFuture<'_> { + fn send( + &self, + request: HttpRequest, + ) -> crate::api::http_transport::HttpTransportFuture<'_> { let bridge = Rc::clone(&self.0); Box::pin(async move { let cancellation = HttpCancellation::new(); let body = match request.body { - HttpRequestBody::Empty => HttpBody::new(Box::new(EmptyBody), cancellation.clone()), - HttpRequestBody::Bytes(bytes) => HttpBody::new(Box::new(BytesBody(Some(bytes))), cancellation.clone()), + HttpRequestBody::Empty => { + HttpBody::new(Box::new(EmptyBody), cancellation.clone()) + } + HttpRequestBody::Bytes(bytes) => { + HttpBody::new(Box::new(BytesBody(Some(bytes))), cancellation.clone()) + } HttpRequestBody::Multipart(parts) => { let boundary = multipart_boundary(&parts); let fields = parts @@ -203,36 +226,73 @@ mod browser { ) }) .collect(); - let source = HttpMultipartSource::new(boundary, fields) - .map_err(|error| HttpTransportError { message: error.to_string() })?; + let source = + HttpMultipartSource::new(boundary, fields).map_err(|error| { + HttpTransportError { + message: error.to_string(), + } + })?; let (body, content_type) = source.into_body(cancellation.clone()); let mut headers = request.headers; headers.push(content_type); - let response = request_stream(&bridge, HttpStreamRequest { - method: request.method, - path: request.path, - headers, - body, - }).await.map_err(|error| HttpTransportError { message: error.to_string() })?; - let bytes = collect_body(response.body).await.map_err(|error| HttpTransportError { message: error.to_string() })?; - return Ok(HttpResponse { status: response.status, headers: response.headers, body: bytes }); + let response = request_stream( + &bridge, + HttpStreamRequest { + method: request.method, + path: request.path, + headers, + body, + }, + ) + .await + .map_err(|error| HttpTransportError { + message: error.to_string(), + })?; + let bytes = collect_body(response.body).await.map_err(|error| { + HttpTransportError { + message: error.to_string(), + } + })?; + return Ok(HttpResponse { + status: response.status, + headers: response.headers, + body: bytes, + }); } }; - let response = request_stream(&bridge, HttpStreamRequest { - method: request.method, - path: request.path, - headers: request.headers, - body, - }).await.map_err(|error| HttpTransportError { message: error.to_string() })?; - let bytes = collect_body(response.body).await.map_err(|error| HttpTransportError { message: error.to_string() })?; - Ok(HttpResponse { status: response.status, headers: response.headers, body: bytes }) + let response = request_stream( + &bridge, + HttpStreamRequest { + method: request.method, + path: request.path, + headers: request.headers, + body, + }, + ) + .await + .map_err(|error| HttpTransportError { + message: error.to_string(), + })?; + let bytes = + collect_body(response.body) + .await + .map_err(|error| HttpTransportError { + message: error.to_string(), + })?; + Ok(HttpResponse { + status: response.status, + headers: response.headers, + body: bytes, + }) }) } fn send_stream(&self, request: HttpStreamRequest) -> HttpStreamFuture<'_> { let bridge = Rc::clone(&self.0); let cancellation = request.body.cancellation(); - HttpStreamFuture::new(cancellation, async move { request_stream(&bridge, request).await }) + HttpStreamFuture::new(cancellation, async move { + request_stream(&bridge, request).await + }) } } @@ -251,7 +311,10 @@ mod browser { unreachable!("u64 boundary space cannot be exhausted") } - async fn request_stream(bridge: &JsValue, request: HttpStreamRequest) -> Result { + async fn request_stream( + bridge: &JsValue, + request: HttpStreamRequest, + ) -> Result { if resolve_remote_api_url_from_base("", &request.path).is_none() { return Err(HttpStreamError::Transport( "Remote request path is outside /api/v1".to_owned(), @@ -262,26 +325,66 @@ mod browser { let controller = web_sys::AbortController::new().map_err(js_transport)?; let stream = body_stream(request.body); let init = Object::new(); - set(&init, "method", JsValue::from_str(method_name(request.method)))?; + set( + &init, + "method", + JsValue::from_str(method_name(request.method)), + )?; set(&init, "path", JsValue::from_str(&request.path))?; set(&init, "headers", header_array(&request.headers).into())?; set(&init, "body", stream.into())?; set(&init, "signal", controller.signal().into())?; - let promise = method(bridge, "request").map_err(transport_message)?.call1(bridge, &init).map_err(js_transport)? - .dyn_into::().map_err(|_| HttpStreamError::Transport("Remote request did not return a Promise".to_owned()))?; + let promise = method(bridge, "request") + .map_err(transport_message)? + .call1(bridge, &init) + .map_err(js_transport)? + .dyn_into::() + .map_err(|_| { + HttpStreamError::Transport("Remote request did not return a Promise".to_owned()) + })?; let abort = controller.clone(); + let (watcher, registration) = AbortHandle::new_pair(); + let watcher = CancellationWatcher { + task: watcher, + controller, + finished: false, + }; wasm_bindgen_futures::spawn_local(async move { - cancellation.cancelled().await; - abort.abort(); + let _ = Abortable::new( + async move { + cancellation.cancelled().await; + abort.abort(); + }, + registration, + ) + .await; }); let value = JsFuture::from(promise).await.map_err(js_transport)?; let status = integer(&value, "status").map_err(transport_message)?; let headers = parse_headers(get(&value, "headers").map_err(transport_message)?)?; - let raw = get(&value, "body").map_err(transport_message)? + let raw = get(&value, "body") + .map_err(transport_message)? .dyn_into::() - .map_err(|_| HttpStreamError::Transport("Remote response body is not a ReadableStream".to_owned()))?; - let body = HttpBody::new(Box::new(JsBody { stream: Box::pin(wasm_streams::ReadableStream::from_raw(raw).into_stream()) }), response_cancellation); - Ok(HttpStreamResponse { status: u16::try_from(status).map_err(|_| HttpStreamError::Transport("Remote response status is invalid".to_owned()))?, headers, body }) + .map_err(|_| { + HttpStreamError::Transport( + "Remote response body is not a ReadableStream".to_owned(), + ) + })?; + let body = HttpBody::new( + Box::new(JsBody { + stream: Box::pin(wasm_streams::ReadableStream::from_raw(raw).into_stream()), + buffered: None, + watcher, + }), + response_cancellation, + ); + Ok(HttpStreamResponse { + status: u16::try_from(status).map_err(|_| { + HttpStreamError::Transport("Remote response status is invalid".to_owned()) + })?, + headers, + body, + }) } fn body_stream(body: HttpBody) -> web_sys::ReadableStream { @@ -297,85 +400,278 @@ mod browser { async fn collect_body(mut body: HttpBody) -> Result, HttpStreamError> { let mut bytes = Vec::new(); - while let Some(chunk) = body.read_chunk(NonZeroUsize::new(64 * 1024).unwrap()).await? { bytes.extend(chunk); } + while let Some(chunk) = body + .read_chunk(NonZeroUsize::new(64 * 1024).unwrap()) + .await? + { + bytes.extend(chunk); + } Ok(bytes) } struct EmptyBody; impl HttpBodySource for EmptyBody { - fn exact_length(&self) -> Option { Some(0) } - fn poll_chunk(&mut self, _: &mut Context<'_>, _: NonZeroUsize) -> Poll>, HttpStreamError>> { Poll::Ready(Ok(None)) } + fn exact_length(&self) -> Option { + Some(0) + } + fn poll_chunk( + &mut self, + _: &mut Context<'_>, + _: NonZeroUsize, + ) -> Poll>, HttpStreamError>> { + Poll::Ready(Ok(None)) + } fn cancel(&mut self) {} } struct BytesBody(Option>); impl HttpBodySource for BytesBody { - fn exact_length(&self) -> Option { self.0.as_ref().map(|v| v.len() as u64) } - fn poll_chunk(&mut self, _: &mut Context<'_>, maximum: NonZeroUsize) -> Poll>, HttpStreamError>> { - let Some(mut bytes) = self.0.take() else { return Poll::Ready(Ok(None)); }; - if bytes.len() <= maximum.get() { return Poll::Ready(Ok(Some(bytes))); } - let rest = bytes.split_off(maximum.get()); self.0 = Some(rest); Poll::Ready(Ok(Some(bytes))) + fn exact_length(&self) -> Option { + self.0.as_ref().map(|v| v.len() as u64) + } + fn poll_chunk( + &mut self, + _: &mut Context<'_>, + maximum: NonZeroUsize, + ) -> Poll>, HttpStreamError>> { + let Some(mut bytes) = self.0.take() else { + return Poll::Ready(Ok(None)); + }; + if bytes.len() <= maximum.get() { + return Poll::Ready(Ok(Some(bytes))); + } + let rest = bytes.split_off(maximum.get()); + self.0 = Some(rest); + Poll::Ready(Ok(Some(bytes))) + } + fn cancel(&mut self) { + self.0 = None; } - fn cancel(&mut self) { self.0 = None; } } - struct JsBody { stream: Pin>>> } + struct JsBody { + stream: Pin>>>, + buffered: Option>, + watcher: CancellationWatcher, + } impl HttpBodySource for JsBody { - fn exact_length(&self) -> Option { None } - fn poll_chunk(&mut self, cx: &mut Context<'_>, maximum: NonZeroUsize) -> Poll>, HttpStreamError>> { + fn exact_length(&self) -> Option { + None + } + fn poll_chunk( + &mut self, + cx: &mut Context<'_>, + maximum: NonZeroUsize, + ) -> Poll>, HttpStreamError>> { + if let Some(mut bytes) = self.buffered.take() { + if bytes.len() > maximum.get() { + let rest = bytes.split_off(maximum.get()); + self.buffered = Some(rest); + } + return Poll::Ready(Ok(Some(bytes))); + } match self.stream.as_mut().poll_next(cx) { Poll::Ready(Some(Ok(value))) => { - let bytes = Uint8Array::new(&value).to_vec(); - if bytes.is_empty() || bytes.len() > maximum.get() { Poll::Ready(Err(HttpStreamError::InvalidChunk)) } else { Poll::Ready(Ok(Some(bytes))) } + let mut bytes = Uint8Array::new(&value).to_vec(); + if bytes.is_empty() { + return Poll::Ready(Err(HttpStreamError::InvalidChunk)); + } + if bytes.len() > maximum.get() { + let rest = bytes.split_off(maximum.get()); + self.buffered = Some(rest); + } + Poll::Ready(Ok(Some(bytes))) } Poll::Ready(Some(Err(error))) => Poll::Ready(Err(js_transport(error))), - Poll::Ready(None) => Poll::Ready(Ok(None)), + Poll::Ready(None) => { + self.watcher.finish(); + Poll::Ready(Ok(None)) + } Poll::Pending => Poll::Pending, } } - fn cancel(&mut self) {} + fn cancel(&mut self) { + self.watcher.cancel(); + self.buffered = None; + } + } + + impl Drop for JsBody { + fn drop(&mut self) { + self.watcher.cancel(); + } + } + + struct CancellationWatcher { + task: AbortHandle, + controller: web_sys::AbortController, + finished: bool, + } + + impl CancellationWatcher { + fn cancel(&mut self) { + if !self.finished { + self.finished = true; + self.controller.abort(); + } + self.task.abort(); + } + + fn finish(&mut self) { + self.finished = true; + self.task.abort(); + } + } + + impl Drop for CancellationWatcher { + fn drop(&mut self) { + self.cancel(); + } } struct BridgeWebSocket(Rc); - struct BridgeSocket { value: JsValue, _callbacks: Vec> } + struct BridgeSocket { + value: JsValue, + _callbacks: Vec>, + } impl WebSocketConnection for BridgeSocket { fn send(&self, message: WebSocketMessage) -> Result<(), WebSocketTransportError> { - let value = match message { WebSocketMessage::Text(v) => JsValue::from_str(&v), WebSocketMessage::Binary(v) => Uint8Array::from(v.to_vec().as_slice()).into() }; - method(&self.value, "send").and_then(|f| f.call1(&self.value, &value).map(|_| ()).map_err(|_| "Remote socket send failed")).map_err(ws_error) + let value = match message { + WebSocketMessage::Text(v) => JsValue::from_str(&v), + WebSocketMessage::Binary(v) => Uint8Array::from(v.to_vec().as_slice()).into(), + }; + method(&self.value, "send") + .and_then(|f| { + f.call1(&self.value, &value) + .map(|_| ()) + .map_err(|_| "Remote socket send failed") + }) + .map_err(ws_error) + } + fn close(&self) -> Result<(), WebSocketTransportError> { + call0(&self.value, "close").map(|_| ()).map_err(ws_error) + } + } + impl Drop for BridgeSocket { + fn drop(&mut self) { + for name in ["onopen", "onmessage", "onclose", "onerror"] { + let _ = Reflect::set(&self.value, &JsValue::from_str(name), &JsValue::NULL); + } + let _ = call0(&self.value, "close"); } - fn close(&self) -> Result<(), WebSocketTransportError> { call0(&self.value, "close").map(|_| ()).map_err(ws_error) } } impl WebSocketTransport for BridgeWebSocket { - fn connect(&self, request: WebSocketConnectRequest, events: WebSocketEventHandler) -> Result, WebSocketTransportError> { + fn connect( + &self, + request: WebSocketConnectRequest, + events: WebSocketEventHandler, + ) -> Result, WebSocketTransportError> { if resolve_remote_api_url_from_base("", &request.path).is_none() { return Err(ws_error("Remote socket path is outside /api/v1")); } - let socket = method(&self.0, "openSocket").and_then(|f| f.call1(&self.0, &JsValue::from_str(&request.path)).map_err(|_| "Remote openSocket failed")).map_err(ws_error)?; + let socket = method(&self.0, "openSocket") + .and_then(|f| { + f.call1(&self.0, &JsValue::from_str(&request.path)) + .map_err(|_| "Remote openSocket failed") + }) + .map_err(ws_error)?; let mut callbacks = Vec::new(); - for (name, event) in [("onopen", WebSocketEvent::Opened), ("onerror", WebSocketEvent::Error { message: "Remote socket error".to_owned() })] { - let events = Rc::clone(&events); let event = event.clone(); - let callback = Closure::wrap(Box::new(move |_: JsValue| events(event.clone())) as Box); - Reflect::set(&socket, &JsValue::from_str(name), callback.as_ref()).map_err(|_| ws_error("Remote socket callback install failed"))?; callbacks.push(callback); + for (name, event) in [ + ("onopen", WebSocketEvent::Opened), + ( + "onerror", + WebSocketEvent::Error { + message: "Remote socket error".to_owned(), + }, + ), + ] { + let events = Rc::clone(&events); + let event = event.clone(); + let callback = + Closure::wrap(Box::new(move |_: JsValue| events(event.clone())) + as Box); + Reflect::set(&socket, &JsValue::from_str(name), callback.as_ref()) + .map_err(|_| ws_error("Remote socket callback install failed"))?; + callbacks.push(callback); } let messages = Rc::clone(&events); let callback = Closure::wrap(Box::new(move |value: JsValue| { - if let Some(text) = value.as_string() { messages(WebSocketEvent::Message(WebSocketMessage::Text(text))); } - else { messages(WebSocketEvent::Message(WebSocketMessage::Binary(WebSocketBinaryFrame::from_bytes(Uint8Array::new(&value).to_vec())))); } + if let Some(text) = value.as_string() { + messages(WebSocketEvent::Message(WebSocketMessage::Text(text))); + } else { + messages(WebSocketEvent::Message(WebSocketMessage::Binary( + WebSocketBinaryFrame::from_bytes(Uint8Array::new(&value).to_vec()), + ))); + } }) as Box); - Reflect::set(&socket, &JsValue::from_str("onmessage"), callback.as_ref()).map_err(|_| ws_error("Remote socket callback install failed"))?; callbacks.push(callback); + Reflect::set(&socket, &JsValue::from_str("onmessage"), callback.as_ref()) + .map_err(|_| ws_error("Remote socket callback install failed"))?; + callbacks.push(callback); let closes = Rc::clone(&events); - let callback = Closure::wrap(Box::new(move |value: JsValue| closes(WebSocketEvent::Closed { code: Reflect::get(&value, &JsValue::from_str("code")).ok().and_then(|v| v.as_f64()).unwrap_or(1006.0) as u16, reason: Reflect::get(&value, &JsValue::from_str("reason")).ok().and_then(|v| v.as_string()).unwrap_or_default() })) as Box); - Reflect::set(&socket, &JsValue::from_str("onclose"), callback.as_ref()).map_err(|_| ws_error("Remote socket callback install failed"))?; callbacks.push(callback); - Ok(Rc::new(BridgeSocket { value: socket, _callbacks: callbacks })) + let callback = Closure::wrap(Box::new(move |value: JsValue| { + closes(WebSocketEvent::Closed { + code: Reflect::get(&value, &JsValue::from_str("code")) + .ok() + .and_then(|v| v.as_f64()) + .unwrap_or(1006.0) as u16, + reason: Reflect::get(&value, &JsValue::from_str("reason")) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default(), + }) + }) as Box); + Reflect::set(&socket, &JsValue::from_str("onclose"), callback.as_ref()) + .map_err(|_| ws_error("Remote socket callback install failed"))?; + callbacks.push(callback); + Ok(Rc::new(BridgeSocket { + value: socket, + _callbacks: callbacks, + })) } } - fn get(value: &JsValue, name: &str) -> Result { Reflect::get(value, &JsValue::from_str(name)).map_err(|_| "remote_contract_invalid") } - fn method(value: &JsValue, name: &str) -> Result { get(value, name)?.dyn_into().map_err(|_| "remote_contract_invalid") } - fn call0(value: &JsValue, name: &str) -> Result { method(value, name)?.call0(value).map_err(|_| "remote_contract_invalid") } - fn string(value: &JsValue, name: &str) -> Result { get(value, name)?.as_string().ok_or("remote_contract_invalid") } - fn integer(value: &JsValue, name: &str) -> Result { let number = get(value, name)?.as_f64().ok_or("remote_contract_invalid")?; if number.fract() == 0.0 && number >= 0.0 && number <= u32::MAX as f64 { Ok(number as u32) } else { Err("remote_contract_invalid") } } - fn set(object: &Object, name: &str, value: JsValue) -> Result<(), HttpStreamError> { Reflect::set(object, &JsValue::from_str(name), &value).map(|_| ()).map_err(js_transport) } - fn method_name(method: HttpMethod) -> &'static str { match method { HttpMethod::Get => "GET", HttpMethod::Head => "HEAD", HttpMethod::Post => "POST", HttpMethod::Put => "PUT", HttpMethod::Patch => "PATCH", HttpMethod::Delete => "DELETE" } } + fn get(value: &JsValue, name: &str) -> Result { + Reflect::get(value, &JsValue::from_str(name)).map_err(|_| "remote_contract_invalid") + } + fn method(value: &JsValue, name: &str) -> Result { + get(value, name)? + .dyn_into() + .map_err(|_| "remote_contract_invalid") + } + fn call0(value: &JsValue, name: &str) -> Result { + method(value, name)? + .call0(value) + .map_err(|_| "remote_contract_invalid") + } + fn string(value: &JsValue, name: &str) -> Result { + get(value, name)? + .as_string() + .ok_or("remote_contract_invalid") + } + fn integer(value: &JsValue, name: &str) -> Result { + let number = get(value, name)? + .as_f64() + .ok_or("remote_contract_invalid")?; + if number.fract() == 0.0 && number >= 0.0 && number <= u32::MAX as f64 { + Ok(number as u32) + } else { + Err("remote_contract_invalid") + } + } + fn set(object: &Object, name: &str, value: JsValue) -> Result<(), HttpStreamError> { + Reflect::set(object, &JsValue::from_str(name), &value) + .map(|_| ()) + .map_err(js_transport) + } + fn method_name(method: HttpMethod) -> &'static str { + match method { + HttpMethod::Get => "GET", + HttpMethod::Head => "HEAD", + HttpMethod::Post => "POST", + HttpMethod::Put => "PUT", + HttpMethod::Patch => "PATCH", + HttpMethod::Delete => "DELETE", + } + } fn header_array(headers: &[HttpHeader]) -> Array { let rows = Array::new(); for header in headers { @@ -386,10 +682,185 @@ mod browser { } rows } - fn parse_headers(value: JsValue) -> Result, HttpStreamError> { let rows = Array::from(&value); rows.iter().map(|row| { let pair = Array::from(&row); Ok(HttpHeader { name: pair.get(0).as_string().ok_or_else(|| HttpStreamError::Transport("Remote response header name is invalid".to_owned()))?, value: pair.get(1).as_string().ok_or_else(|| HttpStreamError::Transport("Remote response header value is invalid".to_owned()))? }) }).collect() } - fn js_transport(error: JsValue) -> HttpStreamError { HttpStreamError::Transport(error.as_string().unwrap_or_else(|| "Remote bridge JavaScript error".to_owned())) } - fn transport_message(message: &str) -> HttpStreamError { HttpStreamError::Transport(message.to_owned()) } - fn ws_error(message: &str) -> WebSocketTransportError { WebSocketTransportError { message: message.to_owned() } } + fn parse_headers(value: JsValue) -> Result, HttpStreamError> { + let rows = Array::from(&value); + rows.iter() + .map(|row| { + let pair = Array::from(&row); + Ok(HttpHeader { + name: pair.get(0).as_string().ok_or_else(|| { + HttpStreamError::Transport( + "Remote response header name is invalid".to_owned(), + ) + })?, + value: pair.get(1).as_string().ok_or_else(|| { + HttpStreamError::Transport( + "Remote response header value is invalid".to_owned(), + ) + })?, + }) + }) + .collect() + } + fn js_transport(error: JsValue) -> HttpStreamError { + HttpStreamError::Transport( + error + .as_string() + .unwrap_or_else(|| "Remote bridge JavaScript error".to_owned()), + ) + } + fn transport_message(message: &str) -> HttpStreamError { + HttpStreamError::Transport(message.to_owned()) + } + fn ws_error(message: &str) -> WebSocketTransportError { + WebSocketTransportError { + message: message.to_owned(), + } + } + + #[cfg(test)] + mod tests { + use std::{ + future::Future, + num::NonZeroUsize, + rc::Rc, + task::{Context, Waker}, + }; + + use js_sys::Promise; + use wasm_bindgen::prelude::*; + use wasm_bindgen_futures::JsFuture; + use wasm_bindgen_test::*; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen(inline_js = r#" +export function requestFixture(mode) { + window.__remoteBridgeProbe = {aborted: 0, uploaded: []}; + return { + request: async init => { + init.signal.addEventListener('abort', () => window.__remoteBridgeProbe.aborted++); + if (mode === 'pending') return new Promise(() => {}); + const bytes = new Uint8Array(await new Response(init.body).arrayBuffer()); + window.__remoteBridgeProbe.uploaded = Array.from(bytes); + return { + status: 200, + headers: [['content-type', 'application/octet-stream']], + body: new ReadableStream({start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3, 4, 5])); + controller.close(); + }}), + }; + }, + }; +} +export function uploaded() { return new Uint8Array(window.__remoteBridgeProbe.uploaded); } +export function aborts() { return window.__remoteBridgeProbe.aborted; } +export function nextTask() { return new Promise(resolve => setTimeout(resolve, 0)); } +export function socketFixture() { + window.__remoteSocketProbe = null; + return {openSocket() { + const socket = {closes: 0, send() {}, close() { this.closes++; }}; + window.__remoteSocketProbe = socket; + return socket; + }}; +} +export function socketClosed() { return window.__remoteSocketProbe.closes; } +export function socketHandlersCleared() { + const s = window.__remoteSocketProbe; + return s.onopen === null && s.onmessage === null && s.onclose === null && s.onerror === null; +} +"#)] + extern "C" { + #[wasm_bindgen(js_name = requestFixture)] + fn request_fixture(mode: &str) -> JsValue; + fn uploaded() -> js_sys::Uint8Array; + fn aborts() -> u32; + #[wasm_bindgen(js_name = nextTask)] + fn next_task() -> Promise; + #[wasm_bindgen(js_name = socketFixture)] + fn socket_fixture() -> JsValue; + #[wasm_bindgen(js_name = socketClosed)] + fn socket_closed() -> u32; + #[wasm_bindgen(js_name = socketHandlersCleared)] + fn socket_handlers_cleared() -> bool; + } + + fn stream_request(body: Vec) -> HttpStreamRequest { + let cancellation = HttpCancellation::new(); + HttpStreamRequest { + method: HttpMethod::Post, + path: "/api/v1/upload".to_owned(), + headers: Vec::new(), + body: HttpBody::new(Box::new(BytesBody(Some(body))), cancellation), + } + } + + #[wasm_bindgen_test] + async fn request_stream_carries_uploads_and_splits_browser_chunks() { + let transport = BridgeHttp(Rc::new(request_fixture("complete"))); + let mut response = transport + .send_stream(stream_request(vec![9, 8, 7])) + .await + .expect("response headers"); + assert_eq!(uploaded().to_vec(), vec![9, 8, 7]); + let maximum = NonZeroUsize::new(2).expect("nonzero"); + assert_eq!( + response.body.read_chunk(maximum).await.expect("chunk"), + Some(vec![1, 2]) + ); + assert_eq!( + response.body.read_chunk(maximum).await.expect("chunk"), + Some(vec![3, 4]) + ); + assert_eq!( + response.body.read_chunk(maximum).await.expect("chunk"), + Some(vec![5]) + ); + assert!( + response + .body + .read_chunk(maximum) + .await + .expect("EOF") + .is_none() + ); + } + + #[wasm_bindgen_test] + async fn dropping_pending_headers_aborts_the_bridge_signal() { + let transport = BridgeHttp(Rc::new(request_fixture("pending"))); + let mut future = Box::pin(transport.send_stream(stream_request(vec![1]))); + assert!( + future + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_pending() + ); + drop(future); + JsFuture::from(next_task()).await.expect("next task"); + assert_eq!(aborts(), 1); + } + + #[wasm_bindgen_test] + fn dropping_socket_closes_it_before_releasing_callbacks() { + let transport = BridgeWebSocket(Rc::new(socket_fixture())); + let connection = transport + .connect( + WebSocketConnectRequest { + path: "/api/v1/ws".to_owned(), + protocol: "hypercolor".to_owned(), + }, + Rc::new(|_| {}), + ) + .expect("socket"); + drop(connection); + assert_eq!(socket_closed(), 1); + assert!(socket_handlers_cleared()); + } + } } #[cfg(target_arch = "wasm32")] @@ -403,13 +874,33 @@ mod tests { #[test] fn rebases_api_routes_under_the_remote_daemon_mount() { - assert_eq!(resolve_remote_api_url(&format!("/remote/{DAEMON}"), DAEMON, "/api/v1/devices?limit=2"), Some(format!("/remote/{DAEMON}/_d/api/v1/devices?limit=2"))); + assert_eq!( + resolve_remote_api_url( + &format!("/remote/{DAEMON}"), + DAEMON, + "/api/v1/devices?limit=2" + ), + Some(format!("/remote/{DAEMON}/_d/api/v1/devices?limit=2")) + ); } #[test] fn refuses_absolute_protocol_relative_and_traversal_routes() { - for path in ["https://evil.test/api/v1", "//evil.test/api/v1", "/api/v1/../admin", "/api/v1/%2e%2e/admin", "/api/v1/%2E%2E/admin", "/api/v1/%2fadmin", "/api/v1/a\\b", "/api/v2/devices"] { - assert_eq!(resolve_remote_api_url(&format!("/remote/{DAEMON}"), DAEMON, path), None, "{path}"); + for path in [ + "https://evil.test/api/v1", + "//evil.test/api/v1", + "/api/v1/../admin", + "/api/v1/%2e%2e/admin", + "/api/v1/%2E%2E/admin", + "/api/v1/%2fadmin", + "/api/v1/a\\b", + "/api/v2/devices", + ] { + assert_eq!( + resolve_remote_api_url(&format!("/remote/{DAEMON}"), DAEMON, path), + None, + "{path}" + ); } } @@ -420,7 +911,11 @@ mod tests { None ); assert_eq!( - resolve_remote_api_url("/remote/018f4c36-4a44-7cc9-9f57-0d2e9224d2f2", DAEMON, "/api/v1/devices"), + resolve_remote_api_url( + "/remote/018f4c36-4a44-7cc9-9f57-0d2e9224d2f2", + DAEMON, + "/api/v1/devices" + ), None ); } diff --git a/crates/hypercolor-ui/tests/remote_bridge_tests.rs b/crates/hypercolor-ui/tests/remote_bridge_tests.rs index 8371adfc3..2e95f9e90 100644 --- a/crates/hypercolor-ui/tests/remote_bridge_tests.rs +++ b/crates/hypercolor-ui/tests/remote_bridge_tests.rs @@ -7,14 +7,18 @@ use wasm_bindgen_test::*; wasm_bindgen_test_configure!(run_in_browser); #[wasm_bindgen(inline_js = r#" -export function installIncompatibleBridge() { +export function installBridge(mode) { window.__hypercolorRemoteFatal = null; window.__HYPERCOLOR_REMOTE__ = { - contract: {min: 2, max: 3}, + contract: mode === 'zero' ? {min: 0, max: 1} + : mode === 'missing-request' ? {min: 1, max: 1} + : {min: 2, max: 3}, daemonId: "018f4c36-4a44-7cc9-9f57-0d2e9224d2f1", mount: "/remote/018f4c36-4a44-7cc9-9f57-0d2e9224d2f1", + request() {}, openSocket() {}, ready() {}, fatal(code) { window.__hypercolorRemoteFatal = code; }, }; + if (mode === 'missing-request') delete window.__HYPERCOLOR_REMOTE__.request; } export function recordedFatal() { @@ -27,8 +31,8 @@ export function clearBridge() { } "#)] extern "C" { - #[wasm_bindgen(js_name = installIncompatibleBridge)] - fn install_incompatible_bridge(); + #[wasm_bindgen(js_name = installBridge)] + fn install_bridge(mode: &str); #[wasm_bindgen(js_name = recordedFatal)] fn recorded_fatal() -> Option; #[wasm_bindgen(js_name = clearBridge)] @@ -37,8 +41,16 @@ extern "C" { #[wasm_bindgen_test] fn incompatible_contract_calls_fatal_and_refuses_startup() { - install_incompatible_bridge(); + for mode in ["mismatch", "zero"] { + install_bridge(mode); + assert!(remote_bridge::initialize().is_err()); + assert_eq!( + recorded_fatal().as_deref(), + Some("remote_contract_mismatch") + ); + } + install_bridge("missing-request"); assert!(remote_bridge::initialize().is_err()); - assert_eq!(recorded_fatal().as_deref(), Some("remote_contract_mismatch")); + assert_eq!(recorded_fatal().as_deref(), Some("remote_contract_invalid")); clear_bridge(); } From 33cb3f5a176b10416994e878d040a20005caf9f0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 12:15:43 -0700 Subject: [PATCH 4/9] feat(ui): expose Remote runtime detection Let extension crates detect the browser Remote bridge before app transport initialization, preventing accidental same-origin requests during bootstrap. Pin the current reviewed raw HTML sinks so new injection surfaces fail CI. Co-Authored-By: Nova (GPT-5.6 Sol) --- .github/workflows/ci.yml | 3 ++ crates/hypercolor-ui/src/remote_bridge.rs | 42 +++++++++++++++++------ scripts/check-ui-html-sinks.sh | 24 +++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) create mode 100755 scripts/check-ui-html-sinks.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4038ffee1..b4c34bfbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -290,6 +290,9 @@ jobs: - name: Check macOS GPU-only architecture run: ./scripts/check-macos-gpu-only.sh + - name: Refuse new raw HTML sinks + run: ./scripts/check-ui-html-sinks.sh + - name: Test local build fabric run: | ./scripts/tests/cargo-cache-build-tests.sh diff --git a/crates/hypercolor-ui/src/remote_bridge.rs b/crates/hypercolor-ui/src/remote_bridge.rs index 7d02dd19e..10b0a4f38 100644 --- a/crates/hypercolor-ui/src/remote_bridge.rs +++ b/crates/hypercolor-ui/src/remote_bridge.rs @@ -8,6 +8,18 @@ use crate::route_ui::UiMount; pub const CONTRACT_MIN: u32 = 1; pub const CONTRACT_MAX: u32 = 1; +/// Whether this browser page exposes the Remote host bridge. +/// +/// Callers may use this before [`crate::run_with_extensions`] initializes the +/// transport to avoid issuing ordinary same-origin requests from a Remote +/// page. The full contract is still validated by [`initialize`]. +/// Native builds never expose the browser Remote bridge. +#[cfg(not(target_arch = "wasm32"))] +#[must_use] +pub const fn is_available() -> bool { + false +} + /// Resolve a daemon API path inside the host-provided Remote mount. /// /// Only relative `/api/v1` routes are accepted. Browser normalization never @@ -138,17 +150,15 @@ mod browser { } } + #[must_use] + pub fn is_available() -> bool { + bridge_value().is_some() + } + pub fn initialize() -> Result, RemoteBridgeError> { - let Some(window) = web_sys::window() else { - return Ok(None); - }; - let Ok(value) = Reflect::get(window.as_ref(), &JsValue::from_str("__HYPERCOLOR_REMOTE__")) - else { + let Some(value) = bridge_value() else { return Ok(None); }; - if value.is_null() || value.is_undefined() { - return Ok(None); - } let result = initialize_value(value.clone()); if let Err(code) = &result { fatal(&value, code); @@ -156,6 +166,13 @@ mod browser { result.map(Some).map_err(|code| RemoteBridgeError { code }) } + fn bridge_value() -> Option { + let window = web_sys::window()?; + Reflect::get(window.as_ref(), &JsValue::from_str("__HYPERCOLOR_REMOTE__")) + .ok() + .filter(|value| !value.is_null() && !value.is_undefined()) + } + fn initialize_value(value: JsValue) -> Result { let contract = get(&value, "contract")?; let minimum = integer(&contract, "min")?; @@ -864,14 +881,19 @@ export function socketHandlersCleared() { } #[cfg(target_arch = "wasm32")] -pub use browser::{RemoteBridge, RemoteBridgeError, initialize}; +pub use browser::{RemoteBridge, RemoteBridgeError, initialize, is_available}; #[cfg(test)] mod tests { - use super::resolve_remote_api_url; + use super::{is_available, resolve_remote_api_url}; const DAEMON: &str = "018f4c36-4a44-7cc9-9f57-0d2e9224d2f1"; + #[test] + fn native_runtime_never_reports_a_browser_bridge() { + assert!(!is_available()); + } + #[test] fn rebases_api_routes_under_the_remote_daemon_mount() { assert_eq!( diff --git a/scripts/check-ui-html-sinks.sh b/scripts/check-ui-html-sinks.sh new file mode 100755 index 000000000..ae2c54488 --- /dev/null +++ b/scripts/check-ui-html-sinks.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +expected="$(mktemp)" +actual="$(mktemp)" +trap 'rm -f "$expected" "$actual"' EXIT + +cat >"$expected" <<'EOF' +crates/hypercolor-ui/src/components/attachment_panel.rs:1 +crates/hypercolor-ui/src/components/component_picker.rs:1 +crates/hypercolor-ui/src/components/device_card.rs:1 +crates/hypercolor-ui/src/pages/studio/device_card.rs:1 +crates/hypercolor-ui/src/vendors.rs:1 +EOF + +cd "$repo_root" +rg --count-matches 'inner_html\s*=' crates/hypercolor-ui/src --glob '*.rs' \ + | sort >"$actual" || true + +if ! diff -u "$expected" "$actual"; then + echo "Raw HTML sinks changed. Replace the new sink or review the allowlist." >&2 + exit 1 +fi From 83bc43d3b58add3a9cb10789d9fc678f05af7930 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 12:29:49 -0700 Subject: [PATCH 5/9] fix(ui): eliminate raw HTML rendering sinks Render shapes through typed SVG nodes. Load vendor marks as rebased CSS mask assets so tinting works without injected SVG markup. Reject every future inner_html attribute in the UI security gate. Co-Authored-By: Nova (GPT-5.6 Sol) --- crates/hypercolor-ui/input.css | 6 -- .../src/components/attachment_panel.rs | 13 +-- .../src/components/component_picker.rs | 57 +++++++----- .../src/components/device_card.rs | 70 ++++++++++---- .../src/pages/studio/device_card.rs | 20 ++-- crates/hypercolor-ui/src/vendors.rs | 92 ++++++++++--------- scripts/check-ui-html-sinks.sh | 16 +--- 7 files changed, 159 insertions(+), 115 deletions(-) diff --git a/crates/hypercolor-ui/input.css b/crates/hypercolor-ui/input.css index 13e408d4b..a920d6e7a 100644 --- a/crates/hypercolor-ui/input.css +++ b/crates/hypercolor-ui/input.css @@ -1011,12 +1011,6 @@ input[type="range"].slider-silk::-moz-range-track { contain-intrinsic-size: 300px 225px; } -.vendor-mark-svg > svg { - display: block; - width: 100%; - height: 100%; -} - /* Accent edge glow — for active/important panels. Pairs with one of the `.accent-*` classes below, which set `--glow-rgb` to the right SilkCircuit palette value. Without an accent class the default is diff --git a/crates/hypercolor-ui/src/components/attachment_panel.rs b/crates/hypercolor-ui/src/components/attachment_panel.rs index 4be19ed6a..3afe8f73c 100644 --- a/crates/hypercolor-ui/src/components/attachment_panel.rs +++ b/crates/hypercolor-ui/src/components/attachment_panel.rs @@ -16,7 +16,7 @@ use crate::async_helpers::spawn_identify; use crate::channel_names; use crate::components::attachment_editor; use crate::components::component_picker::ComponentPicker; -use crate::components::device_card::topology_shape_svg; +use crate::components::device_card::{TopologyShape, topology_shape_kind}; use crate::icons::*; use crate::layout_geometry; use crate::layout_utils::channel_name_matches_slot_alias; @@ -159,9 +159,9 @@ pub fn WiringPanel( ) }) .cloned(); - let zone_svg = zone_match.as_ref() - .map(|z| topology_shape_svg(&z.topology)) - .unwrap_or_else(|| topology_shape_svg("strip")); + let zone_shape = zone_match.as_ref() + .map(|z| topology_shape_kind(&z.topology)) + .unwrap_or_else(|| topology_shape_kind("strip")); let zone_id = zone_match.as_ref().map(|z| z.id.clone()); // Channel name: localStorage → layout zone name → driver default @@ -243,8 +243,9 @@ pub fn WiringPanel( "color: rgba({accent}, 0.95); \ background: rgba({accent}, 0.08); \ box-shadow: inset 0 0 8px rgba({accent}, 0.12)" - ) - inner_html=format!(r#"{zone_svg}"#) /> + )> + + // Editable name diff --git a/crates/hypercolor-ui/src/components/component_picker.rs b/crates/hypercolor-ui/src/components/component_picker.rs index ccecb2042..4858e96d8 100644 --- a/crates/hypercolor-ui/src/components/component_picker.rs +++ b/crates/hypercolor-ui/src/components/component_picker.rs @@ -17,43 +17,57 @@ use crate::icons::*; // ── Category shape SVGs ───────────────────────────────────────────────────── -fn category_shape_svg(category: &str, size: u32) -> String { +#[component] +fn CategoryShape(category: String, size: u32) -> impl IntoView { let s = size; let half = s / 2; let r = half.saturating_sub(2).max(3); let inner_r = r / 3; - match category { - "fan" | "aio" | "ring" | "heatsink" => { - format!( - r#""# - ) + let shape = match category.as_str() { + "fan" | "aio" | "ring" | "heatsink" => view! { + + } + .into_any(), "strip" | "radiator" | "case" => { let y = half.saturating_sub(2); let w = s.saturating_sub(4); - format!( - r#""# - ) + view! { + + } + .into_any() } "strimer" => { let y = half.saturating_sub(3); let w = s.saturating_sub(4); - format!( - r#""# - ) + view! { + + } + .into_any() } "matrix" => { let p = 3_u32; let sz = s.saturating_sub(p * 2); - format!( - r#""# - ) + view! { + + } + .into_any() } - _ => { - format!( - r#""# - ) + _ => view! { + } + .into_any(), + }; + + view! { + } } @@ -300,7 +314,6 @@ pub fn ComponentPicker( ); results.into_iter().enumerate().map(|(index, t)| { let is_selected = selected_index == Some(index); - let svg = category_shape_svg(t.category.as_str(), 16); let tid = t.id.clone(); let tname = t.name.clone(); let tname_display = tname.clone(); @@ -329,7 +342,9 @@ pub fn ComponentPicker( } >
+ style="color: rgba(128, 255, 234, 0.4)"> + +
{tname_display}
diff --git a/crates/hypercolor-ui/src/components/device_card.rs b/crates/hypercolor-ui/src/components/device_card.rs index 0bc4f2d4c..13aa43019 100644 --- a/crates/hypercolor-ui/src/components/device_card.rs +++ b/crates/hypercolor-ui/src/components/device_card.rs @@ -251,22 +251,59 @@ fn connection_icon(device: &DeviceSummary) -> icondata_core::Icon { } } -/// Zone topology → inline SVG shape hint for zone display. -pub fn topology_shape_svg(topology: &str) -> &'static str { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TopologyShapeKind { + Strip, + Ring, + Matrix, + Point, + Other, +} + +/// Zone topology to a typed SVG shape hint for zone display. +pub fn topology_shape_kind(topology: &str) -> TopologyShapeKind { match topology { - "strip" => { - r#""# + "strip" => TopologyShapeKind::Strip, + "ring" | "concentric_rings" => TopologyShapeKind::Ring, + "matrix" | "perimeter_loop" => TopologyShapeKind::Matrix, + "point" => TopologyShapeKind::Point, + _ => TopologyShapeKind::Other, + } +} + +#[component] +pub fn TopologyShape(kind: TopologyShapeKind, size: u32) -> impl IntoView { + let shape = match kind { + TopologyShapeKind::Strip => view! { + + } + .into_any(), + TopologyShapeKind::Ring => view! { + } - "ring" | "concentric_rings" => { - r#""# + .into_any(), + TopologyShapeKind::Matrix => view! { + } - "matrix" | "perimeter_loop" => { - r#""# + .into_any(), + TopologyShapeKind::Point => view! { + } - "point" => r#""#, - _ => { - r#""# + .into_any(), + TopologyShapeKind::Other => view! { + } + .into_any(), + }; + + view! { + } } @@ -341,14 +378,14 @@ pub fn DeviceCard( "225, 53, 255", "110, 180, 255", ]; - let zone_previews: Vec<(&'static str, usize, &'static str)> = device + let zone_previews: Vec<(TopologyShapeKind, usize, &'static str)> = device .segments .iter() .take(5) .enumerate() .map(|(i, z)| { ( - topology_shape_svg(&z.topology), + topology_shape_kind(&z.topology), z.led_count as usize, zone_palette[i % zone_palette.len()], ) @@ -543,7 +580,7 @@ pub fn DeviceCard( {if zone_count > 0 { Some(view! {
- {zone_previews.into_iter().map(|(svg, led_count, zrgb)| { + {zone_previews.into_iter().map(|(shape, led_count, zrgb)| { view! {
-
{svg}"#) /> +
+ +
{led_count}
diff --git a/crates/hypercolor-ui/src/pages/studio/device_card.rs b/crates/hypercolor-ui/src/pages/studio/device_card.rs index 66660dadd..d7c6279d2 100644 --- a/crates/hypercolor-ui/src/pages/studio/device_card.rs +++ b/crates/hypercolor-ui/src/pages/studio/device_card.rs @@ -17,8 +17,8 @@ use hypercolor_types::scene::ZoneRole; use crate::api::{self, DeviceSummary, SegmentTopologySummary}; use crate::channel_names; use crate::components::device_card::{ - brand_colors, brand_label, brand_vendor, classify_brand, classify_device, device_class_icon, - driver_identifier_label, topology_shape_svg, + TopologyShape, TopologyShapeKind, brand_colors, brand_label, brand_vendor, classify_brand, + classify_device, device_class_icon, driver_identifier_label, topology_shape_kind, }; use crate::icons::*; use crate::layout_utils; @@ -55,7 +55,7 @@ pub enum CardMode { struct ComponentRow { /// User-facing channel label, identical to the row's display name. name: String, - shape_svg: &'static str, + shape: TopologyShapeKind, led_count: usize, /// `Output.id` (`Output.id`) when this channel has an output /// in the current zone — `None` for the Unassigned bucket or a @@ -179,7 +179,7 @@ pub fn StudioDeviceCard( }); ComponentRow { name: display_name.clone(), - shape_svg: topology_shape_svg(&channel.topology), + shape: topology_shape_kind(&channel.topology), led_count: channel.led_count as usize, output_id, slot_id: channel.id.clone(), @@ -818,7 +818,7 @@ fn component_row_view( ) -> impl IntoView { let ComponentRow { name, - shape_svg, + shape, led_count, output_id, slot_id, @@ -927,13 +927,9 @@ fn component_row_view( } on:mouseleave=move |_| studio.hovered_output_ids.set(HashSet::new()) > -
{shape_svg}"#, - ) - /> +
+ +
{name}
{move || { diff --git a/crates/hypercolor-ui/src/vendors.rs b/crates/hypercolor-ui/src/vendors.rs index 7fd4c1d5b..c09fd0596 100644 --- a/crates/hypercolor-ui/src/vendors.rs +++ b/crates/hypercolor-ui/src/vendors.rs @@ -75,9 +75,9 @@ pub struct VendorBrand { /// SVGs. pub monogram: &'static str, pub mark_font: VendorFont, - /// Embedded SVG content rendered inline with `currentColor` tinting. - /// Preferred over `image_path` when both are set. - pub svg_content: Option<&'static str>, + /// SVG asset path rendered as a CSS mask so brand tinting does not require + /// injecting markup into the document. Preferred over `image_path`. + pub svg_path: Option<&'static str>, /// Asset path served by Trunk (e.g. `/assets/vendors/nollie.png`). Used /// when the brand has a non-SVG image (Nollie's gradient wordmark PNG). pub image_path: Option<&'static str>, @@ -96,7 +96,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "192, 249, 75", monogram: "ABL", mark_font: VendorFont::Sans, - svg_content: Some(include_str!("../assets/vendors/ableton.svg")), + svg_path: Some("/assets/vendors/ableton.svg"), image_path: None, website: "https://ableton.com", aliases: &["ableton", "push2"], @@ -108,7 +108,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "30, 215, 255", monogram: "AW", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/alienware.svg")), + svg_path: Some("/assets/vendors/alienware.svg"), image_path: None, website: "https://dell.com/alienware", aliases: &["alienware", "dell"], @@ -120,7 +120,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "64, 196, 255", monogram: "AQUA", mark_font: VendorFont::Sans, - svg_content: None, + svg_path: None, image_path: None, website: "https://aquacomputer.de", aliases: &["aquacomputer", "aqua"], @@ -132,7 +132,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "96, 165, 220", monogram: "AR", mark_font: VendorFont::Sans, - svg_content: Some(include_str!("../assets/vendors/asrock.svg")), + svg_path: Some("/assets/vendors/asrock.svg"), image_path: None, website: "https://asrock.com", aliases: &["asrock"], @@ -144,7 +144,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "0, 174, 239", monogram: "A", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/asus.svg")), + svg_path: Some("/assets/vendors/asus.svg"), image_path: None, website: "https://asus.com", aliases: &["asus", "rog"], @@ -156,7 +156,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "154, 100, 255", monogram: "CM", mark_font: VendorFont::Sans, - svg_content: Some(include_str!("../assets/vendors/coolermaster.svg")), + svg_path: Some("/assets/vendors/coolermaster.svg"), image_path: None, website: "https://coolermaster.com", aliases: &["coolermaster", "cooler_master", "cm"], @@ -168,7 +168,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 200, 0", monogram: "C", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/corsair.svg")), + svg_path: Some("/assets/vendors/corsair.svg"), image_path: None, website: "https://corsair.com", aliases: &["corsair", "icue"], @@ -180,7 +180,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 100, 180", monogram: "DYG", mark_font: VendorFont::Sans, - svg_content: None, + svg_path: None, image_path: None, website: "https://dygma.com", aliases: &["dygma"], @@ -192,7 +192,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "80, 150, 220", monogram: "E", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/evga.svg")), + svg_path: Some("/assets/vendors/evga.svg"), image_path: None, website: "https://evga.com", aliases: &["evga"], @@ -204,7 +204,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 140, 60", monogram: "FN", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/fnatic.svg")), + svg_path: Some("/assets/vendors/fnatic.svg"), image_path: None, website: "https://fnatic.com", aliases: &["fnatic"], @@ -216,7 +216,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "250, 117, 0", monogram: "GB", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/gigabyte.svg")), + svg_path: Some("/assets/vendors/gigabyte.svg"), image_path: None, website: "https://gigabyte.com", aliases: &["gigabyte", "aorus"], @@ -228,7 +228,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "215, 195, 130", monogram: "GLR", mark_font: VendorFont::Sans, - svg_content: Some(include_str!("../assets/vendors/glorious.svg")), + svg_path: Some("/assets/vendors/glorious.svg"), image_path: None, website: "https://gloriousgaming.com", aliases: &["glorious"], @@ -240,7 +240,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 130, 60", monogram: "GOV", mark_font: VendorFont::Sans, - svg_content: None, + svg_path: None, image_path: None, website: "https://govee.com", aliases: &["govee"], @@ -252,7 +252,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 80, 90", monogram: "HX", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/hyperx.svg")), + svg_path: Some("/assets/vendors/hyperx.svg"), image_path: None, website: "https://hyperx.com", aliases: &["hyperx", "kingston"], @@ -264,7 +264,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "80, 250, 200", monogram: "HY", mark_font: VendorFont::Display, - svg_content: None, + svg_path: None, image_path: None, website: "https://hyte.com", aliases: &["hyte"], @@ -276,7 +276,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 90, 100", monogram: "LL", mark_font: VendorFont::Sans, - svg_content: Some(include_str!("../assets/vendors/lianli.svg")), + svg_path: Some("/assets/vendors/lianli.svg"), image_path: None, website: "https://lian-li.com", aliases: &["lianli", "lian_li", "lian-li"], @@ -288,7 +288,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "80, 220, 255", monogram: "L", mark_font: VendorFont::Sans, - svg_content: Some(include_str!("../assets/vendors/logitech.svg")), + svg_path: Some("/assets/vendors/logitech.svg"), image_path: None, website: "https://logitech.com", aliases: &["logitech", "logi", "logitech_g"], @@ -300,7 +300,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 160, 50", monogram: "MTN", mark_font: VendorFont::Sans, - svg_content: None, + svg_path: None, image_path: None, website: "https://mountain.gg", aliases: &["mountain"], @@ -312,7 +312,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "220, 30, 30", monogram: "MSI", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/msi.svg")), + svg_path: Some("/assets/vendors/msi.svg"), image_path: None, website: "https://msi.com", aliases: &["msi"], @@ -324,7 +324,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 165, 60", monogram: "NANO", mark_font: VendorFont::Sans, - svg_content: None, + svg_path: None, image_path: Some("/assets/vendors/nanoleaf.png"), website: "https://nanoleaf.me", aliases: &["nanoleaf"], @@ -336,7 +336,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 106, 193", monogram: "N", mark_font: VendorFont::Sans, - svg_content: None, + svg_path: None, image_path: Some("/assets/vendors/nollie.png"), website: "https://nollie.gg", aliases: &["nollie"], @@ -348,7 +348,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "165, 80, 255", monogram: "NZ", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/nzxt.svg")), + svg_path: Some("/assets/vendors/nzxt.svg"), image_path: None, website: "https://nzxt.com", aliases: &["nzxt"], @@ -360,7 +360,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "70, 220, 255", monogram: "H", mark_font: VendorFont::Sans, - svg_content: Some(include_str!("../assets/vendors/philipshue.svg")), + svg_path: Some("/assets/vendors/philipshue.svg"), image_path: None, website: "https://philips-hue.com", aliases: &["hue", "philips", "philipshue", "philips_hue", "signify"], @@ -372,7 +372,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "128, 255, 234", monogram: "PRSM", mark_font: VendorFont::Display, - svg_content: None, + svg_path: None, image_path: None, website: "", aliases: &["prismrgb", "prism_rgb", "prism"], @@ -384,7 +384,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "100, 175, 255", monogram: "Q", mark_font: VendorFont::Mono, - svg_content: Some(include_str!("../assets/vendors/qmk.svg")), + svg_path: Some("/assets/vendors/qmk.svg"), image_path: None, website: "https://qmk.fm", aliases: &["qmk", "vial"], @@ -396,7 +396,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "100, 240, 80", monogram: "R", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/razer.svg")), + svg_path: Some("/assets/vendors/razer.svg"), image_path: None, website: "https://razer.com", aliases: &["razer", "chroma"], @@ -408,7 +408,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "60, 180, 240", monogram: "ROC", mark_font: VendorFont::Display, - svg_content: None, + svg_path: None, image_path: Some("/assets/vendors/roccat.png"), website: "https://roccat.com", aliases: &["roccat"], @@ -420,7 +420,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "50, 160, 240", monogram: "S", mark_font: VendorFont::Sans, - svg_content: Some(include_str!("../assets/vendors/sony.svg")), + svg_path: Some("/assets/vendors/sony.svg"), image_path: None, website: "https://sony.com", aliases: &["sony", "playstation", "ps"], @@ -432,7 +432,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 140, 50", monogram: "SS", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/steelseries.svg")), + svg_path: Some("/assets/vendors/steelseries.svg"), image_path: None, website: "https://steelseries.com", aliases: &["steelseries", "steel_series"], @@ -444,7 +444,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 90, 90", monogram: "TT", mark_font: VendorFont::Display, - svg_content: Some(include_str!("../assets/vendors/thermaltake.svg")), + svg_path: Some("/assets/vendors/thermaltake.svg"), image_path: None, website: "https://thermaltake.com", aliases: &["thermaltake", "tt"], @@ -456,7 +456,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "80, 220, 240", monogram: "WLED", mark_font: VendorFont::Mono, - svg_content: None, + svg_path: None, image_path: Some("/assets/vendors/wled.png"), website: "https://kno.wled.ge", aliases: &["wled"], @@ -468,7 +468,7 @@ pub const VENDORS: &[VendorBrand] = &[ secondary_rgb: "255, 150, 60", monogram: "WOOT", mark_font: VendorFont::Display, - svg_content: None, + svg_path: None, image_path: None, website: "https://wooting.io", aliases: &["wooting"], @@ -564,10 +564,13 @@ pub fn VendorMark( box-shadow: inset 0 0 8px rgba({primary}, 0.10), 0 0 8px rgba({primary}, 0.18)" ); - if let Some(svg) = vendor.svg_content { + if let Some(svg_path) = vendor.svg_path { + let svg_href = crate::route_ui::asset_href(svg_path); let svg_style = format!( "width: {inner_px}px; height: {inner_px}px; color: rgb({primary}); \ - display: flex; align-items: center; justify-content: center; \ + background-color: currentColor; \ + mask: url('{svg_href}') center / contain no-repeat; \ + -webkit-mask: url('{svg_href}') center / contain no-repeat; \ filter: drop-shadow(0 0 3px rgba({primary}, 0.35))" ); return view! { @@ -576,7 +579,7 @@ pub fn VendorMark( style=chip_style title=display_name > -
+
} .into_any(); @@ -687,14 +690,19 @@ mod tests { } #[test] - fn embedded_svgs_are_non_empty() { - let with_svg = VENDORS.iter().filter(|v| v.svg_content.is_some()).count(); + fn svg_mask_assets_are_present_and_tintable() { + let with_svg = VENDORS.iter().filter(|v| v.svg_path.is_some()).count(); assert!( with_svg >= 18, - "expected ≥18 vendors with embedded SVGs, got {with_svg}" + "expected ≥18 vendors with SVG masks, got {with_svg}" ); for v in VENDORS { - if let Some(svg) = v.svg_content { + if let Some(path) = v.svg_path { + assert!(path.starts_with("/assets/vendors/")); + let asset = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join(path.trim_start_matches('/')); + let svg = std::fs::read_to_string(&asset) + .unwrap_or_else(|error| panic!("{}: {error}", asset.display())); assert!(svg.contains(""$expected" <<'EOF' -crates/hypercolor-ui/src/components/attachment_panel.rs:1 -crates/hypercolor-ui/src/components/component_picker.rs:1 -crates/hypercolor-ui/src/components/device_card.rs:1 -crates/hypercolor-ui/src/pages/studio/device_card.rs:1 -crates/hypercolor-ui/src/vendors.rs:1 -EOF +trap 'rm -f "$actual"' EXIT cd "$repo_root" rg --count-matches 'inner_html\s*=' crates/hypercolor-ui/src --glob '*.rs' \ | sort >"$actual" || true -if ! diff -u "$expected" "$actual"; then - echo "Raw HTML sinks changed. Replace the new sink or review the allowlist." >&2 +if [[ -s "$actual" ]]; then + cat "$actual" >&2 + echo "Raw HTML sinks are forbidden in the UI." >&2 exit 1 fi From c5ac2d0254c4efd493c3423ab065debdb0d41b42 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 12:52:59 -0700 Subject: [PATCH 6/9] fix(ui): support strict Trusted Types hosts Vendor tachys 0.2.18 with a narrow hc-static policy for compiler-generated DOM templates. Validate owned icon SVG against an inert structural grammar before conversion and leave general raw HTML assignments browser-blocked. Create preview workers through the private policy. Only registered blob URLs from embedded static source can become TrustedScriptURL values. Co-Authored-By: Nova (GPT-5.6 Sol) --- Cargo.toml | 2 +- crates/hypercolor-ui/Cargo.lock | 3 +- crates/hypercolor-ui/Cargo.toml | 4 + .../src/components/preview_runtime/worker.rs | 14 +- vendor/tachys/.cargo-ok | 1 + vendor/tachys/.cargo_vcs_info.json | 6 + vendor/tachys/.gitignore | 1 + vendor/tachys/Cargo.lock | 821 +++++++++++++ vendor/tachys/Cargo.toml | 338 ++++++ vendor/tachys/Cargo.toml.orig | 217 ++++ vendor/tachys/HYPERCOLOR-PATCH.md | 25 + vendor/tachys/LICENSE | 21 + vendor/tachys/Makefile.toml | 1 + vendor/tachys/README.md | 179 +++ vendor/tachys/build.rs | 8 + vendor/tachys/src/dom.rs | 70 ++ vendor/tachys/src/erased.rs | 75 ++ .../src/html/attribute/any_attribute.rs | 407 +++++++ vendor/tachys/src/html/attribute/aria.rs | 282 +++++ vendor/tachys/src/html/attribute/custom.rs | 196 ++++ vendor/tachys/src/html/attribute/global.rs | 483 ++++++++ vendor/tachys/src/html/attribute/key.rs | 659 +++++++++++ .../maybe_next_attr_erasure_macros.rs | 26 + vendor/tachys/src/html/attribute/mod.rs | 606 ++++++++++ vendor/tachys/src/html/attribute/value.rs | 707 ++++++++++++ vendor/tachys/src/html/class.rs | 681 +++++++++++ vendor/tachys/src/html/directive.rs | 303 +++++ vendor/tachys/src/html/element/custom.rs | 40 + vendor/tachys/src/html/element/element_ext.rs | 87 ++ vendor/tachys/src/html/element/elements.rs | 432 +++++++ vendor/tachys/src/html/element/inner_html.rs | 410 +++++++ vendor/tachys/src/html/element/mod.rs | 857 ++++++++++++++ vendor/tachys/src/html/event.rs | 767 +++++++++++++ vendor/tachys/src/html/islands.rs | 390 +++++++ vendor/tachys/src/html/mod.rs | 221 ++++ vendor/tachys/src/html/node_ref.rs | 156 +++ vendor/tachys/src/html/property.rs | 457 ++++++++ vendor/tachys/src/html/style.rs | 793 +++++++++++++ vendor/tachys/src/hydration.rs | 250 ++++ vendor/tachys/src/lib.rs | 159 +++ vendor/tachys/src/mathml/mod.rs | 170 +++ vendor/tachys/src/oco.rs | 377 ++++++ vendor/tachys/src/reactive_graph/bind.rs | 597 ++++++++++ vendor/tachys/src/reactive_graph/class.rs | 1005 ++++++++++++++++ .../tachys/src/reactive_graph/inner_html.rs | 286 +++++ vendor/tachys/src/reactive_graph/mod.rs | 1014 +++++++++++++++++ vendor/tachys/src/reactive_graph/node_ref.rs | 179 +++ vendor/tachys/src/reactive_graph/owned.rs | 218 ++++ vendor/tachys/src/reactive_graph/property.rs | 259 +++++ vendor/tachys/src/reactive_graph/style.rs | 557 +++++++++ vendor/tachys/src/reactive_graph/suspense.rs | 463 ++++++++ vendor/tachys/src/renderer/dom.rs | 935 +++++++++++++++ vendor/tachys/src/renderer/mock_dom.rs | 749 ++++++++++++ vendor/tachys/src/renderer/mod.rs | 210 ++++ vendor/tachys/src/renderer/sledgehammer.rs | 585 ++++++++++ vendor/tachys/src/ssr/mod.rs | 702 ++++++++++++ vendor/tachys/src/svg/mod.rs | 309 +++++ vendor/tachys/src/view/add_attr.rs | 36 + vendor/tachys/src/view/any_view.rs | 830 ++++++++++++++ vendor/tachys/src/view/either.rs | 943 +++++++++++++++ vendor/tachys/src/view/error_boundary.rs | 246 ++++ vendor/tachys/src/view/fragment.rs | 130 +++ vendor/tachys/src/view/iterators.rs | 825 ++++++++++++++ vendor/tachys/src/view/keyed.rs | 913 +++++++++++++++ vendor/tachys/src/view/mod.rs | 513 +++++++++ vendor/tachys/src/view/primitives.rs | 226 ++++ vendor/tachys/src/view/static_types.rs | 264 +++++ vendor/tachys/src/view/strings.rs | 564 +++++++++ vendor/tachys/src/view/template.rs | 125 ++ vendor/tachys/src/view/tuples.rs | 453 ++++++++ 70 files changed, 25828 insertions(+), 10 deletions(-) create mode 100644 vendor/tachys/.cargo-ok create mode 100644 vendor/tachys/.cargo_vcs_info.json create mode 100644 vendor/tachys/.gitignore create mode 100644 vendor/tachys/Cargo.lock create mode 100644 vendor/tachys/Cargo.toml create mode 100644 vendor/tachys/Cargo.toml.orig create mode 100644 vendor/tachys/HYPERCOLOR-PATCH.md create mode 100644 vendor/tachys/LICENSE create mode 100644 vendor/tachys/Makefile.toml create mode 100644 vendor/tachys/README.md create mode 100644 vendor/tachys/build.rs create mode 100644 vendor/tachys/src/dom.rs create mode 100644 vendor/tachys/src/erased.rs create mode 100644 vendor/tachys/src/html/attribute/any_attribute.rs create mode 100644 vendor/tachys/src/html/attribute/aria.rs create mode 100644 vendor/tachys/src/html/attribute/custom.rs create mode 100644 vendor/tachys/src/html/attribute/global.rs create mode 100644 vendor/tachys/src/html/attribute/key.rs create mode 100644 vendor/tachys/src/html/attribute/maybe_next_attr_erasure_macros.rs create mode 100644 vendor/tachys/src/html/attribute/mod.rs create mode 100644 vendor/tachys/src/html/attribute/value.rs create mode 100644 vendor/tachys/src/html/class.rs create mode 100644 vendor/tachys/src/html/directive.rs create mode 100644 vendor/tachys/src/html/element/custom.rs create mode 100644 vendor/tachys/src/html/element/element_ext.rs create mode 100644 vendor/tachys/src/html/element/elements.rs create mode 100644 vendor/tachys/src/html/element/inner_html.rs create mode 100644 vendor/tachys/src/html/element/mod.rs create mode 100644 vendor/tachys/src/html/event.rs create mode 100644 vendor/tachys/src/html/islands.rs create mode 100644 vendor/tachys/src/html/mod.rs create mode 100644 vendor/tachys/src/html/node_ref.rs create mode 100644 vendor/tachys/src/html/property.rs create mode 100644 vendor/tachys/src/html/style.rs create mode 100644 vendor/tachys/src/hydration.rs create mode 100644 vendor/tachys/src/lib.rs create mode 100644 vendor/tachys/src/mathml/mod.rs create mode 100644 vendor/tachys/src/oco.rs create mode 100644 vendor/tachys/src/reactive_graph/bind.rs create mode 100644 vendor/tachys/src/reactive_graph/class.rs create mode 100644 vendor/tachys/src/reactive_graph/inner_html.rs create mode 100644 vendor/tachys/src/reactive_graph/mod.rs create mode 100644 vendor/tachys/src/reactive_graph/node_ref.rs create mode 100644 vendor/tachys/src/reactive_graph/owned.rs create mode 100644 vendor/tachys/src/reactive_graph/property.rs create mode 100644 vendor/tachys/src/reactive_graph/style.rs create mode 100644 vendor/tachys/src/reactive_graph/suspense.rs create mode 100644 vendor/tachys/src/renderer/dom.rs create mode 100644 vendor/tachys/src/renderer/mock_dom.rs create mode 100644 vendor/tachys/src/renderer/mod.rs create mode 100644 vendor/tachys/src/renderer/sledgehammer.rs create mode 100644 vendor/tachys/src/ssr/mod.rs create mode 100644 vendor/tachys/src/svg/mod.rs create mode 100644 vendor/tachys/src/view/add_attr.rs create mode 100644 vendor/tachys/src/view/any_view.rs create mode 100644 vendor/tachys/src/view/either.rs create mode 100644 vendor/tachys/src/view/error_boundary.rs create mode 100644 vendor/tachys/src/view/fragment.rs create mode 100644 vendor/tachys/src/view/iterators.rs create mode 100644 vendor/tachys/src/view/keyed.rs create mode 100644 vendor/tachys/src/view/mod.rs create mode 100644 vendor/tachys/src/view/primitives.rs create mode 100644 vendor/tachys/src/view/static_types.rs create mode 100644 vendor/tachys/src/view/strings.rs create mode 100644 vendor/tachys/src/view/template.rs create mode 100644 vendor/tachys/src/view/tuples.rs diff --git a/Cargo.toml b/Cargo.toml index 1b4bd2c58..50ee00ef9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = ["crates/*"] -exclude = ["crates/hypercolor-ui"] +exclude = ["crates/hypercolor-ui", "vendor/tachys"] resolver = "3" [patch.crates-io] diff --git a/crates/hypercolor-ui/Cargo.lock b/crates/hypercolor-ui/Cargo.lock index 2eabf15e6..2746acb2f 100644 --- a/crates/hypercolor-ui/Cargo.lock +++ b/crates/hypercolor-ui/Cargo.lock @@ -835,6 +835,7 @@ dependencies = [ "serde", "serde_json", "strum", + "tachys", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -1965,8 +1966,6 @@ dependencies = [ [[package]] name = "tachys" version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92ba81187437cc5df4281f2326a2e13cc81e8f96448292d1112388e2025ca66" dependencies = [ "any_spawner", "async-trait", diff --git a/crates/hypercolor-ui/Cargo.toml b/crates/hypercolor-ui/Cargo.toml index f4fc86c1a..766f68bc3 100644 --- a/crates/hypercolor-ui/Cargo.toml +++ b/crates/hypercolor-ui/Cargo.toml @@ -33,6 +33,7 @@ hypercolor-leptos-ext = { path = "../hypercolor-leptos-ext", default-features = uuid = { version = "1.11", features = ["js"] } # enable WASM RNG for transitive uuid leptos_icons = "0.7" icondata = { version = "0.7", default-features = false, features = ["lucide"] } +tachys = "=0.2.18" icondata_core = "0.1" strum = { version = "0.28", features = ["derive"] } leptoaster = { version = "0.2", features = ["csr"] } @@ -114,3 +115,6 @@ opt-level = "z" lto = true codegen-units = 1 panic = "abort" + +[patch.crates-io] +tachys = { path = "../../vendor/tachys" } diff --git a/crates/hypercolor-ui/src/components/preview_runtime/worker.rs b/crates/hypercolor-ui/src/components/preview_runtime/worker.rs index d6c75bb05..d7e4b8ba5 100644 --- a/crates/hypercolor-ui/src/components/preview_runtime/worker.rs +++ b/crates/hypercolor-ui/src/components/preview_runtime/worker.rs @@ -2,11 +2,11 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; use hypercolor_leptos_ext::canvas::{ - bitmap_renderer_context, message_image_bitmap, revoke_blob_url, script_blob_url, - set_canvas_size, supports_global, supports_offscreen_canvas_2d_bitmap, + bitmap_renderer_context, message_image_bitmap, revoke_blob_url, set_canvas_size, + supports_global, supports_offscreen_canvas_2d_bitmap, }; use hypercolor_leptos_ext::events::{WorkerMessageHandler, post_worker_canvas_frame}; -use wasm_bindgen::JsValue; +use wasm_bindgen::{JsCast, JsValue}; use web_sys::{HtmlCanvasElement, ImageBitmapRenderingContext, MessageEvent, Worker}; use crate::ws::{CanvasFrame, CanvasPixelFormat}; @@ -223,8 +223,7 @@ impl PreviewWorkerRuntime { let bitmap_ctx = bitmap_renderer_context(canvas).ok_or(())?; probe_worker_support(frame.pixel_format())?; - let worker_url = create_worker_url().map_err(|_| ())?; - let worker = Worker::new(&worker_url).map_err(|_| ())?; + let (worker, worker_url) = create_worker().map_err(|_| ())?; let failed = Rc::new(Cell::new(false)); let dispatch_state = Rc::new(RefCell::new(FrameDispatchState::default())); let failed_handle = Rc::clone(&failed); @@ -316,8 +315,9 @@ impl Drop for PreviewWorkerRuntime { } } -fn create_worker_url() -> Result { - script_blob_url(PREVIEW_WORKER_SOURCE) +fn create_worker() -> Result<(Worker, String), JsValue> { + let (worker, url) = tachys::renderer::dom::create_static_worker(PREVIEW_WORKER_SOURCE)?; + Ok((worker.dyn_into()?, url)) } fn post_frame(worker: &Worker, frame: &CanvasFrame) -> Result<(), JsValue> { diff --git a/vendor/tachys/.cargo-ok b/vendor/tachys/.cargo-ok new file mode 100644 index 000000000..5f8b79583 --- /dev/null +++ b/vendor/tachys/.cargo-ok @@ -0,0 +1 @@ +{"v":1} \ No newline at end of file diff --git a/vendor/tachys/.cargo_vcs_info.json b/vendor/tachys/.cargo_vcs_info.json new file mode 100644 index 000000000..2c5871e41 --- /dev/null +++ b/vendor/tachys/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "7c0d1c169b4353ed169006b1d4a1563ce2e6f3d5" + }, + "path_in_vcs": "tachys" +} \ No newline at end of file diff --git a/vendor/tachys/.gitignore b/vendor/tachys/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/vendor/tachys/.gitignore @@ -0,0 +1 @@ +/target diff --git a/vendor/tachys/Cargo.lock b/vendor/tachys/Cargo.lock new file mode 100644 index 000000000..2db2cbc59 --- /dev/null +++ b/vendor/tachys/Cargo.lock @@ -0,0 +1,821 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "any_spawner" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" +dependencies = [ + "futures", + "thiserror", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "either_of" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" +dependencies = [ + "paste", + "pin-project-lite", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "reactive_graph" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c5a025366836190c7030e883cc2bcd9e384ff555336e3c7954741ca411b177" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "indexmap", + "or_poisoned", + "paste", + "pin-project-lite", + "rustc-hash 2.1.1", + "rustc_version", + "send_wrapper", + "slotmap", + "thiserror", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30fd35b7d299c591293bb69fed47a703eb2703b1cff0493e78b16ed007e5382" +dependencies = [ + "guardian", + "indexmap", + "itertools", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash 2.1.1", + "send_wrapper", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68072edd607edd30b9ebf57d984ba45d8ab8809e598d0f6046278373fb76a5a0" +dependencies = [ + "convert_case", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", + "wasm-bindgen", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f62f06db0370222f7f498ef478fce9f8df5828848d1d3517e3331936d7074f55" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tachys" +version = "0.2.18" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "either_of", + "erased", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "next_tuple", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash 2.1.1", + "rustc_version", + "send_wrapper", + "serde", + "serde_json", + "sledgehammer_bindgen", + "sledgehammer_utils", + "slotmap", + "throw_error", + "tokio", + "tokio-test", + "tracing", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "throw_error" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "zmij" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de9211a9f64b825911bdf0240f58b7a8dac217fe260fc61f080a07f61372fbd5" diff --git a/vendor/tachys/Cargo.toml b/vendor/tachys/Cargo.toml new file mode 100644 index 000000000..f20c78250 --- /dev/null +++ b/vendor/tachys/Cargo.toml @@ -0,0 +1,338 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.88" +name = "tachys" +version = "0.2.18" +authors = ["Greg Johnston"] +build = "build.rs" +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Tools for building reactivity-agnostic, renderer-generic, statically-typed view trees for user interface libraries." +readme = "README.md" +license = "MIT" +repository = "https://github.com/leptos-rs/leptos" + +[package.metadata.cargo-all-features] +denylist = [ + "tracing", + "sledgehammer", +] +skip_feature_sets = [ + [ + "ssr", + "hydrate", +], + [ + "hydrate", + "islands", +], + [ + "ssr", + "delegation", +], + ["nightly"], +] +max_combination_size = 2 + +[features] +default = ["testing"] +delegation = [] +error-hook = [] +hydrate = [] +islands = [ + "dep:serde", + "dep:serde_json", +] +mark_branches = [] +nightly = ["reactive_graph/nightly"] +oco = ["dep:oco_ref"] +reactive_graph = [ + "dep:reactive_graph", + "dep:any_spawner", +] +reactive_stores = [ + "reactive_graph", + "dep:reactive_stores", +] +sledgehammer = [ + "dep:sledgehammer_bindgen", + "dep:sledgehammer_utils", +] +ssr = [] +testing = ["dep:slotmap"] +tracing = ["dep:tracing"] + +[lib] +name = "tachys" +path = "src/lib.rs" + +[dependencies.any_spawner] +version = "0.3.0" +optional = true + +[dependencies.async-trait] +version = "0.1" +default-features = true + +[dependencies.const_str_slice_concat] +version = "0.1" + +[dependencies.drain_filter_polyfill] +version = "0.1" +default-features = true + +[dependencies.either_of] +version = "0.1.9" + +[dependencies.erased] +version = "0.1" +default-features = true + +[dependencies.futures] +version = "0.3" +default-features = true + +[dependencies.html-escape] +version = "0.2" +default-features = true + +[dependencies.indexmap] +version = "2.13" +default-features = true + +[dependencies.itertools] +version = "0.14" +default-features = true + +[dependencies.js-sys] +version = "0.3" +default-features = true + +[dependencies.next_tuple] +version = "0.1.0" + +[dependencies.oco_ref] +version = "0.2.1" +optional = true + +[dependencies.or_poisoned] +version = "0.1.0" + +[dependencies.paste] +version = "1.0" +default-features = true + +[dependencies.reactive_graph] +version = "0.2.14" +optional = true + +[dependencies.reactive_stores] +version = "0.4.3" +optional = true + +[dependencies.rustc-hash] +version = "2.1" +default-features = true + +[dependencies.send_wrapper] +version = "0.6" +default-features = true + +[dependencies.serde] +version = "1.0" +optional = true +default-features = true + +[dependencies.serde_json] +version = "1.0" +optional = true +default-features = true + +[dependencies.sledgehammer_bindgen] +version = "0.6" +features = ["web"] +optional = true +default-features = true + +[dependencies.sledgehammer_utils] +version = "0.3" +optional = true +default-features = true + +[dependencies.slotmap] +version = "1.1" +optional = true +default-features = true + +[dependencies.throw_error] +version = "0.3.1" + +[dependencies.tracing] +version = "0.1" +optional = true +default-features = true + +[dependencies.wasm-bindgen] +version = "0.2" +default-features = true + +[dependencies.web-sys] +version = "0.3" +features = [ + "Window", + "Document", + "HtmlElement", + "HtmlInputElement", + "Element", + "Event", + "console", + "Comment", + "Text", + "Node", + "HtmlTemplateElement", + "DocumentFragment", + "DomTokenList", + "CssStyleDeclaration", + "ShadowRoot", + "HtmlCollection", + "DomStringMap", + "AddEventListenerOptions", + "AnimationEvent", + "BeforeUnloadEvent", + "ClipboardEvent", + "CompositionEvent", + "CustomEvent", + "DeviceMotionEvent", + "DeviceOrientationEvent", + "DragEvent", + "ErrorEvent", + "Event", + "FocusEvent", + "GamepadEvent", + "HashChangeEvent", + "InputEvent", + "KeyboardEvent", + "MessageEvent", + "MouseEvent", + "PageTransitionEvent", + "PointerEvent", + "PopStateEvent", + "ProgressEvent", + "PromiseRejectionEvent", + "SecurityPolicyViolationEvent", + "StorageEvent", + "SubmitEvent", + "TouchEvent", + "TransitionEvent", + "UiEvent", + "WheelEvent", + "HtmlHtmlElement", + "HtmlBaseElement", + "HtmlHeadElement", + "HtmlLinkElement", + "HtmlMetaElement", + "HtmlStyleElement", + "HtmlTitleElement", + "HtmlBodyElement", + "HtmlHeadingElement", + "HtmlQuoteElement", + "HtmlDivElement", + "HtmlDListElement", + "HtmlHrElement", + "HtmlLiElement", + "HtmlOListElement", + "HtmlParagraphElement", + "HtmlPreElement", + "HtmlUListElement", + "HtmlAnchorElement", + "HtmlBrElement", + "HtmlDataElement", + "HtmlQuoteElement", + "HtmlSpanElement", + "HtmlTimeElement", + "HtmlAreaElement", + "HtmlAudioElement", + "HtmlImageElement", + "HtmlMapElement", + "HtmlTrackElement", + "HtmlVideoElement", + "HtmlEmbedElement", + "HtmlIFrameElement", + "HtmlObjectElement", + "HtmlParamElement", + "HtmlPictureElement", + "HtmlSourceElement", + "SvgElement", + "HtmlCanvasElement", + "HtmlScriptElement", + "HtmlModElement", + "HtmlTableCaptionElement", + "HtmlTableColElement", + "HtmlTableColElement", + "HtmlTableElement", + "HtmlTableSectionElement", + "HtmlTableCellElement", + "HtmlTableSectionElement", + "HtmlTableCellElement", + "HtmlTableSectionElement", + "HtmlTableRowElement", + "HtmlButtonElement", + "HtmlDataListElement", + "HtmlFieldSetElement", + "HtmlFormElement", + "HtmlInputElement", + "HtmlLabelElement", + "HtmlLegendElement", + "HtmlMeterElement", + "HtmlOptGroupElement", + "HtmlOutputElement", + "HtmlProgressElement", + "HtmlSelectElement", + "HtmlTextAreaElement", + "HtmlDetailsElement", + "HtmlDialogElement", + "HtmlMenuElement", + "HtmlSlotElement", + "HtmlTemplateElement", + "HtmlOptionElement", +] +default-features = true + +[dev-dependencies.tokio] +version = "1.49" +features = [ + "rt", + "macros", +] +default-features = true + +[dev-dependencies.tokio-test] +version = "0.4" +default-features = true + +[build-dependencies.rustc_version] +version = "0.4" +default-features = true + +[lints.rust.unexpected_cfgs] +level = "warn" +priority = 0 +check-cfg = [ + "cfg(leptos_debuginfo)", + "cfg(erase_components)", + "cfg(rustc_nightly)", +] diff --git a/vendor/tachys/Cargo.toml.orig b/vendor/tachys/Cargo.toml.orig new file mode 100644 index 000000000..732b92e0e --- /dev/null +++ b/vendor/tachys/Cargo.toml.orig @@ -0,0 +1,217 @@ +[package] +name = "tachys" +version = "0.2.18" +authors = ["Greg Johnston"] +license = "MIT" +readme = "../README.md" +repository = "https://github.com/leptos-rs/leptos" +description = "Tools for building reactivity-agnostic, renderer-generic, statically-typed view trees for user interface libraries." +rust-version.workspace = true +edition.workspace = true + +[dependencies] +throw_error = { workspace = true } +any_spawner = { workspace = true, optional = true } +const_str_slice_concat = { workspace = true } +either_of = { workspace = true } +next_tuple = { workspace = true } +or_poisoned = { workspace = true } +reactive_graph = { workspace = true, optional = true } +reactive_stores = { workspace = true, optional = true } +slotmap = { optional = true, workspace = true, default-features = true } +oco_ref = { workspace = true, optional = true } +async-trait = { workspace = true, default-features = true } +paste = { workspace = true, default-features = true } +erased = { workspace = true, default-features = true } +wasm-bindgen = { workspace = true, default-features = true } +html-escape = { workspace = true, default-features = true } +js-sys = { workspace = true, default-features = true } +web-sys = { features = [ + "Window", + "Document", + "HtmlElement", + "HtmlInputElement", + "Element", + "Event", + "console", + "Comment", + "Text", + "Node", + "HtmlTemplateElement", + "DocumentFragment", + "DomTokenList", + "CssStyleDeclaration", + "ShadowRoot", + "HtmlCollection", + "DomStringMap", + + # Events we cast to in leptos_macro -- added here so we don't force users to import them + "AddEventListenerOptions", + "AnimationEvent", + "BeforeUnloadEvent", + "ClipboardEvent", + "CompositionEvent", + "CustomEvent", + "DeviceMotionEvent", + "DeviceOrientationEvent", + "DragEvent", + "ErrorEvent", + "Event", + "FocusEvent", + "GamepadEvent", + "HashChangeEvent", + "InputEvent", + "KeyboardEvent", + "MessageEvent", + "MouseEvent", + "PageTransitionEvent", + "PointerEvent", + "PopStateEvent", + "ProgressEvent", + "PromiseRejectionEvent", + "SecurityPolicyViolationEvent", + "StorageEvent", + "SubmitEvent", + "TouchEvent", + "TransitionEvent", + "UiEvent", + "WheelEvent", + + # HTML Element Types + "HtmlHtmlElement", + "HtmlBaseElement", + "HtmlHeadElement", + "HtmlLinkElement", + "HtmlMetaElement", + "HtmlStyleElement", + "HtmlTitleElement", + "HtmlBodyElement", + "HtmlHeadingElement", + "HtmlQuoteElement", + "HtmlDivElement", + "HtmlDListElement", + "HtmlHrElement", + "HtmlLiElement", + "HtmlOListElement", + "HtmlParagraphElement", + "HtmlPreElement", + "HtmlUListElement", + "HtmlAnchorElement", + "HtmlBrElement", + "HtmlDataElement", + "HtmlQuoteElement", + "HtmlSpanElement", + "HtmlTimeElement", + "HtmlAreaElement", + "HtmlAudioElement", + "HtmlImageElement", + "HtmlMapElement", + "HtmlTrackElement", + "HtmlVideoElement", + "HtmlEmbedElement", + "HtmlIFrameElement", + "HtmlObjectElement", + "HtmlParamElement", + "HtmlPictureElement", + "HtmlSourceElement", + "SvgElement", + "HtmlCanvasElement", + "HtmlScriptElement", + "HtmlModElement", + "HtmlTableCaptionElement", + "HtmlTableColElement", + "HtmlTableColElement", + "HtmlTableElement", + "HtmlTableSectionElement", + "HtmlTableCellElement", + "HtmlTableSectionElement", + "HtmlTableCellElement", + "HtmlTableSectionElement", + "HtmlTableRowElement", + "HtmlButtonElement", + "HtmlDataListElement", + "HtmlFieldSetElement", + "HtmlFormElement", + "HtmlInputElement", + "HtmlLabelElement", + "HtmlLegendElement", + "HtmlMeterElement", + "HtmlOptGroupElement", + "HtmlOutputElement", + "HtmlProgressElement", + "HtmlSelectElement", + "HtmlTextAreaElement", + "HtmlDetailsElement", + "HtmlDialogElement", + "HtmlMenuElement", + "HtmlSlotElement", + "HtmlTemplateElement", + "HtmlOptionElement", +], workspace = true, default-features = true } +drain_filter_polyfill = { workspace = true, default-features = true } +indexmap = { workspace = true, default-features = true } +rustc-hash = { workspace = true, default-features = true } +futures = { workspace = true, default-features = true } +itertools = { workspace = true, default-features = true } +send_wrapper = { workspace = true, default-features = true } +sledgehammer_bindgen = { features = [ + "web", +], optional = true, workspace = true, default-features = true } +sledgehammer_utils = { optional = true, workspace = true, default-features = true } +tracing = { optional = true, workspace = true, default-features = true } +serde = { optional = true, workspace = true, default-features = true } +serde_json = { optional = true, workspace = true, default-features = true } + +[dev-dependencies] +tokio-test = { workspace = true, default-features = true } +tokio = { features = [ + "rt", + "macros", +], workspace = true, default-features = true } + +[build-dependencies] +rustc_version = { workspace = true, default-features = true } + +[features] +default = ["testing"] +delegation = [] # enables event delegation +error-hook = [] +hydrate = [] +islands = ["dep:serde", "dep:serde_json"] +ssr = [] +oco = ["dep:oco_ref"] +nightly = ["reactive_graph/nightly"] +testing = ["dep:slotmap"] +reactive_graph = ["dep:reactive_graph", "dep:any_spawner"] +reactive_stores = ["reactive_graph", "dep:reactive_stores"] +sledgehammer = ["dep:sledgehammer_bindgen", "dep:sledgehammer_utils"] +tracing = ["dep:tracing"] +mark_branches = [] + +[package.metadata.cargo-all-features] +denylist = ["tracing", "sledgehammer"] +skip_feature_sets = [ + [ + "ssr", + "hydrate", + ], + [ + "hydrate", + "islands", + ], + [ + "ssr", + "delegation", + ], + [ + "nightly", + ], +] +max_combination_size = 2 + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(leptos_debuginfo)', + 'cfg(erase_components)', + 'cfg(rustc_nightly)', +] } diff --git a/vendor/tachys/HYPERCOLOR-PATCH.md b/vendor/tachys/HYPERCOLOR-PATCH.md new file mode 100644 index 000000000..d41fc19e3 --- /dev/null +++ b/vendor/tachys/HYPERCOLOR-PATCH.md @@ -0,0 +1,25 @@ +# Hypercolor patch + +This directory vendors `tachys` 0.2.18 from crates.io. The packaged source +records upstream commit `7c0d1c169b4353ed169006b1d4a1563ce2e6f3d5` in +`.cargo_vcs_info.json`; `LICENSE` is copied from that exact upstream commit. + +Hypercolor changes only the browser DOM renderer's static template assignment. +Chromium blocks string writes to `innerHTML` when the host UI uses +`require-trusted-types-for 'script'`. The patch creates a module-private named +`hc-static` policy and uses it for markup generated by `ToTemplate`, plus +borrowed-static HTML and SVG `InertElement` values. `leptos_icons` wraps static +icon data in an owned string, so owned SVG fragments receive the policy only +after a strict element and attribute grammar accepts the inert icon shape. +Other owned `InertElement` values and the generic renderer `set_inner_html` +path keep using the ordinary string setter, so the browser continues to reject +runtime raw markup under that CSP. + +The same module owns the only `hc-static` policy instance. Its static-worker +helper accepts embedded `&'static str` source, creates the Blob URL internally, +and registers that exact URL for one policy conversion before constructing the +worker. Callers cannot promote an arbitrary URL to `TrustedScriptURL`. + +When updating tachys, reapply the narrow changes in +`src/renderer/dom.rs`, rerun the raw-sink gate, and exercise the UI in Chromium +with `trusted-types hc-static` and no default policy. diff --git a/vendor/tachys/LICENSE b/vendor/tachys/LICENSE new file mode 100644 index 000000000..77d5625cb --- /dev/null +++ b/vendor/tachys/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Greg Johnston + +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/vendor/tachys/Makefile.toml b/vendor/tachys/Makefile.toml new file mode 100644 index 000000000..3d822c68d --- /dev/null +++ b/vendor/tachys/Makefile.toml @@ -0,0 +1 @@ +extend = { path = "../cargo-make/main.toml" } diff --git a/vendor/tachys/README.md b/vendor/tachys/README.md new file mode 100644 index 000000000..d87233510 --- /dev/null +++ b/vendor/tachys/README.md @@ -0,0 +1,179 @@ + + + Leptos Logo + + +[![crates.io](https://img.shields.io/crates/v/leptos.svg)](https://crates.io/crates/leptos) +[![docs.rs](https://docs.rs/leptos/badge.svg)](https://docs.rs/leptos) +![Crates.io MSRV](https://img.shields.io/crates/msrv/leptos) +[![Discord](https://img.shields.io/discord/1031524867910148188?color=%237289DA&label=discord)](https://discord.gg/YdRAhS7eQB) +[![Matrix](https://img.shields.io/badge/Matrix-leptos-grey?logo=matrix&labelColor=white&logoColor=black)](https://matrix.to/#/#leptos:matrix.org) + +[Website](https://leptos.dev) | [Book](https://leptos-rs.github.io/leptos/) | [Docs.rs](https://docs.rs/leptos/latest/leptos/) | [Playground](https://codesandbox.io/p/devbox/playground-j23dz7?file=%2Fsrc%2Fmain.rs) | [Discord](https://discord.gg/YdRAhS7eQB) + +You can find a list of useful libraries and example projects at [`awesome-leptos`](https://github.com/leptos-rs/awesome-leptos). + +# Leptos + +```rust +use leptos::*; + +#[component] +pub fn SimpleCounter(initial_value: i32) -> impl IntoView { + // create a reactive signal with the initial value + let (value, set_value) = signal(initial_value); + + // create event handlers for our buttons + // note that `value` and `set_value` are `Copy`, so it's super easy to move them into closures + let clear = move |_| set_value(0); + let decrement = move |_| set_value.update(|value| *value -= 1); + let increment = move |_| set_value.update(|value| *value += 1); + + // create user interfaces with the declarative `view!` macro + view! { +
+ + + // text nodes can be quoted or unquoted + "Value: " {value} "!" + +
+ } +} + +// we also support a builder syntax rather than the JSX-like `view` macro +#[component] +pub fn SimpleCounterWithBuilder(initial_value: i32) -> impl IntoView { + use leptos::html::*; + + let (value, set_value) = signal(initial_value); + let clear = move |_| set_value(0); + let decrement = move |_| set_value.update(|value| *value -= 1); + let increment = move |_| set_value.update(|value| *value += 1); + + // the `view` macro above expands to this builder syntax + div().child(( + button().on(ev::click, clear).child("Clear"), + button().on(ev::click, decrement).child("-1"), + span().child(("Value: ", value, "!")), + button().on(ev::click, increment).child("+1") + )) +} + +// Easy to use with Trunk (trunk-rs.github.io/trunk) or with a simple wasm-bindgen setup +pub fn main() { + mount_to_body(|| view! { + + }) +} +``` + +## About the Framework + +Leptos is a full-stack, isomorphic Rust web framework leveraging fine-grained reactivity to build declarative user interfaces. + +## What does that mean? + +- **Full-stack**: Leptos can be used to build apps that run in the browser (client-side rendering), on the server (server-side rendering), or by rendering HTML on the server and then adding interactivity in the browser (server-side rendering with hydration). This includes support for HTTP streaming of both data ([`Resource`s](https://docs.rs/leptos/latest/leptos/prelude/struct.Resource.html)) and HTML (out-of-order or in-order streaming of [``](https://docs.rs/leptos/latest/leptos/suspense/fn.Suspense.html) components.) +- **Isomorphic**: Leptos provides primitives to write isomorphic [server functions](https://docs.rs/server_fn/latest/server_fn/), i.e., functions that can be called with the “same shape” on the client or server, but only run on the server. This means you can write your server-only logic (database requests, authentication etc.) alongside the client-side components that will consume it, and call server functions as if they were running in the browser, without needing to create and maintain a separate REST or other API. +- **Web**: Leptos is built on the Web platform and Web standards. The [router](https://docs.rs/leptos_router/latest/leptos_router/) is designed to use Web fundamentals (like links and forms) and build on top of them rather than trying to replace them. +- **Framework**: Leptos provides most of what you need to build a modern web app: a reactive system, templating library, and a router that works on both the server and client side. +- **Fine-grained reactivity**: The entire framework is built from reactive primitives. This allows for extremely performant code with minimal overhead: when a reactive signal’s value changes, it can update a single text node, toggle a single class, or remove an element from the DOM without any other code running. (So, no virtual DOM overhead!) +- **Declarative**: Tell Leptos how you want the page to look, and let the framework tell the browser how to do it. + +## Learn more + +Here are some resources for learning more about Leptos: + +- [Book](https://leptos-rs.github.io/leptos/) (work in progress) +- [Examples](https://github.com/leptos-rs/leptos/tree/main/examples) +- [API Documentation](https://docs.rs/leptos/latest/leptos/) +- [Common Bugs](https://github.com/leptos-rs/leptos/tree/main/docs/COMMON_BUGS.md) (and how to fix them!) + +### Random numbers on wasm (`rand` / `getrandom`) + +When you compile a Leptos app to `wasm32-unknown-unknown`, `rand` and `getrandom` need a JavaScript-backed source of randomness. If that backend isn’t enabled, your build can fail or randomness just won’t work in the browser. + +Leptos itself takes care of this for its own code, but that does **not** automatically configure your app’s own `rand` / `getrandom` dependencies. If you use them directly, you need to turn on the JS backend yourself. + +A simple setup in your `Cargo.toml` might look like this: + +```toml +[dependencies] +# Make sure getrandom works on wasm by enabling its JS backend +getrandom = { version = "0.2", features = ["js"] } +rand = { version = "0.8", features = ["small_rng"] } +``` + +Some of the examples in this repo (for example `js-framework-benchmark` and `hackernews_js_fetch`) already do this, so you can use them as a reference if you’re unsure. + +## `cargo-leptos` + +[`cargo-leptos`](https://github.com/leptos-rs/cargo-leptos) is a build tool that's designed to make it easy to build apps that run on both the client and the server, with seamless integration. The best way to get started with a real Leptos project right now is to use `cargo-leptos` and our starter templates for [Actix](https://github.com/leptos-rs/start) or [Axum](https://github.com/leptos-rs/start-axum). + +```bash +cargo install cargo-leptos --locked +cargo leptos new --git https://github.com/leptos-rs/start-axum +cd [your project name] +cargo leptos watch +``` + +Open browser to [http://localhost:3000/](http://localhost:3000/). + +## FAQs + +### What’s up with the name? + +_Leptos_ (λεπτός) is an ancient Greek word meaning “thin, light, refined, fine-grained.” To me, a classicist and not a dog owner, it evokes the lightweight reactive system that powers the framework. I've since learned the same word is at the root of the medical term “leptospirosis,” a blood infection that affects humans and animals... My bad. No dogs were harmed in the creation of this framework. + +### Is it production ready? + +People usually mean one of three things by this question. + +1. **Are the APIs stable?** i.e., will I have to rewrite my whole app from Leptos 0.1 to 0.2 to 0.3 to 0.4, or can I write it now and benefit from new features and updates as new versions come? + +The APIs are basically settled. We’re adding new features, but we’re very happy with where the type system and patterns have landed. I would not expect major breaking changes to your code to adapt to future releases, in terms of architecture. + +2. **Are there bugs?** + +Yes, I’m sure there are. You can see from the state of our issue tracker over time that there aren’t that _many_ bugs and they’re usually resolved pretty quickly. But for sure, there may be moments where you encounter something that requires a fix at the framework level, which may not be immediately resolved. + +3. **Am I a consumer or a contributor?** + +This may be the big one: “production ready” implies a certain orientation to a library: that you can basically use it, without any special knowledge of its internals or ability to contribute. Everyone has this at some level in their stack: for example I (@gbj) don’t have the capacity or knowledge to contribute to something like `wasm-bindgen` at this point: I simply rely on it to work. + +There are several people in the community using Leptos right now for many websites at work, who have also become significant contributors. There may be missing features that you need, and you may end up building them! But, if you're willing to contribute a few missing pieces along the way, the framework is most definitely usable for production applications, especially given the ecosystem of libraries that have sprung up around it. + +### Can I use this for native GUI? + +Sure! Obviously the `view` macro is for generating DOM nodes but you can use the reactive system to drive any native GUI toolkit that uses the same kind of object-oriented, event-callback-based framework as the DOM pretty easily. The principles are the same: + +- Use signals, derived signals, and memos to create your reactive system +- Create GUI widgets +- Use event listeners to update signals +- Create effects to update the UI + +The 0.7 update originally set out to create a "generic rendering" approach that would allow us to reuse most of the same view logic to do all of the above. Unfortunately, this has had to be shelved for now due to difficulties encountered by the Rust compiler when building larger-scale applications with the number of generics spread throughout the codebase that this required. It's an approach I'm looking forward to exploring again in the future; feel free to reach out if you're interested in this kind of work. + +### How is this different from Yew? + +Yew is the most-used library for Rust web UI development, but there are several differences between Yew and Leptos, in philosophy, approach, and performance. + +- **VDOM vs. fine-grained:** Yew is built on the virtual DOM (VDOM) model: state changes cause components to re-render, generating a new virtual DOM tree. Yew diffs this against the previous VDOM, and applies those patches to the actual DOM. Component functions rerun whenever state changes. Leptos takes an entirely different approach. Components run once, creating (and returning) actual DOM nodes and setting up a reactive system to update those DOM nodes. +- **Performance:** This has huge performance implications: Leptos is simply much faster at both creating and updating the UI than Yew is. +- **Server integration:** Yew was created in an era in which browser-rendered single-page apps (SPAs) were the dominant paradigm. While Leptos supports client-side rendering, it also focuses on integrating with the server side of your application via server functions and multiple modes of serving HTML, including out-of-order streaming. + +### How is this different from Dioxus? + +Like Leptos, Dioxus is a framework for building UIs using web technologies. However, there are significant differences in approach and features. + +- **VDOM vs. fine-grained:** While Dioxus has a performant virtual DOM (VDOM), it still uses coarse-grained/component-scoped reactivity: changing a stateful value reruns the component function and diffs the old UI against the new one. Leptos components use a different mental model, creating (and returning) actual DOM nodes and setting up a reactive system to update those DOM nodes. +- **Web vs. desktop priorities:** Dioxus uses Leptos server functions in its fullstack mode, but does not have the same ``-based support for things like streaming HTML rendering, or share the same focus on holistic web performance. Leptos tends to prioritize holistic web performance (streaming HTML rendering, smaller WASM binary sizes, etc.), whereas Dioxus has an unparalleled experience when building desktop apps, because your application logic runs as a native Rust binary. + +### How is this different from Sycamore? + +Sycamore and Leptos are both heavily influenced by SolidJS. At this point, Leptos has a larger community and ecosystem and is more actively developed. Other differences: + +- **Templating DSLs:** Sycamore uses a custom templating language for its views, while Leptos uses a JSX-like template format. +- **`'static` signals:** One of Leptos’s main innovations was the creation of `Copy + 'static` signals, which have excellent ergonomics. Sycamore is in the process of adopting the same pattern, but this is not yet released. +- **Perseus vs. server functions:** The Perseus metaframework provides an opinionated way to build Sycamore apps that include server functionality. Leptos instead provides primitives like server functions in the core of the framework. diff --git a/vendor/tachys/build.rs b/vendor/tachys/build.rs new file mode 100644 index 000000000..26023daae --- /dev/null +++ b/vendor/tachys/build.rs @@ -0,0 +1,8 @@ +use rustc_version::{version_meta, Channel}; + +fn main() { + // Set cfg flags depending on release channel + if matches!(version_meta().unwrap().channel, Channel::Nightly) { + println!("cargo:rustc-cfg=rustc_nightly"); + } +} diff --git a/vendor/tachys/src/dom.rs b/vendor/tachys/src/dom.rs new file mode 100644 index 000000000..bb8f2ae7b --- /dev/null +++ b/vendor/tachys/src/dom.rs @@ -0,0 +1,70 @@ +use wasm_bindgen::JsCast; +use web_sys::{Document, HtmlElement, Window}; + +thread_local! { + pub(crate) static WINDOW: web_sys::Window = web_sys::window().unwrap(); + + pub(crate) static DOCUMENT: web_sys::Document = web_sys::window().unwrap().document().unwrap(); +} + +/// Returns the [`Window`](https://developer.mozilla.org/en-US/docs/Web/API/Window). +/// +/// This is cached as a thread-local variable, so calling `window()` multiple times +/// requires only one call out to JavaScript. +pub fn window() -> Window { + WINDOW.with(Clone::clone) +} + +/// Returns the [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document). +/// +/// This is cached as a thread-local variable, so calling `document()` multiple times +/// requires only one call out to JavaScript. +/// +/// ## Panics +/// Panics if called outside a browser environment. +pub fn document() -> Document { + DOCUMENT.with(Clone::clone) +} + +/// The `` element. +/// +/// ## Panics +/// Panics if there is no `` in the current document, or if it is called outside a browser +/// environment. +pub fn body() -> HtmlElement { + document().body().unwrap() +} + +/// Helper function to extract [`Event.target`](https://developer.mozilla.org/en-US/docs/Web/API/Event/target) +/// from any event. +pub fn event_target(event: &web_sys::Event) -> T +where + T: JsCast, +{ + event.target().unwrap().unchecked_into::() +} + +/// Helper function to extract `event.target.value` from an event. +/// +/// This is useful in the `on:input` or `on:change` listeners for an `` element. +pub fn event_target_value(event: &T) -> String +where + T: JsCast, +{ + event + .unchecked_ref::() + .target() + .unwrap() + .unchecked_into::() + .value() +} + +/// Helper function to extract `event.target.checked` from an event. +/// +/// This is useful in the `on:change` listeners for an `` element. +pub fn event_target_checked(ev: &web_sys::Event) -> bool { + ev.target() + .unwrap() + .unchecked_into::() + .checked() +} diff --git a/vendor/tachys/src/erased.rs b/vendor/tachys/src/erased.rs new file mode 100644 index 000000000..aceeca395 --- /dev/null +++ b/vendor/tachys/src/erased.rs @@ -0,0 +1,75 @@ +use erased::ErasedBox; + +#[cfg(not(erase_components))] +fn check(id_1: &std::any::TypeId, id_2: &std::any::TypeId) { + if id_1 != id_2 { + panic!("Erased: type mismatch") + } +} + +macro_rules! erased { + ([$($new_t_params:tt)*], $name:ident) => { + /// A type-erased item. This is slightly more efficient than using `Box`. + /// + /// With the caveat that T must always be correct upon retrieval. + /// In erased mode T retrieval is unchecked to minimise codegen, in other modes T will be verified and a panic otherwise. + pub struct $name { + #[cfg(not(erase_components))] + type_id: std::any::TypeId, + value: Option, + drop: fn(ErasedBox), + } + + + impl $name { + /// Create a new type-erased item. + pub fn new(item: T) -> Self { + Self { + #[cfg(not(erase_components))] + type_id: std::any::TypeId::of::(), + value: Some(ErasedBox::new(Box::new(item))), + drop: |value| { + let _ = unsafe { value.into_inner::() }; + }, + } + } + + /// Get a reference to the inner value. + pub fn get_ref(&self) -> &T { + #[cfg(not(erase_components))] + check(&self.type_id, &std::any::TypeId::of::()); + unsafe { self.value.as_ref().unwrap().get_ref::() } + } + + /// Get a mutable reference to the inner value. + pub fn get_mut(&mut self) -> &mut T { + #[cfg(not(erase_components))] + check(&self.type_id, &std::any::TypeId::of::()); + unsafe { self.value.as_mut().unwrap().get_mut::() } + } + + /// Consume the item and return the inner value. + pub fn into_inner(mut self) -> T { + #[cfg(not(erase_components))] + check(&self.type_id, &std::any::TypeId::of::()); + *unsafe { self.value.take().unwrap().into_inner::() } + } + } + + /// If into_inner() wasn't called, the value would leak and destructors wouldn't run, this prevents that from happening. + impl Drop for $name { + fn drop(&mut self) { + if let Some(value) = self.value.take() { + (self.drop)(value); + } + } + } + }; + +} + +erased!([Send + 'static], Erased); +erased!(['static], ErasedLocal); + +/// SAFETY: `Erased::new` ensures that `T` is `Send` and `'static`. +unsafe impl Send for Erased {} diff --git a/vendor/tachys/src/html/attribute/any_attribute.rs b/vendor/tachys/src/html/attribute/any_attribute.rs new file mode 100644 index 000000000..d1effd0c5 --- /dev/null +++ b/vendor/tachys/src/html/attribute/any_attribute.rs @@ -0,0 +1,407 @@ +use super::{Attribute, NextAttribute}; +use crate::{ + erased::{Erased, ErasedLocal}, + html::attribute::NamedAttributeKey, + renderer::{dom::Element, Rndr}, +}; +use std::{any::TypeId, fmt::Debug, mem}; +#[cfg(feature = "ssr")] +use std::{future::Future, pin::Pin}; + +/// A type-erased container for any [`Attribute`]. +pub struct AnyAttribute { + type_id: TypeId, + html_len: usize, + value: Erased, + clone: fn(&Erased) -> AnyAttribute, + #[cfg(feature = "ssr")] + to_html: fn(Erased, &mut String, &mut String, &mut String, &mut String), + build: fn(Erased, el: crate::renderer::types::Element) -> AnyAttributeState, + rebuild: fn(Erased, &mut AnyAttributeState), + #[cfg(feature = "hydrate")] + hydrate_from_server: fn(Erased, crate::renderer::types::Element) -> AnyAttributeState, + #[cfg(feature = "hydrate")] + hydrate_from_template: fn(Erased, crate::renderer::types::Element) -> AnyAttributeState, + #[cfg(feature = "ssr")] + #[allow(clippy::type_complexity)] + resolve: fn(Erased) -> Pin + Send>>, + #[cfg(feature = "ssr")] + dry_resolve: fn(&mut Erased), + keys: fn(&Erased) -> Vec, +} + +impl Clone for AnyAttribute { + fn clone(&self) -> Self { + (self.clone)(&self.value) + } +} + +impl Debug for AnyAttribute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AnyAttribute").finish_non_exhaustive() + } +} + +/// View state for [`AnyAttribute`]. +pub struct AnyAttributeState { + type_id: TypeId, + state: ErasedLocal, + el: crate::renderer::types::Element, + keys: Vec, +} + +/// Converts an [`Attribute`] into [`AnyAttribute`]. +pub trait IntoAnyAttribute { + /// Wraps the given attribute. + fn into_any_attr(self) -> AnyAttribute; +} + +impl IntoAnyAttribute for T +where + Self: Send, + T: Attribute, + crate::renderer::types::Element: Clone, +{ + fn into_any_attr(self) -> AnyAttribute { + fn clone(value: &Erased) -> AnyAttribute { + value.get_ref::().clone().into_any_attr() + } + + #[cfg(feature = "ssr")] + fn to_html( + value: Erased, + buf: &mut String, + class: &mut String, + style: &mut String, + inner_html: &mut String, + ) { + value + .into_inner::() + .to_html(buf, class, style, inner_html); + } + + fn build( + value: Erased, + el: crate::renderer::types::Element, + ) -> AnyAttributeState { + AnyAttributeState { + type_id: TypeId::of::(), + keys: value.get_ref::().keys(), + state: ErasedLocal::new(value.into_inner::().build(&el)), + el, + } + } + + #[cfg(feature = "hydrate")] + fn hydrate_from_server( + value: Erased, + el: crate::renderer::types::Element, + ) -> AnyAttributeState { + AnyAttributeState { + type_id: TypeId::of::(), + keys: value.get_ref::().keys(), + state: ErasedLocal::new(value.into_inner::().hydrate::(&el)), + el, + } + } + + #[cfg(feature = "hydrate")] + fn hydrate_from_template( + value: Erased, + el: crate::renderer::types::Element, + ) -> AnyAttributeState { + AnyAttributeState { + type_id: TypeId::of::(), + keys: value.get_ref::().keys(), + state: ErasedLocal::new(value.into_inner::().hydrate::(&el)), + el, + } + } + + fn rebuild(value: Erased, state: &mut AnyAttributeState) { + let value = value.into_inner::(); + let state = state.state.get_mut::(); + value.rebuild(state); + } + + #[cfg(feature = "ssr")] + fn dry_resolve(value: &mut Erased) { + value.get_mut::().dry_resolve(); + } + + #[cfg(feature = "ssr")] + fn resolve( + value: Erased, + ) -> Pin + Send>> { + use futures::FutureExt; + + async move { value.into_inner::().resolve().await.into_any_attr() }.boxed() + } + + fn keys(value: &Erased) -> Vec { + value.get_ref::().keys() + } + + let value = self.into_cloneable_owned(); + AnyAttribute { + type_id: TypeId::of::(), + html_len: value.html_len(), + value: Erased::new(value), + clone: clone::, + #[cfg(feature = "ssr")] + to_html: to_html::, + build: build::, + rebuild: rebuild::, + #[cfg(feature = "hydrate")] + hydrate_from_server: hydrate_from_server::, + #[cfg(feature = "hydrate")] + hydrate_from_template: hydrate_from_template::, + #[cfg(feature = "ssr")] + resolve: resolve::, + #[cfg(feature = "ssr")] + dry_resolve: dry_resolve::, + keys: keys::, + } + } +} + +impl NextAttribute for AnyAttribute { + type Output = Vec; + + fn add_any_attr(self, new_attr: NewAttr) -> Self::Output { + vec![self, new_attr.into_any_attr()] + } +} + +impl Attribute for AnyAttribute { + const MIN_LENGTH: usize = 0; + + type AsyncOutput = AnyAttribute; + type State = AnyAttributeState; + type Cloneable = AnyAttribute; + type CloneableOwned = AnyAttribute; + + fn html_len(&self) -> usize { + self.html_len + } + + #[allow(unused)] // they are used in SSR + fn to_html( + self, + buf: &mut String, + class: &mut String, + style: &mut String, + inner_html: &mut String, + ) { + #[cfg(feature = "ssr")] + { + (self.to_html)(self.value, buf, class, style, inner_html); + } + #[cfg(not(feature = "ssr"))] + panic!( + "You are rendering AnyAttribute to HTML without the `ssr` feature \ + enabled." + ); + } + + fn hydrate(self, el: &crate::renderer::types::Element) -> Self::State { + #[cfg(feature = "hydrate")] + if FROM_SERVER { + (self.hydrate_from_server)(self.value, el.clone()) + } else { + (self.hydrate_from_template)(self.value, el.clone()) + } + #[cfg(not(feature = "hydrate"))] + { + _ = el; + panic!( + "You are trying to hydrate AnyAttribute without the `hydrate` \ + feature enabled." + ); + } + } + + fn build(self, el: &crate::renderer::types::Element) -> Self::State { + (self.build)(self.value, el.clone()) + } + + fn rebuild(self, state: &mut Self::State) { + if self.type_id == state.type_id { + (self.rebuild)(self.value, state) + } else { + let new = self.build(&state.el); + *state = new; + } + } + + fn into_cloneable(self) -> Self::Cloneable { + self + } + + fn into_cloneable_owned(self) -> Self::CloneableOwned { + self + } + + fn dry_resolve(&mut self) { + #[cfg(feature = "ssr")] + { + (self.dry_resolve)(&mut self.value) + } + #[cfg(not(feature = "ssr"))] + panic!( + "You are rendering AnyAttribute to HTML without the `ssr` feature \ + enabled." + ); + } + + async fn resolve(self) -> Self::AsyncOutput { + #[cfg(feature = "ssr")] + { + (self.resolve)(self.value).await + } + #[cfg(not(feature = "ssr"))] + panic!( + "You are rendering AnyAttribute to HTML without the `ssr` feature \ + enabled." + ); + } + + fn keys(&self) -> Vec { + (self.keys)(&self.value) + } +} + +impl NextAttribute for Vec { + type Output = Self; + + fn add_any_attr(mut self, new_attr: NewAttr) -> Self::Output { + self.push(new_attr.into_any_attr()); + self + } +} + +impl Attribute for Vec { + const MIN_LENGTH: usize = 0; + + type AsyncOutput = Vec; + type State = (Element, Vec); + type Cloneable = Vec; + type CloneableOwned = Vec; + + fn html_len(&self) -> usize { + self.iter().map(|attr| attr.html_len()).sum() + } + + #[allow(unused)] // they are used in SSR + fn to_html( + self, + buf: &mut String, + class: &mut String, + style: &mut String, + inner_html: &mut String, + ) { + #[cfg(feature = "ssr")] + { + for mut attr in self { + attr.to_html(buf, class, style, inner_html) + } + } + #[cfg(not(feature = "ssr"))] + panic!( + "You are rendering AnyAttribute to HTML without the `ssr` feature \ + enabled." + ); + } + + fn hydrate(self, el: &crate::renderer::types::Element) -> Self::State { + #[cfg(feature = "hydrate")] + if FROM_SERVER { + ( + el.clone(), + self.into_iter() + .map(|attr| attr.hydrate::(el)) + .collect(), + ) + } else { + ( + el.clone(), + self.into_iter() + .map(|attr| attr.hydrate::(el)) + .collect(), + ) + } + #[cfg(not(feature = "hydrate"))] + { + _ = el; + panic!( + "You are trying to hydrate AnyAttribute without the `hydrate` \ + feature enabled." + ); + } + } + + fn build(self, el: &crate::renderer::types::Element) -> Self::State { + ( + el.clone(), + self.into_iter().map(|attr| attr.build(el)).collect(), + ) + } + + fn rebuild(self, state: &mut Self::State) { + let (el, state) = state; + for old in mem::take(state) { + for key in old.keys { + match key { + NamedAttributeKey::InnerHtml => { + Rndr::set_inner_html(&old.el, ""); + } + NamedAttributeKey::Property(prop_name) => { + Rndr::set_property(&old.el, &prop_name, &wasm_bindgen::JsValue::UNDEFINED); + } + NamedAttributeKey::Attribute(key) => { + Rndr::remove_attribute(&old.el, &key); + } + } + } + } + *state = self.into_iter().map(|s| s.build(el)).collect(); + } + + fn into_cloneable(self) -> Self::Cloneable { + self + } + + fn into_cloneable_owned(self) -> Self::CloneableOwned { + self + } + + fn dry_resolve(&mut self) { + #[cfg(feature = "ssr")] + { + for attr in self.iter_mut() { + attr.dry_resolve() + } + } + #[cfg(not(feature = "ssr"))] + panic!( + "You are rendering AnyAttribute to HTML without the `ssr` feature \ + enabled." + ); + } + + async fn resolve(self) -> Self::AsyncOutput { + #[cfg(feature = "ssr")] + { + futures::future::join_all(self.into_iter().map(|attr| attr.resolve())).await + } + #[cfg(not(feature = "ssr"))] + panic!( + "You are rendering AnyAttribute to HTML without the `ssr` feature \ + enabled." + ); + } + + fn keys(&self) -> Vec { + self.iter().flat_map(|s| s.keys()).collect() + } +} diff --git a/vendor/tachys/src/html/attribute/aria.rs b/vendor/tachys/src/html/attribute/aria.rs new file mode 100644 index 000000000..4c78e97a3 --- /dev/null +++ b/vendor/tachys/src/html/attribute/aria.rs @@ -0,0 +1,282 @@ +use crate::{ + html::{ + attribute::{Attr, *}, + element::{ElementType, HtmlElement}, + }, + renderer::Rndr, + view::{add_attr::AddAnyAttr, RenderHtml}, +}; + +/// Applies ARIA attributes to an HTML element. +pub trait AriaAttributes +where + Self: Sized + AddAnyAttr, + V: AttributeValue, +{ + /// Identifies the currently active descendant of a composite widget. + fn aria_activedescendant( + self, + value: V, + ) -> ::Output> { + self.add_any_attr(aria_activedescendant(value)) + } + + /// Indicates whether assistive technologies will present all, or only parts of, the changed region based on the change notifications defined by the `aria-relevant` attribute. + fn aria_atomic(self, value: V) -> ::Output> { + self.add_any_attr(aria_atomic(value)) + } + + /// Indicates whether user input completion suggestions are provided. + fn aria_autocomplete( + self, + value: V, + ) -> ::Output> { + self.add_any_attr(aria_autocomplete(value)) + } + + /// Indicates whether an element, and its subtree, are currently being updated. + fn aria_busy(self, value: V) -> ::Output> { + self.add_any_attr(aria_busy(value)) + } + + /// Indicates the current "checked" state of checkboxes, radio buttons, and other widgets. + fn aria_checked(self, value: V) -> ::Output> { + self.add_any_attr(aria_checked(value)) + } + + /// Defines the number of columns in a table, grid, or treegrid. + fn aria_colcount(self, value: V) -> ::Output> { + self.add_any_attr(aria_colcount(value)) + } + + /// Defines an element's column index or position with respect to the total number of columns within a table, grid, or treegrid. + fn aria_colindex(self, value: V) -> ::Output> { + self.add_any_attr(aria_colindex(value)) + } + + /// Defines the number of columns spanned by a cell or gridcell within a table, grid, or treegrid. + fn aria_colspan(self, value: V) -> ::Output> { + self.add_any_attr(aria_colspan(value)) + } + + /// Identifies the element (or elements) whose contents or presence are controlled by the current element. + fn aria_controls(self, value: V) -> ::Output> { + self.add_any_attr(aria_controls(value)) + } + + /// Indicates the element that represents the current item within a container or set of related elements. + fn aria_current(self, value: V) -> ::Output> { + self.add_any_attr(aria_current(value)) + } + + /// Identifies the element (or elements) that describes the object. + fn aria_describedby(self, value: V) -> ::Output> { + self.add_any_attr(aria_describedby(value)) + } + + /// Defines a string value that describes or annotates the current element. + fn aria_description(self, value: V) -> ::Output> { + self.add_any_attr(aria_description(value)) + } + + /// Identifies the element that provides additional information related to the object. + fn aria_details(self, value: V) -> ::Output> { + self.add_any_attr(aria_details(value)) + } + + /// Indicates that the element is perceivable but disabled, so it is not editable or otherwise operable. + fn aria_disabled(self, value: V) -> ::Output> { + self.add_any_attr(aria_disabled(value)) + } + + /// Indicates what functions can be performed when a dragged object is released on the drop target. + fn aria_dropeffect(self, value: V) -> ::Output> { + self.add_any_attr(aria_dropeffect(value)) + } + + /// Defines the element that provides an error message related to the object. + fn aria_errormessage( + self, + value: V, + ) -> ::Output> { + self.add_any_attr(aria_errormessage(value)) + } + + /// Indicates whether the element, or another grouping element it controls, is currently expanded or collapsed. + fn aria_expanded(self, value: V) -> ::Output> { + self.add_any_attr(aria_expanded(value)) + } + + /// Identifies the next element (or elements) in an alternate reading order of content. + fn aria_flowto(self, value: V) -> ::Output> { + self.add_any_attr(aria_flowto(value)) + } + + /// Indicates an element's "grabbed" state in a drag-and-drop operation. + fn aria_grabbed(self, value: V) -> ::Output> { + self.add_any_attr(aria_grabbed(value)) + } + + /// Indicates the availability and type of interactive popup element, such as menu or dialog, that can be triggered by an element. + fn aria_haspopup(self, value: V) -> ::Output> { + self.add_any_attr(aria_haspopup(value)) + } + + /// Indicates whether the element is exposed to an accessibility API. + fn aria_hidden(self, value: V) -> ::Output> { + self.add_any_attr(aria_hidden(value)) + } + + /// Indicates the entered value does not conform to the format expected by the application. + fn aria_invalid(self, value: V) -> ::Output> { + self.add_any_attr(aria_invalid(value)) + } + + /// Indicates keyboard shortcuts that an author has implemented to activate or give focus to an element. + fn aria_keyshortcuts( + self, + value: V, + ) -> ::Output> { + self.add_any_attr(aria_keyshortcuts(value)) + } + + /// Defines a string value that labels the current element. + fn aria_label(self, value: V) -> ::Output> { + self.add_any_attr(aria_label(value)) + } + + /// Identifies the element (or elements) that labels the current element. + fn aria_labelledby(self, value: V) -> ::Output> { + self.add_any_attr(aria_labelledby(value)) + } + + /// Indicates that an element will be updated, and describes the types of updates the user agents, assistive technologies, and user can expect from the live region. + fn aria_live(self, value: V) -> ::Output> { + self.add_any_attr(aria_live(value)) + } + + /// Indicates whether an element is modal when displayed. + fn aria_modal(self, value: V) -> ::Output> { + self.add_any_attr(aria_modal(value)) + } + + /// Indicates whether a text box accepts multiple lines of input or only a single line. + fn aria_multiline(self, value: V) -> ::Output> { + self.add_any_attr(aria_multiline(value)) + } + + /// Indicates that the user may select more than one item from the current selectable descendants. + fn aria_multiselectable( + self, + value: V, + ) -> ::Output> { + self.add_any_attr(aria_multiselectable(value)) + } + + /// Indicates whether the element's orientation is horizontal, vertical, or undefined. + fn aria_orientation(self, value: V) -> ::Output> { + self.add_any_attr(aria_orientation(value)) + } + + /// Identifies an element (or elements) in order to define a visual, functional, or contextual parent/child relationship between DOM elements where the DOM hierarchy cannot be used to represent the relationship. + fn aria_owns(self, value: V) -> ::Output> { + self.add_any_attr(aria_owns(value)) + } + + /// Defines a short hint (a word or short phrase) intended to help the user with data entry when the control has no value. + fn aria_placeholder(self, value: V) -> ::Output> { + self.add_any_attr(aria_placeholder(value)) + } + + /// Defines an element's number or position in the current set of listitems or treeitems. + fn aria_posinset(self, value: V) -> ::Output> { + self.add_any_attr(aria_posinset(value)) + } + + /// Indicates the current "pressed" state of toggle buttons. + fn aria_pressed(self, value: V) -> ::Output> { + self.add_any_attr(aria_pressed(value)) + } + + /// Indicates that the element is not editable, but is otherwise operable. + fn aria_readonly(self, value: V) -> ::Output> { + self.add_any_attr(aria_readonly(value)) + } + + /// Indicates what notifications the user agent will trigger when the accessibility tree within a live region is modified. + fn aria_relevant(self, value: V) -> ::Output> { + self.add_any_attr(aria_relevant(value)) + } + + /// Indicates that user input is required on the element before a form may be submitted. + fn aria_required(self, value: V) -> ::Output> { + self.add_any_attr(aria_required(value)) + } + + /// Defines a human-readable, author-localized description for the role of an element. + fn aria_roledescription( + self, + value: V, + ) -> ::Output> { + self.add_any_attr(aria_roledescription(value)) + } + + /// Defines the total number of rows in a table, grid, or treegrid. + fn aria_rowcount(self, value: V) -> ::Output> { + self.add_any_attr(aria_rowcount(value)) + } + + /// Defines an element's row index or position with respect to the total number of rows within a table, grid, or treegrid. + fn aria_rowindex(self, value: V) -> ::Output> { + self.add_any_attr(aria_rowindex(value)) + } + + /// Defines the number of rows spanned by a cell or gridcell within a table, grid, or treegrid. + fn aria_rowspan(self, value: V) -> ::Output> { + self.add_any_attr(aria_rowspan(value)) + } + + /// Indicates the current "selected" state of various widgets. + fn aria_selected(self, value: V) -> ::Output> { + self.add_any_attr(aria_selected(value)) + } + + /// Defines the number of items in the current set of listitems or treeitems. + fn aria_setsize(self, value: V) -> ::Output> { + self.add_any_attr(aria_setsize(value)) + } + + /// Indicates if items in a table or grid are sorted in ascending or descending order. + fn aria_sort(self, value: V) -> ::Output> { + self.add_any_attr(aria_sort(value)) + } + + /// Defines the maximum allowed value for a range widget. + fn aria_valuemax(self, value: V) -> ::Output> { + self.add_any_attr(aria_valuemax(value)) + } + + /// Defines the minimum allowed value for a range widget. + fn aria_valuemin(self, value: V) -> ::Output> { + self.add_any_attr(aria_valuemin(value)) + } + + /// Defines the current value for a range widget. + fn aria_valuenow(self, value: V) -> ::Output> { + self.add_any_attr(aria_valuenow(value)) + } + + /// Defines the human-readable text alternative of `aria-valuenow` for a range widget. + fn aria_valuetext(self, value: V) -> ::Output> { + self.add_any_attr(aria_valuetext(value)) + } +} + +impl AriaAttributes for HtmlElement +where + El: ElementType + Send, + At: Attribute + Send, + Ch: RenderHtml + Send, + V: AttributeValue, +{ +} diff --git a/vendor/tachys/src/html/attribute/custom.rs b/vendor/tachys/src/html/attribute/custom.rs new file mode 100644 index 000000000..d1b61228c --- /dev/null +++ b/vendor/tachys/src/html/attribute/custom.rs @@ -0,0 +1,196 @@ +use super::{maybe_next_attr_erasure_macros::next_attr_output_type, NextAttribute}; +use crate::{ + html::attribute::{ + maybe_next_attr_erasure_macros::next_attr_combine, Attribute, AttributeValue, + NamedAttributeKey, + }, + view::{add_attr::AddAnyAttr, Position, ToTemplate}, +}; +use std::{borrow::Cow, sync::Arc}; + +/// Adds a custom attribute with any key-value combination. +#[inline(always)] +pub fn custom_attribute(key: K, value: V) -> CustomAttr +where + K: CustomAttributeKey, + V: AttributeValue, +{ + CustomAttr { key, value } +} + +/// A custom attribute with any key-value combination. +#[derive(Debug)] +pub struct CustomAttr +where + K: CustomAttributeKey, + V: AttributeValue, +{ + key: K, + value: V, +} + +impl Clone for CustomAttr +where + K: CustomAttributeKey, + V: AttributeValue + Clone, +{ + fn clone(&self) -> Self { + Self { + key: self.key.clone(), + value: self.value.clone(), + } + } +} + +impl Attribute for CustomAttr +where + K: CustomAttributeKey, + V: AttributeValue, +{ + const MIN_LENGTH: usize = 0; + type AsyncOutput = CustomAttr; + type State = V::State; + type Cloneable = CustomAttr; + type CloneableOwned = CustomAttr; + + fn html_len(&self) -> usize { + self.key.as_ref().len() + 3 + self.value.html_len() + } + + fn to_html( + self, + buf: &mut String, + _class: &mut String, + _style: &mut String, + _inner_html: &mut String, + ) { + self.value.to_html(self.key.as_ref(), buf); + } + + fn hydrate(self, el: &crate::renderer::types::Element) -> Self::State { + if !K::KEY.is_empty() { + self.value.hydrate::(self.key.as_ref(), el) + } else { + self.value.build(el, self.key.as_ref()) + } + } + + fn build(self, el: &crate::renderer::types::Element) -> Self::State { + self.value.build(el, self.key.as_ref()) + } + + fn rebuild(self, state: &mut Self::State) { + self.value.rebuild(self.key.as_ref(), state); + } + + fn into_cloneable(self) -> Self::Cloneable { + CustomAttr { + key: self.key, + value: self.value.into_cloneable(), + } + } + + fn into_cloneable_owned(self) -> Self::CloneableOwned { + CustomAttr { + key: self.key, + value: self.value.into_cloneable_owned(), + } + } + + fn dry_resolve(&mut self) { + self.value.dry_resolve(); + } + + async fn resolve(self) -> Self::AsyncOutput { + CustomAttr { + key: self.key, + value: self.value.resolve().await, + } + } + + fn keys(&self) -> Vec { + vec![NamedAttributeKey::Attribute( + self.key.as_ref().to_string().into(), + )] + } +} + +impl NextAttribute for CustomAttr +where + K: CustomAttributeKey, + V: AttributeValue, +{ + next_attr_output_type!(Self, NewAttr); + + fn add_any_attr(self, new_attr: NewAttr) -> Self::Output { + next_attr_combine!(self, new_attr) + } +} + +impl ToTemplate for CustomAttr +where + K: CustomAttributeKey, + V: AttributeValue, +{ + fn to_template( + buf: &mut String, + _class: &mut String, + _style: &mut String, + _inner_html: &mut String, + _position: &mut Position, + ) { + if !K::KEY.is_empty() { + V::to_template(K::KEY, buf); + } + } +} + +// TODO this needs to be a method, not a const +/// Defines a custom attribute key. +pub trait CustomAttributeKey: Clone + AsRef + Send + 'static { + /// The attribute name. + const KEY: &'static str; +} + +impl CustomAttributeKey for &'static str { + const KEY: &'static str = ""; +} + +impl CustomAttributeKey for Cow<'static, str> { + const KEY: &'static str = ""; +} + +impl CustomAttributeKey for String { + const KEY: &'static str = ""; +} + +impl CustomAttributeKey for Arc { + const KEY: &'static str = ""; +} + +#[cfg(all(feature = "nightly", rustc_nightly))] +impl CustomAttributeKey for crate::view::static_types::Static { + const KEY: &'static str = K; +} + +/// Adds a custom attribute to an element. +pub trait CustomAttribute +where + K: CustomAttributeKey, + V: AttributeValue, + + Self: Sized + AddAnyAttr, +{ + /// Adds an HTML attribute by key and value. + fn attr(self, key: K, value: V) -> ::Output> { + self.add_any_attr(custom_attribute(key, value)) + } +} + +impl CustomAttribute for T +where + T: AddAnyAttr, + K: CustomAttributeKey, + V: AttributeValue, +{ +} diff --git a/vendor/tachys/src/html/attribute/global.rs b/vendor/tachys/src/html/attribute/global.rs new file mode 100644 index 000000000..568a0ac53 --- /dev/null +++ b/vendor/tachys/src/html/attribute/global.rs @@ -0,0 +1,483 @@ +use super::Lang; +use crate::{ + html::{ + attribute::*, + class::{class, Class, IntoClass}, + element::{ElementType, HasElementType, HtmlElement}, + event::{on, on_target, EventDescriptor, On, Targeted}, + property::{prop, IntoProperty, Property}, + style::{style, IntoStyle, Style}, + }, + prelude::RenderHtml, + view::add_attr::AddAnyAttr, +}; +use core::convert::From; + +/// Adds an attribute that modifies the `class`. +pub trait ClassAttribute +where + C: IntoClass, +{ + /// The type of the element with the new attribute added. + type Output; + + /// Adds a CSS class to an element. + fn class(self, value: C) -> Self::Output; +} + +impl ClassAttribute for HtmlElement +where + E: ElementType + Send, + At: Attribute + Send, + Ch: RenderHtml + Send, + C: IntoClass, +{ + type Output = ::Output>; + + fn class(self, value: C) -> Self::Output { + self.add_any_attr(class(value)) + } +} + +/// Adds an attribute that modifies the DOM properties. +pub trait PropAttribute +where + P: IntoProperty, +{ + /// The type of the element with the new attribute added. + type Output; + + /// Adds a DOM property to an element. + fn prop(self, key: K, value: P) -> Self::Output; +} + +impl PropAttribute for HtmlElement +where + E: ElementType + Send, + At: Attribute + Send, + Ch: RenderHtml + Send, + K: AsRef + Send, + P: IntoProperty, +{ + type Output = ::Output>; + + fn prop(self, key: K, value: P) -> Self::Output { + self.add_any_attr(prop(key, value)) + } +} + +/// Adds an attribute that modifies the CSS styles. +pub trait StyleAttribute +where + S: IntoStyle, +{ + /// The type of the element with the new attribute added. + type Output; + + /// Adds a CSS style to an element. + fn style(self, value: S) -> Self::Output; +} + +impl StyleAttribute for HtmlElement +where + E: ElementType + Send, + At: Attribute + Send, + Ch: RenderHtml + Send, + S: IntoStyle, +{ + type Output = ::Output>; + + fn style(self, value: S) -> Self::Output { + self.add_any_attr(style(value)) + } +} + +/// Adds an event listener to an element definition. +pub trait OnAttribute { + /// The type of the element with the event listener added. + type Output; + + /// Adds an event listener to an element. + fn on(self, event: E, cb: F) -> Self::Output; +} + +impl OnAttribute for HtmlElement +where + El: ElementType + Send, + At: Attribute + Send, + Ch: RenderHtml + Send, + E: EventDescriptor + Send + 'static, + E::EventType: 'static, + E::EventType: From, + F: FnMut(E::EventType) + 'static, +{ + type Output = ::Output>; + + fn on(self, event: E, cb: F) -> Self::Output { + self.add_any_attr(on(event, cb)) + } +} + +/// Adds an event listener with a typed target to an element definition. +pub trait OnTargetAttribute { + /// The type of the element with the new attribute added. + type Output; + + /// Adds an event listener with a typed target to an element definition. + fn on_target(self, event: E, cb: F) -> Self::Output; +} + +impl OnTargetAttribute for HtmlElement +where + El: ElementType + Send, + At: Attribute + Send, + Ch: RenderHtml + Send, + E: EventDescriptor + Send + 'static, + E::EventType: 'static, + E::EventType: From, + F: FnMut(Targeted::ElementType>) + 'static, +{ + type Output = ::Output>>; + + fn on_target(self, event: E, cb: F) -> Self::Output { + self.add_any_attr(on_target::, F>(event, cb)) + } +} + +/// Global attributes can be added to any HTML element. +pub trait GlobalAttributes +where + Self: Sized + AddAnyAttr, + V: AttributeValue, +{ + /// The `accesskey` global attribute provides a hint for generating a keyboard shortcut for the current element. + fn accesskey(self, value: V) -> ::Output> { + self.add_any_attr(accesskey(value)) + } + + /// The `autocapitalize` global attribute controls whether and how text input is automatically capitalized as it is entered/edited by the user. + fn autocapitalize(self, value: V) -> ::Output> { + self.add_any_attr(autocapitalize(value)) + } + + /// The `autofocus` global attribute is a Boolean attribute indicating that an element should receive focus as soon as the page is loaded. + fn autofocus(self, value: V) -> ::Output> { + self.add_any_attr(autofocus(value)) + } + + /// The `contenteditable` global attribute is an enumerated attribute indicating if the element should be editable by the user. + fn contenteditable(self, value: V) -> ::Output> { + self.add_any_attr(contenteditable(value)) + } + + /// The `dir` global attribute is an enumerated attribute indicating the directionality of the element's text. + fn dir(self, value: V) -> ::Output> { + self.add_any_attr(dir(value)) + } + + /// The `draggable` global attribute is an enumerated attribute indicating whether the element can be dragged. + fn draggable(self, value: V) -> ::Output> { + self.add_any_attr(draggable(value)) + } + + /// The `enterkeyhint` global attribute is used to customize the enter key on virtual keyboards. + fn enterkeyhint(self, value: V) -> ::Output> { + self.add_any_attr(enterkeyhint(value)) + } + + /// The `exportparts` attribute enables the sharing of parts of an element's shadow DOM with a containing document. + fn exportparts(self, value: V) -> ::Output> { + self.add_any_attr(exportparts(value)) + } + + /// The `hidden` global attribute is a Boolean attribute indicating that the element is not yet, or is no longer, relevant. + fn hidden(self, value: V) -> ::Output> { + self.add_any_attr(hidden(value)) + } + + /// The `id` global attribute defines a unique identifier (ID) which must be unique in the whole document. + fn id(self, value: V) -> ::Output> { + self.add_any_attr(id(value)) + } + + /// The `inert` global attribute is a Boolean attribute that makes an element behave inertly. + fn inert(self, value: V) -> ::Output> { + self.add_any_attr(inert(value)) + } + + /// The `inputmode` global attribute provides a hint to browsers for which virtual keyboard to display. + fn inputmode(self, value: V) -> ::Output> { + self.add_any_attr(inputmode(value)) + } + + /// The `is` global attribute allows you to specify that a standard HTML element should behave like a custom built-in element. + fn is(self, value: V) -> ::Output> { + self.add_any_attr(is(value)) + } + + /// The `itemid` global attribute is used to specify the unique, global identifier of an item. + fn itemid(self, value: V) -> ::Output> { + self.add_any_attr(itemid(value)) + } + + /// The `itemprop` global attribute is used to add properties to an item. + fn itemprop(self, value: V) -> ::Output> { + self.add_any_attr(itemprop(value)) + } + + /// The `itemref` global attribute is used to refer to other elements. + fn itemref(self, value: V) -> ::Output> { + self.add_any_attr(itemref(value)) + } + + /// The `itemscope` global attribute is used to create a new item. + fn itemscope(self, value: V) -> ::Output> { + self.add_any_attr(itemscope(value)) + } + + /// The `itemtype` global attribute is used to specify the types of items. + fn itemtype(self, value: V) -> ::Output> { + self.add_any_attr(itemtype(value)) + } + + /// The `lang` global attribute helps define the language of an element. + fn lang(self, value: V) -> ::Output> { + self.add_any_attr(lang(value)) + } + + /// The `nonce` global attribute is used to specify a cryptographic nonce. + fn nonce(self, value: V) -> ::Output> { + self.add_any_attr(nonce(value)) + } + + /// The `part` global attribute identifies the element as a part of a component. + fn part(self, value: V) -> ::Output> { + self.add_any_attr(part(value)) + } + + /// The `popover` global attribute defines the popover's behavior. + fn popover(self, value: V) -> ::Output> { + self.add_any_attr(popover(value)) + } + + /// The `role` global attribute defines the role of an element in ARIA. + fn role(self, value: V) -> ::Output> { + self.add_any_attr(role(value)) + } + + /// The `slot` global attribute assigns a slot in a shadow DOM. + fn slot(self, value: V) -> ::Output> { + self.add_any_attr(slot(value)) + } + + /// The `spellcheck` global attribute is an enumerated attribute that defines whether the element may be checked for spelling errors. + fn spellcheck(self, value: V) -> ::Output> { + self.add_any_attr(spellcheck(value)) + } + + /// The `tabindex` global attribute indicates if the element can take input focus. + fn tabindex(self, value: V) -> ::Output> { + self.add_any_attr(tabindex(value)) + } + + /// The `title` global attribute contains text representing advisory information. + fn title(self, value: V) -> ::Output> { + self.add_any_attr(title(value)) + } + + /// The `translate` global attribute is an enumerated attribute that specifies whether an element's attribute values and text content should be translated when the page is localized. + fn translate(self, value: V) -> ::Output> { + self.add_any_attr(translate(value)) + } + + /// The `virtualkeyboardpolicy` global attribute specifies the behavior of the virtual keyboard. + fn virtualkeyboardpolicy( + self, + value: V, + ) -> ::Output> { + self.add_any_attr(virtualkeyboardpolicy(value)) + } +} + +impl GlobalAttributes for HtmlElement +where + El: ElementType + Send, + At: Attribute + Send, + Ch: RenderHtml + Send, + V: AttributeValue, +{ +} + +macro_rules! on_definitions { + ($(#[$meta:meta] $key:ident $html:literal),* $(,)?) => { + paste::paste! { + $( + #[doc = concat!("Adds the HTML `", $html, "` attribute to the element.\n\n**Note**: This is the HTML attribute, which takes a JavaScript string, not an `on:` listener that takes application logic written in Rust.")] + #[track_caller] + fn $key( + self, + value: V, + ) -> ::Output], V>> + { + self.add_any_attr($key(value)) + } + )* + } + } +} + +/// Provides methods for HTML event listener attributes. +pub trait GlobalOnAttributes +where + Self: Sized + AddAnyAttr, + V: AttributeValue, +{ + on_definitions! { + /// The `onabort` attribute specifies the event handler for the abort event. + onabort "onabort", + /// The `onautocomplete` attribute specifies the event handler for the autocomplete event. + onautocomplete "onautocomplete", + /// The `onautocompleteerror` attribute specifies the event handler for the autocompleteerror event. + onautocompleteerror "onautocompleteerror", + /// The `onblur` attribute specifies the event handler for the blur event. + onblur "onblur", + /// The `oncancel` attribute specifies the event handler for the cancel event. + oncancel "oncancel", + /// The `oncanplay` attribute specifies the event handler for the canplay event. + oncanplay "oncanplay", + /// The `oncanplaythrough` attribute specifies the event handler for the canplaythrough event. + oncanplaythrough "oncanplaythrough", + /// The `onchange` attribute specifies the event handler for the change event. + onchange "onchange", + /// The `onclick` attribute specifies the event handler for the click event. + onclick "onclick", + /// The `onclose` attribute specifies the event handler for the close event. + onclose "onclose", + /// The `oncontextmenu` attribute specifies the event handler for the contextmenu event. + oncontextmenu "oncontextmenu", + /// The `oncuechange` attribute specifies the event handler for the cuechange event. + oncuechange "oncuechange", + /// The `ondblclick` attribute specifies the event handler for the double click event. + ondblclick "ondblclick", + /// The `ondrag` attribute specifies the event handler for the drag event. + ondrag "ondrag", + /// The `ondragend` attribute specifies the event handler for the dragend event. + ondragend "ondragend", + /// The `ondragenter` attribute specifies the event handler for the dragenter event. + ondragenter "ondragenter", + /// The `ondragleave` attribute specifies the event handler for the dragleave event. + ondragleave "ondragleave", + /// The `ondragover` attribute specifies the event handler for the dragover event. + ondragover "ondragover", + /// The `ondragstart` attribute specifies the event handler for the dragstart event. + ondragstart "ondragstart", + /// The `ondrop` attribute specifies the event handler for the drop event. + ondrop "ondrop", + /// The `ondurationchange` attribute specifies the event handler for the durationchange event. + ondurationchange "ondurationchange", + /// The `onemptied` attribute specifies the event handler for the emptied event. + onemptied "onemptied", + /// The `onended` attribute specifies the event handler for the ended event. + onended "onended", + /// The `onerror` attribute specifies the event handler for the error event. + onerror "onerror", + /// The `onfocus` attribute specifies the event handler for the focus event. + onfocus "onfocus", + /// The `onformdata` attribute specifies the event handler for the formdata event. + onformdata "onformdata", + /// The `oninput` attribute specifies the event handler for the input event. + oninput "oninput", + /// The `oninvalid` attribute specifies the event handler for the invalid event. + oninvalid "oninvalid", + /// The `onkeydown` attribute specifies the event handler for the keydown event. + onkeydown "onkeydown", + /// The `onkeypress` attribute specifies the event handler for the keypress event. + onkeypress "onkeypress", + /// The `onkeyup` attribute specifies the event handler for the keyup event. + onkeyup "onkeyup", + /// The `onlanguagechange` attribute specifies the event handler for the languagechange event. + onlanguagechange "onlanguagechange", + /// The `onload` attribute specifies the event handler for the load event. + onload "onload", + /// The `onloadeddata` attribute specifies the event handler for the loadeddata event. + onloadeddata "onloadeddata", + /// The `onloadedmetadata` attribute specifies the event handler for the loadedmetadata event. + onloadedmetadata "onloadedmetadata", + /// The `onloadstart` attribute specifies the event handler for the loadstart event. + onloadstart "onloadstart", + /// The `onmousedown` attribute specifies the event handler for the mousedown event. + onmousedown "onmousedown", + /// The `onmouseenter` attribute specifies the event handler for the mouseenter event. + onmouseenter "onmouseenter", + /// The `onmouseleave` attribute specifies the event handler for the mouseleave event. + onmouseleave "onmouseleave", + /// The `onmousemove` attribute specifies the event handler for the mousemove event. + onmousemove "onmousemove", + /// The `onmouseout` attribute specifies the event handler for the mouseout event. + onmouseout "onmouseout", + /// The `onmouseover` attribute specifies the event handler for the mouseover event. + onmouseover "onmouseover", + /// The `onmouseup` attribute specifies the event handler for the mouseup event. + onmouseup "onmouseup", + /// The `onpause` attribute specifies the event handler for the pause event. + onpause "onpause", + /// The `onplay` attribute specifies the event handler for the play event. + onplay "onplay", + /// The `onplaying` attribute specifies the event handler for the playing event. + onplaying "onplaying", + /// The `onprogress` attribute specifies the event handler for the progress event. + onprogress "onprogress", + /// The `onratechange` attribute specifies the event handler for the ratechange event. + onratechange "onratechange", + /// The `onreset` attribute specifies the event handler for the reset event. + onreset "onreset", + /// The `onresize` attribute specifies the event handler for the resize event. + onresize "onresize", + /// The `onscroll` attribute specifies the event handler for the scroll event. + onscroll "onscroll", + /// The `onsecuritypolicyviolation` attribute specifies the event handler for the securitypolicyviolation event. + onsecuritypolicyviolation "onsecuritypolicyviolation", + /// The `onseeked` attribute specifies the event handler for the seeked event. + onseeked "onseeked", + /// The `onseeking` attribute specifies the event handler for the seeking event. + onseeking "onseeking", + /// The `onselect` attribute specifies the event handler for the select event. + onselect "onselect", + /// The `onslotchange` attribute specifies the event handler for the slotchange event. + onslotchange "onslotchange", + /// The `onstalled` attribute specifies the event handler for the stalled event. + onstalled "onstalled", + /// The `onsubmit` attribute specifies the event handler for the submit event. + onsubmit "onsubmit", + /// The `onsuspend` attribute specifies the event handler for the suspend event. + onsuspend "onsuspend", + /// The `ontimeupdate` attribute specifies the event handler for the timeupdate event. + ontimeupdate "ontimeupdate", + /// The `ontoggle` attribute specifies the event handler for the toggle event. + ontoggle "ontoggle", + /// The `onvolumechange` attribute specifies the event handler for the volumechange event. + onvolumechange "onvolumechange", + /// The `onwaiting` attribute specifies the event handler for the waiting event. + onwaiting "onwaiting", + /// The `onwebkitanimationend` attribute specifies the event handler for the webkitanimationend event. + onwebkitanimationend "onwebkitanimationend", + /// The `onwebkitanimationiteration` attribute specifies the event handler for the webkitanimationiteration event. + onwebkitanimationiteration "onwebkitanimationiteration", + /// The `onwebkitanimationstart` attribute specifies the event handler for the webkitanimationstart event. + onwebkitanimationstart "onwebkitanimationstart", + /// The `onwebkittransitionend` attribute specifies the event handler for the webkittransitionend event. + onwebkittransitionend "onwebkittransitionend", + /// The `onwheel` attribute specifies the event handler for the wheel event. + onwheel "onwheel", + + } +} + +impl GlobalOnAttributes for HtmlElement +where + El: ElementType + Send, + At: Attribute + Send, + Ch: RenderHtml + Send, + V: AttributeValue, +{ +} diff --git a/vendor/tachys/src/html/attribute/key.rs b/vendor/tachys/src/html/attribute/key.rs new file mode 100644 index 000000000..6f04cd137 --- /dev/null +++ b/vendor/tachys/src/html/attribute/key.rs @@ -0,0 +1,659 @@ +use super::{Attr, AttributeValue}; +use std::fmt::Debug; + +/// An HTML attribute key. +pub trait AttributeKey: Clone + Send + 'static { + /// The name of the attribute. + const KEY: &'static str; +} + +macro_rules! attributes { + ($(#[$meta:meta] $key:ident $html:literal),* $(,)?) => { + paste::paste! { + $( + #[$meta] + #[track_caller] + pub fn $key(value: V) -> Attr<[<$key:camel>], V> + where V: AttributeValue, + + { + Attr([<$key:camel>], value) + } + + #[$meta] + #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] + pub struct [<$key:camel>]; + + impl AttributeKey for [<$key:camel>] { + const KEY: &'static str = $html; + } + )* + } + } +} + +attributes! { + // HTML + /// The `abbr` attribute specifies an abbreviated form of the element's content. + abbr "abbr", + /// The `accept-charset` attribute specifies the character encodings that are to be used for the form submission. + accept_charset "accept-charset", + /// The `accept` attribute specifies a list of types the server accepts, typically a file type. + accept "accept", + /// The `accesskey` attribute specifies a shortcut key to activate or focus an element. + accesskey "accesskey", + /// The `action` attribute defines the URL to which the form data will be sent. + action "action", + /// The `align` attribute specifies the alignment of an element. + align "align", + /// The `allow` attribute defines a feature policy for the content in an iframe. + allow "allow", + /// The `allowfullscreen` attribute allows the iframe to be displayed in fullscreen mode. + allowfullscreen "allowfullscreen", + /// The `allowpaymentrequest` attribute allows a cross-origin iframe to invoke the Payment Request API. + allowpaymentrequest "allowpaymentrequest", + /// The `alt` attribute provides alternative text for an image, if the image cannot be displayed. + alt "alt", + // ARIA + /// The `aria-activedescendant` attribute identifies the currently active element when DOM focus is on a composite widget, textbox, group, or application. + aria_activedescendant "aria-activedescendant", + /// The `aria-atomic` attribute indicates whether assistive technologies will present all, or only parts of, the changed region based on the change notifications defined by the aria-relevant attribute. + aria_atomic "aria-atomic", + /// The `aria-autocomplete` attribute indicates whether user input completion suggestions are provided. + aria_autocomplete "aria-autocomplete", + /// The `aria-busy` attribute indicates whether an element, and its subtree, are currently being updated. + aria_busy "aria-busy", + /// The `aria-checked` attribute indicates the current "checked" state of checkboxes, radio buttons, and other widgets. + aria_checked "aria-checked", + /// The `aria-colcount` attribute defines the total number of columns in a table, grid, or treegrid. + aria_colcount "aria-colcount", + /// The `aria-colindex` attribute defines an element's column index or position with respect to the total number of columns within a table, grid, or treegrid. + aria_colindex "aria-colindex", + /// The `aria-colspan` attribute defines the number of columns spanned by a cell or gridcell within a table, grid, or treegrid. + aria_colspan "aria-colspan", + /// The `aria-controls` attribute identifies the element (or elements) whose contents or presence are controlled by the current element. + aria_controls "aria-controls", + /// The `aria-current` attribute indicates the element representing the current item within a container or set of related elements. + aria_current "aria-current", + /// The `aria-describedby` attribute identifies the element (or elements) that describes the object. + aria_describedby "aria-describedby", + /// The `aria-description` attribute provides a string value that describes or annotates the current element. + aria_description "aria-description", + /// The `aria-details` attribute identifies the element that provides a detailed, extended description for the object. + aria_details "aria-details", + /// The `aria-disabled` attribute indicates that the element is perceivable but disabled, so it is not editable or otherwise operable. + aria_disabled "aria-disabled", + /// The `aria-dropeffect` attribute indicates what functions can be performed when a dragged object is released on the drop target. + aria_dropeffect "aria-dropeffect", + /// The `aria-errormessage` attribute identifies the element that provides an error message for the object. + aria_errormessage "aria-errormessage", + /// The `aria-expanded` attribute indicates whether an element, or another grouping element it controls, is currently expanded or collapsed. + aria_expanded "aria-expanded", + /// The `aria-flowto` attribute identifies the next element (or elements) in an alternate reading order of content. + aria_flowto "aria-flowto", + /// The `aria-grabbed` attribute indicates an element's "grabbed" state in a drag-and-drop operation. + aria_grabbed "aria-grabbed", + /// The `aria-haspopup` attribute indicates the availability and type of interactive popup element, such as menu or dialog, that can be triggered by an element. + aria_haspopup "aria-haspopup", + /// The `aria-hidden` attribute indicates whether the element is exposed to an accessibility API. + aria_hidden "aria-hidden", + /// The `aria-invalid` attribute indicates the entered value does not conform to the format expected by the application. + aria_invalid "aria-invalid", + /// The `aria-keyshortcuts` attribute indicates keyboard shortcuts that an author has implemented to activate or give focus to an element. + aria_keyshortcuts "aria-keyshortcuts", + /// The `aria-label` attribute defines a string value that labels the current element. + aria_label "aria-label", + /// The `aria-labelledby` attribute identifies the element (or elements) that labels the current element. + aria_labelledby "aria-labelledby", + /// The `aria-live` attribute indicates that an element will be updated, and describes the types of updates the user agents, assistive technologies, and user can expect from the live region. + aria_live "aria-live", + /// The `aria-modal` attribute indicates whether an element is modal when displayed. + aria_modal "aria-modal", + /// The `aria-multiline` attribute indicates whether a text box accepts multiple lines of input or only a single line. + aria_multiline "aria-multiline", + /// The `aria-multiselectable` attribute indicates that the user may select more than one item from the current selectable descendants. + aria_multiselectable "aria-multiselectable", + /// The `aria-orientation` attribute indicates whether the element's orientation is horizontal, vertical, or unknown/ambiguous. + aria_orientation "aria-orientation", + /// The `aria-owns` attribute identifies an element (or elements) in order to define a relationship between the element with `aria-owns` and the target element. + aria_owns "aria-owns", + /// The `aria-placeholder` attribute defines a short hint (a word or short phrase) intended to aid the user with data entry when the control has no value. + aria_placeholder "aria-placeholder", + /// The `aria-posinset` attribute defines an element's position within a set or treegrid. + aria_posinset "aria-posinset", + /// The `aria-pressed` attribute indicates the current "pressed" state of toggle buttons. + aria_pressed "aria-pressed", + /// The `aria-readonly` attribute indicates that the element is not editable, but is otherwise operable. + aria_readonly "aria-readonly", + /// The `aria-relevant` attribute indicates what user agent changes to the accessibility tree should be monitored. + aria_relevant "aria-relevant", + /// The `aria-required` attribute indicates that user input is required on the element before a form may be submitted. + aria_required "aria-required", + /// The `aria-roledescription` attribute defines a human-readable, author-localized description for the role of an element. + aria_roledescription "aria-roledescription", + /// The `aria-rowcount` attribute defines the total number of rows in a table, grid, or treegrid. + aria_rowcount "aria-rowcount", + /// The `aria-rowindex` attribute defines an element's row index or position with respect to the total number of rows within a table, grid, or treegrid. + aria_rowindex "aria-rowindex", + /// The `aria-rowspan` attribute defines the number of rows spanned by a cell or gridcell within a table, grid, or treegrid. + aria_rowspan "aria-rowspan", + /// The `aria-selected` attribute indicates the current "selected" state of various widgets. + aria_selected "aria-selected", + /// The `aria-setsize` attribute defines the number of items in the current set of listitems or treeitems. + aria_setsize "aria-setsize", + /// The `aria-sort` attribute indicates if items in a table or grid are sorted in ascending or descending order. + aria_sort "aria-sort", + /// The `aria-valuemax` attribute defines the maximum allowed value for a range widget. + aria_valuemax "aria-valuemax", + /// The `aria-valuemin` attribute defines the minimum allowed value for a range widget. + aria_valuemin "aria-valuemin", + /// The `aria-valuenow` attribute defines the current value for a range widget. + aria_valuenow "aria-valuenow", + /// The `aria-valuetext` attribute defines the human-readable text alternative of aria-valuenow for a range widget. + aria_valuetext "aria-valuetext", + /// The `as` attribute specifies the type of destination for the content of the link. + r#as "as", + /// The `async` attribute indicates that the script should be executed asynchronously. + r#async "async", + /// The `attributionsrc` attribute indicates that you want the browser to send an `Attribution-Reporting-Eligible` header along with a request. + attributionsrc "attributionsrc", + /// The `autocapitalize` attribute controls whether and how text input is automatically capitalized as it is entered/edited by the user. + autocapitalize "autocapitalize", + /// The `autocomplete` attribute indicates whether an input field can have its value automatically completed by the browser. + autocomplete "autocomplete", + /// The `autofocus` attribute indicates that an element should be focused on page load. + autofocus "autofocus", + /// The `autoplay` attribute indicates that the media should start playing as soon as it is loaded. + autoplay "autoplay", + /// The `background` attribute sets the URL of the background image for the document. + background "background", + /// The `bgcolor` attribute sets the background color of an element. + bgcolor "bgcolor", + /// The `blocking` attribute indicates that the script will block the page loading until it is executed. + blocking "blocking", + /// The `border` attribute sets the width of an element's border. + border "border", + /// The `buffered` attribute contains the time ranges that the media has been buffered. + buffered "buffered", + /// The `capture` attribute indicates that the user must capture media using a camera or microphone instead of selecting a file from the file picker. + capture "capture", + /// The `challenge` attribute specifies the challenge string that is paired with the keygen element. + challenge "challenge", + /// The `closedby` attribute specifies the types of user actions that can be used to close the associated `` element. + closedby "closedby", + /// The `charset` attribute specifies the character encoding of the HTML document. + charset "charset", + /// The `checked` attribute indicates whether an input element is checked or not. + checked "checked", + /// The `cite` attribute contains a URL that points to the source of the quotation or change. + cite "cite", + // class is handled in ../class.rs instead + //class "class", + /// The `code` attribute specifies the URL of the applet's class file to be loaded and executed. + code "code", + /// The `color` attribute specifies the color of an element's text. + color "color", + /// The `cols` attribute specifies the visible width of a text area. + cols "cols", + /// The `colspan` attribute defines the number of columns a cell should span. + colspan "colspan", + /// The `command` attribute defines the command to be invoked when user clicks the `