we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement iframe isolation, nested browsing contexts, and postMessage

- Add BrowsingContext model with sandbox flags, origin tracking, and
parent-child relationships (browser crate)
- Add iframe element parsing with RAWTEXT mode in HTML tree builder
- Add iframe loader: discover iframes, fetch src URLs, parse nested
documents, render to pixel buffers as replaced elements
- Add window object, postMessage API, and MessageEvent to JS engine
- Add contentWindow/contentDocument with same-origin policy enforcement
- Add iframe_windows map on DomBridge for cross-context window proxies
- Integrate iframe sizes into layout pipeline as replaced elements
- Comprehensive tests for sandbox flags, browsing context tree, iframe
sizing, message serialization, and message queue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1485 -1
+450
crates/browser/src/browsing_context.rs
··· 1 + //! Browsing context model: nested browsing contexts, iframe isolation, and postMessage. 2 + //! 3 + //! Implements the browsing context tree per the HTML spec. Each iframe creates 4 + //! a nested browsing context with its own Document, origin, and security boundary. 5 + 6 + use std::collections::HashMap; 7 + 8 + use we_dom::{Document, NodeId}; 9 + use we_url::{Origin, Url}; 10 + 11 + /// Unique identifier for a browsing context. 12 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 13 + pub struct BrowsingContextId(u64); 14 + 15 + impl BrowsingContextId { 16 + pub fn new(id: u64) -> Self { 17 + BrowsingContextId(id) 18 + } 19 + 20 + pub fn value(self) -> u64 { 21 + self.0 22 + } 23 + } 24 + 25 + /// Sandbox flags per the HTML spec. 26 + /// 27 + /// When the sandbox attribute is present with no value, all flags are set. 28 + /// Individual `allow-*` tokens clear specific flags. 29 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 30 + pub struct SandboxFlags { 31 + bits: u32, 32 + } 33 + 34 + impl SandboxFlags { 35 + /// No restrictions. 36 + pub const NONE: SandboxFlags = SandboxFlags { bits: 0 }; 37 + 38 + // Individual flag bits. 39 + const NAVIGATION: u32 = 1 << 0; 40 + const PLUGINS: u32 = 1 << 1; 41 + const SCRIPTS: u32 = 1 << 2; 42 + const FORMS: u32 = 1 << 3; 43 + const POPUPS: u32 = 1 << 4; 44 + const TOP_NAVIGATION: u32 = 1 << 5; 45 + const ORIGIN: u32 = 1 << 6; 46 + const POINTER_LOCK: u32 = 1 << 7; 47 + const MODALS: u32 = 1 << 8; 48 + 49 + /// All restrictions applied (empty sandbox attribute). 50 + pub fn all() -> Self { 51 + SandboxFlags { 52 + bits: Self::NAVIGATION 53 + | Self::PLUGINS 54 + | Self::SCRIPTS 55 + | Self::FORMS 56 + | Self::POPUPS 57 + | Self::TOP_NAVIGATION 58 + | Self::ORIGIN 59 + | Self::POINTER_LOCK 60 + | Self::MODALS, 61 + } 62 + } 63 + 64 + /// Parse the sandbox attribute value. If `value` is empty or None, all 65 + /// restrictions are applied. Individual `allow-*` tokens remove restrictions. 66 + pub fn parse(value: Option<&str>) -> Self { 67 + let mut flags = Self::all(); 68 + let value = match value { 69 + Some(v) if !v.trim().is_empty() => v, 70 + _ => return flags, 71 + }; 72 + 73 + for token in value.split_ascii_whitespace() { 74 + match token.to_ascii_lowercase().as_str() { 75 + "allow-scripts" => flags.bits &= !Self::SCRIPTS, 76 + "allow-forms" => flags.bits &= !Self::FORMS, 77 + "allow-same-origin" => flags.bits &= !Self::ORIGIN, 78 + "allow-top-navigation" => flags.bits &= !Self::TOP_NAVIGATION, 79 + "allow-popups" => flags.bits &= !Self::POPUPS, 80 + _ => {} // Unknown tokens are ignored per spec. 81 + } 82 + } 83 + 84 + flags 85 + } 86 + 87 + pub fn scripts_blocked(self) -> bool { 88 + self.bits & Self::SCRIPTS != 0 89 + } 90 + 91 + pub fn forms_blocked(self) -> bool { 92 + self.bits & Self::FORMS != 0 93 + } 94 + 95 + pub fn same_origin_forced(self) -> bool { 96 + self.bits & Self::ORIGIN != 0 97 + } 98 + 99 + pub fn top_navigation_blocked(self) -> bool { 100 + self.bits & Self::TOP_NAVIGATION != 0 101 + } 102 + 103 + pub fn popups_blocked(self) -> bool { 104 + self.bits & Self::POPUPS != 0 105 + } 106 + 107 + pub fn is_none(self) -> bool { 108 + self.bits == 0 109 + } 110 + } 111 + 112 + /// A browsing context: represents a tab or iframe with its own Document. 113 + pub struct BrowsingContext { 114 + /// Unique ID for this context. 115 + pub id: BrowsingContextId, 116 + /// Parent context (None for top-level). 117 + pub parent_id: Option<BrowsingContextId>, 118 + /// The NodeId of the iframe element in the parent document (None for top-level). 119 + pub iframe_node_id: Option<NodeId>, 120 + /// The parsed DOM document for this context. 121 + pub document: Document, 122 + /// The origin of this context. 123 + pub origin: Origin, 124 + /// The URL that was loaded for this context. 125 + pub url: Option<Url>, 126 + /// Sandbox flags (from iframe sandbox attribute). 127 + pub sandbox: SandboxFlags, 128 + /// Child browsing context IDs. 129 + pub children: Vec<BrowsingContextId>, 130 + /// Name attribute from the iframe element. 131 + pub name: String, 132 + } 133 + 134 + /// Manages the tree of browsing contexts. 135 + pub struct BrowsingContextTree { 136 + contexts: HashMap<BrowsingContextId, BrowsingContext>, 137 + top_level: Option<BrowsingContextId>, 138 + next_id: u64, 139 + } 140 + 141 + impl Default for BrowsingContextTree { 142 + fn default() -> Self { 143 + Self::new() 144 + } 145 + } 146 + 147 + impl BrowsingContextTree { 148 + pub fn new() -> Self { 149 + BrowsingContextTree { 150 + contexts: HashMap::new(), 151 + top_level: None, 152 + next_id: 0, 153 + } 154 + } 155 + 156 + fn alloc_id(&mut self) -> BrowsingContextId { 157 + let id = BrowsingContextId::new(self.next_id); 158 + self.next_id += 1; 159 + id 160 + } 161 + 162 + /// Create the top-level browsing context. 163 + pub fn create_top_level( 164 + &mut self, 165 + document: Document, 166 + origin: Origin, 167 + url: Option<Url>, 168 + ) -> BrowsingContextId { 169 + let id = self.alloc_id(); 170 + let ctx = BrowsingContext { 171 + id, 172 + parent_id: None, 173 + iframe_node_id: None, 174 + document, 175 + origin, 176 + url, 177 + sandbox: SandboxFlags::NONE, 178 + children: Vec::new(), 179 + name: String::new(), 180 + }; 181 + self.contexts.insert(id, ctx); 182 + self.top_level = Some(id); 183 + id 184 + } 185 + 186 + /// Create a nested browsing context for an iframe. 187 + #[allow(clippy::too_many_arguments)] 188 + pub fn create_nested( 189 + &mut self, 190 + parent_id: BrowsingContextId, 191 + iframe_node_id: NodeId, 192 + document: Document, 193 + origin: Origin, 194 + url: Option<Url>, 195 + sandbox: SandboxFlags, 196 + name: String, 197 + ) -> BrowsingContextId { 198 + let id = self.alloc_id(); 199 + 200 + // Determine effective origin: if sandbox forces unique origin, use Opaque. 201 + let effective_origin = if sandbox.same_origin_forced() { 202 + Origin::Opaque 203 + } else { 204 + origin 205 + }; 206 + 207 + let ctx = BrowsingContext { 208 + id, 209 + parent_id: Some(parent_id), 210 + iframe_node_id: Some(iframe_node_id), 211 + document, 212 + origin: effective_origin, 213 + url, 214 + sandbox, 215 + children: Vec::new(), 216 + name, 217 + }; 218 + self.contexts.insert(id, ctx); 219 + 220 + // Register as child of parent. 221 + if let Some(parent) = self.contexts.get_mut(&parent_id) { 222 + parent.children.push(id); 223 + } 224 + 225 + id 226 + } 227 + 228 + /// Get a browsing context by ID. 229 + pub fn get(&self, id: BrowsingContextId) -> Option<&BrowsingContext> { 230 + self.contexts.get(&id) 231 + } 232 + 233 + /// Get a mutable reference to a browsing context. 234 + pub fn get_mut(&mut self, id: BrowsingContextId) -> Option<&mut BrowsingContext> { 235 + self.contexts.get_mut(&id) 236 + } 237 + 238 + /// Get the top-level context ID. 239 + pub fn top_level(&self) -> Option<BrowsingContextId> { 240 + self.top_level 241 + } 242 + 243 + /// Find the browsing context that contains a given iframe NodeId in its 244 + /// parent document. Returns the child context ID. 245 + pub fn find_by_iframe_node(&self, iframe_node_id: NodeId) -> Option<BrowsingContextId> { 246 + self.contexts 247 + .values() 248 + .find(|ctx| ctx.iframe_node_id == Some(iframe_node_id)) 249 + .map(|ctx| ctx.id) 250 + } 251 + 252 + /// Check if two contexts are same-origin. 253 + pub fn same_origin(&self, a: BrowsingContextId, b: BrowsingContextId) -> bool { 254 + match (self.contexts.get(&a), self.contexts.get(&b)) { 255 + (Some(ctx_a), Some(ctx_b)) => ctx_a.origin.same_origin(&ctx_b.origin), 256 + _ => false, 257 + } 258 + } 259 + 260 + /// Get all context IDs. 261 + pub fn context_ids(&self) -> Vec<BrowsingContextId> { 262 + self.contexts.keys().copied().collect() 263 + } 264 + 265 + /// Get the parent context of a given context. 266 + pub fn parent_context(&self, id: BrowsingContextId) -> Option<BrowsingContextId> { 267 + self.contexts.get(&id).and_then(|ctx| ctx.parent_id) 268 + } 269 + } 270 + 271 + /// A pending postMessage delivery. 272 + #[derive(Debug)] 273 + pub struct PendingMessage { 274 + /// The serialized message data (JSON-like string). 275 + pub data: String, 276 + /// The origin of the sender. 277 + pub sender_origin: String, 278 + /// The target context to deliver to. 279 + pub target_context: BrowsingContextId, 280 + /// The sender context (for `event.source`). 281 + pub sender_context: BrowsingContextId, 282 + /// The targetOrigin specified by the sender ("*" matches all). 283 + pub target_origin: String, 284 + } 285 + 286 + /// Structured clone for simple values: serializes JS-compatible values to a 287 + /// string representation for cross-context messaging. 288 + pub fn structured_clone_serialize(value: &str) -> String { 289 + // For now, the message is passed as a string directly. 290 + // When full structured clone is needed, this will serialize 291 + // objects/arrays to a transferable format. 292 + value.to_string() 293 + } 294 + 295 + #[cfg(test)] 296 + mod tests { 297 + use super::*; 298 + 299 + #[test] 300 + fn sandbox_flags_parse_empty() { 301 + let flags = SandboxFlags::parse(None); 302 + assert!(flags.scripts_blocked()); 303 + assert!(flags.forms_blocked()); 304 + assert!(flags.same_origin_forced()); 305 + assert!(flags.top_navigation_blocked()); 306 + assert!(flags.popups_blocked()); 307 + } 308 + 309 + #[test] 310 + fn sandbox_flags_parse_empty_string() { 311 + let flags = SandboxFlags::parse(Some("")); 312 + assert!(flags.scripts_blocked()); 313 + assert!(flags.forms_blocked()); 314 + } 315 + 316 + #[test] 317 + fn sandbox_flags_parse_allow_scripts() { 318 + let flags = SandboxFlags::parse(Some("allow-scripts")); 319 + assert!(!flags.scripts_blocked()); 320 + assert!(flags.forms_blocked()); 321 + assert!(flags.same_origin_forced()); 322 + } 323 + 324 + #[test] 325 + fn sandbox_flags_parse_multiple() { 326 + let flags = SandboxFlags::parse(Some("allow-scripts allow-same-origin allow-forms")); 327 + assert!(!flags.scripts_blocked()); 328 + assert!(!flags.forms_blocked()); 329 + assert!(!flags.same_origin_forced()); 330 + assert!(flags.top_navigation_blocked()); 331 + } 332 + 333 + #[test] 334 + fn sandbox_flags_none() { 335 + let flags = SandboxFlags::NONE; 336 + assert!(flags.is_none()); 337 + assert!(!flags.scripts_blocked()); 338 + assert!(!flags.forms_blocked()); 339 + } 340 + 341 + #[test] 342 + fn browsing_context_tree_basic() { 343 + let mut tree = BrowsingContextTree::new(); 344 + let doc = Document::new(); 345 + let origin = Origin::Opaque; 346 + let top_id = tree.create_top_level(doc, origin, None); 347 + 348 + assert!(tree.top_level().is_some()); 349 + assert_eq!(tree.top_level().unwrap(), top_id); 350 + assert!(tree.get(top_id).is_some()); 351 + assert!(tree.get(top_id).unwrap().parent_id.is_none()); 352 + } 353 + 354 + #[test] 355 + fn browsing_context_tree_nested() { 356 + let mut tree = BrowsingContextTree::new(); 357 + let doc = Document::new(); 358 + let origin = Origin::Tuple( 359 + "https".to_string(), 360 + we_url::Host::Domain("example.com".to_string()), 361 + None, 362 + ); 363 + let top_id = tree.create_top_level(doc, origin.clone(), None); 364 + 365 + let child_doc = Document::new(); 366 + let iframe_node = NodeId::from_index(5); 367 + let child_id = tree.create_nested( 368 + top_id, 369 + iframe_node, 370 + child_doc, 371 + origin.clone(), 372 + None, 373 + SandboxFlags::NONE, 374 + "child".to_string(), 375 + ); 376 + 377 + assert_eq!(tree.get(child_id).unwrap().parent_id, Some(top_id)); 378 + assert!(tree.same_origin(top_id, child_id)); 379 + assert_eq!(tree.find_by_iframe_node(iframe_node), Some(child_id)); 380 + assert_eq!(tree.get(top_id).unwrap().children, vec![child_id]); 381 + } 382 + 383 + #[test] 384 + fn sandbox_forces_opaque_origin() { 385 + let mut tree = BrowsingContextTree::new(); 386 + let doc = Document::new(); 387 + let origin = Origin::Tuple( 388 + "https".to_string(), 389 + we_url::Host::Domain("example.com".to_string()), 390 + None, 391 + ); 392 + let top_id = tree.create_top_level(doc, origin.clone(), None); 393 + 394 + let child_doc = Document::new(); 395 + let sandbox = SandboxFlags::parse(Some("allow-scripts")); // no allow-same-origin 396 + let child_id = tree.create_nested( 397 + top_id, 398 + NodeId::from_index(5), 399 + child_doc, 400 + origin, 401 + None, 402 + sandbox, 403 + String::new(), 404 + ); 405 + 406 + // Sandbox without allow-same-origin forces opaque origin. 407 + assert!(!tree.same_origin(top_id, child_id)); 408 + assert_eq!(tree.get(child_id).unwrap().origin, Origin::Opaque); 409 + } 410 + 411 + #[test] 412 + fn sandbox_allow_same_origin_preserves_origin() { 413 + let mut tree = BrowsingContextTree::new(); 414 + let doc = Document::new(); 415 + let origin = Origin::Tuple( 416 + "https".to_string(), 417 + we_url::Host::Domain("example.com".to_string()), 418 + None, 419 + ); 420 + let top_id = tree.create_top_level(doc, origin.clone(), None); 421 + 422 + let child_doc = Document::new(); 423 + let sandbox = SandboxFlags::parse(Some("allow-scripts allow-same-origin")); 424 + let child_id = tree.create_nested( 425 + top_id, 426 + NodeId::from_index(5), 427 + child_doc, 428 + origin, 429 + None, 430 + sandbox, 431 + String::new(), 432 + ); 433 + 434 + // allow-same-origin preserves the real origin. 435 + assert!(tree.same_origin(top_id, child_id)); 436 + } 437 + 438 + #[test] 439 + fn pending_message_fields() { 440 + let msg = PendingMessage { 441 + data: "hello".to_string(), 442 + sender_origin: "https://a.com".to_string(), 443 + target_context: BrowsingContextId::new(0), 444 + sender_context: BrowsingContextId::new(1), 445 + target_origin: "*".to_string(), 446 + }; 447 + assert_eq!(msg.data, "hello"); 448 + assert_eq!(msg.target_origin, "*"); 449 + } 450 + }
+347
crates/browser/src/iframe_loader.rs
··· 1 + //! Iframe loading: discover iframe elements, fetch their content, parse into 2 + //! nested Documents, and produce rendered surfaces for layout/rendering. 3 + 4 + use std::collections::HashMap; 5 + 6 + use we_dom::{Document, NodeData, NodeId}; 7 + use we_image::pixel::Image; 8 + use we_url::{Origin, Url}; 9 + 10 + use crate::browsing_context::{BrowsingContextId, BrowsingContextTree, SandboxFlags}; 11 + use crate::loader::{Resource, ResourceLoader}; 12 + 13 + /// Data about a loaded iframe for rendering. 14 + pub struct IframeData { 15 + /// The browsing context ID for this iframe. 16 + pub context_id: BrowsingContextId, 17 + /// The NodeId of the iframe element in the parent document. 18 + pub node_id: NodeId, 19 + /// Display width (from attributes or default 300). 20 + pub width: f32, 21 + /// Display height (from attributes or default 150). 22 + pub height: f32, 23 + } 24 + 25 + /// Default iframe width per HTML spec. 26 + const DEFAULT_IFRAME_WIDTH: f32 = 300.0; 27 + /// Default iframe height per HTML spec. 28 + const DEFAULT_IFRAME_HEIGHT: f32 = 150.0; 29 + 30 + /// Scan a document for iframe elements and return their dimensions for layout. 31 + /// Adds entries to the image_sizes map so layout treats iframes as replaced elements. 32 + pub fn collect_iframe_sizes(doc: &Document, image_sizes: &mut HashMap<NodeId, (f32, f32)>) { 33 + collect_iframe_sizes_recursive(doc, doc.root(), image_sizes); 34 + } 35 + 36 + fn collect_iframe_sizes_recursive( 37 + doc: &Document, 38 + node: NodeId, 39 + sizes: &mut HashMap<NodeId, (f32, f32)>, 40 + ) { 41 + if let NodeData::Element { ref tag_name, .. } = *doc.node_data(node) { 42 + if tag_name == "iframe" { 43 + let width = doc 44 + .get_attribute(node, "width") 45 + .and_then(|v| v.parse::<f32>().ok()) 46 + .unwrap_or(DEFAULT_IFRAME_WIDTH); 47 + let height = doc 48 + .get_attribute(node, "height") 49 + .and_then(|v| v.parse::<f32>().ok()) 50 + .unwrap_or(DEFAULT_IFRAME_HEIGHT); 51 + sizes.insert(node, (width, height)); 52 + return; // Don't recurse into iframe content. 53 + } 54 + } 55 + for child in doc.children(node) { 56 + collect_iframe_sizes_recursive(doc, child, sizes); 57 + } 58 + } 59 + 60 + /// Load all iframes found in a document. For each iframe with a `src` attribute, 61 + /// fetches the HTML, parses it, and creates a nested browsing context. 62 + /// 63 + /// Returns a list of loaded iframe data and adds rendered surfaces to the 64 + /// iframe_images map. 65 + pub fn load_iframes( 66 + doc: &Document, 67 + loader: &mut ResourceLoader, 68 + base_url: &Url, 69 + parent_origin: &Origin, 70 + parent_context_id: BrowsingContextId, 71 + context_tree: &mut BrowsingContextTree, 72 + ) -> Vec<IframeData> { 73 + let mut results = Vec::new(); 74 + load_iframes_recursive( 75 + doc, 76 + doc.root(), 77 + loader, 78 + base_url, 79 + parent_origin, 80 + parent_context_id, 81 + context_tree, 82 + &mut results, 83 + ); 84 + results 85 + } 86 + 87 + #[allow(clippy::too_many_arguments)] 88 + fn load_iframes_recursive( 89 + doc: &Document, 90 + node: NodeId, 91 + loader: &mut ResourceLoader, 92 + base_url: &Url, 93 + parent_origin: &Origin, 94 + parent_context_id: BrowsingContextId, 95 + context_tree: &mut BrowsingContextTree, 96 + results: &mut Vec<IframeData>, 97 + ) { 98 + if let NodeData::Element { ref tag_name, .. } = *doc.node_data(node) { 99 + if tag_name == "iframe" { 100 + if let Some(iframe_data) = load_single_iframe( 101 + doc, 102 + node, 103 + loader, 104 + base_url, 105 + parent_origin, 106 + parent_context_id, 107 + context_tree, 108 + ) { 109 + results.push(iframe_data); 110 + } 111 + return; // Don't recurse into iframe content. 112 + } 113 + } 114 + for child in doc.children(node) { 115 + load_iframes_recursive( 116 + doc, 117 + child, 118 + loader, 119 + base_url, 120 + parent_origin, 121 + parent_context_id, 122 + context_tree, 123 + results, 124 + ); 125 + } 126 + } 127 + 128 + /// Load a single iframe element. 129 + fn load_single_iframe( 130 + doc: &Document, 131 + node: NodeId, 132 + loader: &mut ResourceLoader, 133 + base_url: &Url, 134 + parent_origin: &Origin, 135 + parent_context_id: BrowsingContextId, 136 + context_tree: &mut BrowsingContextTree, 137 + ) -> Option<IframeData> { 138 + let width = doc 139 + .get_attribute(node, "width") 140 + .and_then(|v| v.parse::<f32>().ok()) 141 + .unwrap_or(DEFAULT_IFRAME_WIDTH); 142 + let height = doc 143 + .get_attribute(node, "height") 144 + .and_then(|v| v.parse::<f32>().ok()) 145 + .unwrap_or(DEFAULT_IFRAME_HEIGHT); 146 + 147 + // Parse sandbox attribute. 148 + let sandbox = if doc.get_attribute(node, "sandbox").is_some() { 149 + SandboxFlags::parse(doc.get_attribute(node, "sandbox")) 150 + } else { 151 + SandboxFlags::NONE 152 + }; 153 + 154 + let name = doc.get_attribute(node, "name").unwrap_or("").to_string(); 155 + 156 + // Check for srcdoc first, then src. 157 + let (iframe_doc, iframe_origin, iframe_url) = 158 + if let Some(srcdoc) = doc.get_attribute(node, "srcdoc") { 159 + // srcdoc: parse the attribute value as HTML. Origin is parent's origin. 160 + let parsed = we_html::parse_html(srcdoc); 161 + (parsed, parent_origin.clone(), None) 162 + } else if let Some(src) = doc.get_attribute(node, "src") { 163 + let src = src.to_string(); 164 + match load_iframe_src(&src, loader, base_url) { 165 + Some((parsed_doc, url)) => { 166 + let origin = url.origin(); 167 + (parsed_doc, origin, Some(url)) 168 + } 169 + None => { 170 + // Failed to load: create empty document. 171 + (Document::new(), Origin::Opaque, None) 172 + } 173 + } 174 + } else { 175 + // No src or srcdoc: empty iframe (about:blank). 176 + (Document::new(), parent_origin.clone(), None) 177 + }; 178 + 179 + let context_id = context_tree.create_nested( 180 + parent_context_id, 181 + node, 182 + iframe_doc, 183 + iframe_origin, 184 + iframe_url, 185 + sandbox, 186 + name, 187 + ); 188 + 189 + Some(IframeData { 190 + context_id, 191 + node_id: node, 192 + width, 193 + height, 194 + }) 195 + } 196 + 197 + /// Fetch and parse an iframe's src URL. 198 + fn load_iframe_src( 199 + src: &str, 200 + loader: &mut ResourceLoader, 201 + base_url: &Url, 202 + ) -> Option<(Document, Url)> { 203 + // about:blank is handled specially. 204 + if src == "about:blank" || src.is_empty() { 205 + return Some((Document::new(), Url::parse("about:blank").ok()?)); 206 + } 207 + 208 + // Resolve relative URL against base. 209 + let url = Url::parse_with_base(src, base_url).ok()?; 210 + 211 + // Fetch the iframe content as a navigation (always allowed cross-origin). 212 + match loader.fetch(&url) { 213 + Ok(resource) => { 214 + let html_text = match resource { 215 + Resource::Html { text, .. } => text, 216 + Resource::Other { data, .. } => { 217 + // Try to decode as text. 218 + String::from_utf8(data).unwrap_or_default() 219 + } 220 + _ => return None, 221 + }; 222 + let doc = we_html::parse_html(&html_text); 223 + Some((doc, url)) 224 + } 225 + Err(e) => { 226 + eprintln!("[we] iframe load error for {src}: {e}"); 227 + None 228 + } 229 + } 230 + } 231 + 232 + /// Render an iframe's content to an RGBA pixel buffer. 233 + /// 234 + /// This creates a mini rendering pipeline for the iframe document: 235 + /// resolve styles → layout → build display list → software render. 236 + pub fn render_iframe_to_image( 237 + doc: &Document, 238 + width: u32, 239 + height: u32, 240 + font: &we_text::font::Font, 241 + ) -> Image { 242 + use we_css::parser::Stylesheet; 243 + use we_layout::layout; 244 + use we_render::build_display_list; 245 + 246 + let stylesheet = Stylesheet { rules: Vec::new() }; 247 + let image_sizes = HashMap::new(); 248 + let viewport = (width as f32, height as f32); 249 + let styled = match we_style::computed::resolve_styles( 250 + doc, 251 + std::slice::from_ref(&stylesheet), 252 + viewport, 253 + ) { 254 + Some(s) => s, 255 + None => { 256 + // Empty document: return white image. 257 + return Image { 258 + width, 259 + height, 260 + data: vec![255u8; (width * height * 4) as usize], 261 + }; 262 + } 263 + }; 264 + let tree = layout( 265 + &styled, 266 + doc, 267 + width as f32, 268 + height as f32, 269 + font, 270 + &image_sizes, 271 + ); 272 + let display_list = build_display_list(&tree); 273 + 274 + // Software render to pixel buffer. 275 + let image_refs = HashMap::new(); 276 + let mut renderer = we_render::Renderer::new(width, height); 277 + renderer.paint_display_list(&display_list, font, &image_refs); 278 + 279 + Image { 280 + width, 281 + height, 282 + data: renderer.pixels().to_vec(), 283 + } 284 + } 285 + 286 + #[cfg(test)] 287 + mod tests { 288 + use super::*; 289 + 290 + #[test] 291 + fn collect_iframe_sizes_default() { 292 + let mut doc = Document::new(); 293 + let root = doc.root(); 294 + let html = doc.create_element("html"); 295 + let body = doc.create_element("body"); 296 + let iframe = doc.create_element("iframe"); 297 + doc.append_child(root, html); 298 + doc.append_child(html, body); 299 + doc.append_child(body, iframe); 300 + 301 + let mut sizes = HashMap::new(); 302 + collect_iframe_sizes(&doc, &mut sizes); 303 + 304 + assert_eq!(sizes.get(&iframe), Some(&(300.0, 150.0))); 305 + } 306 + 307 + #[test] 308 + fn collect_iframe_sizes_custom() { 309 + let mut doc = Document::new(); 310 + let root = doc.root(); 311 + let html = doc.create_element("html"); 312 + let body = doc.create_element("body"); 313 + let iframe = doc.create_element("iframe"); 314 + doc.set_attribute(iframe, "width", "500"); 315 + doc.set_attribute(iframe, "height", "400"); 316 + doc.append_child(root, html); 317 + doc.append_child(html, body); 318 + doc.append_child(body, iframe); 319 + 320 + let mut sizes = HashMap::new(); 321 + collect_iframe_sizes(&doc, &mut sizes); 322 + 323 + assert_eq!(sizes.get(&iframe), Some(&(500.0, 400.0))); 324 + } 325 + 326 + #[test] 327 + fn collect_iframe_sizes_multiple() { 328 + let mut doc = Document::new(); 329 + let root = doc.root(); 330 + let html = doc.create_element("html"); 331 + let body = doc.create_element("body"); 332 + let iframe1 = doc.create_element("iframe"); 333 + let iframe2 = doc.create_element("iframe"); 334 + doc.set_attribute(iframe2, "width", "200"); 335 + doc.append_child(root, html); 336 + doc.append_child(html, body); 337 + doc.append_child(body, iframe1); 338 + doc.append_child(body, iframe2); 339 + 340 + let mut sizes = HashMap::new(); 341 + collect_iframe_sizes(&doc, &mut sizes); 342 + 343 + assert_eq!(sizes.len(), 2); 344 + assert_eq!(sizes.get(&iframe1), Some(&(300.0, 150.0))); 345 + assert_eq!(sizes.get(&iframe2), Some(&(200.0, 150.0))); 346 + } 347 + }
+2
crates/browser/src/lib.rs
··· 1 1 //! Event loop, resource loading, navigation, UI chrome. 2 2 3 + pub mod browsing_context; 3 4 pub mod csp; 4 5 pub mod css_loader; 5 6 pub mod font_loader; 7 + pub mod iframe_loader; 6 8 pub mod img_loader; 7 9 pub mod indexeddb; 8 10 pub mod loader;
+3
crates/browser/src/main.rs
··· 196 196 let svg_sizes = collect_svg_sizes(&page.doc); 197 197 sizes.extend(svg_sizes); 198 198 199 + // Add iframe sizes so layout treats them as replaced elements. 200 + we_browser::iframe_loader::collect_iframe_sizes(&page.doc, &mut sizes); 201 + 199 202 let svg_images = rasterize_svgs(&page.doc, font); 200 203 let refs = image_refs(&page.images, &svg_images); 201 204
+9
crates/html/src/tree_builder.rs
··· 391 391 self.open_elements.push(elem); 392 392 self.svg_depth = 1; 393 393 } 394 + Token::StartTag { ref name, .. } if name == "iframe" => { 395 + // Per HTML spec, <iframe> uses RAWTEXT parsing: content between 396 + // <iframe> and </iframe> is raw text (fallback content), not HTML. 397 + let elem = self.create_element_from_token(&token); 398 + self.insert_node(elem); 399 + self.open_elements.push(elem); 400 + self.original_insertion_mode = Some(self.insertion_mode); 401 + self.insertion_mode = InsertionMode::Text; 402 + } 394 403 Token::StartTag { ref name, .. } if is_void_element(name) => { 395 404 let elem = self.create_element_from_token(&token); 396 405 self.insert_node(elem);
+31
crates/js/src/dom_bridge.rs
··· 2525 2525 vm.set_global("sessionStorage", Value::Object(session_ref)); 2526 2526 } 2527 2527 2528 + // ── Public wrappers for cross-module access ──────────────────────── 2529 + 2530 + /// Public wrapper for `get_node_id` — used by `iframe_bridge`. 2531 + pub fn get_node_id_pub(gc: &Gc<HeapObject>, wrapper: GcRef) -> Option<NodeId> { 2532 + get_node_id(gc, wrapper) 2533 + } 2534 + 2535 + /// Public wrapper for `event_target_add_listener`. 2536 + pub fn event_target_add_listener_pub( 2537 + args: &[Value], 2538 + ctx: &mut NativeContext, 2539 + ) -> Result<Value, RuntimeError> { 2540 + event_target_add_listener(args, ctx) 2541 + } 2542 + 2543 + /// Public wrapper for `event_target_remove_listener`. 2544 + pub fn event_target_remove_listener_pub( 2545 + args: &[Value], 2546 + ctx: &mut NativeContext, 2547 + ) -> Result<Value, RuntimeError> { 2548 + event_target_remove_listener(args, ctx) 2549 + } 2550 + 2551 + /// Public wrapper for `event_target_dispatch_event`. 2552 + pub fn event_target_dispatch_event_pub( 2553 + args: &[Value], 2554 + ctx: &mut NativeContext, 2555 + ) -> Result<Value, RuntimeError> { 2556 + event_target_dispatch_event(args, ctx) 2557 + } 2558 + 2528 2559 // ── Tests ─────────────────────────────────────────────────────────── 2529 2560 2530 2561 #[cfg(test)]
+611
crates/js/src/iframe_bridge.rs
··· 1 + //! Iframe and window bridge: window object, postMessage, MessageEvent, 2 + //! contentWindow/contentDocument with origin-based access checks. 3 + 4 + use crate::builtins::{make_native, set_builtin_prop}; 5 + use crate::gc::{Gc, GcRef}; 6 + use crate::vm::*; 7 + use std::cell::RefCell; 8 + use we_dom::NodeId; 9 + 10 + /// Internal key marking an object as a window proxy. 11 + /// Value is the browsing context ID as a string. 12 + const WINDOW_CONTEXT_ID_KEY: &str = "__window_context_id__"; 13 + 14 + /// Internal key storing the origin of a window for postMessage checks. 15 + const WINDOW_ORIGIN_KEY: &str = "__window_origin__"; 16 + 17 + /// Pending messages queued by postMessage, to be delivered after the 18 + /// current script execution completes. 19 + pub struct MessageQueue { 20 + pub messages: Vec<PendingJsMessage>, 21 + } 22 + 23 + /// A message queued by postMessage. 24 + pub struct PendingJsMessage { 25 + /// The serialized message data. 26 + pub data: String, 27 + /// The sender's origin string. 28 + pub sender_origin: String, 29 + /// The target origin filter ("*" matches all). 30 + pub target_origin: String, 31 + /// The sender's window GcRef (for event.source). 32 + pub sender_window: Option<GcRef>, 33 + /// The target context ID. 34 + pub target_context_id: String, 35 + } 36 + 37 + thread_local! { 38 + /// Global message queue for cross-context postMessage. 39 + static MESSAGE_QUEUE: RefCell<MessageQueue> = const { RefCell::new(MessageQueue { 40 + messages: Vec::new(), 41 + }) }; 42 + } 43 + 44 + /// Drain all pending messages from the queue. 45 + pub fn drain_message_queue() -> Vec<PendingJsMessage> { 46 + MESSAGE_QUEUE.with(|q| { 47 + let mut queue = q.borrow_mut(); 48 + std::mem::take(&mut queue.messages) 49 + }) 50 + } 51 + 52 + /// Initialize the `window` global object on the VM. 53 + /// 54 + /// The window object is a proxy to the global scope. It also serves as the 55 + /// target for postMessage and event listeners. 56 + pub fn init_window_object(vm: &mut Vm, context_id: &str, origin: &str) { 57 + let mut data = ObjectData::new(); 58 + if let Some(proto) = vm.object_prototype { 59 + data.prototype = Some(proto); 60 + } 61 + 62 + // Mark this as a window object with context ID and origin. 63 + data.properties.insert( 64 + WINDOW_CONTEXT_ID_KEY.to_string(), 65 + Property::builtin(Value::String(context_id.to_string())), 66 + ); 67 + data.properties.insert( 68 + WINDOW_ORIGIN_KEY.to_string(), 69 + Property::builtin(Value::String(origin.to_string())), 70 + ); 71 + 72 + // window.self, window.window refer to window itself (set after alloc). 73 + let window_ref = vm.gc.alloc(HeapObject::Object(data)); 74 + 75 + set_builtin_prop(&mut vm.gc, window_ref, "self", Value::Object(window_ref)); 76 + set_builtin_prop(&mut vm.gc, window_ref, "window", Value::Object(window_ref)); 77 + 78 + // window.location (basic stub). 79 + set_builtin_prop( 80 + &mut vm.gc, 81 + window_ref, 82 + "location", 83 + Value::Object(window_ref), 84 + ); 85 + 86 + // window.origin 87 + set_builtin_prop( 88 + &mut vm.gc, 89 + window_ref, 90 + "origin", 91 + Value::String(origin.to_string()), 92 + ); 93 + 94 + // Register postMessage method. 95 + let post_msg = make_native(&mut vm.gc, "postMessage", window_post_message); 96 + set_builtin_prop( 97 + &mut vm.gc, 98 + window_ref, 99 + "postMessage", 100 + Value::Function(post_msg), 101 + ); 102 + 103 + // Register event target methods on window. 104 + let add_listener = make_native( 105 + &mut vm.gc, 106 + "addEventListener", 107 + crate::dom_bridge::event_target_add_listener_pub, 108 + ); 109 + let remove_listener = make_native( 110 + &mut vm.gc, 111 + "removeEventListener", 112 + crate::dom_bridge::event_target_remove_listener_pub, 113 + ); 114 + let dispatch_event = make_native( 115 + &mut vm.gc, 116 + "dispatchEvent", 117 + crate::dom_bridge::event_target_dispatch_event_pub, 118 + ); 119 + set_builtin_prop( 120 + &mut vm.gc, 121 + window_ref, 122 + "addEventListener", 123 + Value::Function(add_listener), 124 + ); 125 + set_builtin_prop( 126 + &mut vm.gc, 127 + window_ref, 128 + "removeEventListener", 129 + Value::Function(remove_listener), 130 + ); 131 + set_builtin_prop( 132 + &mut vm.gc, 133 + window_ref, 134 + "dispatchEvent", 135 + Value::Function(dispatch_event), 136 + ); 137 + 138 + // Set as global. 139 + vm.set_global("window", Value::Object(window_ref)); 140 + 141 + // Also make document accessible from window if it exists. 142 + if let Some(doc_val) = vm.get_global("document").cloned() { 143 + set_builtin_prop(&mut vm.gc, window_ref, "document", doc_val); 144 + } 145 + 146 + // Register MessageEvent constructor. 147 + let msg_event_ctor = make_native(&mut vm.gc, "MessageEvent", message_event_constructor); 148 + vm.set_global("MessageEvent", Value::Function(msg_event_ctor)); 149 + } 150 + 151 + /// `window.postMessage(message, targetOrigin)` implementation. 152 + fn window_post_message(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 153 + let message = args 154 + .first() 155 + .map(|v| serialize_for_post_message(v, ctx.gc)) 156 + .unwrap_or_default(); 157 + 158 + let target_origin = args 159 + .get(1) 160 + .map(|v| v.to_js_string(ctx.gc)) 161 + .unwrap_or_else(|| "/".to_string()); 162 + 163 + // Get the target window's context ID and sender's origin. 164 + let (target_context_id, _sender_origin) = if let Value::Object(this_ref) = &ctx.this { 165 + let ctx_id = get_string_prop(ctx.gc, *this_ref, WINDOW_CONTEXT_ID_KEY).unwrap_or_default(); 166 + let origin = get_string_prop(ctx.gc, *this_ref, WINDOW_ORIGIN_KEY).unwrap_or_default(); 167 + (ctx_id, origin) 168 + } else { 169 + (String::new(), String::new()) 170 + }; 171 + 172 + // Get sender's origin from the dom bridge. 173 + let sender_origin = ctx 174 + .dom_bridge 175 + .map(|b| b.origin.borrow().clone()) 176 + .unwrap_or_default(); 177 + 178 + MESSAGE_QUEUE.with(|q| { 179 + q.borrow_mut().messages.push(PendingJsMessage { 180 + data: message, 181 + sender_origin, 182 + target_origin, 183 + sender_window: None, // Set by the delivery mechanism. 184 + target_context_id, 185 + }); 186 + }); 187 + 188 + Ok(Value::Undefined) 189 + } 190 + 191 + /// Serialize a JS Value to a string for postMessage (simplified structured clone). 192 + fn serialize_for_post_message(value: &Value, gc: &Gc<HeapObject>) -> String { 193 + match value { 194 + Value::Undefined => "undefined".to_string(), 195 + Value::Null => "null".to_string(), 196 + Value::Boolean(b) => b.to_string(), 197 + Value::Number(n) => { 198 + if n.is_nan() || n.is_infinite() { 199 + "null".to_string() 200 + } else if *n == 0.0 { 201 + "0".to_string() 202 + } else { 203 + format!("{n}") 204 + } 205 + } 206 + Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")), 207 + Value::Object(r) => { 208 + if let Some(HeapObject::Object(data)) = gc.get(*r) { 209 + // Check if it's an array-like object. 210 + if data.properties.contains_key("length") { 211 + let len = data 212 + .properties 213 + .get("length") 214 + .map(|p| match &p.value { 215 + Value::Number(n) => *n as usize, 216 + _ => 0, 217 + }) 218 + .unwrap_or(0); 219 + let mut items = Vec::new(); 220 + for i in 0..len { 221 + if let Some(prop) = data.properties.get(&i.to_string()) { 222 + items.push(serialize_for_post_message(&prop.value, gc)); 223 + } else { 224 + items.push("null".to_string()); 225 + } 226 + } 227 + return format!("[{}]", items.join(",")); 228 + } 229 + // Plain object: serialize enumerable properties. 230 + let mut pairs = Vec::new(); 231 + for (k, prop) in &data.properties { 232 + if prop.enumerable { 233 + let v = serialize_for_post_message(&prop.value, gc); 234 + pairs.push(format!( 235 + "\"{}\":{}", 236 + k.replace('\\', "\\\\").replace('"', "\\\""), 237 + v 238 + )); 239 + } 240 + } 241 + format!("{{{}}}", pairs.join(",")) 242 + } else { 243 + "null".to_string() 244 + } 245 + } 246 + Value::Function(_) => "null".to_string(), // Functions are not cloneable. 247 + } 248 + } 249 + 250 + /// `MessageEvent` constructor: `new MessageEvent(type, options)`. 251 + fn message_event_constructor( 252 + args: &[Value], 253 + ctx: &mut NativeContext, 254 + ) -> Result<Value, RuntimeError> { 255 + let event_type = args 256 + .first() 257 + .map(|v| v.to_js_string(ctx.gc)) 258 + .unwrap_or_else(|| "message".to_string()); 259 + 260 + let mut data = Value::Undefined; 261 + let mut origin = String::new(); 262 + let mut source = Value::Null; 263 + 264 + if let Some(Value::Object(opts_ref)) = args.get(1) { 265 + if let Some(HeapObject::Object(opts)) = ctx.gc.get(*opts_ref) { 266 + if let Some(prop) = opts.properties.get("data") { 267 + data = prop.value.clone(); 268 + } 269 + if let Some(prop) = opts.properties.get("origin") { 270 + origin = prop.value.to_js_string(ctx.gc); 271 + } 272 + if let Some(prop) = opts.properties.get("source") { 273 + source = prop.value.clone(); 274 + } 275 + } 276 + } 277 + 278 + let event_ref = create_message_event(ctx.gc, &event_type, data, &origin, source); 279 + Ok(Value::Object(event_ref)) 280 + } 281 + 282 + /// Create a MessageEvent object. 283 + pub fn create_message_event( 284 + gc: &mut Gc<HeapObject>, 285 + event_type: &str, 286 + data: Value, 287 + origin: &str, 288 + source: Value, 289 + ) -> GcRef { 290 + let mut obj = ObjectData::new(); 291 + 292 + // Standard Event properties. 293 + obj.properties.insert( 294 + "type".to_string(), 295 + Property::data(Value::String(event_type.to_string())), 296 + ); 297 + obj.properties 298 + .insert("bubbles".to_string(), Property::data(Value::Boolean(false))); 299 + obj.properties.insert( 300 + "cancelable".to_string(), 301 + Property::data(Value::Boolean(false)), 302 + ); 303 + obj.properties.insert( 304 + "defaultPrevented".to_string(), 305 + Property::data(Value::Boolean(false)), 306 + ); 307 + obj.properties 308 + .insert("eventPhase".to_string(), Property::data(Value::Number(0.0))); 309 + obj.properties 310 + .insert("target".to_string(), Property::data(Value::Null)); 311 + obj.properties 312 + .insert("currentTarget".to_string(), Property::data(Value::Null)); 313 + obj.properties 314 + .insert("timeStamp".to_string(), Property::data(Value::Number(0.0))); 315 + 316 + // Internal event state keys. 317 + obj.properties.insert( 318 + "__event_type__".to_string(), 319 + Property::builtin(Value::String(event_type.to_string())), 320 + ); 321 + obj.properties.insert( 322 + "__event_bubbles__".to_string(), 323 + Property::builtin(Value::Boolean(false)), 324 + ); 325 + obj.properties.insert( 326 + "__event_cancelable__".to_string(), 327 + Property::builtin(Value::Boolean(false)), 328 + ); 329 + obj.properties.insert( 330 + "__event_stop_prop__".to_string(), 331 + Property::builtin(Value::Boolean(false)), 332 + ); 333 + obj.properties.insert( 334 + "__event_stop_immediate__".to_string(), 335 + Property::builtin(Value::Boolean(false)), 336 + ); 337 + obj.properties.insert( 338 + "__event_default_prevented__".to_string(), 339 + Property::builtin(Value::Boolean(false)), 340 + ); 341 + obj.properties.insert( 342 + "__event_phase__".to_string(), 343 + Property::builtin(Value::Number(0.0)), 344 + ); 345 + 346 + // MessageEvent-specific properties. 347 + obj.properties 348 + .insert("data".to_string(), Property::data(data)); 349 + obj.properties.insert( 350 + "origin".to_string(), 351 + Property::data(Value::String(origin.to_string())), 352 + ); 353 + obj.properties 354 + .insert("source".to_string(), Property::data(source)); 355 + obj.properties.insert( 356 + "lastEventId".to_string(), 357 + Property::data(Value::String(String::new())), 358 + ); 359 + obj.properties 360 + .insert("ports".to_string(), Property::data(Value::Null)); 361 + 362 + gc.alloc(HeapObject::Object(obj)) 363 + } 364 + 365 + /// Resolve dynamic properties on iframe element wrappers. 366 + /// Returns `Some(value)` for `contentWindow` and `contentDocument`. 367 + pub fn resolve_iframe_property( 368 + gc: &mut Gc<HeapObject>, 369 + bridge: &DomBridge, 370 + node_id: NodeId, 371 + key: &str, 372 + ) -> Option<Value> { 373 + let doc = bridge.document.borrow(); 374 + let tag_name = doc.tag_name(node_id)?; 375 + if tag_name != "iframe" { 376 + return None; 377 + } 378 + drop(doc); 379 + 380 + match key { 381 + "contentWindow" => { 382 + // Return a window proxy for the iframe's browsing context. 383 + // The actual window object is managed by the browsing context tree 384 + // in the browser crate. For now, return a placeholder that the 385 + // browser can populate via set_iframe_window. 386 + let windows = bridge.iframe_windows.borrow(); 387 + let idx = node_id.index(); 388 + if let Some(window_ref) = windows.get(&idx) { 389 + Some(Value::Object(*window_ref)) 390 + } else { 391 + Some(Value::Null) 392 + } 393 + } 394 + "contentDocument" => { 395 + // Same-origin check: only return document if origins match. 396 + let windows = bridge.iframe_windows.borrow(); 397 + let idx = node_id.index(); 398 + if let Some(window_ref) = windows.get(&idx) { 399 + // Check if the iframe has a document property. 400 + if let Some(HeapObject::Object(data)) = gc.get(*window_ref) { 401 + // Check origin. 402 + let iframe_origin = data 403 + .properties 404 + .get(WINDOW_ORIGIN_KEY) 405 + .and_then(|p| { 406 + if let Value::String(s) = &p.value { 407 + Some(s.clone()) 408 + } else { 409 + None 410 + } 411 + }) 412 + .unwrap_or_default(); 413 + 414 + let parent_origin = bridge.origin.borrow().clone(); 415 + 416 + if iframe_origin == parent_origin || iframe_origin.is_empty() { 417 + // Same-origin: return the iframe's document. 418 + if let Some(prop) = data.properties.get("document") { 419 + return Some(prop.value.clone()); 420 + } 421 + } 422 + // Cross-origin: return null (security restriction). 423 + // In a full implementation this would throw a SecurityError. 424 + } 425 + Some(Value::Null) 426 + } else { 427 + Some(Value::Null) 428 + } 429 + } 430 + "src" => { 431 + let doc = bridge.document.borrow(); 432 + let src = doc 433 + .get_attribute(node_id, "src") 434 + .map(|s| Value::String(s.to_string())) 435 + .unwrap_or(Value::String(String::new())); 436 + Some(src) 437 + } 438 + "sandbox" => { 439 + let doc = bridge.document.borrow(); 440 + let sandbox = doc 441 + .get_attribute(node_id, "sandbox") 442 + .map(|s| Value::String(s.to_string())) 443 + .unwrap_or(Value::Null); 444 + Some(sandbox) 445 + } 446 + "name" => { 447 + let doc = bridge.document.borrow(); 448 + let name_val = doc 449 + .get_attribute(node_id, "name") 450 + .map(|s| Value::String(s.to_string())) 451 + .unwrap_or(Value::String(String::new())); 452 + Some(name_val) 453 + } 454 + _ => None, 455 + } 456 + } 457 + 458 + /// Resolve `window.parent` and `window.top` properties. 459 + pub fn resolve_window_property(gc: &Gc<HeapObject>, gc_ref: GcRef, key: &str) -> Option<Value> { 460 + // Check if this is a window object. 461 + let is_window = if let Some(HeapObject::Object(data)) = gc.get(gc_ref) { 462 + data.properties.contains_key(WINDOW_CONTEXT_ID_KEY) 463 + } else { 464 + false 465 + }; 466 + 467 + if !is_window { 468 + return None; 469 + } 470 + 471 + match key { 472 + "parent" => { 473 + // Return the parent window. If not set, return self. 474 + if let Some(HeapObject::Object(data)) = gc.get(gc_ref) { 475 + if let Some(prop) = data.properties.get("__parent_window__") { 476 + return Some(prop.value.clone()); 477 + } 478 + } 479 + Some(Value::Object(gc_ref)) // Top-level: parent == self 480 + } 481 + "top" => { 482 + // Walk parent chain to find the top window. 483 + let mut current = gc_ref; 484 + loop { 485 + if let Some(HeapObject::Object(data)) = gc.get(current) { 486 + if let Some(prop) = data.properties.get("__parent_window__") { 487 + if let Value::Object(parent_ref) = &prop.value { 488 + if *parent_ref == current { 489 + break; 490 + } 491 + current = *parent_ref; 492 + continue; 493 + } 494 + } 495 + } 496 + break; 497 + } 498 + Some(Value::Object(current)) 499 + } 500 + "frames" => { 501 + // window.frames is the same as window. 502 + Some(Value::Object(gc_ref)) 503 + } 504 + "length" => { 505 + // Number of child frames. Read from __child_frames_count__ if set. 506 + if let Some(HeapObject::Object(data)) = gc.get(gc_ref) { 507 + if let Some(prop) = data.properties.get("__child_frames_count__") { 508 + return Some(prop.value.clone()); 509 + } 510 + } 511 + Some(Value::Number(0.0)) 512 + } 513 + _ => None, 514 + } 515 + } 516 + 517 + /// Helper: get a string property from an object. 518 + fn get_string_prop(gc: &Gc<HeapObject>, gc_ref: GcRef, key: &str) -> Option<String> { 519 + if let Some(HeapObject::Object(data)) = gc.get(gc_ref) { 520 + if let Some(prop) = data.properties.get(key) { 521 + if let Value::String(s) = &prop.value { 522 + return Some(s.clone()); 523 + } 524 + } 525 + } 526 + None 527 + } 528 + 529 + #[cfg(test)] 530 + mod tests { 531 + use super::*; 532 + use crate::gc::Gc; 533 + 534 + #[test] 535 + fn serialize_primitives() { 536 + let gc = Gc::new(); 537 + assert_eq!( 538 + serialize_for_post_message(&Value::String("hello".to_string()), &gc), 539 + "\"hello\"" 540 + ); 541 + assert_eq!(serialize_for_post_message(&Value::Number(42.0), &gc), "42"); 542 + assert_eq!( 543 + serialize_for_post_message(&Value::Boolean(true), &gc), 544 + "true" 545 + ); 546 + assert_eq!(serialize_for_post_message(&Value::Null, &gc), "null"); 547 + assert_eq!( 548 + serialize_for_post_message(&Value::Undefined, &gc), 549 + "undefined" 550 + ); 551 + } 552 + 553 + #[test] 554 + fn serialize_string_escapes() { 555 + let gc = Gc::new(); 556 + assert_eq!( 557 + serialize_for_post_message(&Value::String("he\"llo".to_string()), &gc), 558 + "\"he\\\"llo\"" 559 + ); 560 + } 561 + 562 + #[test] 563 + fn message_event_creation() { 564 + let mut gc = Gc::<HeapObject>::new(); 565 + let event_ref = create_message_event( 566 + &mut gc, 567 + "message", 568 + Value::String("test data".to_string()), 569 + "https://example.com", 570 + Value::Null, 571 + ); 572 + 573 + if let Some(HeapObject::Object(data)) = gc.get(event_ref) { 574 + match data.properties.get("type").map(|p| &p.value) { 575 + Some(Value::String(s)) => assert_eq!(s, "message"), 576 + other => panic!("expected 'message', got {other:?}"), 577 + } 578 + match data.properties.get("data").map(|p| &p.value) { 579 + Some(Value::String(s)) => assert_eq!(s, "test data"), 580 + other => panic!("expected 'test data', got {other:?}"), 581 + } 582 + match data.properties.get("origin").map(|p| &p.value) { 583 + Some(Value::String(s)) => assert_eq!(s, "https://example.com"), 584 + other => panic!("expected 'https://example.com', got {other:?}"), 585 + } 586 + } else { 587 + panic!("Expected Object"); 588 + } 589 + } 590 + 591 + #[test] 592 + fn message_queue_drain() { 593 + MESSAGE_QUEUE.with(|q| { 594 + q.borrow_mut().messages.push(PendingJsMessage { 595 + data: "test".to_string(), 596 + sender_origin: "https://a.com".to_string(), 597 + target_origin: "*".to_string(), 598 + sender_window: None, 599 + target_context_id: "0".to_string(), 600 + }); 601 + }); 602 + 603 + let messages = drain_message_queue(); 604 + assert_eq!(messages.len(), 1); 605 + assert_eq!(messages[0].data, "test"); 606 + 607 + // Queue should be empty after drain. 608 + let messages = drain_message_queue(); 609 + assert!(messages.is_empty()); 610 + } 611 + }
+1
crates/js/src/lib.rs
··· 7 7 pub mod dom_bridge; 8 8 pub mod fetch; 9 9 pub mod gc; 10 + pub mod iframe_bridge; 10 11 pub mod indexeddb; 11 12 pub mod lexer; 12 13 pub mod parser;
+31 -1
crates/js/src/vm.rs
··· 250 250 pub session_storage: RefCell<crate::storage::StorageArea>, 251 251 /// IndexedDB state for the current origin. 252 252 pub indexeddb: RefCell<crate::indexeddb::IndexedDbState>, 253 + /// Window proxies for iframe elements, keyed by the iframe's NodeId index. 254 + /// Used to implement `contentWindow` / `contentDocument`. 255 + pub iframe_windows: RefCell<HashMap<usize, GcRef>>, 253 256 } 254 257 255 258 /// Context passed to native functions, providing GC access and `this` binding. ··· 859 862 local_storage: RefCell::new(crate::storage::StorageArea::new()), 860 863 session_storage: RefCell::new(crate::storage::StorageArea::new()), 861 864 indexeddb: RefCell::new(crate::indexeddb::IndexedDbState::new()), 865 + iframe_windows: RefCell::new(HashMap::new()), 862 866 }); 863 867 self.dom_bridge = Some(bridge); 864 868 crate::dom_bridge::init_document_object(self); ··· 945 949 .indexeddb 946 950 .replace(crate::indexeddb::IndexedDbState::new()) 947 951 }) 952 + } 953 + 954 + /// Register an iframe's window proxy so that `contentWindow` can return it. 955 + /// 956 + /// `iframe_node_idx` is the `NodeId::index()` of the iframe element in the 957 + /// parent document. `window_ref` is the GcRef of the window object for the 958 + /// iframe's browsing context. 959 + pub fn set_iframe_window(&mut self, iframe_node_idx: usize, window_ref: GcRef) { 960 + if let Some(bridge) = &self.dom_bridge { 961 + bridge 962 + .iframe_windows 963 + .borrow_mut() 964 + .insert(iframe_node_idx, window_ref); 965 + } 948 966 } 949 967 950 968 /// Detach the DOM document from the VM, returning it. ··· 2188 2206 if let Some(val) = crate::dom_bridge::resolve_storage_get(&self.gc, &bridge, gc_ref, key) { 2189 2207 return Some(val); 2190 2208 } 2191 - // Try node wrapper properties first. 2209 + // Try window properties (parent, top, frames, length). 2210 + if let Some(val) = crate::iframe_bridge::resolve_window_property(&self.gc, gc_ref, key) { 2211 + return Some(val); 2212 + } 2213 + // Try iframe element properties (contentWindow, contentDocument). 2214 + if let Some(node_id) = crate::dom_bridge::get_node_id_pub(&self.gc, gc_ref) { 2215 + if let Some(val) = 2216 + crate::iframe_bridge::resolve_iframe_property(&mut self.gc, &bridge, node_id, key) 2217 + { 2218 + return Some(val); 2219 + } 2220 + } 2221 + // Try node wrapper properties. 2192 2222 if let Some(val) = crate::dom_bridge::resolve_dom_get(&mut self.gc, &bridge, gc_ref, key) { 2193 2223 return Some(val); 2194 2224 }