diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs index 8bb2847..8ede02f 100644 --- a/crates/obscura-dom/src/selector.rs +++ b/crates/obscura-dom/src/selector.rs @@ -565,11 +565,11 @@ impl DomTree { // querySelector matches strict descendants of root only, so the // indexed element must have root among its ancestors. Some(nid) if self.ancestors(nid).contains(&root) => return Ok(Some(nid)), - // No element has this id at all: a bare id selector cannot match. - None => return Ok(None), - // Indexed (first) element is not under a scoped root; a later - // duplicate could still be a descendant, so fall through to scan. - Some(_) => {} + // Index miss or stale entry (detached node) — fall through to full scan. + // The id_index is best-effort: it only registers nodes at creation time + // and does not update on reparent, so it can point to a detached clone + // while the live node (with the same id) is elsewhere in the tree. + _ => {} } } let selector_list = parse_selector(selector)?; diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 0652b58..5aa8116 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1622,7 +1622,7 @@ class Element extends Node { el._iframeDoc = new _IframeDocument('', fullUrl, el); el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl); } - _registerIframe(el); + if (typeof el.onload === 'function') { try { el.onload(); } catch(e) {} } else { @@ -1632,7 +1632,7 @@ class Element extends Node { }).catch(() => { el._iframeDoc = new _IframeDocument('', fullUrl, el); el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl); - _registerIframe(el); + if (typeof el.onload === 'function') try { el.onload(); } catch(e) {} }); } @@ -1655,7 +1655,8 @@ class Element extends Node { get contentWindow() { if (this.localName !== 'iframe') return undefined; if (!this._iframeWin) { - this.contentDocument; // side effect: creates _iframeDoc + _iframeWin + if (this.parentNode === null) return null; + this.contentDocument; } return this._iframeWin; } @@ -1796,7 +1797,7 @@ class Element extends Node { toJSON() { return this; }, }; } - getClientRects() { return [this.getBoundingClientRect()]; } + getClientRects() { return new DOMRectList([this.getBoundingClientRect()]); } // No layout engine: a stub that always returns true unblocks Playwright's // actionability polling. With a real layout we'd check display, visibility, // opacity and rect dimensions per spec. @@ -2332,6 +2333,9 @@ class Document extends Node { } class DocumentFragment extends Node { + constructor(nid) { + super(nid !== undefined ? nid : +_dom("create_document_fragment")); + } get nodeType() { return 11; } get nodeName() { return "#document-fragment"; } get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; } @@ -2533,17 +2537,30 @@ Object.defineProperty(globalThis.Window, Symbol.hasInstance, { }); -const _iframeRegistry = []; -function _registerIframe(iframeEl) { - const idx = _iframeRegistry.length; - _iframeRegistry.push(iframeEl); - globalThis.length = _iframeRegistry.length; - Object.defineProperty(globalThis, idx, { - get() { return iframeEl._iframeWin || null; }, +// Remove the static _iframeRegistry and replace with dynamic getters. +Object.defineProperty(globalThis, 'length', { + get() { + return document.querySelectorAll('iframe').length; + }, + configurable: true, + enumerable: true +}); + +// Since we cannot define a Proxy on globalThis easily, we'll define a reasonable number of indexed getters. +for (let i = 0; i < 50; i++) { + Object.defineProperty(globalThis, i, { + get() { + const iframes = document.querySelectorAll('iframe'); + if (i < iframes.length) { + return iframes[i].contentWindow; + } + return undefined; + }, configurable: true, - enumerable: false, + enumerable: false }); } + // Navigator constructor so that typeof Navigator !== 'undefined' and // navigatorPrototype checks don't throw a ReferenceError. function Navigator() {} @@ -4486,7 +4503,11 @@ Object.defineProperty(Document.prototype, 'fonts', { }, configurable: true, }); -globalThis.crypto = globalThis.crypto || { getRandomValues(arr) { for(let i=0;i{const r=Math.random()*16|0;return(c==="x"?r:(r&3|8)).toString(16);}); } }; +globalThis.Crypto = class Crypto { + getRandomValues(arr) { for(let i=0;i{const r=Math.random()*16|0;return(c==="x"?r:(r&3|8)).toString(16);}); } +}; +globalThis.crypto = globalThis.crypto || new globalThis.Crypto(); globalThis.structuredClone = globalThis.structuredClone || ((v) => JSON.parse(JSON.stringify(v))); globalThis.reportError = globalThis.reportError || ((e) => console.error(e)); @@ -4920,8 +4941,20 @@ globalThis.Range = class Range { insertNode(node) { if (node && this._sc && this._sc.insertBefore) { const kids = this._sc.childNodes; this._sc.insertBefore(node, kids[this._so] || null); } } surroundContents(node) { this.insertNode(node); } detach() {} - getBoundingClientRect() { return new DOMRect(); } - getClientRects() { return []; } + getBoundingClientRect() { + if (this.collapsed) return new DOMRect(); + let cac = this.commonAncestorContainer; + while (cac && cac.nodeType !== 1 && cac.nodeType !== 9) cac = cac.parentNode; + if (cac && cac.getBoundingClientRect) { + const r = cac.getBoundingClientRect(); + return new DOMRect(r.x, r.y, r.width, r.height); + } + return new DOMRect(); + } + getClientRects() { + if (this.collapsed) return new DOMRectList([]); + return new DOMRectList([this.getBoundingClientRect()]); + } static get START_TO_START() { return 0; } static get START_TO_END() { return 1; } static get END_TO_END() { return 2; } @@ -5011,6 +5044,7 @@ _markNative(globalThis.Selection); Element.prototype.before, Element.prototype.after, Element.prototype.replaceWith, HTMLFormElement.prototype.reset, Element.prototype.getContext, Element.prototype.toDataURL, Element.prototype.toBlob, + Element.prototype.getBBox, Node.prototype.appendChild, Node.prototype.removeChild, Node.prototype.replaceChild, Node.prototype.insertBefore, Node.prototype.contains, Node.prototype.hasChildNodes, Node.prototype.cloneNode, @@ -5297,7 +5331,7 @@ class _Canvas2D { this._stateStack = []; } _parseColor(css) { - if (!css || css === 'none') return [0,0,0,0]; + if (!css || typeof css !== 'string' || css === 'none') return [0,0,0,0]; if (css.startsWith('#')) { const hex = css.slice(1); if (hex.length === 3) return [parseInt(hex[0]+hex[0],16),parseInt(hex[1]+hex[1],16),parseInt(hex[2]+hex[2],16),255]; @@ -5493,6 +5527,13 @@ Element.prototype.getContext = function getContext(type) { if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') { return { canvas: this, + MAX_VIEWPORT_DIMS: 0x0D33, + MAX_TEXTURE_SIZE: 0x0D33, + MAX_RENDERBUFFER_SIZE: 0x84E8, + MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0x84EA, + MAX_DRAW_BUFFERS_WEBGL: 0x8824, + getContextAttributes() { return { alpha: true, antialias: true, depth: true, failIfMajorPerformanceCaveat: false, powerPreference: "default", premultipliedAlpha: true, preserveDrawingBuffer: false, stencil: true, desynchronized: false }; }, + uniform2f() {}, getExtension(name) { if (name === 'WEBGL_debug_renderer_info') return { UNMASKED_VENDOR_WEBGL: 0x9245, UNMASKED_RENDERER_WEBGL: 0x9246 }; return null; @@ -5504,6 +5545,10 @@ Element.prototype.getContext = function getContext(type) { if (pname === 0x1F00) return 'WebKit'; // GL_VENDOR if (pname === 0x1F02) return 'OpenGL ES 3.0 (ANGLE)'; // GL_VERSION if (pname === 0x8B8C) return 'WebGL GLSL ES 3.00 (ANGLE)'; // GL_SHADING_LANGUAGE_VERSION + if (pname === undefined) return [0, 0]; + // Some properties like MAX_VIEWPORT_DIMS return arrays + if (pname === 0x0D33) return [8192, 8192]; + if (pname === 0x8A2A) return [8192, 8192]; return 0; }, getSupportedExtensions() { return ['WEBGL_debug_renderer_info','EXT_texture_filter_anisotropic','WEBGL_compressed_texture_s3tc','WEBGL_lose_context']; }, @@ -5536,6 +5581,10 @@ Element.prototype.toDataURL = function(type) { return _fp('canvasFingerprint'); }; Element.prototype.toBlob = function(cb, type, q) { cb(new Blob([''])); }; +Element.prototype.getBBox = function() { return { x: 0, y: 0, width: 0, height: 0 }; }; +Element.prototype.getComputedTextLength = function() { return 0; }; +Element.prototype.getExtentOfChar = function(ch) { return { x: 0, y: 0, width: 0, height: 0 }; }; +Element.prototype.getSubStringLength = function(ch, len) { return 0; }; _markNative(Element.prototype.getContext); _markNative(Element.prototype.toDataURL); @@ -5673,24 +5722,37 @@ if (typeof Document !== 'undefined' && typeof Document.parseHTMLUnsafe !== 'func _markNative(Document.parseHTMLUnsafe); } +globalThis.AudioBuffer = class AudioBuffer { + constructor(opts) { + var o = (typeof opts === 'object' && opts !== null) ? opts : {}; + this.numberOfChannels = o.numberOfChannels || 1; + this.length = o.length || 0; + this.sampleRate = o.sampleRate || 44100; + this.duration = this.length / (this.sampleRate || 44100); + this._chs = []; + for (var c = 0; c < this.numberOfChannels; c++) this._chs.push(new Float32Array(this.length)); + } + getChannelData(c) { return this._chs[c] || this._chs[0] || new Float32Array(0); } + copyFromChannel(dst, ch, start) { var s=this._chs[ch]||this._chs[0]; start=start||0; for(var i=0;ih!==fn); } + _ap(v, min=-3.4028235e38, max=3.4028235e38) { return { value: v, defaultValue: v, minValue: min, maxValue: max, setValueAtTime(){} }; } + createOscillator() { return {context:this,type:'sine',frequency:this._ap(440, -22050, 22050),detune:this._ap(0, -153600, 153600),connect(){},start(){},stop(){},disconnect(){},addEventListener(){},removeEventListener(){}}; } + createDynamicsCompressor() { return {context:this,threshold:this._ap(_fp('compThreshold'), -100, 0),knee:this._ap(_fp('compKnee'), 0, 40),ratio:this._ap(_fp('compRatio'), 1, 20),attack:this._ap(0.003, 0, 1),release:this._ap(0.25, 0, 1),reduction:0,connect(){},disconnect(){}}; } createAnalyser() { - return {fftSize:2048,frequencyBinCount:1024,connect(){},disconnect(){}, + return {context:this,fftSize:2048,frequencyBinCount:1024,channelCount:2,channelCountMode:'max',channelInterpretation:'speakers',maxDecibels:-30,minDecibels:-100,numberOfInputs:1,numberOfOutputs:1,smoothingTimeConstant:0.8,connect(){},disconnect(){}, getByteFrequencyData(a){for(let i=0;i 0 ? target / s : 0; for (var i = 0; i < self.length; i++) data[i] *= scale; - // Fire oncomplete on next microtask so callers can set ctx.oncomplete - // synchronously after calling startRendering() (fpCollect pattern). + // Fire oncomplete + 'complete' listeners on next microtask so callers + // can register handlers synchronously after startRendering(). var p = Promise.resolve().then(function() { + var evt = {renderedBuffer: buf, target: self, type: 'complete'}; if (typeof self.oncomplete === 'function') { - try { self.oncomplete({renderedBuffer: buf, target: self, type: 'complete'}); } catch(e) {} + try { self.oncomplete(evt); } catch(e) {} + } + var listeners = (self._listeners && self._listeners['complete']) || []; + for (var i = 0; i < listeners.length; i++) { + try { listeners[i](evt); } catch(e) {} } return buf; }); @@ -5939,18 +6016,75 @@ globalThis.Worker = class Worker { this._listeners = {}; const worker = this; - if (typeof url === 'string' && (url.startsWith('blob:') || url.startsWith('http'))) { - const blobContent = globalThis.__blobStore?.[url]; - if (blobContent) { - this._code = blobContent; - } else { - (async () => { - try { - const resp = await fetch(url); - worker._code = await resp.text(); - } catch(e) { if (worker.onerror) worker.onerror(e); } - })(); + let resolvedUrl = url; + if (typeof url === 'string') { + const blob = globalThis.__blobStore?.[url]; + if (blob) { + worker._code = blob; + // Auto-start on next tick so caller can set onmessage first. + setTimeout(() => worker._autoRun(), 0); + return; } + // Resolve relative URLs against the current page. + if (!url.startsWith('http') && !url.startsWith('blob:') && !url.startsWith('data:')) { + try { resolvedUrl = new URL(url, globalThis.location?.href || '').href; } catch(e) {} + } + (async () => { + try { + const resp = await fetch(resolvedUrl); + worker._code = await resp.text(); + if (!worker._terminated) worker._autoRun(); + } catch(e) { if (worker.onerror) worker.onerror(e); } + })(); + } + } + _makeScope() { + const worker = this; + // WorkerGlobalScope defined + no document property → IS_WORKER_SCOPE = true in creepjs + const scope = { + WorkerGlobalScope: function WorkerGlobalScope() {}, + DedicatedWorkerGlobalScope: function DedicatedWorkerGlobalScope() {}, + postMessage: (msg) => { + if (worker._terminated) return; + const evt = { data: msg }; + if (worker.onmessage) worker.onmessage(evt); + const ls = worker._listeners['message'] || []; + for (const h of ls) h(evt); + }, + addEventListener: (type, fn) => { + if (!scope._ev) scope._ev = {}; + if (!scope._ev[type]) scope._ev[type] = []; + scope._ev[type].push(fn); + }, + close: () => { worker._terminated = true; }, + crypto: globalThis.crypto, + Crypto: globalThis.Crypto, + TextEncoder: globalThis.TextEncoder, + TextDecoder: globalThis.TextDecoder, + atob: globalThis.atob, + btoa: globalThis.btoa, + setTimeout: globalThis.setTimeout, + setInterval: globalThis.setInterval, + clearTimeout: globalThis.clearTimeout, + clearInterval: globalThis.clearInterval, + fetch: globalThis.fetch, + console: globalThis.console, + performance: globalThis.performance, + location: globalThis.location, + }; + scope.self = scope; + return scope; + } + _autoRun() { + if (this._terminated || !this._code) return; + const worker = this; + const scope = worker._makeScope(); + try { + const fn = new Function('self', 'postMessage', 'addEventListener', 'close', worker._code); + fn(scope, scope.postMessage, scope.addEventListener, scope.close); + } catch(e) { + console.error('Worker error:', e.message); + if (worker.onerror) worker.onerror(e); } } postMessage(data) { @@ -5958,32 +6092,13 @@ globalThis.Worker = class Worker { const worker = this; setTimeout(() => { if (worker._terminated || !worker._code) return; + const scope = worker._makeScope(); try { - const workerSelf = { - onmessage: null, - postMessage: (msg) => { - const evt = { data: msg }; - if (worker.onmessage) worker.onmessage(evt); - const handlers = worker._listeners['message'] || []; - for (const h of handlers) h(evt); - }, - addEventListener: (type, fn) => { workerSelf['on' + type] = fn; }, - close: () => { worker._terminated = true; }, - crypto: globalThis.crypto, - TextEncoder: globalThis.TextEncoder, - TextDecoder: globalThis.TextDecoder, - atob: globalThis.atob, - btoa: globalThis.btoa, - setTimeout: globalThis.setTimeout, - setInterval: globalThis.setInterval, - clearTimeout: globalThis.clearTimeout, - clearInterval: globalThis.clearInterval, - fetch: globalThis.fetch, - console: globalThis.console, - }; const fn = new Function('self', 'postMessage', 'addEventListener', 'close', worker._code); - fn(workerSelf, workerSelf.postMessage, workerSelf.addEventListener, workerSelf.close); - if (workerSelf.onmessage) workerSelf.onmessage({ data }); + fn(scope, scope.postMessage, scope.addEventListener, scope.close); + const evs = (scope._ev && scope._ev['message']) || []; + if (evs.length) { for (const h of evs) h({ data }); } + else if (scope.onmessage) scope.onmessage({ data }); } catch(e) { console.error('Worker error:', e.message); if (worker.onerror) worker.onerror(e); @@ -6128,6 +6243,20 @@ if (typeof DOMRect === 'undefined') { static fromRect(r={}) { return new DOMRect(r.x,r.y,r.width,r.height); } }; } + +if (typeof DOMRectList === 'undefined') { + globalThis.DOMRectList = class DOMRectList { + constructor(arr=[]) { + this.length = arr.length; + for (let i = 0; i < arr.length; i++) this[i] = arr[i]; + } + item(i) { return this[i] || null; } + [Symbol.iterator]() { + let i = 0, self = this; + return { next() { const done = i >= self.length; return { value: done ? undefined : self[i++], done }; } }; + } + }; +} if (typeof DOMPoint === 'undefined') { globalThis.DOMPoint = class DOMPoint { constructor(x=0,y=0,z=0,w=1) { this.x=x;this.y=y;this.z=z;this.w=w; } @@ -6391,10 +6520,44 @@ if (typeof Range === 'undefined') { setStart(n,o){this.startContainer=n;this.startOffset=o;} setEnd(n,o){this.endContainer=n;this.endOffset=o;} collapse(){} selectNode(){} selectNodeContents(){} cloneContents(){return document?.createDocumentFragment();} deleteContents(){} insertNode(){} getBoundingClientRect(){return new DOMRect();} - getClientRects(){return [];} cloneRange(){return new Range();} toString(){return '';} + getClientRects(){return new DOMRectList([]);} cloneRange(){return new Range();} toString(){return '';} }; } +if (typeof FontFace === 'undefined') { + globalThis.FontFace = class FontFace { + constructor(family, source, descriptors={}) { + this.family = family; + this.style = descriptors.style || 'normal'; + this.weight = descriptors.weight || 'normal'; + this.stretch = descriptors.stretch || 'normal'; + this.unicodeRange = descriptors.unicodeRange || 'U+0-10FFFF'; + this.variant = descriptors.variant || 'normal'; + this.featureSettings = descriptors.featureSettings || 'normal'; + this.status = 'unloaded'; + } + load() { this.status = 'loaded'; return Promise.resolve(this); } + }; + globalThis.FontFaceSet = class FontFaceSet extends EventTarget { + constructor() { super(); this.status = 'loaded'; this.ready = Promise.resolve(this); } + add() { return this; } + check() { return true; } + clear() {} + delete() { return false; } + load() { return Promise.resolve([]); } + forEach() {} + has() { return false; } + [Symbol.iterator]() { return [][Symbol.iterator](); } + }; + Object.defineProperty(Document.prototype, 'fonts', { + get() { + if (!this._fonts) this._fonts = new FontFaceSet(); + return this._fonts; + }, + configurable: true + }); +} + if (typeof SharedWorker === 'undefined') { globalThis.SharedWorker = class SharedWorker { constructor() { this.port = { postMessage(){}, onmessage:null, start(){}, close(){}, addEventListener(){}, removeEventListener(){} }; this.onerror = null; } @@ -6478,7 +6641,7 @@ globalThis.__obscura_init = function() { globalThis.visualViewport = { width:sw, height:sh-80, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} }; globalThis.devicePixelRatio = sw >= 2560 ? 2 : 1; globalThis.innerWidth = sw; globalThis.innerHeight = sh - 80; - globalThis.outerWidth = sw; globalThis.outerHeight = sh; + globalThis.outerWidth = sw; globalThis.outerHeight = sh - 40; var hwValues = globalThis.__obscura_stealth ? [4, 6, 8, 12, 16] : [2, 4, 6, 8, 12, 16]; globalThis.navigator.hardwareConcurrency = hwValues[Math.floor(_fpRand(400) * hwValues.length)]; diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index cd015eb..911e8af 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -137,7 +137,21 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str "null".into() } "get_element_by_id" => { - dom.get_element_by_id(&arg1).map(|id| id.index().to_string()).unwrap_or("-1".into()) + // Verify the indexed node is in the live document. The id_index is best-effort: + // it only registers nodes at creation time and doesn't update on reparent, so + // it can point to a detached clone while the live node is elsewhere in the tree. + let doc = dom.document(); + let nid = dom.get_element_by_id(&arg1); + let live = nid.filter(|&n| dom.ancestors(n).contains(&doc)); + match live { + Some(n) => n.index().to_string(), + None => { + // Fall back to full scan for the live document. + let sel = format!("[id=\"{}\"]", arg1.replace('\\', "\\\\").replace('"', "\\\"")); + dom.query_selector(&sel).ok().flatten() + .map(|id| id.index().to_string()).unwrap_or("-1".into()) + } + } } "query_selector" => { dom.query_selector(&arg1).ok().flatten().map(|id| id.index().to_string()).unwrap_or("-1".into()) @@ -235,19 +249,22 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str serde_json::to_string(&dom.outer_html(NodeId::new(nid))).unwrap_or("\"\"".into()) } "append_child" => { - let parent = arg1.parse::().unwrap_or(0); - let child = arg2.parse::().unwrap_or(0); + // Reject if either nid failed to parse (was "undefined"/empty) — those + // default to 0 which is the document root, and silently operating on it + // corrupts the tree. Require both args to be valid positive integers. + let parent = match arg1.parse::() { Ok(n) => n, Err(_) => return "false".into() }; + let child = match arg2.parse::() { Ok(n) => n, Err(_) => return "false".into() }; dom.append_child(NodeId::new(parent), NodeId::new(child)); "true".into() } "remove_child" => { - let child = arg1.parse::().unwrap_or(0); + let child = match arg1.parse::() { Ok(n) => n, Err(_) => return "false".into() }; dom.remove_child(NodeId::new(child)); "true".into() } "insert_before" => { - let new_node = arg1.parse::().unwrap_or(0); - let ref_node = arg2.parse::().unwrap_or(0); + let new_node = match arg1.parse::() { Ok(n) => n, Err(_) => return "false".into() }; + let ref_node = match arg2.parse::() { Ok(n) => n, Err(_) => return "false".into() }; dom.insert_before(NodeId::new(ref_node), NodeId::new(new_node)); "true".into() } @@ -261,7 +278,12 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str "true".into() } "set_inner_html" => { - let nid = arg1.parse::().unwrap_or(0); + let nid = match arg1.parse::() { + Ok(n) if n > 0 => n, + // nid=0 is the document root; never allow innerHTML to clear it. + // nid parse failure (e.g. "undefined") also falls here. + _ => return "false".into(), + }; let target = NodeId::new(nid); let children = dom.children(target); for child in children {