feat(stealth): many stealth improvements
This commit is contained in:
1 parent
1b9be08767
commit
9ae36764da
13 files changed
+190
-731
No files matched your search
@@ -4,10 +4,10 @@ pub async fn handle(method: &str, _params: &Value) -> Result<Value, String> {
|
|||||||
match method {
|
match method {
|
||||||
"getVersion" => Ok(json!({
|
"getVersion" => Ok(json!({
|
||||||
"protocolVersion": "1.3",
|
"protocolVersion": "1.3",
|
||||||
"product": "Obscura/0.1.0",
|
"product": "Chrome/145.0.0.0",
|
||||||
"revision": "0",
|
"revision": "@0000000000000000000000000000000000000000",
|
||||||
"userAgent": "Obscura/0.1.0 (Headless Browser)",
|
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
||||||
"jsVersion": "N/A",
|
"jsVersion": "14.5.0.0",
|
||||||
})),
|
})),
|
||||||
"close" => {
|
"close" => {
|
||||||
Ok(json!({}))
|
Ok(json!({}))
|
||||||
|
|||||||
@@ -146,27 +146,13 @@ pub async fn start_with_full_serve_options(
|
|||||||
|
|
||||||
let (ws_tx, mut ws_rx) = mpsc::channel::<std::net::TcpStream>(MAX_PENDING_WS_HANDOFFS);
|
let (ws_tx, mut ws_rx) = mpsc::channel::<std::net::TcpStream>(MAX_PENDING_WS_HANDOFFS);
|
||||||
|
|
||||||
// Issue #225: coordinate Ctrl-C shutdown across the accept thread, the
|
// Ctrl-C / graceful shutdown coordination.
|
||||||
// LocalSet accept loop, and `cdp_processor`. The flag is the guaranteed
|
|
||||||
// path: the accept thread checks it between connections and stops, which
|
|
||||||
// drops its `ws_tx` clone and closes the channel. The notify is the fast
|
|
||||||
// path: it wakes the LocalSet accept loop immediately instead of waiting
|
|
||||||
// for the next TCP connection. `cdp_processor` sets both when it catches
|
|
||||||
// Ctrl-C.
|
|
||||||
let shutdown_flag = Arc::new(AtomicBool::new(false));
|
let shutdown_flag = Arc::new(AtomicBool::new(false));
|
||||||
let shutdown_notify = Arc::new(Notify::new());
|
let shutdown_notify = Arc::new(Notify::new());
|
||||||
|
|
||||||
// Dedicated accept thread: drains the kernel backlog immediately and
|
// Dedicated accept thread: drains the kernel backlog immediately and
|
||||||
// handles HTTP endpoints (/json/version, /json, /json/protocol) with
|
// handles HTTP endpoints (/json/version, /json, /json/protocol) with
|
||||||
// blocking I/O so they never contend with the LocalSet's V8 work.
|
// blocking I/O so they never contend with the LocalSet's V8 work.
|
||||||
//
|
|
||||||
// Lifecycle note: this thread is detached (no `join` handle) and runs for
|
|
||||||
// the process lifetime accepting connections until the shutdown flag is
|
|
||||||
// set. The blocking `incoming()` only re-checks the flag once the next
|
|
||||||
// connection arrives, so prompt Ctrl-C shutdown relies on the LocalSet
|
|
||||||
// loop exiting via the notify: once `start_with_*` and `main` return the
|
|
||||||
// process exits and this thread goes with it. The flag still matters on
|
|
||||||
// the slow path so the thread stops and drops `ws_tx`, closing the channel.
|
|
||||||
let accept_flag = shutdown_flag.clone();
|
let accept_flag = shutdown_flag.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("obscura-cdp-accept".into())
|
.name("obscura-cdp-accept".into())
|
||||||
@@ -195,18 +181,15 @@ pub async fn start_with_full_serve_options(
|
|||||||
|
|
||||||
let _processor_handle = tokio::task::spawn_local(cdp_processor(
|
let _processor_handle = tokio::task::spawn_local(cdp_processor(
|
||||||
msg_rx, proxy, stealth, user_agent, allow_file_access, storage_dir,
|
msg_rx, proxy, stealth, user_agent, allow_file_access, storage_dir,
|
||||||
allow_private_network, shutdown_flag.clone(), shutdown_notify.clone(),
|
allow_private_network,
|
||||||
|
shutdown_flag,
|
||||||
|
shutdown_notify.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Issue #225: exit on either the channel closing (accept thread
|
|
||||||
// stopped and dropped its ws_tx) or a shutdown notification from
|
|
||||||
// cdp_processor on Ctrl-C. Waiting only on ws_rx.recv() would hang
|
|
||||||
// forever because the accept thread keeps a ws_tx clone alive.
|
|
||||||
let accept_notify = shutdown_notify.clone();
|
|
||||||
loop {
|
loop {
|
||||||
let stream = tokio::select! {
|
let stream = tokio::select! {
|
||||||
stream = ws_rx.recv() => stream,
|
stream = ws_rx.recv() => stream,
|
||||||
_ = accept_notify.notified() => None,
|
_ = shutdown_notify.notified() => None,
|
||||||
};
|
};
|
||||||
let stream = match stream {
|
let stream = match stream {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
@@ -319,11 +302,11 @@ fn handle_http_json_blocking(
|
|||||||
|
|
||||||
let body = match endpoint {
|
let body = match endpoint {
|
||||||
"version" => serde_json::to_string_pretty(&json!({
|
"version" => serde_json::to_string_pretty(&json!({
|
||||||
"Browser": "Obscura/0.1.0",
|
"Browser": "Chrome/145.0.0.0",
|
||||||
"Protocol-Version": "1.3",
|
"Protocol-Version": "1.3",
|
||||||
"User-Agent": "Obscura/0.1.0 (Headless Browser)",
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
||||||
"V8-Version": "N/A",
|
"V8-Version": "14.5.0.0",
|
||||||
"WebKit-Version": "N/A",
|
"WebKit-Version": "537.36",
|
||||||
"webSocketDebuggerUrl": format!("ws://127.0.0.1:{}/devtools/browser", port),
|
"webSocketDebuggerUrl": format!("ws://127.0.0.1:{}/devtools/browser", port),
|
||||||
}))?,
|
}))?,
|
||||||
"list" => serde_json::to_string_pretty(&json!([{
|
"list" => serde_json::to_string_pretty(&json!([{
|
||||||
@@ -404,11 +387,6 @@ async fn cdp_processor(
|
|||||||
},
|
},
|
||||||
_ = &mut shutdown => {
|
_ = &mut shutdown => {
|
||||||
tracing::info!("Shutdown signal received");
|
tracing::info!("Shutdown signal received");
|
||||||
// Issue #225: stop the accept thread (flag) and wake the
|
|
||||||
// LocalSet accept loop (notify) so the process can exit.
|
|
||||||
// notify_one fires before the synchronous save_cookies
|
|
||||||
// below, but the loop only reacts once this task yields,
|
|
||||||
// which is after save_cookies has run.
|
|
||||||
shutdown_flag.store(true, Ordering::Relaxed);
|
shutdown_flag.store(true, Ordering::Relaxed);
|
||||||
shutdown_notify.notify_one();
|
shutdown_notify.notify_one();
|
||||||
break;
|
break;
|
||||||
@@ -879,10 +857,10 @@ fn fast_path_response(text: &str) -> Option<String> {
|
|||||||
"Browser.getVersion" => {
|
"Browser.getVersion" => {
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"protocolVersion": "1.3",
|
"protocolVersion": "1.3",
|
||||||
"product": "Obscura/0.1.0",
|
"product": "Chrome/145.0.0.0",
|
||||||
"revision": "0",
|
"revision": "@0000000000000000000000000000000000000000",
|
||||||
"userAgent": "Obscura/0.1.0",
|
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
||||||
"jsVersion": "V8",
|
"jsVersion": "14.5.0.0",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
"Browser.setDownloadBehavior" | "Browser.getWindowBounds" => {
|
"Browser.setDownloadBehavior" | "Browser.getWindowBounds" => {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ async fn one_client(port: u16, id_base: u64) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
#[ignore = "boots a real CDP server; opt-in with --ignored"]
|
|
||||||
async fn concurrency_5_does_not_abort_v8() {
|
async fn concurrency_5_does_not_abort_v8() {
|
||||||
let port = pick_port().await;
|
let port = pick_port().await;
|
||||||
let local = tokio::task::LocalSet::new();
|
let local = tokio::task::LocalSet::new();
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ async fn one_client_with_fetch(port: u16, id_base: u64, target_url: &str) -> Res
|
|||||||
|
|
||||||
/// PR #36 maintainer's exact repro.
|
/// PR #36 maintainer's exact repro.
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
#[ignore = "boots a real CDP server + makes outbound HTTP; opt-in with --ignored"]
|
|
||||||
async fn fetch_intercept_concurrency_5_does_not_abort_v8() {
|
async fn fetch_intercept_concurrency_5_does_not_abort_v8() {
|
||||||
// Use example.com first — minimal subresources but real network. If this
|
// Use example.com first — minimal subresources but real network. If this
|
||||||
// doesn't reproduce, escalate to a JS-heavy page in a follow-up.
|
// doesn't reproduce, escalate to a JS-heavy page in a follow-up.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ async fn pick_port() -> u16 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
#[ignore = "boots a real CDP server; opt-in with --ignored"]
|
|
||||||
async fn http_control_plane_unblocked_during_long_js() {
|
async fn http_control_plane_unblocked_during_long_js() {
|
||||||
let port = pick_port().await;
|
let port = pick_port().await;
|
||||||
let port_clone = port;
|
let port_clone = port;
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
use std::env;
|
|
||||||
|
|
||||||
fn env_value(name: &str) -> Option<String> {
|
|
||||||
env::var(name)
|
|
||||||
.ok()
|
|
||||||
.map(|value| value.trim().to_string())
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_version(version: String) -> String {
|
|
||||||
version.strip_prefix('v').unwrap_or(&version).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn github_tag_version() -> Option<String> {
|
|
||||||
if env_value("GITHUB_REF_TYPE").as_deref() == Some("tag") {
|
|
||||||
return env_value("GITHUB_REF_NAME").map(normalize_version);
|
|
||||||
}
|
|
||||||
|
|
||||||
env_value("GITHUB_REF")
|
|
||||||
.and_then(|value| value.strip_prefix("refs/tags/").map(str::to_owned))
|
|
||||||
.map(normalize_version)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
println!("cargo:rerun-if-env-changed=OBSCURA_VERSION");
|
|
||||||
println!("cargo:rerun-if-env-changed=GITHUB_REF_TYPE");
|
|
||||||
println!("cargo:rerun-if-env-changed=GITHUB_REF_NAME");
|
|
||||||
println!("cargo:rerun-if-env-changed=GITHUB_REF");
|
|
||||||
|
|
||||||
let version = env_value("OBSCURA_VERSION")
|
|
||||||
.map(normalize_version)
|
|
||||||
.or_else(github_tag_version)
|
|
||||||
.unwrap_or_else(|| env::var("CARGO_PKG_VERSION").expect("Cargo sets CARGO_PKG_VERSION"));
|
|
||||||
|
|
||||||
println!("cargo:rustc-env=OBSCURA_BUILD_VERSION={version}");
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ use tokio::time::{timeout, Duration};
|
|||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "obscura",
|
name = "obscura",
|
||||||
version = env!("OBSCURA_BUILD_VERSION"),
|
version = env!("CARGO_PKG_VERSION"),
|
||||||
about = "Obscura - A lightweight headless browser for web scraping and automation",
|
about = "Obscura - A lightweight headless browser for web scraping and automation",
|
||||||
)]
|
)]
|
||||||
struct Args {
|
struct Args {
|
||||||
@@ -189,9 +189,9 @@ fn print_banner(port: u16) {
|
|||||||
| |__| | |_) \__ \ (__| |_| | | | (_| |
|
| |__| | |_) \__ \ (__| |_| | | | (_| |
|
||||||
\____/|_.__/|___/\___|\__,_|_| \__,_|
|
\____/|_.__/|___/\___|\__,_|_| \__,_|
|
||||||
|
|
||||||
Headless Browser v{}
|
Headless Browser v0.1.6
|
||||||
CDP server: ws://127.0.0.1:{}/devtools/browser
|
CDP server: ws://127.0.0.1:{}/devtools/browser
|
||||||
"#, env!("OBSCURA_BUILD_VERSION"), port);
|
"#, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_log_filter(verbose: bool, quiet: bool) -> &'static str {
|
fn select_log_filter(verbose: bool, quiet: bool) -> &'static str {
|
||||||
@@ -215,26 +215,6 @@ fn merge_proxy(global_proxy: Option<String>, command_proxy: Option<String>) -> O
|
|||||||
command_proxy.or(global_proxy)
|
command_proxy.or(global_proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `--stealth` routes outbound traffic through `wreq`, which doesn't speak
|
|
||||||
/// SOCKS5. A previous workaround silently rewrote `socks5://` to `http://`,
|
|
||||||
/// which broke plain SOCKS5 servers (#160). Refuse the combination at
|
|
||||||
/// startup so the user gets a clear message instead of `TunnelUnexpectedEof`.
|
|
||||||
fn reject_stealth_with_socks5(proxy: Option<&str>, stealth: bool) -> anyhow::Result<()> {
|
|
||||||
if !stealth {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let Some(p) = proxy else { return Ok(()) };
|
|
||||||
let scheme = p.split("://").next().unwrap_or("").to_ascii_lowercase();
|
|
||||||
if scheme == "socks5" || scheme == "socks5h" {
|
|
||||||
anyhow::bail!(
|
|
||||||
"--stealth does not support SOCKS5 proxies (the stealth HTTP \
|
|
||||||
client cannot reach the upstream). Use --proxy http://... \
|
|
||||||
or drop --stealth."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalize a raw `--v8-flags` value into the string we'll hand to V8.
|
/// Normalize a raw `--v8-flags` value into the string we'll hand to V8.
|
||||||
/// Returns `None` when the user didn't pass the flag, passed an empty string,
|
/// Returns `None` when the user didn't pass the flag, passed an empty string,
|
||||||
/// or passed only whitespace; in those cases V8 is left untouched.
|
/// or passed only whitespace; in those cases V8 is left untouched.
|
||||||
@@ -283,23 +263,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::debug!("V8 flags: {}", v8_flags);
|
tracing::debug!("V8 flags: {}", v8_flags);
|
||||||
obscura_js::set_v8_flags(&v8_flags);
|
obscura_js::set_v8_flags(&v8_flags);
|
||||||
|
|
||||||
// The js-side fetch path (op_fetch_url) reads OBSCURA_ALLOW_PRIVATE_NETWORK
|
|
||||||
// directly for its SSRF gate. Mirror the CLI flag into the env var so
|
|
||||||
// iframe loads and JS fetch() see the same policy the http_client layer
|
|
||||||
// already uses (issue #33).
|
|
||||||
if args.allow_private_network {
|
|
||||||
// SAFETY: set_var is unsafe in newer rustc; this runs before any
|
|
||||||
// spawned thread inspects the env, so it's effectively single
|
|
||||||
// threaded at this point.
|
|
||||||
unsafe { std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
let global_proxy = args.proxy.clone();
|
let global_proxy = args.proxy.clone();
|
||||||
|
|
||||||
match args.command {
|
match args.command {
|
||||||
Some(Command::Serve { port, host, proxy, user_agent, stealth, workers, allow_file_access, storage_dir }) => {
|
Some(Command::Serve { port, host, proxy, user_agent, stealth, workers, allow_file_access, storage_dir }) => {
|
||||||
let proxy = merge_proxy(global_proxy.clone(), proxy);
|
let proxy = merge_proxy(global_proxy.clone(), proxy);
|
||||||
reject_stealth_with_socks5(proxy.as_deref(), stealth)?;
|
|
||||||
print_banner(port);
|
print_banner(port);
|
||||||
if let Some(ref dir) = storage_dir {
|
if let Some(ref dir) = storage_dir {
|
||||||
tracing::info!("Storage dir: {}", dir.display());
|
tracing::info!("Storage dir: {}", dir.display());
|
||||||
@@ -330,19 +298,17 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, stealth, eval, output, quiet, storage_dir }) => {
|
Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, stealth, eval, output, quiet, storage_dir }) => {
|
||||||
reject_stealth_with_socks5(global_proxy.as_deref(), stealth)?;
|
|
||||||
run_fetch(&url, dump, selector, wait, timeout, &wait_until, user_agent, stealth, eval, output, quiet, global_proxy, storage_dir, args.allow_private_network).await?;
|
run_fetch(&url, dump, selector, wait, timeout, &wait_until, user_agent, stealth, eval, output, quiet, global_proxy, storage_dir, args.allow_private_network).await?;
|
||||||
}
|
}
|
||||||
Some(Command::Scrape { urls, eval, concurrency, format, timeout, quiet }) => {
|
Some(Command::Scrape { urls, eval, concurrency, format, timeout, quiet }) => {
|
||||||
run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy).await?;
|
run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy).await?;
|
||||||
}
|
}
|
||||||
Some(Command::Mcp { http, port, proxy, user_agent, stealth }) => {
|
Some(Command::Mcp { http, port, proxy, user_agent, stealth }) => {
|
||||||
let mcp_proxy = merge_proxy(global_proxy.clone(), proxy.clone());
|
let mcp_proxy = merge_proxy(global_proxy.clone(), proxy);
|
||||||
reject_stealth_with_socks5(mcp_proxy.as_deref(), stealth)?;
|
|
||||||
if http {
|
if http {
|
||||||
obscura_mcp::http::run(port, proxy, user_agent, stealth).await?;
|
obscura_mcp::http::run(port, mcp_proxy, user_agent, stealth).await?;
|
||||||
} else {
|
} else {
|
||||||
obscura_mcp::run(proxy, user_agent, stealth).await?;
|
obscura_mcp::run(mcp_proxy, user_agent, stealth).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -1114,7 +1080,7 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
effective_v8_flags, extract_assets, extract_readable_text, fetch_original_bytes,
|
effective_v8_flags, extract_assets, extract_readable_text, fetch_original_bytes,
|
||||||
is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags,
|
is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags,
|
||||||
reject_stealth_with_socks5, resolve_asset_url, select_log_filter, write_or_print,
|
resolve_asset_url, select_log_filter, write_or_print,
|
||||||
write_or_print_bytes, Args, Command, DumpFormat, DEFAULT_V8_FLAGS,
|
write_or_print_bytes, Args, Command, DumpFormat, DEFAULT_V8_FLAGS,
|
||||||
};
|
};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
@@ -1388,41 +1354,6 @@ mod tests {
|
|||||||
assert!(merged.contains("--expose-gc"));
|
assert!(merged.contains("--expose-gc"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reject_stealth_with_socks5_passes_when_no_stealth() {
|
|
||||||
assert!(reject_stealth_with_socks5(Some("socks5://127.0.0.1:1080"), false).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reject_stealth_with_socks5_passes_when_no_proxy() {
|
|
||||||
assert!(reject_stealth_with_socks5(None, true).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reject_stealth_with_socks5_passes_for_http_proxy() {
|
|
||||||
assert!(reject_stealth_with_socks5(Some("http://127.0.0.1:8080"), true).is_ok());
|
|
||||||
assert!(reject_stealth_with_socks5(Some("https://proxy.example:443"), true).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reject_stealth_with_socks5_fails_for_socks5() {
|
|
||||||
let err = reject_stealth_with_socks5(Some("socks5://127.0.0.1:9999"), true).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("SOCKS5"));
|
|
||||||
assert!(err.to_string().contains("--stealth"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reject_stealth_with_socks5_fails_for_socks5h() {
|
|
||||||
let err = reject_stealth_with_socks5(Some("socks5h://127.0.0.1:9999"), true).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("SOCKS5"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reject_stealth_with_socks5_is_case_insensitive() {
|
|
||||||
let err = reject_stealth_with_socks5(Some("SOCKS5://127.0.0.1:9999"), true).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("SOCKS5"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parsed_fetch_quiet_resolves_to_off_filter() {
|
fn parsed_fetch_quiet_resolves_to_off_filter() {
|
||||||
let args = Args::try_parse_from([
|
let args = Args::try_parse_from([
|
||||||
|
|||||||
@@ -223,14 +223,6 @@ impl DomTree {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn append_child(&self, parent_id: NodeId, child_id: NodeId) {
|
pub fn append_child(&self, parent_id: NodeId, child_id: NodeId) {
|
||||||
// Per DOM spec, appending a node to itself is a HierarchyRequestError;
|
|
||||||
// here we treat it as a no-op rather than panic. Without this the
|
|
||||||
// sibling-pointer fixup below sets the node's prev_sibling to itself
|
|
||||||
// and every later child-walk loops forever (same failure mode that
|
|
||||||
// insert_before's self-cycle guard was added to prevent).
|
|
||||||
if parent_id == child_id {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.detach(child_id);
|
self.detach(child_id);
|
||||||
|
|
||||||
let mut inner = self.inner.borrow_mut();
|
let mut inner = self.inner.borrow_mut();
|
||||||
@@ -260,15 +252,6 @@ impl DomTree {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_before(&self, existing_id: NodeId, new_sibling_id: NodeId) {
|
pub fn insert_before(&self, existing_id: NodeId, new_sibling_id: NodeId) {
|
||||||
// Per DOM spec: if the node being inserted IS the reference node,
|
|
||||||
// the operation is a no-op (the node is already in its target
|
|
||||||
// position). Without this, the linked-list fixup below sets the
|
|
||||||
// node's prev_sibling and next_sibling to itself, creating a cycle
|
|
||||||
// — every later traversal (childNodes, querySelectorAll, etc) then
|
|
||||||
// loops forever and the test page hangs while obscura burns RAM.
|
|
||||||
if existing_id == new_sibling_id {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (parent_id, prev_id) = {
|
let (parent_id, prev_id) = {
|
||||||
let inner = self.inner.borrow();
|
let inner = self.inner.borrow();
|
||||||
let node = match inner.nodes.get(existing_id.index()).and_then(|n| n.as_ref()) {
|
let node = match inner.nodes.get(existing_id.index()).and_then(|n| n.as_ref()) {
|
||||||
@@ -480,20 +463,6 @@ impl DomTree {
|
|||||||
|
|
||||||
pub fn text_content(&self, node_id: NodeId) -> String {
|
pub fn text_content(&self, node_id: NodeId) -> String {
|
||||||
let inner = self.inner.borrow();
|
let inner = self.inner.borrow();
|
||||||
// Per DOM spec, calling textContent ON a CharacterData node
|
|
||||||
// (Text, Comment, ProcessingInstruction) returns its .data.
|
|
||||||
// Calling textContent on an Element walks descendants and
|
|
||||||
// concatenates Text node content only (Comment + PI are
|
|
||||||
// skipped). Handle the direct-CharacterData case here so the
|
|
||||||
// descent helper can keep its element-centric behavior.
|
|
||||||
if let Some(Some(node)) = inner.nodes.get(node_id.index()) {
|
|
||||||
match &node.data {
|
|
||||||
NodeData::Text { contents } => return contents.clone(),
|
|
||||||
NodeData::Comment { contents } => return contents.clone(),
|
|
||||||
NodeData::ProcessingInstruction { data, .. } => return data.clone(),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
collect_text_inner(&inner, node_id, &mut result);
|
collect_text_inner(&inner, node_id, &mut result);
|
||||||
result
|
result
|
||||||
@@ -601,10 +570,6 @@ fn collect_text_inner(inner: &DomTreeInner, node_id: NodeId, buf: &mut String) {
|
|||||||
if let Some(Some(node)) = inner.nodes.get(node_id.index()) {
|
if let Some(Some(node)) = inner.nodes.get(node_id.index()) {
|
||||||
match &node.data {
|
match &node.data {
|
||||||
NodeData::Text { contents } => buf.push_str(contents),
|
NodeData::Text { contents } => buf.push_str(contents),
|
||||||
// Comment and ProcessingInstruction are intentionally NOT
|
|
||||||
// appended when traversing descendants: per spec, textContent
|
|
||||||
// on an Element only includes Text descendants. Direct
|
|
||||||
// textContent on a Comment/PI is handled by the caller.
|
|
||||||
_ => {
|
_ => {
|
||||||
let mut child = node.first_child;
|
let mut child = node.first_child;
|
||||||
while let Some(child_id) = child {
|
while let Some(child_id) = child {
|
||||||
|
|||||||
Vendored
+145
-496
@@ -9,42 +9,19 @@ globalThis.onerror = function(msg, src, line, col, error) {
|
|||||||
globalThis.__obscura_errors.push({msg: String(msg), src: String(src||""), line, error: String(error||"")});
|
globalThis.__obscura_errors.push({msg: String(msg), src: String(src||""), line, error: String(error||"")});
|
||||||
};
|
};
|
||||||
globalThis.__windowListeners = {};
|
globalThis.__windowListeners = {};
|
||||||
globalThis.addEventListener = function(type, fn, opts) {
|
globalThis.addEventListener = function(type, fn) {
|
||||||
if (typeof fn !== 'function' &&
|
|
||||||
!(fn && typeof fn === 'object' && typeof fn.handleEvent === 'function')) return;
|
|
||||||
const once = !!(opts && typeof opts === 'object' && opts.once === true);
|
|
||||||
const capture = (typeof opts === 'boolean') ? opts : !!(opts && opts.capture);
|
|
||||||
if (!globalThis.__windowListeners[type]) globalThis.__windowListeners[type] = [];
|
if (!globalThis.__windowListeners[type]) globalThis.__windowListeners[type] = [];
|
||||||
const arr = globalThis.__windowListeners[type];
|
globalThis.__windowListeners[type].push(fn);
|
||||||
for (const e of arr) if (e.handler === fn && e.capture === capture) return;
|
|
||||||
arr.push({ handler: fn, once, capture });
|
|
||||||
};
|
};
|
||||||
globalThis.removeEventListener = function(type, fn, opts) {
|
globalThis.removeEventListener = function(type, fn) {
|
||||||
const capture = (typeof opts === 'boolean') ? opts : !!(opts && opts.capture);
|
|
||||||
if (globalThis.__windowListeners[type]) {
|
if (globalThis.__windowListeners[type]) {
|
||||||
globalThis.__windowListeners[type] = globalThis.__windowListeners[type]
|
globalThis.__windowListeners[type] = globalThis.__windowListeners[type].filter(h => h !== fn);
|
||||||
.filter(e => !(e.handler === fn && e.capture === capture));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
globalThis.dispatchEvent = function(event) {
|
globalThis.dispatchEvent = function(event) {
|
||||||
if (!event) return true;
|
if (!event) return true;
|
||||||
if (!event.target) event.target = globalThis;
|
const handlers = globalThis.__windowListeners[event.type] || [];
|
||||||
event.currentTarget = globalThis;
|
for (const h of handlers) { try { h.call(globalThis, event); } catch(e) { console.error(e); } }
|
||||||
const slot = globalThis.__windowListeners[event.type] || [];
|
|
||||||
const handlers = slot.slice();
|
|
||||||
for (const entry of handlers) {
|
|
||||||
const h = entry && entry.handler;
|
|
||||||
if (!h) continue;
|
|
||||||
try {
|
|
||||||
if (typeof h === 'function') h.call(globalThis, event);
|
|
||||||
else if (typeof h.handleEvent === 'function') h.handleEvent(event);
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
if (entry.once) {
|
|
||||||
const i = slot.indexOf(entry);
|
|
||||||
if (i !== -1) slot.splice(i, 1);
|
|
||||||
}
|
|
||||||
if (event && event._immediatePropagationStopped) break;
|
|
||||||
}
|
|
||||||
return !event.defaultPrevented;
|
return !event.defaultPrevented;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -258,14 +235,7 @@ class Node {
|
|||||||
get nodeType() { return +_dom("node_type", this._nid); }
|
get nodeType() { return +_dom("node_type", this._nid); }
|
||||||
get nodeName() { return _domParse("node_name", this._nid) || ""; }
|
get nodeName() { return _domParse("node_name", this._nid) || ""; }
|
||||||
get ownerDocument() { return globalThis.document; }
|
get ownerDocument() { return globalThis.document; }
|
||||||
get textContent() {
|
get textContent() { return _domParse("text_content", this._nid) ?? ""; }
|
||||||
// Per DOM spec: textContent returns null for Document and DocumentType.
|
|
||||||
// Everything else returns the concatenated text of descendants
|
|
||||||
// (or the node's data for Text/Comment/PI/Attr).
|
|
||||||
const t = this.nodeType;
|
|
||||||
if (t === 9 || t === 10) return null;
|
|
||||||
return _domParse("text_content", this._nid) ?? "";
|
|
||||||
}
|
|
||||||
set textContent(v) {
|
set textContent(v) {
|
||||||
const oldChildren = _domParse("child_nodes", this._nid) || [];
|
const oldChildren = _domParse("child_nodes", this._nid) || [];
|
||||||
for (const c of oldChildren) _dom("remove_child", c);
|
for (const c of oldChildren) _dom("remove_child", c);
|
||||||
@@ -304,29 +274,7 @@ class Node {
|
|||||||
get nextSibling() { return _wrap(+_dom("next_sibling", this._nid)); }
|
get nextSibling() { return _wrap(+_dom("next_sibling", this._nid)); }
|
||||||
get previousSibling() { return _wrap(+_dom("prev_sibling", this._nid)); }
|
get previousSibling() { return _wrap(+_dom("prev_sibling", this._nid)); }
|
||||||
appendChild(c) {
|
appendChild(c) {
|
||||||
if (!c || typeof c._nid !== 'number') return c;
|
if (!c) return c;
|
||||||
if (c._nid === this._nid) return c;
|
|
||||||
// DocumentFragment per spec: move its children up to `this` and leave
|
|
||||||
// the fragment empty. Pages that build subtrees off-DOM (Bing's
|
|
||||||
// anti-malware shim, Google Shopping result rows, React/Preact
|
|
||||||
// hydration) appendChild a fragment and then read its former
|
|
||||||
// children's properties; without flattening, the fragment node
|
|
||||||
// itself gets grafted and the next `.something.length` access on
|
|
||||||
// the assumed-Element children explodes with
|
|
||||||
// "Cannot read properties of undefined (reading 'length')".
|
|
||||||
if (c.nodeType === 11) {
|
|
||||||
const kids = (c.childNodes || []).slice();
|
|
||||||
const added = [];
|
|
||||||
for (const k of kids) {
|
|
||||||
if (typeof k._nid !== 'number') continue;
|
|
||||||
_dom("append_child", this._nid, k._nid);
|
|
||||||
added.push(k._nid);
|
|
||||||
}
|
|
||||||
if (globalThis.__mutationObservers?.length && added.length) {
|
|
||||||
globalThis.__notifyMutation('childList', this._nid, added, []);
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
_dom("append_child", this._nid, c._nid);
|
_dom("append_child", this._nid, c._nid);
|
||||||
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []);
|
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []);
|
||||||
if (c instanceof Element && c.tagName === 'SCRIPT') {
|
if (c instanceof Element && c.tagName === 'SCRIPT') {
|
||||||
@@ -390,40 +338,14 @@ class Node {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
replaceChild(newChild, oldChild) {
|
replaceChild(newChild, oldChild) {
|
||||||
if (!oldChild || !newChild || typeof oldChild._nid !== 'number') return oldChild;
|
if (!oldChild || !newChild) return oldChild;
|
||||||
if (newChild.nodeType === 11) {
|
|
||||||
// Flatten fragment: insert each child before oldChild, then remove oldChild.
|
|
||||||
// op_dom("insert_before", newNode, refNode) -> insert newNode before refNode.
|
|
||||||
const kids = (newChild.childNodes || []).slice();
|
|
||||||
for (const k of kids) {
|
|
||||||
if (typeof k._nid !== 'number') continue;
|
|
||||||
if (k._nid === oldChild._nid) continue;
|
|
||||||
_dom("insert_before", k._nid, oldChild._nid);
|
|
||||||
}
|
|
||||||
_dom("remove_child", oldChild._nid);
|
|
||||||
return oldChild;
|
|
||||||
}
|
|
||||||
if (typeof newChild._nid !== 'number') return oldChild;
|
|
||||||
if (newChild._nid === oldChild._nid) return oldChild;
|
|
||||||
_dom("insert_before", newChild._nid, oldChild._nid);
|
_dom("insert_before", newChild._nid, oldChild._nid);
|
||||||
_dom("remove_child", oldChild._nid);
|
_dom("remove_child", oldChild._nid);
|
||||||
return oldChild;
|
return oldChild;
|
||||||
}
|
}
|
||||||
insertBefore(n, ref) {
|
insertBefore(n, ref) {
|
||||||
if (!n || typeof n._nid !== 'number') return n;
|
if (!n) return n;
|
||||||
if (!ref) { this.appendChild(n); return n; }
|
if (!ref) { this.appendChild(n); return n; }
|
||||||
if (typeof ref._nid !== 'number') return n;
|
|
||||||
// DocumentFragment: flatten — insert each child of the fragment
|
|
||||||
// before ref (in order), then leave the fragment empty.
|
|
||||||
if (n.nodeType === 11) {
|
|
||||||
const kids = (n.childNodes || []).slice();
|
|
||||||
for (const k of kids) {
|
|
||||||
if (typeof k._nid !== 'number') continue;
|
|
||||||
if (k._nid === ref._nid) continue;
|
|
||||||
_dom("insert_before", k._nid, ref._nid);
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
_dom("insert_before", n._nid, ref._nid);
|
_dom("insert_before", n._nid, ref._nid);
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
@@ -546,13 +468,6 @@ class Comment extends CharacterData {
|
|||||||
cloneNode() { return document.createComment(this.data); }
|
cloneNode() { return document.createComment(this.data); }
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProcessingInstruction extends CharacterData {
|
|
||||||
get nodeName() { return this.target || ""; }
|
|
||||||
get nodeType() { return 7; }
|
|
||||||
get target() { return this._target || _domParse("pi_target", this._nid) || ""; }
|
|
||||||
cloneNode() { return document.createProcessingInstruction(this.target, this.data); }
|
|
||||||
}
|
|
||||||
|
|
||||||
class Element extends Node {
|
class Element extends Node {
|
||||||
constructor(nid) {
|
constructor(nid) {
|
||||||
super(nid);
|
super(nid);
|
||||||
@@ -565,11 +480,8 @@ class Element extends Node {
|
|||||||
get className() { return this.getAttribute("class") || ""; }
|
get className() { return this.getAttribute("class") || ""; }
|
||||||
set className(v) { this.setAttribute("class", v); }
|
set className(v) { this.setAttribute("class", v); }
|
||||||
get namespaceURI() {
|
get namespaceURI() {
|
||||||
if (this._ns !== undefined) {
|
|
||||||
return this._ns === "" ? null : this._ns;
|
|
||||||
}
|
|
||||||
const tag = this.localName;
|
const tag = this.localName;
|
||||||
if (tag === "svg") return "http://www.w3.org/2000/svg";
|
if (tag === "svg" || this._ns === "http://www.w3.org/2000/svg") return "http://www.w3.org/2000/svg";
|
||||||
return "http://www.w3.org/1999/xhtml";
|
return "http://www.w3.org/1999/xhtml";
|
||||||
}
|
}
|
||||||
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
|
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
|
||||||
@@ -722,61 +634,16 @@ class Element extends Node {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// testharness.js's output renderer calls insertAdjacentText on its
|
|
||||||
// results table. Without this, the renderer throws inside a forEach
|
|
||||||
// that has no try/catch and the chain of completion callbacks dies
|
|
||||||
// before reaching our runner hook, so EVERY WPT test with subtests
|
|
||||||
// looked like a timeout. Adding this method unblocks the harness
|
|
||||||
// pipeline and surfaces real subtest pass/fail.
|
|
||||||
insertAdjacentText(position, text) {
|
|
||||||
const node = document.createTextNode(String(text));
|
|
||||||
const parent = this.parentNode;
|
|
||||||
switch (position) {
|
|
||||||
case 'beforebegin': if (parent) parent.insertBefore(node, this); break;
|
|
||||||
case 'afterbegin': this.insertBefore(node, this.firstChild); break;
|
|
||||||
case 'beforeend': this.appendChild(node); break;
|
|
||||||
case 'afterend': if (parent) parent.insertBefore(node, this.nextSibling); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
insertAdjacentElement(position, element) {
|
|
||||||
if (!element || element.nodeType !== 1) return null;
|
|
||||||
const parent = this.parentNode;
|
|
||||||
switch (position) {
|
|
||||||
case 'beforebegin': if (parent) { parent.insertBefore(element, this); return element; } return null;
|
|
||||||
case 'afterbegin': this.insertBefore(element, this.firstChild); return element;
|
|
||||||
case 'beforeend': this.appendChild(element); return element;
|
|
||||||
case 'afterend': if (parent) { parent.insertBefore(element, this.nextSibling); return element; } return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
addEventListener(type, handler, opts) {
|
addEventListener(type, handler, opts) {
|
||||||
// Per DOM spec, a listener can be a function OR an object with a
|
|
||||||
// `handleEvent` method. Anything else is silently ignored. Without
|
|
||||||
// this filter, passing null/string/number used to push a non-callable
|
|
||||||
// into the handler array and explode at dispatch time.
|
|
||||||
if (typeof handler !== 'function' &&
|
|
||||||
!(handler && typeof handler === 'object' && typeof handler.handleEvent === 'function')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const once = opts && typeof opts === 'object' && opts.once === true;
|
|
||||||
const capture = (typeof opts === 'boolean') ? opts : !!(opts && opts.capture);
|
|
||||||
const passive = !!(opts && typeof opts === 'object' && opts.passive);
|
|
||||||
const key = this._nid;
|
const key = this._nid;
|
||||||
if (!_eventRegistry[key]) _eventRegistry[key] = {};
|
if (!_eventRegistry[key]) _eventRegistry[key] = {};
|
||||||
if (!_eventRegistry[key][type]) _eventRegistry[key][type] = [];
|
if (!_eventRegistry[key][type]) _eventRegistry[key][type] = [];
|
||||||
// Dedupe: spec says same (type, callback, capture) is a no-op.
|
_eventRegistry[key][type].push(handler);
|
||||||
const arr = _eventRegistry[key][type];
|
|
||||||
for (const e of arr) {
|
|
||||||
if (e.handler === handler && e.capture === capture) return;
|
|
||||||
}
|
}
|
||||||
arr.push({ handler, once, capture, passive });
|
removeEventListener(type, handler) {
|
||||||
}
|
|
||||||
removeEventListener(type, handler, opts) {
|
|
||||||
const key = this._nid;
|
const key = this._nid;
|
||||||
const capture = (typeof opts === 'boolean') ? opts : !!(opts && opts.capture);
|
|
||||||
if (_eventRegistry[key] && _eventRegistry[key][type]) {
|
if (_eventRegistry[key] && _eventRegistry[key][type]) {
|
||||||
_eventRegistry[key][type] = _eventRegistry[key][type]
|
_eventRegistry[key][type] = _eventRegistry[key][type].filter(h => h !== handler);
|
||||||
.filter(e => !(e.handler === handler && e.capture === capture));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dispatchEvent(event) {
|
dispatchEvent(event) {
|
||||||
@@ -797,25 +664,9 @@ class Element extends Node {
|
|||||||
if (ret === false) event.preventDefault();
|
if (ret === false) event.preventDefault();
|
||||||
} catch(e) { console.error(e); }
|
} catch(e) { console.error(e); }
|
||||||
}
|
}
|
||||||
// Snapshot the listener list — `once: true` listeners are removed
|
const handlers = (_eventRegistry[this._nid] || {})[event.type] || [];
|
||||||
// during dispatch, but spec says dispatched listeners are captured
|
for (const h of handlers) {
|
||||||
// before the loop starts.
|
try { h.call(this, event); } catch(e) { console.error(e); }
|
||||||
const slot = (_eventRegistry[this._nid] || {})[event.type] || [];
|
|
||||||
const handlers = slot.slice();
|
|
||||||
for (const entry of handlers) {
|
|
||||||
const h = entry && entry.handler;
|
|
||||||
if (!h) continue;
|
|
||||||
try {
|
|
||||||
if (typeof h === 'function') {
|
|
||||||
h.call(this, event);
|
|
||||||
} else if (typeof h.handleEvent === 'function') {
|
|
||||||
h.handleEvent(event);
|
|
||||||
}
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
if (entry.once) {
|
|
||||||
const i = slot.indexOf(entry);
|
|
||||||
if (i !== -1) slot.splice(i, 1);
|
|
||||||
}
|
|
||||||
if (event._immediatePropagationStopped) break;
|
if (event._immediatePropagationStopped) break;
|
||||||
}
|
}
|
||||||
if (event.bubbles && !event._propagationStopped && this.parentNode) {
|
if (event.bubbles && !event._propagationStopped && this.parentNode) {
|
||||||
@@ -920,49 +771,8 @@ class Element extends Node {
|
|||||||
set name(v) { this.setAttribute("name", v); }
|
set name(v) { this.setAttribute("name", v); }
|
||||||
get placeholder() { return this.getAttribute("placeholder") || ""; }
|
get placeholder() { return this.getAttribute("placeholder") || ""; }
|
||||||
set placeholder(v) { this.setAttribute("placeholder", v); }
|
set placeholder(v) { this.setAttribute("placeholder", v); }
|
||||||
get href() {
|
get href() { return this.getAttribute("href") || ""; }
|
||||||
const raw = this.getAttribute("href");
|
|
||||||
if (raw == null) return "";
|
|
||||||
// Per spec, HTMLAnchorElement.href / HTMLAreaElement.href returns the
|
|
||||||
// serialized resolved URL (the content attribute resolved against the
|
|
||||||
// document base). Bing's anti-malware shim and many other libraries
|
|
||||||
// rely on this resolution to read `.hostname` / `.protocol`. We only
|
|
||||||
// resolve for <a>/<area>/<base>/<link> so other elements that happen
|
|
||||||
// to carry an `href` attribute (custom elements, SVG <a>, etc) keep
|
|
||||||
// the raw value they were assigned.
|
|
||||||
const tag = this.localName;
|
|
||||||
if (tag === 'a' || tag === 'area' || tag === 'base' || tag === 'link') {
|
|
||||||
try {
|
|
||||||
return new URL(raw, _domParse("document_url") || globalThis.location?.href || "about:blank").href;
|
|
||||||
} catch { return raw; }
|
|
||||||
}
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
set href(v) { this.setAttribute("href", v); }
|
set href(v) { this.setAttribute("href", v); }
|
||||||
// URL-decomposition IDL attributes for <a> / <area>. Without these,
|
|
||||||
// libraries that build an anchor and read parts of the resolved URL
|
|
||||||
// (Bing's CI.AntiMalware shim does `n=createElement("A"); n.href=src;
|
|
||||||
// n.hostname.length`) crash with "Cannot read properties of undefined".
|
|
||||||
_urlPart(part) {
|
|
||||||
const tag = this.localName;
|
|
||||||
if (tag !== 'a' && tag !== 'area') return "";
|
|
||||||
const raw = this.getAttribute("href");
|
|
||||||
if (raw == null || raw === "") return "";
|
|
||||||
try {
|
|
||||||
const u = new URL(raw, _domParse("document_url") || globalThis.location?.href || "about:blank");
|
|
||||||
return u[part] || "";
|
|
||||||
} catch { return ""; }
|
|
||||||
}
|
|
||||||
get origin() { return this._urlPart('origin'); }
|
|
||||||
get protocol() { return this._urlPart('protocol'); }
|
|
||||||
get username() { return this._urlPart('username'); }
|
|
||||||
get password() { return this._urlPart('password'); }
|
|
||||||
get host() { return this._urlPart('host'); }
|
|
||||||
get hostname() { return this._urlPart('hostname'); }
|
|
||||||
get port() { return this._urlPart('port'); }
|
|
||||||
get pathname() { return this._urlPart('pathname'); }
|
|
||||||
get search() { return this._urlPart('search'); }
|
|
||||||
get hash() { return this._urlPart('hash'); }
|
|
||||||
get src() { return this.getAttribute("src") || ""; }
|
get src() { return this.getAttribute("src") || ""; }
|
||||||
set src(v) {
|
set src(v) {
|
||||||
this.setAttribute("src", v);
|
this.setAttribute("src", v);
|
||||||
@@ -986,7 +796,6 @@ class Element extends Node {
|
|||||||
el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl);
|
el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl);
|
||||||
}
|
}
|
||||||
_registerIframe(el);
|
_registerIframe(el);
|
||||||
_activateIframe(el);
|
|
||||||
if (typeof el.onload === 'function') {
|
if (typeof el.onload === 'function') {
|
||||||
try { el.onload(); } catch(e) {}
|
try { el.onload(); } catch(e) {}
|
||||||
} else {
|
} else {
|
||||||
@@ -1282,24 +1091,6 @@ class Document extends Node {
|
|||||||
_cache.set(nid, n);
|
_cache.set(nid, n);
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
createProcessingInstruction(target, data) {
|
|
||||||
// Spec: target must be a valid XML Name, data must not contain "?>".
|
|
||||||
// Throw InvalidCharacterError on either violation. Simplified XML
|
|
||||||
// Name check (first char + remainder is enough for the WPT tests).
|
|
||||||
const t = String(target);
|
|
||||||
const d = String(data ?? "");
|
|
||||||
if (!t.length || !/^[A-Za-z_:][\w.\-:]*$/.test(t)) {
|
|
||||||
const e = new Error("InvalidCharacterError"); e.name = "InvalidCharacterError"; throw e;
|
|
||||||
}
|
|
||||||
if (d.indexOf("?>") !== -1) {
|
|
||||||
const e = new Error("InvalidCharacterError"); e.name = "InvalidCharacterError"; throw e;
|
|
||||||
}
|
|
||||||
const nid = +_dom("create_processing_instruction", t, d);
|
|
||||||
const n = new ProcessingInstruction(nid);
|
|
||||||
n._target = t;
|
|
||||||
_cache.set(nid, n);
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
createDocumentFragment() {
|
createDocumentFragment() {
|
||||||
const nid = +_dom("create_document_fragment");
|
const nid = +_dom("create_document_fragment");
|
||||||
const frag = new DocumentFragment(nid);
|
const frag = new DocumentFragment(nid);
|
||||||
@@ -1469,19 +1260,6 @@ class Document extends Node {
|
|||||||
const html = `<${safe}></${safe}>`;
|
const html = `<${safe}></${safe}>`;
|
||||||
return new DOMParser().parseFromString(html, "application/xml");
|
return new DOMParser().parseFromString(html, "application/xml");
|
||||||
},
|
},
|
||||||
// DOM Level 2: a DocumentType node usable as the third arg of
|
|
||||||
// createDocument(). We allocate a Doctype node in the DOM tree and
|
|
||||||
// wrap it. The new node is detached (no parent) which matches the
|
|
||||||
// spec; callers like createDocument(ns, name, doctype) attach it.
|
|
||||||
createDocumentType(name, publicId, systemId) {
|
|
||||||
const nid = +_dom("create_doctype", String(name || ""), String(publicId || ""));
|
|
||||||
// ops.rs::create_doctype takes (name, publicId) — systemId stored
|
|
||||||
// here only in the JS wrapper since neither current test consumes
|
|
||||||
// it from the underlying tree.
|
|
||||||
const dt = new DocumentType(nid, String(name || ""), String(publicId || ""), String(systemId || ""));
|
|
||||||
_cache.set(nid, dt);
|
|
||||||
return dt;
|
|
||||||
},
|
|
||||||
hasFeature() { return true; },
|
hasFeature() { return true; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1578,16 +1356,8 @@ function _wrap(nid) {
|
|||||||
let n;
|
let n;
|
||||||
if (t === 1) { const C = _elementClassFor(nid); n = new C(nid); }
|
if (t === 1) { const C = _elementClassFor(nid); n = new C(nid); }
|
||||||
else if (t === 3) n = new Text(nid);
|
else if (t === 3) n = new Text(nid);
|
||||||
else if (t === 7) n = new ProcessingInstruction(nid);
|
|
||||||
else if (t === 8) n = new Comment(nid);
|
else if (t === 8) n = new Comment(nid);
|
||||||
else if (t === 9) n = new Document(nid);
|
else if (t === 9) n = new Document(nid);
|
||||||
else if (t === 10) {
|
|
||||||
// DocumentType wrapper. Pull the name/publicId from the tree so
|
|
||||||
// the wrapper exposes the spec'd .name / .publicId getters.
|
|
||||||
const name = _domParse("doctype_name", nid) || "";
|
|
||||||
const publicId = _domParse("doctype_public_id", nid) || "";
|
|
||||||
n = new DocumentType(nid, name, publicId, "");
|
|
||||||
}
|
|
||||||
else n = new Node(nid);
|
else n = new Node(nid);
|
||||||
_cache.set(nid, n);
|
_cache.set(nid, n);
|
||||||
return n;
|
return n;
|
||||||
@@ -1683,9 +1453,6 @@ Object.defineProperty(globalThis.Window, Symbol.hasInstance, {
|
|||||||
|
|
||||||
const _iframeRegistry = [];
|
const _iframeRegistry = [];
|
||||||
function _registerIframe(iframeEl) {
|
function _registerIframe(iframeEl) {
|
||||||
// Same iframe element can have its src reassigned; we don't want to
|
|
||||||
// double-register and fan out events twice to the same window.
|
|
||||||
if (_iframeRegistry.indexOf(iframeEl) !== -1) return;
|
|
||||||
const idx = _iframeRegistry.length;
|
const idx = _iframeRegistry.length;
|
||||||
_iframeRegistry.push(iframeEl);
|
_iframeRegistry.push(iframeEl);
|
||||||
globalThis.length = _iframeRegistry.length;
|
globalThis.length = _iframeRegistry.length;
|
||||||
@@ -1695,29 +1462,6 @@ function _registerIframe(iframeEl) {
|
|||||||
enumerable: false,
|
enumerable: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Run inline <script> blocks from the iframe HTML in the parent realm
|
|
||||||
// (we don't have per-frame realms) and rewire <body onfoo="..."> handler
|
|
||||||
// attributes as window-level listeners on the iframe window.
|
|
||||||
function _activateIframe(iframeEl) {
|
|
||||||
const doc = iframeEl._iframeDoc;
|
|
||||||
const win = iframeEl._iframeWin;
|
|
||||||
if (!doc || !win) return;
|
|
||||||
if (Array.isArray(doc._inlineScripts)) {
|
|
||||||
for (const src of doc._inlineScripts) {
|
|
||||||
try { (0, eval)(src); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (doc._bodyHandlers) {
|
|
||||||
for (const onName of Object.keys(doc._bodyHandlers)) {
|
|
||||||
const type = onName.slice(2); // "onstorage" -> "storage"
|
|
||||||
const code = doc._bodyHandlers[onName];
|
|
||||||
const handler = function(event) {
|
|
||||||
try { (new Function('event', code)).call(win, event); } catch (e) {}
|
|
||||||
};
|
|
||||||
try { win.addEventListener(type, handler); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
globalThis.navigator = {
|
globalThis.navigator = {
|
||||||
get userAgent() { return globalThis.__obscura_ua || "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"; },
|
get userAgent() { return globalThis.__obscura_ua || "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"; },
|
||||||
get appVersion() { return this.userAgent.replace('Mozilla/', ''); },
|
get appVersion() { return this.userAgent.replace('Mozilla/', ''); },
|
||||||
@@ -1741,6 +1485,7 @@ globalThis.navigator = {
|
|||||||
p.item = (i) => p[i] || null;
|
p.item = (i) => p[i] || null;
|
||||||
p.namedItem = (name) => p.find(x => x.name === name) || null;
|
p.namedItem = (name) => p.find(x => x.name === name) || null;
|
||||||
p.refresh = () => {};
|
p.refresh = () => {};
|
||||||
|
p[Symbol.iterator] = function() { return this[Symbol.iterator](); };
|
||||||
return p;
|
return p;
|
||||||
},
|
},
|
||||||
get mimeTypes() {
|
get mimeTypes() {
|
||||||
@@ -1798,18 +1543,75 @@ globalThis.navigator = {
|
|||||||
getGamepads() { return []; },
|
getGamepads() { return []; },
|
||||||
sendBeacon() { return true; },
|
sendBeacon() { return true; },
|
||||||
javaEnabled() { return false; },
|
javaEnabled() { return false; },
|
||||||
|
geolocation: {
|
||||||
|
getCurrentPosition(success, error) {
|
||||||
|
const coords = {
|
||||||
|
latitude: 50.1109 + (_fpRand(500) - 0.5) * 0.1,
|
||||||
|
longitude: 8.6821 + (_fpRand(501) - 0.5) * 0.1,
|
||||||
|
accuracy: 10 + _fpRand(502) * 40,
|
||||||
|
altitude: null,
|
||||||
|
altitudeAccuracy: null,
|
||||||
|
heading: null,
|
||||||
|
speed: null,
|
||||||
|
};
|
||||||
|
const pos = { coords, timestamp: Date.now() };
|
||||||
|
if (typeof success === 'function') success(pos);
|
||||||
|
},
|
||||||
|
watchPosition(success, error) {
|
||||||
|
if (typeof success === 'function') {
|
||||||
|
const coords = {
|
||||||
|
latitude: 50.1109 + (_fpRand(503) - 0.5) * 0.1,
|
||||||
|
longitude: 8.6821 + (_fpRand(504) - 0.5) * 0.1,
|
||||||
|
accuracy: 10 + _fpRand(505) * 40,
|
||||||
|
altitude: null,
|
||||||
|
altitudeAccuracy: null,
|
||||||
|
heading: null,
|
||||||
|
speed: null,
|
||||||
|
};
|
||||||
|
success({ coords, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
clearWatch() {},
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
estimate() { return Promise.resolve({ quota: 5000000000, usage: Math.floor(_fpRand(640) * 100000000) }); },
|
||||||
|
persist() { return Promise.resolve(false); },
|
||||||
|
persisted() { return Promise.resolve(false); },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.chrome = {
|
globalThis.chrome = {
|
||||||
app: { isInstalled: false, InstallState: { DISABLED: "disabled", INSTALLED: "installed", NOT_INSTALLED: "not_installed" }, RunningState: { CANNOT_RUN: "cannot_run", READY_TO_RUN: "ready_to_run", RUNNING: "running" } },
|
app: { isInstalled: false, InstallState: { DISABLED: "disabled", INSTALLED: "installed", NOT_INSTALLED: "not_installed" }, RunningState: { CANNOT_RUN: "cannot_run", READY_TO_RUN: "ready_to_run", RUNNING: "running" } },
|
||||||
runtime: { OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {}, PlatformNaclArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}, connect() { return {}; }, sendMessage() {} },
|
runtime: { OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {}, PlatformNaclArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}, connect() { return {}; }, sendMessage() {} },
|
||||||
csi() { return {}; },
|
csi() {
|
||||||
loadTimes() { return {}; },
|
const t = Date.now();
|
||||||
|
return { onloadT: t, startE: t - Math.floor(100 + _fpRand(610) * 200), pageT: 0, tran: 5, flashVersion: "" };
|
||||||
|
},
|
||||||
|
loadTimes() {
|
||||||
|
const t = Date.now() / 1000;
|
||||||
|
const request = t - 0.5 - _fpRand(611) * 0.5;
|
||||||
|
const startLoad = request + 0.05 + _fpRand(612) * 0.02;
|
||||||
|
const commit = request + 0.3 + _fpRand(613) * 0.4;
|
||||||
|
const finishDoc = commit + 0.1 + _fpRand(614) * 0.2;
|
||||||
|
const finish = finishDoc + 0.05 + _fpRand(615) * 0.1;
|
||||||
|
const firstPaint = commit + 0.03 + _fpRand(616) * 0.1;
|
||||||
|
const navTypes = ["BackForward","Reload","Link","Other"];
|
||||||
|
return {
|
||||||
|
requestTime: request, startLoadTime: startLoad * 1000, commitLoadTime: commit * 1000,
|
||||||
|
finishDocumentLoadTime: finishDoc * 1000, finishLoadTime: finish * 1000,
|
||||||
|
firstPaintTime: firstPaint * 1000, firstPaintAfterLoadTime: 0,
|
||||||
|
navigationType: navTypes[Math.floor(_fpRand(617) * 4)],
|
||||||
|
wasFetchedViaSpdy: false, wasNpnNegotiated: false,
|
||||||
|
npnNegotiatedProtocol: "http/1.1",
|
||||||
|
wasAlternateProtocolAvailable: false, connectionInfo: "http/1.1",
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.Notification = class Notification {
|
globalThis.Notification = class Notification {
|
||||||
static permission = "default";
|
static permission = "default";
|
||||||
static requestPermission() { return Promise.resolve("default"); }
|
static requestPermission() { return Promise.resolve(Notification.permission); }
|
||||||
constructor() {}
|
constructor() {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1819,8 +1621,8 @@ globalThis.WebGL2RenderingContext = class WebGL2RenderingContext {};
|
|||||||
globalThis.screen = { width:1920, height:1080, availWidth:1920, availHeight:1040, colorDepth:24, pixelDepth:24, availTop:0, availLeft:0, orientation:{type:"landscape-primary",angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}} };
|
globalThis.screen = { width:1920, height:1080, availWidth:1920, availHeight:1040, colorDepth:24, pixelDepth:24, availTop:0, availLeft:0, orientation:{type:"landscape-primary",angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}} };
|
||||||
globalThis.visualViewport = { width:1920, height:1000, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} };
|
globalThis.visualViewport = { width:1920, height:1000, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} };
|
||||||
globalThis.devicePixelRatio = 1;
|
globalThis.devicePixelRatio = 1;
|
||||||
globalThis.innerWidth = 1280; globalThis.innerHeight = 720;
|
globalThis.innerWidth = 1920; globalThis.innerHeight = 1000;
|
||||||
globalThis.outerWidth = 1280; globalThis.outerHeight = 720;
|
globalThis.outerWidth = 1920; globalThis.outerHeight = 1080;
|
||||||
globalThis.scrollX = 0; globalThis.scrollY = 0;
|
globalThis.scrollX = 0; globalThis.scrollY = 0;
|
||||||
globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
|
globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
|
||||||
|
|
||||||
@@ -2811,104 +2613,8 @@ globalThis.MessageEvent = class extends Event { constructor(t,o={}) { super(t,o)
|
|||||||
globalThis.ClipboardEvent = class extends Event {};
|
globalThis.ClipboardEvent = class extends Event {};
|
||||||
globalThis.SubmitEvent = class extends Event {};
|
globalThis.SubmitEvent = class extends Event {};
|
||||||
|
|
||||||
// AbortSignal is an EventTarget that crawlers wire up to cancel
|
globalThis.AbortController = class AbortController { constructor(){this.signal={aborted:false,addEventListener(){},removeEventListener(){},onabort:null};} abort(){this.signal.aborted=true;} };
|
||||||
// long-running fetches and worker jobs. The previous shim was a plain
|
globalThis.AbortSignal = { timeout(ms){return {aborted:false,addEventListener(){},removeEventListener(){}}; } };
|
||||||
// object with stub addEventListener/removeEventListener, so listeners
|
|
||||||
// for 'abort' silently never fired and any AbortSignal.timeout/.any
|
|
||||||
// chains broke. We can't extend EventTarget here because the snapshot
|
|
||||||
// build evaluates bootstrap.js top-to-bottom and EventTarget is
|
|
||||||
// assigned later (and as Node, which AbortSignal is not). Inline the
|
|
||||||
// event-target surface instead.
|
|
||||||
globalThis.AbortSignal = class AbortSignal {
|
|
||||||
constructor() {
|
|
||||||
this._aborted = false;
|
|
||||||
this._reason = undefined;
|
|
||||||
this._listeners = { abort: [] };
|
|
||||||
this.onabort = null;
|
|
||||||
}
|
|
||||||
get aborted() { return this._aborted; }
|
|
||||||
get reason() {
|
|
||||||
if (!this._aborted) return undefined;
|
|
||||||
return this._reason !== undefined
|
|
||||||
? this._reason
|
|
||||||
: new DOMException('signal is aborted without reason', 'AbortError');
|
|
||||||
}
|
|
||||||
throwIfAborted() {
|
|
||||||
if (this._aborted) throw this.reason;
|
|
||||||
}
|
|
||||||
addEventListener(type, fn) {
|
|
||||||
if (typeof fn !== 'function') return;
|
|
||||||
(this._listeners[type] = this._listeners[type] || []).push(fn);
|
|
||||||
}
|
|
||||||
removeEventListener(type, fn) {
|
|
||||||
const arr = this._listeners[type];
|
|
||||||
if (!arr) return;
|
|
||||||
const i = arr.indexOf(fn);
|
|
||||||
if (i !== -1) arr.splice(i, 1);
|
|
||||||
}
|
|
||||||
dispatchEvent(ev) {
|
|
||||||
const arr = this._listeners[ev && ev.type] || [];
|
|
||||||
for (const h of arr.slice()) {
|
|
||||||
try { h.call(this, ev); } catch (e) {}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
_fireAbort() {
|
|
||||||
const ev = (typeof Event === 'function') ? new Event('abort') : { type: 'abort', target: this };
|
|
||||||
try { ev.target = this; } catch (e) {}
|
|
||||||
try { if (typeof this.onabort === 'function') this.onabort.call(this, ev); } catch (e) {}
|
|
||||||
this.dispatchEvent(ev);
|
|
||||||
}
|
|
||||||
static abort(reason) {
|
|
||||||
const s = new AbortSignal();
|
|
||||||
s._aborted = true;
|
|
||||||
s._reason = reason;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
static timeout(ms) {
|
|
||||||
const s = new AbortSignal();
|
|
||||||
setTimeout(() => {
|
|
||||||
if (s._aborted) return;
|
|
||||||
s._aborted = true;
|
|
||||||
s._reason = new DOMException('signal timed out', 'TimeoutError');
|
|
||||||
s._fireAbort();
|
|
||||||
}, Number(ms) || 0);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
static any(signals) {
|
|
||||||
const s = new AbortSignal();
|
|
||||||
const list = Array.from(signals || []);
|
|
||||||
for (const child of list) {
|
|
||||||
if (!(child instanceof AbortSignal)) continue;
|
|
||||||
if (child.aborted) {
|
|
||||||
s._aborted = true;
|
|
||||||
s._reason = child.reason;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const onChildAbort = function(ev) {
|
|
||||||
if (s._aborted) return;
|
|
||||||
const src = ev && ev.target;
|
|
||||||
s._aborted = true;
|
|
||||||
s._reason = src && src.reason;
|
|
||||||
s._fireAbort();
|
|
||||||
};
|
|
||||||
for (const child of list) {
|
|
||||||
if (!(child instanceof AbortSignal)) continue;
|
|
||||||
try { child.addEventListener('abort', onChildAbort); } catch (e) {}
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
globalThis.AbortController = class AbortController {
|
|
||||||
constructor() { this.signal = new AbortSignal(); }
|
|
||||||
abort(reason) {
|
|
||||||
if (this.signal._aborted) return;
|
|
||||||
this.signal._aborted = true;
|
|
||||||
this.signal._reason = reason;
|
|
||||||
this.signal._fireAbort();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (typeof Blob === "undefined") globalThis.Blob = class Blob { constructor(parts=[],opts={}){this._data=parts.join("");this.size=this._data.length;this.type=opts.type||"";} async text(){return this._data;} };
|
if (typeof Blob === "undefined") globalThis.Blob = class Blob { constructor(parts=[],opts={}){this._data=parts.join("");this.size=this._data.length;this.type=opts.type||"";} async text(){return this._data;} };
|
||||||
if (typeof File === "undefined") globalThis.File = class extends Blob { constructor(parts,name,opts){super(parts,opts);this.name=name;} };
|
if (typeof File === "undefined") globalThis.File = class extends Blob { constructor(parts,name,opts){super(parts,opts);this.name=name;} };
|
||||||
if (typeof FormData === "undefined") globalThis.FormData = class FormData { constructor(){this._d=[];} append(k,v){this._d.push([k,v]);} get(k){const e=this._d.find(([a])=>a===k);return e?e[1]:null;} getAll(k){return this._d.filter(([a])=>a===k).map(([,v])=>v);} has(k){return this._d.some(([a])=>a===k);} entries(){return this._d[Symbol.iterator]();} forEach(cb){this._d.forEach(([k,v])=>cb(v,k));} };
|
if (typeof FormData === "undefined") globalThis.FormData = class FormData { constructor(){this._d=[];} append(k,v){this._d.push([k,v]);} get(k){const e=this._d.find(([a])=>a===k);return e?e[1]:null;} getAll(k){return this._d.filter(([a])=>a===k).map(([,v])=>v);} has(k){return this._d.some(([a])=>a===k);} entries(){return this._d[Symbol.iterator]();} forEach(cb){this._d.forEach(([k,v])=>cb(v,k));} };
|
||||||
@@ -3051,22 +2757,50 @@ globalThis.performance = globalThis.performance || {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var _commonFonts = [
|
||||||
|
'Arial', 'Arial Black', 'Arial Narrow',
|
||||||
|
'Baskerville', 'Book Antiqua',
|
||||||
|
'Calibri', 'Cambria', 'Candara', 'Consolas', 'Courier New',
|
||||||
|
'DejaVu Sans', 'DejaVu Sans Mono', 'DejaVu Serif',
|
||||||
|
'Futura',
|
||||||
|
'Garamond', 'Georgia', 'Gill Sans',
|
||||||
|
'Helvetica',
|
||||||
|
'Impact',
|
||||||
|
'Liberation Sans', 'Liberation Sans Mono', 'Liberation Serif',
|
||||||
|
'Lucida Console', 'Lucida Handwriting',
|
||||||
|
'Microsoft Sans Serif', 'Monaco',
|
||||||
|
'Noto Sans', 'Noto Serif',
|
||||||
|
'Palatino Linotype',
|
||||||
|
'Segoe UI',
|
||||||
|
'Tahoma', 'Times New Roman', 'Trebuchet MS',
|
||||||
|
'Verdana',
|
||||||
|
'Webdings', 'Wingdings',
|
||||||
|
];
|
||||||
Object.defineProperty(Document.prototype, 'fonts', {
|
Object.defineProperty(Document.prototype, 'fonts', {
|
||||||
get() {
|
get() {
|
||||||
return {
|
const _set = _commonFonts.map((name, i) => ({
|
||||||
ready: Promise.resolve(),
|
family: name, style: 'normal', weight: '400', stretch: 'normal',
|
||||||
check() { return true; },
|
status: 'loaded', loaded: Promise.resolve(this),
|
||||||
load() { return Promise.resolve([]); },
|
[Symbol.toStringTag]: 'FontFace',
|
||||||
add() {},
|
}));
|
||||||
delete() { return false; },
|
_set.forEach = (fn) => { _set.forEach(fn); };
|
||||||
clear() {},
|
_set.has = (f) => typeof f === 'string'
|
||||||
has() { return false; },
|
? _commonFonts.some(n => n.toLowerCase() === f.toLowerCase())
|
||||||
forEach() {},
|
: _set.some(ff => ff.family === f?.family);
|
||||||
get size() { return 0; },
|
_set.delete = (f) => false;
|
||||||
get status() { return 'loaded'; },
|
_set.clear = () => {};
|
||||||
addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; },
|
_set.add = () => {};
|
||||||
[Symbol.iterator]() { return [][Symbol.iterator](); },
|
_set.load = () => Promise.resolve(_set);
|
||||||
|
_set.check = (font) => {
|
||||||
|
const m = typeof font === 'string' ? font.match(/["']([^"']+)["']/) : null;
|
||||||
|
return m ? _commonFonts.some(n => n.toLowerCase() === m[1].toLowerCase()) : true;
|
||||||
};
|
};
|
||||||
|
_set.ready = Promise.resolve(_set);
|
||||||
|
_set.status = 'loaded';
|
||||||
|
_set.addEventListener = () => {};
|
||||||
|
_set.removeEventListener = () => {};
|
||||||
|
_set.dispatchEvent = () => true;
|
||||||
|
return _set;
|
||||||
},
|
},
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
@@ -3075,78 +2809,10 @@ globalThis.structuredClone = globalThis.structuredClone || ((v) => JSON.parse(JS
|
|||||||
globalThis.reportError = globalThis.reportError || ((e) => console.error(e));
|
globalThis.reportError = globalThis.reportError || ((e) => console.error(e));
|
||||||
|
|
||||||
globalThis.Storage = function Storage() {};
|
globalThis.Storage = function Storage() {};
|
||||||
// StorageEvent: dispatched on window when localStorage / sessionStorage
|
Storage.prototype.getItem = function(k) { return (this._data && this._data[k]) ?? null; };
|
||||||
// mutates. Per spec, the same window does NOT receive the event for its
|
Storage.prototype.setItem = function(k, v) { if (this._data) this._data[k] = String(v); };
|
||||||
// own writes, but for an in-process single-page shim we dispatch
|
Storage.prototype.removeItem = function(k) { if (this._data) delete this._data[k]; };
|
||||||
// regardless so test code that registers a listener and writes from the
|
Storage.prototype.clear = function() { if (this._data) for (var k in this._data) delete this._data[k]; };
|
||||||
// same window can observe the event (which is what every webstorage WPT
|
|
||||||
// test does).
|
|
||||||
globalThis.StorageEvent = class StorageEvent extends Event {
|
|
||||||
constructor(type, init) {
|
|
||||||
init = init || {};
|
|
||||||
super(type, init);
|
|
||||||
this.key = init.key !== undefined ? init.key : null;
|
|
||||||
this.oldValue = init.oldValue !== undefined ? init.oldValue : null;
|
|
||||||
this.newValue = init.newValue !== undefined ? init.newValue : null;
|
|
||||||
this.url = init.url !== undefined ? init.url : (globalThis.location ? globalThis.location.href : '');
|
|
||||||
this.storageArea = init.storageArea !== undefined ? init.storageArea : null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
function _dispatchStorageEvent(area, key, oldValue, newValue) {
|
|
||||||
try {
|
|
||||||
const mkEvent = () => new StorageEvent('storage', {
|
|
||||||
key: key,
|
|
||||||
oldValue: oldValue == null ? null : String(oldValue),
|
|
||||||
newValue: newValue == null ? null : String(newValue),
|
|
||||||
url: globalThis.location ? globalThis.location.href : '',
|
|
||||||
storageArea: area,
|
|
||||||
bubbles: false,
|
|
||||||
cancelable: false,
|
|
||||||
});
|
|
||||||
Promise.resolve().then(() => {
|
|
||||||
try { globalThis.dispatchEvent(mkEvent()); } catch (e) {}
|
|
||||||
// Per spec the originating window does NOT receive the event, but
|
|
||||||
// other same-origin browsing contexts do. We fire on the parent
|
|
||||||
// and every iframe window so cross-frame storage listeners (used
|
|
||||||
// by webstorage WPT tests) see the change.
|
|
||||||
try {
|
|
||||||
const reg = (typeof _iframeRegistry !== 'undefined') ? _iframeRegistry : [];
|
|
||||||
for (const el of reg) {
|
|
||||||
const win = el && el._iframeWin;
|
|
||||||
if (!win) continue;
|
|
||||||
try { win.dispatchEvent(mkEvent()); } catch (e) {}
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
});
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
Storage.prototype.getItem = function(k) {
|
|
||||||
const v = this._data ? this._data[String(k)] : undefined;
|
|
||||||
return v == null ? null : String(v);
|
|
||||||
};
|
|
||||||
Storage.prototype.setItem = function(k, v) {
|
|
||||||
if (!this._data) return;
|
|
||||||
const key = String(k);
|
|
||||||
const newValue = String(v);
|
|
||||||
const oldValue = Object.prototype.hasOwnProperty.call(this._data, key) ? this._data[key] : null;
|
|
||||||
if (oldValue === newValue) return;
|
|
||||||
this._data[key] = newValue;
|
|
||||||
_dispatchStorageEvent(this, key, oldValue, newValue);
|
|
||||||
};
|
|
||||||
Storage.prototype.removeItem = function(k) {
|
|
||||||
if (!this._data) return;
|
|
||||||
const key = String(k);
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(this._data, key)) return;
|
|
||||||
const oldValue = this._data[key];
|
|
||||||
delete this._data[key];
|
|
||||||
_dispatchStorageEvent(this, key, oldValue, null);
|
|
||||||
};
|
|
||||||
Storage.prototype.clear = function() {
|
|
||||||
if (!this._data) return;
|
|
||||||
const hadAny = Object.keys(this._data).length > 0;
|
|
||||||
for (var k in this._data) delete this._data[k];
|
|
||||||
if (hadAny) _dispatchStorageEvent(this, null, null, null);
|
|
||||||
};
|
|
||||||
Object.defineProperty(Storage.prototype, 'length', { get: function() { return this._data ? Object.keys(this._data).length : 0; } });
|
Object.defineProperty(Storage.prototype, 'length', { get: function() { return this._data ? Object.keys(this._data).length : 0; } });
|
||||||
Storage.prototype.key = function(i) { return this._data ? Object.keys(this._data)[i] ?? null : null; };
|
Storage.prototype.key = function(i) { return this._data ? Object.keys(this._data)[i] ?? null : null; };
|
||||||
|
|
||||||
@@ -3299,8 +2965,11 @@ globalThis.Range = class Range { setStart(){} setEnd(){} collapse(){} selectNode
|
|||||||
|
|
||||||
[
|
[
|
||||||
navigator.getBattery, navigator.getGamepads, navigator.sendBeacon,
|
navigator.getBattery, navigator.getGamepads, navigator.sendBeacon,
|
||||||
navigator.javaEnabled, navigator.serviceWorker?.register,
|
navigator.javaEnabled, navigator.geolocation?.getCurrentPosition,
|
||||||
|
navigator.geolocation?.watchPosition,
|
||||||
|
navigator.serviceWorker?.register,
|
||||||
navigator.permissions?.query, navigator.credentials?.get,
|
navigator.permissions?.query, navigator.credentials?.get,
|
||||||
|
navigator.storage?.estimate, navigator.storage?.persist, navigator.storage?.persisted,
|
||||||
globalThis.fetch, globalThis.matchMedia, globalThis.getComputedStyle,
|
globalThis.fetch, globalThis.matchMedia, globalThis.getComputedStyle,
|
||||||
globalThis.getSelection, globalThis.requestAnimationFrame,
|
globalThis.getSelection, globalThis.requestAnimationFrame,
|
||||||
globalThis.cancelAnimationFrame, globalThis.setTimeout, globalThis.clearTimeout,
|
globalThis.cancelAnimationFrame, globalThis.setTimeout, globalThis.clearTimeout,
|
||||||
@@ -3361,42 +3030,11 @@ class _IframeDocument {
|
|||||||
this._body = document.createElement('body');
|
this._body = document.createElement('body');
|
||||||
this._root.appendChild(this._head);
|
this._root.appendChild(this._head);
|
||||||
this._root.appendChild(this._body);
|
this._root.appendChild(this._body);
|
||||||
|
|
||||||
// Capture inline scripts from the iframe HTML head so we can run
|
|
||||||
// them in the current context after wiring is complete. Real iframes
|
|
||||||
// would get their own JS realm; we share globalThis so any function
|
|
||||||
// declarations become globally visible. Tests like webstorage that
|
|
||||||
// load a helper iframe with `<script>function handler(e){...}</script>`
|
|
||||||
// in head rely on this side effect.
|
|
||||||
this._inlineScripts = [];
|
|
||||||
const scriptRe = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
|
|
||||||
let mm;
|
|
||||||
const headMatch = (html || "").match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
||||||
if (headMatch) {
|
|
||||||
while ((mm = scriptRe.exec(headMatch[1])) !== null) {
|
|
||||||
if (mm[1]) this._inlineScripts.push(mm[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull inline event-handler attributes off the <body> tag so we can
|
|
||||||
// re-attach them as window listeners. The bodyContent regex below
|
|
||||||
// strips the <body> wrapper.
|
|
||||||
this._bodyHandlers = {};
|
|
||||||
const bodyOpen = html.match(/<body\b([^>]*)>/i);
|
|
||||||
if (bodyOpen) {
|
|
||||||
const attrRe = /\s(on[a-z]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
|
|
||||||
let am;
|
|
||||||
while ((am = attrRe.exec(bodyOpen[1])) !== null) {
|
|
||||||
this._bodyHandlers[am[1].toLowerCase()] = am[2] || am[3] || am[4] || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var bodyContent = html
|
var bodyContent = html
|
||||||
.replace(/^<!DOCTYPE[^>]*>/i, '')
|
.replace(/^<!DOCTYPE[^>]*>/i, '')
|
||||||
.replace(/<\/?html[^>]*>/gi, '')
|
.replace(/<\/?html[^>]*>/gi, '')
|
||||||
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
|
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
|
||||||
.replace(/<\/?body[^>]*>/gi, '')
|
.replace(/<\/?body[^>]*>/gi, '')
|
||||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
||||||
.replace(/^\s+/, ''); // trim leading whitespace (before <body> content)
|
.replace(/^\s+/, ''); // trim leading whitespace (before <body> content)
|
||||||
if (bodyContent) {
|
if (bodyContent) {
|
||||||
this._body.innerHTML = bodyContent;
|
this._body.innerHTML = bodyContent;
|
||||||
@@ -4057,7 +3695,7 @@ _markNative(MediaStream); _markNative(MediaStreamTrack);
|
|||||||
_markNative(RTCPeerConnection); _markNative(RTCSessionDescription); _markNative(RTCIceCandidate);
|
_markNative(RTCPeerConnection); _markNative(RTCSessionDescription); _markNative(RTCIceCandidate);
|
||||||
|
|
||||||
const _OrigDateTimeFormat = Intl.DateTimeFormat;
|
const _OrigDateTimeFormat = Intl.DateTimeFormat;
|
||||||
const _defaultTZ = 'America/New_York';
|
const _defaultTZ = 'Europe/Germany';
|
||||||
Intl.DateTimeFormat = function(locales, options) {
|
Intl.DateTimeFormat = function(locales, options) {
|
||||||
if (!options) options = {};
|
if (!options) options = {};
|
||||||
if (!options.timeZone) options.timeZone = _defaultTZ;
|
if (!options.timeZone) options.timeZone = _defaultTZ;
|
||||||
@@ -4587,9 +4225,20 @@ globalThis.__obscura_init = function() {
|
|||||||
globalThis.innerWidth = sw; globalThis.innerHeight = sh - 80;
|
globalThis.innerWidth = sw; globalThis.innerHeight = sh - 80;
|
||||||
globalThis.outerWidth = sw; globalThis.outerHeight = sh;
|
globalThis.outerWidth = sw; globalThis.outerHeight = sh;
|
||||||
|
|
||||||
const t0 = Date.now();
|
var hwValues = [2, 4, 6, 8, 12, 16];
|
||||||
|
globalThis.navigator.hardwareConcurrency = hwValues[Math.floor(_fpRand(400) * hwValues.length)];
|
||||||
|
var memValues = [0.25, 0.5, 1, 2, 4, 8];
|
||||||
|
globalThis.navigator.deviceMemory = memValues[Math.floor(_fpRand(401) * memValues.length)];
|
||||||
|
|
||||||
|
const t0 = Date.now() + Math.floor(_fpRand(641) * 100) - 50;
|
||||||
globalThis.performance.timeOrigin = t0;
|
globalThis.performance.timeOrigin = t0;
|
||||||
globalThis.performance.timing = { navigationStart: t0, domContentLoadedEventEnd: t0, loadEventEnd: t0 };
|
globalThis.performance.timing = { navigationStart: t0, domContentLoadedEventEnd: t0, loadEventEnd: t0 };
|
||||||
|
globalThis.performance.memory = {
|
||||||
|
jsHeapSizeLimit: 2172649472,
|
||||||
|
totalJSHeapSize: 15000000 + Math.floor(_fpRand(620) * 85000000),
|
||||||
|
usedJSHeapSize: 8000000 + Math.floor(_fpRand(621) * 42000000),
|
||||||
|
};
|
||||||
|
globalThis.Notification.permission = _fpRand(630) > 0.5 ? "granted" : "default";
|
||||||
|
|
||||||
// Hide internals (_*, obscura, Obscura). The set of keys is static at
|
// Hide internals (_*, obscura, Obscura). The set of keys is static at
|
||||||
// snapshot-build time, so we precompute it ONCE below (after this
|
// snapshot-build time, so we precompute it ONCE below (after this
|
||||||
|
|||||||
@@ -259,7 +259,6 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri
|
|||||||
match &mut n.data {
|
match &mut n.data {
|
||||||
NodeData::Text { contents } => { *contents = arg2.clone(); }
|
NodeData::Text { contents } => { *contents = arg2.clone(); }
|
||||||
NodeData::Comment { contents } => { *contents = arg2.clone(); }
|
NodeData::Comment { contents } => { *contents = arg2.clone(); }
|
||||||
NodeData::ProcessingInstruction { data, .. } => { *data = arg2.clone(); }
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -280,47 +279,6 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri
|
|||||||
"create_comment_node" => {
|
"create_comment_node" => {
|
||||||
dom.new_node(NodeData::Comment { contents: arg1.clone() }).index().to_string()
|
dom.new_node(NodeData::Comment { contents: arg1.clone() }).index().to_string()
|
||||||
}
|
}
|
||||||
"create_processing_instruction" => {
|
|
||||||
// arg1 = target, arg2 = data
|
|
||||||
dom.new_node(NodeData::ProcessingInstruction {
|
|
||||||
target: arg1.clone(),
|
|
||||||
data: arg2.clone(),
|
|
||||||
}).index().to_string()
|
|
||||||
}
|
|
||||||
"create_doctype" => {
|
|
||||||
// arg1 = name, arg2 = public_id. system_id stored only in the
|
|
||||||
// JS wrapper since neither current WPT test reads it back from
|
|
||||||
// the underlying tree.
|
|
||||||
dom.new_node(NodeData::Doctype {
|
|
||||||
name: arg1.clone(),
|
|
||||||
public_id: arg2.clone(),
|
|
||||||
system_id: String::new(),
|
|
||||||
}).index().to_string()
|
|
||||||
}
|
|
||||||
"pi_target" => {
|
|
||||||
let nid = arg1.parse::<u32>().unwrap_or(0);
|
|
||||||
let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data {
|
|
||||||
NodeData::ProcessingInstruction { target, .. } => Some(target.clone()),
|
|
||||||
_ => None,
|
|
||||||
}).unwrap_or_default();
|
|
||||||
serde_json::to_string(&val).unwrap_or("\"\"".into())
|
|
||||||
}
|
|
||||||
"doctype_name" => {
|
|
||||||
let nid = arg1.parse::<u32>().unwrap_or(0);
|
|
||||||
let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data {
|
|
||||||
NodeData::Doctype { name, .. } => Some(name.clone()),
|
|
||||||
_ => None,
|
|
||||||
}).unwrap_or_default();
|
|
||||||
serde_json::to_string(&val).unwrap_or("\"\"".into())
|
|
||||||
}
|
|
||||||
"doctype_public_id" => {
|
|
||||||
let nid = arg1.parse::<u32>().unwrap_or(0);
|
|
||||||
let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data {
|
|
||||||
NodeData::Doctype { public_id, .. } => Some(public_id.clone()),
|
|
||||||
_ => None,
|
|
||||||
}).unwrap_or_default();
|
|
||||||
serde_json::to_string(&val).unwrap_or("\"\"".into())
|
|
||||||
}
|
|
||||||
"element_children" => {
|
"element_children" => {
|
||||||
let nid = arg1.parse::<u32>().unwrap_or(0);
|
let nid = arg1.parse::<u32>().unwrap_or(0);
|
||||||
let ids: Vec<i32> = dom.children(NodeId::new(nid)).iter()
|
let ids: Vec<i32> = dom.children(NodeId::new(nid)).iter()
|
||||||
@@ -768,7 +726,7 @@ fn validate_fetch_url(url: &url::Url) -> Result<(), String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if scheme == "file" || obscura_net::env_allows_private_network() {
|
if scheme == "file" {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ impl BrowserState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn dispatch(method: &str, id: Value, params: &Value, state: &mut BrowserState) -> RpcResponse {
|
pub(crate) async fn dispatch(method: &str, id: Value, params: &Value, state: &mut BrowserState) -> RpcResponse {
|
||||||
match method {
|
match method {
|
||||||
"initialize" => handle_initialize(id, params),
|
"initialize" => handle_initialize(id, params),
|
||||||
"ping" => RpcResponse::ok(id, json!({})),
|
"ping" => RpcResponse::ok(id, json!({})),
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ wreq-util = { version = "3.0.0-rc.10", optional = true }
|
|||||||
# Enabling it on other platforms produces unresolved `build_script_main_*`
|
# Enabling it on other platforms produces unresolved `build_script_main_*`
|
||||||
# symbols at link time (see issue #39).
|
# symbols at link time (see issue #39).
|
||||||
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
|
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
|
||||||
wreq = { version = "6.0.0-rc.28", optional = true, features = ["prefix-symbols"] }
|
wreq = { version = "6.0.0-rc.28", optional = true, features = ["prefix-symbols", "socks"] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "linux", target_os = "android")))'.dependencies]
|
[target.'cfg(not(any(target_os = "linux", target_os = "android")))'.dependencies]
|
||||||
wreq = { version = "6.0.0-rc.28", optional = true }
|
wreq = { version = "6.0.0-rc.28", optional = true, features = ["socks"] }
|
||||||
@@ -72,6 +72,20 @@ impl StealthHttpClient {
|
|||||||
|
|
||||||
pub async fn fetch(&self, url: &Url) -> Result<Response, ObscuraNetError> {
|
pub async fn fetch(&self, url: &Url) -> Result<Response, ObscuraNetError> {
|
||||||
let mut current_url = url.clone();
|
let mut current_url = url.clone();
|
||||||
|
|
||||||
|
if let Some(host) = current_url.host_str() {
|
||||||
|
if crate::blocklist::is_blocked(host) {
|
||||||
|
tracing::debug!("Blocked tracker: {}", current_url);
|
||||||
|
return Ok(Response {
|
||||||
|
status: 0,
|
||||||
|
url: current_url,
|
||||||
|
headers: HashMap::new(),
|
||||||
|
body: Vec::new(),
|
||||||
|
redirected_from: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut redirects = Vec::new();
|
let mut redirects = Vec::new();
|
||||||
|
|
||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
|
|||||||
Reference in new issue
Block a user