mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +02:00
fix: switch uBlock loading from --browser-server to --browser-args
The --browser-server (CDP) path caused 'Unexpected server response: 404' on macOS Chrome because simple-cdp's WebSocket upgrade to the debugger endpoint failed after Chrome started — likely a version-specific CDP endpoint shape mismatch. New approach: single-file always manages Chrome. When ARCHIVR_UBLOCK_EXT is set, --headless=new, --load-extension, and --disable-extensions-except are injected via --browser-args. single-file's browser.js prefix-strips its own conflicting flags before appending ours, so --headless=new overrides the default --headless (enabling extension support in headless). Removes allocate_free_port, wait_for_chrome_ready, run_single_file_with_server (all dead code now). Docblock updated to reflect actual behaviour and notes the --single-process caveat: uBOL's declarativeNetRequest static rulesets are expected to work (network-stack level, not service-worker), but this has not been mechanically verified under --single-process. Smoke tested on macOS (this machine): capture with --load-extension + all three browser-scripts (strip, Readability, reader-mode wrapper) produces output file correctly. Ad-blocking verification deferred to manual test with a tracker-heavy URL.
This commit is contained in:
parent
33eb60bf8b
commit
ef8b5ac83b
1 changed files with 54 additions and 155 deletions
|
|
@ -5,10 +5,8 @@ use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
env,
|
env,
|
||||||
io::Read,
|
io::Read,
|
||||||
net::TcpListener,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::Command,
|
process::Command,
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::downloader::cookies::{domain_from_url, write_netscape_cookie_file};
|
use crate::downloader::cookies::{domain_from_url, write_netscape_cookie_file};
|
||||||
|
|
@ -151,15 +149,20 @@ fn resolve_ublock_config(enabled_override: Option<bool>) -> (Option<PathBuf>, bo
|
||||||
/// Inner implementation. Takes binary paths and an optional uBlock extension
|
/// Inner implementation. Takes binary paths and an optional uBlock extension
|
||||||
/// directory explicitly so tests can inject them without touching env vars.
|
/// directory explicitly so tests can inject them without touching env vars.
|
||||||
///
|
///
|
||||||
/// When `ublock_ext` is `Some(path)` we own Chrome's lifecycle:
|
/// single-file always manages Chrome. When `ublock_ext` is `Some(path)`, the
|
||||||
/// 1. allocate a free TCP port,
|
/// extension is loaded by passing `--headless=new`, `--load-extension`, and
|
||||||
/// 2. launch Chrome headless with the extension loaded,
|
/// `--disable-extensions-except` inside the `--browser-args` JSON array.
|
||||||
/// 3. wait for Chrome's DevTools HTTP API to respond,
|
/// single-file's `browser.js` prefix-strips its own conflicting flags before
|
||||||
/// 4. run single-file pointing at our Chrome via `--browser-server`,
|
/// appending ours, so `--headless=new` overrides its default `--headless`.
|
||||||
/// 5. kill Chrome after single-file exits.
|
|
||||||
///
|
///
|
||||||
/// When `ublock_ext` is `None` the original behaviour is preserved:
|
/// Note: single-file always adds `--single-process` to Chrome. uBOL's
|
||||||
/// single-file launches and manages Chrome internally.
|
/// `declarativeNetRequest` **static** rulesets are registered by Chrome's
|
||||||
|
/// network stack at extension load time (not by a service worker), so they are
|
||||||
|
/// expected to apply even in single-process mode. Extension service-worker
|
||||||
|
/// initialisation may fail silently; this does not affect the static filter
|
||||||
|
/// lists. Ad-blocking has not been mechanically verified under `--single-process`
|
||||||
|
/// — if a future test confirms otherwise, consider owning Chrome's lifecycle and
|
||||||
|
/// using a dedicated `--remote-debugging-port` without `--single-process`.
|
||||||
fn save_with(
|
fn save_with(
|
||||||
url: &str,
|
url: &str,
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
|
|
@ -175,11 +178,8 @@ fn save_with(
|
||||||
|
|
||||||
let out_file = temp_dir.join(format!("{timestamp}.html"));
|
let out_file = temp_dir.join(format!("{timestamp}.html"));
|
||||||
|
|
||||||
// Mandatory user script: strips <script> elements from the live DOM just
|
// Mandatory user script: strips <script> elements before SingleFile
|
||||||
// before SingleFile serializes it. Lets scripts execute during capture (so
|
// serialises so JS-applied CSS is captured without broken module imports.
|
||||||
// JS-applied CSS is present) without leaving data:-URL ES modules in the
|
|
||||||
// saved file that would cause "base scheme isn't hierarchical" errors.
|
|
||||||
// JSON-LD structured data is kept.
|
|
||||||
let strip_scripts_path = temp_dir.join("sf-strip-scripts.js");
|
let strip_scripts_path = temp_dir.join("sf-strip-scripts.js");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&strip_scripts_path,
|
&strip_scripts_path,
|
||||||
|
|
@ -190,8 +190,7 @@ fn save_with(
|
||||||
)
|
)
|
||||||
.context("failed to write single-file user script")?;
|
.context("failed to write single-file user script")?;
|
||||||
|
|
||||||
// Reader-mode scripts: Readability.js + wrapper. Written to temp dir and
|
// Optional reader-mode scripts (Readability.js + wrapper).
|
||||||
// passed as additional --browser-script args when reader mode is enabled.
|
|
||||||
let mut extra_browser_scripts: Vec<PathBuf> = Vec::new();
|
let mut extra_browser_scripts: Vec<PathBuf> = Vec::new();
|
||||||
if reader_mode {
|
if reader_mode {
|
||||||
let readability_path = temp_dir.join("sf-readability.js");
|
let readability_path = temp_dir.join("sf-readability.js");
|
||||||
|
|
@ -204,19 +203,41 @@ fn save_with(
|
||||||
extra_browser_scripts.push(wrapper_path);
|
extra_browser_scripts.push(wrapper_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Isolated Chrome profile directory.
|
// Isolated Chrome profile directory; cleaned up with the rest of temp.
|
||||||
let chrome_data_dir = temp_dir.join("chrome-data");
|
let chrome_data_dir = temp_dir.join("chrome-data");
|
||||||
|
|
||||||
// Extra Chrome flags from the environment (e.g. "--no-sandbox" in Docker).
|
// Build Chrome flags passed via --browser-args to single-file.
|
||||||
|
// single-file's browser.js overrides its own defaults with whatever we
|
||||||
|
// pass here (it strips conflicting flags by prefix before appending ours).
|
||||||
|
let mut chrome_flags = vec![
|
||||||
|
"--disable-web-security".to_string(),
|
||||||
|
format!("--user-data-dir={}", chrome_data_dir.display()),
|
||||||
|
"--window-size=1920,1080".to_string(),
|
||||||
|
];
|
||||||
|
if let Some(ext_path) = ublock_ext {
|
||||||
|
// --headless=new is required for --load-extension to work.
|
||||||
|
// It overrides single-file's own --headless default via the prefix-strip logic.
|
||||||
|
chrome_flags.push("--headless=new".to_string());
|
||||||
|
chrome_flags.push(format!("--load-extension={}", ext_path.display()));
|
||||||
|
chrome_flags.push(format!("--disable-extensions-except={}", ext_path.display()));
|
||||||
|
}
|
||||||
|
// Operator extras (e.g. --no-sandbox in Docker).
|
||||||
let extra_chrome_args: Vec<String> = env::var("ARCHIVR_CHROME_ARGS")
|
let extra_chrome_args: Vec<String> = env::var("ARCHIVR_CHROME_ARGS")
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.collect();
|
.collect();
|
||||||
|
chrome_flags.extend(extra_chrome_args);
|
||||||
|
|
||||||
// Write cookie file before running Chrome so it's available immediately.
|
// single-file expects browser-args as a JSON array of strings.
|
||||||
// Never pass cookie values in process args (ps exposure).
|
let quoted: Vec<String> = chrome_flags
|
||||||
|
.iter()
|
||||||
|
.map(|f| format!("\"{}\"", f.replace('\\', "\\\\").replace('"', "\\\"")))
|
||||||
|
.collect();
|
||||||
|
let browser_args = format!("[{}]", quoted.join(","));
|
||||||
|
|
||||||
|
// Write cookie file (secrets must never appear in process args).
|
||||||
let cookie_file: Option<PathBuf> = if !cookies.is_empty() {
|
let cookie_file: Option<PathBuf> = if !cookies.is_empty() {
|
||||||
let cf = temp_dir.join("cookies.txt");
|
let cf = temp_dir.join("cookies.txt");
|
||||||
let domain = domain_from_url(url);
|
let domain = domain_from_url(url);
|
||||||
|
|
@ -227,86 +248,19 @@ fn save_with(
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let sf_output = match ublock_ext {
|
let mut scripts: Vec<&Path> = vec![strip_scripts_path.as_path()];
|
||||||
// ── We own Chrome's lifecycle (uBlock extension mode) ─────────────
|
scripts.extend(extra_browser_scripts.iter().map(|p| p.as_path()));
|
||||||
Some(ext_path) => {
|
|
||||||
let port = allocate_free_port().context("failed to allocate a free TCP port")?;
|
|
||||||
|
|
||||||
let mut chrome_flags = vec![
|
let sf_output = run_single_file_standalone(
|
||||||
"--headless=new".to_string(),
|
url,
|
||||||
format!("--remote-debugging-port={port}"),
|
&out_file,
|
||||||
format!("--user-data-dir={}", chrome_data_dir.display()),
|
single_file,
|
||||||
// Load the extension; disable all others so no unexpected ext loads.
|
chrome,
|
||||||
format!("--load-extension={}", ext_path.display()),
|
&browser_args,
|
||||||
format!("--disable-extensions-except={}", ext_path.display()),
|
&scripts,
|
||||||
// Allow cross-origin font inlining (same reason as standalone mode).
|
cookie_file.as_deref(),
|
||||||
"--disable-web-security".to_string(),
|
)
|
||||||
// Realistic viewport so responsive @media rules are preserved.
|
.with_context(|| format!("failed to spawn single-file ({single_file})"))?;
|
||||||
"--window-size=1920,1080".to_string(),
|
|
||||||
];
|
|
||||||
chrome_flags.extend(extra_chrome_args);
|
|
||||||
|
|
||||||
let mut chrome_child = Command::new(chrome)
|
|
||||||
.args(&chrome_flags)
|
|
||||||
.stdout(std::process::Stdio::null())
|
|
||||||
.stderr(std::process::Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.with_context(|| format!("failed to spawn Chrome ({chrome})"))?;
|
|
||||||
|
|
||||||
if !wait_for_chrome_ready(port, 10) {
|
|
||||||
let _ = chrome_child.kill();
|
|
||||||
let _ = chrome_child.wait();
|
|
||||||
bail!("Chrome did not become ready on port {port} within 10 s");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build scripts list: strip-scripts first, then any reader-mode scripts.
|
|
||||||
let mut scripts: Vec<&Path> = vec![strip_scripts_path.as_path()];
|
|
||||||
scripts.extend(extra_browser_scripts.iter().map(|p| p.as_path()));
|
|
||||||
let out = run_single_file_with_server(
|
|
||||||
url,
|
|
||||||
&out_file,
|
|
||||||
single_file,
|
|
||||||
port,
|
|
||||||
&scripts,
|
|
||||||
cookie_file.as_deref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Always kill Chrome and reap its exit status, even on single-file failure.
|
|
||||||
let _ = chrome_child.kill();
|
|
||||||
let _ = chrome_child.wait();
|
|
||||||
|
|
||||||
out.with_context(|| format!("failed to spawn single-file ({single_file})"))?
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── single-file manages Chrome (original behaviour) ───────────────
|
|
||||||
None => {
|
|
||||||
let mut chrome_flags = vec![
|
|
||||||
"--disable-web-security".to_string(),
|
|
||||||
format!("--user-data-dir={}", chrome_data_dir.display()),
|
|
||||||
"--window-size=1920,1080".to_string(),
|
|
||||||
];
|
|
||||||
chrome_flags.extend(extra_chrome_args);
|
|
||||||
// single-file expects browser-args as a JSON array of strings.
|
|
||||||
let quoted: Vec<String> = chrome_flags
|
|
||||||
.iter()
|
|
||||||
.map(|f| format!("\"{}\"", f.replace('\\', "\\\\").replace('"', "\\\"")))
|
|
||||||
.collect();
|
|
||||||
let browser_args = format!("[{}]", quoted.join(","));
|
|
||||||
|
|
||||||
let mut scripts: Vec<&Path> = vec![strip_scripts_path.as_path()];
|
|
||||||
scripts.extend(extra_browser_scripts.iter().map(|p| p.as_path()));
|
|
||||||
run_single_file_standalone(
|
|
||||||
url,
|
|
||||||
&out_file,
|
|
||||||
single_file,
|
|
||||||
chrome,
|
|
||||||
&browser_args,
|
|
||||||
&scripts,
|
|
||||||
cookie_file.as_deref(),
|
|
||||||
)
|
|
||||||
.with_context(|| format!("failed to spawn single-file ({single_file})"))?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete cookie file unconditionally — including on failure — so secrets
|
// Delete cookie file unconditionally — including on failure — so secrets
|
||||||
// are never left in store/temp when the capture fails.
|
// are never left in store/temp when the capture fails.
|
||||||
|
|
@ -362,61 +316,6 @@ fn save_with(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chrome helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Binds a `TcpListener` to a random OS-assigned port, reads the port number,
|
|
||||||
/// then drops the listener. The tiny TOCTOU window between drop and Chrome
|
|
||||||
/// binding is acceptable in practice.
|
|
||||||
fn allocate_free_port() -> Result<u16> {
|
|
||||||
let listener =
|
|
||||||
TcpListener::bind("127.0.0.1:0").context("could not bind to a free TCP port")?;
|
|
||||||
Ok(listener.local_addr()?.port())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Polls `http://127.0.0.1:{port}/json/version` every 150 ms until Chrome
|
|
||||||
/// responds with HTTP 200 or the deadline (timeout_secs) elapses.
|
|
||||||
fn wait_for_chrome_ready(port: u16, timeout_secs: u64) -> bool {
|
|
||||||
let url = format!("http://127.0.0.1:{port}/json/version");
|
|
||||||
let client = match reqwest::blocking::Client::builder()
|
|
||||||
.timeout(Duration::from_millis(500))
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => return false,
|
|
||||||
};
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
|
||||||
while Instant::now() < deadline {
|
|
||||||
if client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.map(|r| r.status().is_success())
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(150));
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── single-file invocation helpers ───────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Runs single-file pointing at an already-running Chrome via the DevTools HTTP
|
|
||||||
/// API (`--browser-server`). Chrome was started by the caller, which retains
|
|
||||||
/// ownership of the process handle and kills it after this call returns.
|
|
||||||
fn run_single_file_with_server(
|
|
||||||
url: &str,
|
|
||||||
out_file: &Path,
|
|
||||||
single_file: &str,
|
|
||||||
port: u16,
|
|
||||||
scripts: &[&Path],
|
|
||||||
cookie_file: Option<&Path>,
|
|
||||||
) -> std::io::Result<std::process::Output> {
|
|
||||||
let mut cmd = base_single_file_cmd(url, out_file, single_file, scripts, cookie_file);
|
|
||||||
cmd.arg(format!("--browser-server=http://127.0.0.1:{port}"));
|
|
||||||
cmd.output()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs single-file, letting it launch and manage Chrome itself.
|
/// Runs single-file, letting it launch and manage Chrome itself.
|
||||||
fn run_single_file_standalone(
|
fn run_single_file_standalone(
|
||||||
url: &str,
|
url: &str,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue