diff --git a/crates/obscura-cdp/src/domains/dom.rs b/crates/obscura-cdp/src/domains/dom.rs index 53034b2..dbeed0b 100644 --- a/crates/obscura-cdp/src/domains/dom.rs +++ b/crates/obscura-cdp/src/domains/dom.rs @@ -31,6 +31,62 @@ fn resolve_node_id(page: &mut Page, params: &Value) -> Result { Err("nodeId, backendNodeId, or objectId required".to_string()) } +/// Standard base64 (with padding). Used to ferry file bytes to the JS layer for +/// DOM.setFileInputFiles without pulling in a dependency. +fn encode_base64(input: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity((input.len() + 2) / 3 * 4); + for chunk in input.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(T[((n >> 18) & 63) as usize] as char); + out.push(T[((n >> 12) & 63) as usize] as char); + out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' }); + out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' }); + } + out +} + +/// Best-effort MIME type from a file extension, for the File objects created by +/// DOM.setFileInputFiles. Defaults to application/octet-stream. +fn guess_mime(path: &str) -> &'static str { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + match ext.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + "pdf" => "application/pdf", + "txt" => "text/plain", + "html" | "htm" => "text/html", + "css" => "text/css", + "js" | "mjs" => "text/javascript", + "json" => "application/json", + "xml" => "application/xml", + "csv" => "text/csv", + "zip" => "application/zip", + "gz" => "application/gzip", + "mp4" => "video/mp4", + "webm" => "video/webm", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + _ => "application/octet-stream", + } +} + pub async fn handle( method: &str, params: &Value, @@ -180,6 +236,41 @@ pub async fn handle( let _ = page.evaluate(&code); Ok(json!({})) } + "setFileInputFiles" => { + // Puppeteer's ElementHandle.uploadFile / Playwright's setInputFiles + // drive an through this CDP call (issue #359). Read + // each local file, then hand its bytes (base64) to the JS layer, which + // builds real File objects and fires input+change like a real + // selection so page code can read/upload them. + let page = ctx.get_session_page_mut(session_id).ok_or("No page")?; + let node_id = resolve_node_id(page, params)?; + let paths: Vec = params + .get("files") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + + let mut specs = Vec::with_capacity(paths.len()); + for p in &paths { + let bytes = std::fs::read(p) + .map_err(|e| format!("setFileInputFiles: cannot read '{}': {}", p, e))?; + let name = std::path::Path::new(p) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + specs.push(json!({ "name": name, "type": guess_mime(p), "b64": encode_base64(&bytes) })); + } + + let specs_json = serde_json::to_string(&specs).unwrap_or_else(|_| "[]".to_string()); + let code = format!( + "(function() {{ var el = globalThis._wrap && globalThis._wrap({0}); \ + if (el && globalThis.__obscura_setInputFiles) {{ globalThis.__obscura_setInputFiles(el, {1}); return true; }} return false; }})()", + node_id, specs_json + ); + let _ = page.evaluate(&code); + Ok(json!({})) + } "getBoxModel" => { let page = ctx.get_session_page_mut(session_id).ok_or("No page")?; let node_id = match resolve_node_id(page, params) { diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 8686dbd..c91f673 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1584,9 +1584,21 @@ class Element extends Node { const attr = this.getAttribute('value'); return attr !== null ? attr : 'on'; } + if (itype === 'file') { + // Chrome exposes a file input's value as C:\fakepath\. + return (this._files && this._files.length) ? ('C:\\fakepath\\' + this._files[0].name) : ''; + } } return this.getAttribute("value") || ""; } + // FileList for , populated by DOM.setFileInputFiles (Puppeteer + // uploadFile / Playwright setInputFiles). null for non-file inputs, matching + // the DOM. See __obscura_setInputFiles (issue #359). + get files() { + if (this.localName !== 'input') return undefined; + if ((this.getAttribute('type') || '').toLowerCase() !== 'file') return null; + return this._files || _emptyFileList(); + } set value(v) { const tag = this.localName; if (tag === 'select') { @@ -4440,6 +4452,35 @@ globalThis.__obscura_setFieldValue = function(el, field, value) { } catch (_e) {} el[field] = value; }; + +// Build a FileList-like object: an array with the DOM's `item(i)` accessor. +function _makeFileList(files) { + const list = files.slice(); + Object.defineProperty(list, "item", { value: (i) => list[i] || null, enumerable: false }); + return list; +} +function _emptyFileList() { return _makeFileList([]); } + +// Populate an 's FileList from the CDP DOM.setFileInputFiles +// call (Puppeteer uploadFile / Playwright setInputFiles). `specs` is an array of +// { name, type, b64 } where b64 is the base64-encoded file bytes read on the +// Rust side. Real File objects (backed by the bytes) are created so page code can +// read them via FileReader or upload them via fetch/FormData, then input+change +// fire as a genuine selection would (issue #359). +globalThis.__obscura_setInputFiles = function(el, specs) { + const files = (specs || []).map((s) => { + let bytes; + try { + const bin = atob(s.b64 || ""); + bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + } catch (_e) { bytes = new Uint8Array(0); } + return new File([bytes], s.name || "", { type: s.type || "" }); + }); + el._files = _makeFileList(files); + try { el.dispatchEvent(new Event("input", { bubbles: true })); } catch (_e) {} + try { el.dispatchEvent(new Event("change", { bubbles: true })); } catch (_e) {} +}; globalThis.Event = class Event { constructor(t,o={}) { this.type=t;this.bubbles=!!o.bubbles;this.cancelable=!!o.cancelable;this.composed=!!o.composed;this.defaultPrevented=false;this.target=null;this.currentTarget=null;this.eventPhase=0;this.timeStamp=Date.now();this._propagationStopped=false;this._immediatePropagationStopped=false; } get isTrusted() { return _trustedEvents.has(this); }