use std::collections::HashMap;
use std::path::Path;
use std::sync::mpsc::{self, SyncSender};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use base64::Engine as Base64Engine;
use buffr_core::DownloadNoticeQueue;
use buffr_core::find::{FindResult, FindResultSink, new_sink as new_find_sink};
use buffr_downloads::Downloads;
use buffr_engine::{
BrowserEngine, EngineError, MouseButton, NeutralKeyEvent, OsrFrame, OsrViewState,
PermissionsQueue, PopupCloseSink, PopupCreateSink, PopupQueue, PromptOutcome, SharedOsrFrame,
SharedOsrViewState, TabId, TabSummary,
};
use serde_json::Value;
use crate::cdp::{
AttachToTargetParams, CdpCommand, CloseTargetParams, CreateTargetParams,
DetachFromTargetParams, DispatchKeyEventParams, DispatchMouseEventParams,
SetDeviceMetricsParams, key_event_type, mouse_button_str, next_id,
};
use crate::context_menu::{ContextMenuSink, new_context_menu_sink};
use crate::error::BlinkError;
use crate::find::{find_expr, parse_find_result, stop_expr};
use crate::subprocess::{find_chromium, pick_free_port, probe_ws_url, spawn_headless};
use crate::worker::{Command, UrlUpdateSink, new_title_map, new_url_update_sink, run};
use crate::ws::WsClient;
pub const ZOOM_STEP: f64 = 0.25;
pub const ZOOM_MIN: f64 = 0.25;
pub const ZOOM_MAX: f64 = 5.0;
#[inline]
fn clamp_zoom(level: f64) -> f64 {
level.clamp(ZOOM_MIN, ZOOM_MAX)
}
#[derive(Debug, Clone)]
struct CdpTab {
id: TabId,
target_id: String,
session_id: String,
url: String,
title: String,
zoom_level: f64,
}
impl CdpTab {
fn to_summary(&self, is_loading: bool) -> TabSummary {
TabSummary {
id: self.id,
browser_id: 0, title: self.title.clone(),
url: self.url.clone(),
progress: if is_loading { 0.5 } else { 1.0 },
is_loading,
pinned: false,
private: false,
}
}
}
struct EngineState {
tabs: Vec<CdpTab>,
active: Option<TabId>,
next_tab_id: u64,
debug_port: u16,
original_urls: HashMap<String, String>,
}
impl EngineState {
fn new(debug_port: u16) -> Self {
Self {
tabs: Vec::new(),
active: None,
next_tab_id: 1,
debug_port,
original_urls: HashMap::new(),
}
}
fn mint_tab_id(&mut self) -> TabId {
let id = TabId(self.next_tab_id);
self.next_tab_id += 1;
id
}
fn tab_by_id(&self, id: TabId) -> Option<&CdpTab> {
self.tabs.iter().find(|t| t.id == id)
}
fn active_tab(&self) -> Option<&CdpTab> {
let id = self.active?;
self.tab_by_id(id)
}
}
pub type HtmlProvider = Arc<dyn Fn() -> Vec<u8> + Send + Sync>;
fn display_url_for<'a>(tab: &'a CdpTab, original_urls: &'a HashMap<String, String>) -> &'a str {
original_urls
.get(&tab.target_id)
.map(String::as_str)
.unwrap_or(tab.url.as_str())
}
fn html_escape_source(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
fn view_source_html_sync(target_url: &str) -> Vec<u8> {
let config = ureq::Agent::config_builder()
.timeout_connect(Some(std::time::Duration::from_secs(15)))
.timeout_recv_response(Some(std::time::Duration::from_secs(15)))
.build();
let agent = ureq::Agent::new_with_config(config);
let body = match agent.get(target_url).call() {
Ok(mut response) => {
let status = response.status().as_u16();
match response.body_mut().read_to_string() {
Ok(text) => {
let escaped = html_escape_source(&text);
format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>view-source:{target_url}</title>
<style>
body {{ margin: 0; background: #1a1a1a; color: #d4d4d4; font-family: monospace; font-size: 0.85rem; }}
.header {{ background: #252526; padding: 0.5rem 1rem; border-bottom: 1px solid #333; color: #9cdcfe; }}
pre {{ margin: 0; padding: 1rem; white-space: pre-wrap; word-break: break-all; line-height: 1.5; }}
</style>
</head>
<body>
<div class="header">view-source: <strong>{target_url}</strong> — HTTP {status}</div>
<pre>{escaped}</pre>
</body>
</html>"#
)
}
Err(e) => format!(
"<!DOCTYPE html><html><body><p>Error reading response body: {e}</p></body></html>"
),
}
}
Err(e) => format!(
r#"<!DOCTYPE html>
<html>
<head><meta charset="utf-8"/><title>view-source error</title>
<style>body{{font-family:system-ui,sans-serif;background:#1a1a1a;color:#e0e0e0;margin:2rem;}}</style>
</head>
<body><h1>view-source error</h1><p>Could not fetch <code>{}</code>:</p><pre>{}</pre></body>
</html>"#,
html_escape_source(target_url),
html_escape_source(&e.to_string()),
),
};
body.into_bytes()
}
fn view_source_loading_data_url(target_url: &str) -> String {
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>view-source: {target_url}</title>
<style>
body {{ margin: 0; display: flex; align-items: center; justify-content: center;
height: 100vh; background: #1a1a1a; color: #9cdcfe;
font-family: monospace; font-size: 1rem; }}
</style>
</head>
<body>Fetching source: {target_url}</body>
</html>"#
);
let encoded = base64::engine::general_purpose::STANDARD.encode(html.as_bytes());
format!("data:text/html;base64,{encoded}")
}
fn spawn_view_source_fetch(
target_url: String,
session_id: String,
cmd_tx: std::sync::mpsc::SyncSender<Command>,
) {
std::thread::Builder::new()
.name(format!("blink-cdp-view-source:{target_url}"))
.spawn(move || {
tracing::debug!(
target_url,
session_id,
"blink-cdp: view-source fetch thread started"
);
let html_bytes = view_source_html_sync(&target_url);
let encoded = base64::engine::general_purpose::STANDARD.encode(&html_bytes);
let data_url = format!("data:text/html;base64,{encoded}");
let (reply_tx, _reply_rx) = std::sync::mpsc::channel();
if let Err(e) = cmd_tx.try_send(Command::Navigate {
session_id,
url: data_url,
reply: reply_tx,
}) {
tracing::debug!(
target_url,
error = %e,
"blink-cdp: view-source fetch thread: engine gone, dropping result"
);
}
})
.ok(); }
pub struct BlinkCdpEngine {
state: Arc<Mutex<EngineState>>,
cmd_tx: SyncSender<Command>,
osr_frame: SharedOsrFrame,
osr_view: SharedOsrViewState,
worker: Option<JoinHandle<()>>,
subprocess: Arc<Mutex<Option<std::process::Child>>>,
newtab_html_provider: Mutex<Option<HtmlProvider>>,
settings_html_provider: Mutex<Option<HtmlProvider>>,
permissions_queue: PermissionsQueue,
perm_session_map: Arc<Mutex<std::collections::HashMap<String, String>>>,
#[allow(dead_code)]
downloads: Option<Arc<Downloads>>,
#[allow(dead_code)]
notice_queue: Option<DownloadNoticeQueue>,
find_sink: FindResultSink,
find_query: Arc<Mutex<Option<String>>>,
context_menu_sink: ContextMenuSink,
url_update_sink: UrlUpdateSink,
popup_queue: PopupQueue,
popup_create_sink: PopupCreateSink,
popup_close_sink: PopupCloseSink,
loading_state: Arc<Mutex<HashMap<String, bool>>>,
nav_count: Arc<Mutex<HashMap<String, usize>>>,
title_map: crate::worker::TitleMap,
}
impl BlinkCdpEngine {
pub fn new(
data_dir: &Path,
cache_dir: Option<&Path>,
download_dir: Option<&Path>,
downloads: Option<Arc<Downloads>>,
notice_queue: Option<DownloadNoticeQueue>,
find_sink: Option<FindResultSink>,
) -> Result<Self, BlinkError> {
let chromium = find_chromium().ok_or(BlinkError::ChromiumNotFound)?;
let port = pick_free_port()?;
std::fs::create_dir_all(data_dir).map_err(BlinkError::SpawnFailed)?;
let child = spawn_headless(&chromium, port, data_dir, cache_dir)?;
let ws_url = probe_ws_url(
port,
crate::subprocess::WS_PROBE_MAX_RETRIES,
Duration::from_millis(crate::subprocess::WS_PROBE_INTERVAL_MS),
)?;
let ws = WsClient::connect(&ws_url)?;
let osr_frame = Arc::new(Mutex::new(OsrFrame::new(1280, 800)));
let osr_view = Arc::new(OsrViewState::new());
let permissions_queue = buffr_engine::permissions::new_queue();
let perm_session_map: Arc<Mutex<std::collections::HashMap<String, String>>> =
Arc::new(Mutex::new(std::collections::HashMap::new()));
let context_menu_sink = new_context_menu_sink();
let url_update_sink = new_url_update_sink();
let loading_state: Arc<Mutex<HashMap<String, bool>>> = Arc::new(Mutex::new(HashMap::new()));
let nav_count: Arc<Mutex<HashMap<String, usize>>> = Arc::new(Mutex::new(HashMap::new()));
let title_map = new_title_map();
let effective_download_dir = download_dir
.map(|p| p.to_path_buf())
.unwrap_or_else(|| data_dir.join("downloads"));
if let Err(e) = std::fs::create_dir_all(&effective_download_dir) {
tracing::warn!(
path = %effective_download_dir.display(),
error = %e,
"blink-cdp: failed to create download directory"
);
}
let (cmd_tx, cmd_rx) = mpsc::sync_channel::<Command>(crate::worker::WORKER_CMD_CHANNEL_CAP);
let worker_frame = Arc::clone(&osr_frame);
let worker_view = Arc::clone(&osr_view);
let worker_perm_queue = Arc::clone(&permissions_queue);
let worker_perm_session = Arc::clone(&perm_session_map);
let worker_downloads = downloads.clone();
let worker_notice_queue = notice_queue.clone();
let worker_download_dir = effective_download_dir.clone();
let worker_context_menu_sink = Arc::clone(&context_menu_sink);
let worker_url_update_sink = Arc::clone(&url_update_sink);
let worker_loading_state = Arc::clone(&loading_state);
let worker_nav_count = Arc::clone(&nav_count);
let worker_title_map = Arc::clone(&title_map);
let worker = std::thread::Builder::new()
.name("blink-cdp-worker".to_owned())
.spawn(move || {
run(
ws,
cmd_rx,
worker_frame,
worker_view,
worker_perm_queue,
worker_perm_session,
worker_downloads,
worker_notice_queue,
worker_download_dir,
worker_context_menu_sink,
worker_url_update_sink,
worker_loading_state,
worker_nav_count,
worker_title_map,
)
})
.map_err(BlinkError::SpawnFailed)?;
let (reply_tx, reply_rx) = mpsc::channel();
let download_behavior_cmd = crate::cdp::CdpCommand {
id: crate::cdp::next_id(),
method: "Browser.setDownloadBehavior",
params: Some(serde_json::json!({
"behavior": "allow",
"downloadPath": effective_download_dir.to_string_lossy().as_ref(),
"eventsEnabled": true,
})),
session_id: None,
};
let _ = cmd_tx.try_send(Command::BrowserCmd {
cmd: download_behavior_cmd,
reply: reply_tx,
});
match reply_rx.recv_timeout(Duration::from_secs(5)) {
Ok(Ok(_)) => {
tracing::debug!(
path = %effective_download_dir.display(),
"blink-cdp: Browser.setDownloadBehavior configured"
);
}
Ok(Err(e)) => {
tracing::warn!(error = %e, "blink-cdp: Browser.setDownloadBehavior failed");
}
Err(_) => {
tracing::warn!("blink-cdp: Browser.setDownloadBehavior timed out");
}
}
Ok(Self {
state: Arc::new(Mutex::new(EngineState::new(port))),
cmd_tx,
osr_frame,
osr_view,
worker: Some(worker),
subprocess: Arc::new(Mutex::new(Some(child))),
newtab_html_provider: Mutex::new(None),
settings_html_provider: Mutex::new(None),
permissions_queue,
perm_session_map,
downloads,
notice_queue,
find_sink: find_sink.unwrap_or_else(new_find_sink),
find_query: Arc::new(Mutex::new(None)),
context_menu_sink,
url_update_sink,
popup_queue: buffr_engine::new_popup_queue(),
popup_create_sink: buffr_engine::new_popup_create_sink(),
popup_close_sink: buffr_engine::new_popup_close_sink(),
loading_state,
nav_count,
title_map,
})
}
pub fn set_newtab_html_provider(&self, provider: HtmlProvider) {
if let Ok(mut guard) = self.newtab_html_provider.lock() {
*guard = Some(provider);
}
}
pub fn set_settings_html_provider(&self, provider: HtmlProvider) {
if let Ok(mut guard) = self.settings_html_provider.lock() {
*guard = Some(provider);
}
}
fn newtab_html_bytes(&self) -> Vec<u8> {
if let Ok(guard) = self.newtab_html_provider.lock()
&& let Some(ref provider) = *guard
{
provider()
} else {
buffr_engine::newtab::NEW_TAB_HTML_TEMPLATE
.as_bytes()
.to_vec()
}
}
fn settings_html_bytes(&self) -> Vec<u8> {
if let Ok(guard) = self.settings_html_provider.lock()
&& let Some(ref provider) = *guard
{
provider()
} else {
b"<!DOCTYPE html><html><head><meta charset=\"utf-8\"/><title>buffr settings</title></head>\
<body style=\"font-family:system-ui,sans-serif;background:#1a1a1a;color:#e0e0e0;margin:2rem\">\
<h1>buffr settings</h1><p>Settings provider not configured.</p></body></html>"
.to_vec()
}
}
fn translate_internal_url(&self, url: &str) -> Option<String> {
if url.starts_with("buffr://settings") {
let bytes = self.settings_html_bytes();
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
Some(format!("data:text/html;base64,{encoded}"))
} else if url.starts_with("buffr://") {
let bytes = self.newtab_html_bytes();
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
Some(format!("data:text/html;base64,{encoded}"))
} else if url.starts_with("view-source:") {
Some(view_source_loading_data_url(
url.trim_start_matches("view-source:"),
))
} else {
None
}
}
fn schedule_view_source_fetch_if_needed(&self, url: &str, session_id: &str) {
if let Some(target_url) = url.strip_prefix("view-source:") {
spawn_view_source_fetch(
target_url.to_owned(),
session_id.to_owned(),
self.cmd_tx.clone(),
);
}
}
fn adjust_zoom(&self, delta: f64) {
let current = self
.state
.lock()
.unwrap()
.active_tab()
.map(|t| t.zoom_level)
.unwrap_or(1.0);
self.apply_zoom(clamp_zoom(current + delta));
}
fn apply_zoom(&self, level: f64) {
let session_id = {
let mut state = self.state.lock().unwrap();
let Some(id) = state.active else { return };
let Some(tab) = state.tabs.iter_mut().find(|t| t.id == id) else {
return;
};
tab.zoom_level = level;
tab.session_id.clone()
};
tracing::debug!(level, "blink-cdp: apply_zoom");
let _ = self.cmd_tx.try_send(Command::SetZoom { session_id, level });
}
fn browser_cmd(
&self,
method: &'static str,
params: impl serde::Serialize,
) -> Result<Value, BlinkError> {
let (reply_tx, reply_rx) = mpsc::channel();
let cmd = CdpCommand {
id: next_id(),
method,
params: Some(serde_json::to_value(params).unwrap_or(Value::Null)),
session_id: None,
};
self.cmd_tx
.try_send(Command::BrowserCmd {
cmd,
reply: reply_tx,
})
.map_err(|_| BlinkError::WorkerDead)?;
reply_rx
.recv_timeout(Duration::from_secs(
crate::worker::CDP_RESPONSE_TIMEOUT_SECS,
))
.map_err(|_| BlinkError::Timeout { method })
.and_then(|r| r)
}
fn session_cmd(
&self,
session_id: &str,
method: &'static str,
params: impl serde::Serialize,
) -> Result<Value, BlinkError> {
let (reply_tx, reply_rx) = mpsc::channel();
let cmd = CdpCommand {
id: next_id(),
method,
params: Some(serde_json::to_value(params).unwrap_or(Value::Null)),
session_id: Some(session_id.to_owned()),
};
self.cmd_tx
.try_send(Command::SessionCmd {
session_id: session_id.to_owned(),
cmd,
reply: reply_tx,
})
.map_err(|_| BlinkError::WorkerDead)?;
reply_rx
.recv_timeout(Duration::from_secs(
crate::worker::CDP_RESPONSE_TIMEOUT_SECS,
))
.map_err(|_| BlinkError::Timeout { method })
.and_then(|r| r)
}
fn create_and_attach(&self, url: &str) -> Result<(String, String), BlinkError> {
let result = self.browser_cmd(
"Target.createTarget",
CreateTargetParams {
url: url.to_owned(),
},
)?;
let target_id = result
.get("targetId")
.and_then(|v| v.as_str())
.ok_or_else(|| {
BlinkError::Protocol("missing targetId in createTarget response".into())
})?
.to_owned();
let result = self.browser_cmd(
"Target.attachToTarget",
AttachToTargetParams {
target_id: target_id.clone(),
flatten: true,
},
)?;
let session_id = result
.get("sessionId")
.and_then(|v| v.as_str())
.ok_or_else(|| {
BlinkError::Protocol("missing sessionId in attachToTarget response".into())
})?
.to_owned();
Ok((target_id, session_id))
}
fn open_tab_internal(
&self,
url: &str,
make_active: bool,
insert_idx: Option<usize>,
) -> Result<TabId, EngineError> {
let translated = self.translate_internal_url(url);
let navigate_url = translated.as_deref().unwrap_or(url);
let original_url = if translated.is_some() {
Some(url.to_owned())
} else {
None
};
let (target_id, session_id) = self
.create_and_attach("about:blank")
.map_err(EngineError::from)?;
let setup_cmd = |engine: &Self, method: &'static str, params: serde_json::Value| {
if let Err(e) = engine.session_cmd(&session_id, method, params) {
tracing::warn!(
target_id = %target_id,
method,
error = %e,
"blink-cdp: session setup command failed"
);
}
};
setup_cmd(self, "Page.enable", serde_json::json!({}));
setup_cmd(self, "Runtime.enable", serde_json::json!({}));
setup_cmd(
self,
"Page.setLifecycleEventsEnabled",
serde_json::json!({ "enabled": true }),
);
if let Ok(mut map) = self.loading_state.lock() {
map.insert(session_id.clone(), true);
}
let (w, h) = {
let v = &self.osr_view;
use std::sync::atomic::Ordering;
(
v.width.load(Ordering::Relaxed),
v.height.load(Ordering::Relaxed),
)
};
setup_cmd(
self,
"Page.setDeviceMetricsOverride",
serde_json::to_value(SetDeviceMetricsParams {
width: w.max(1),
height: h.max(1),
device_scale_factor: 1.0,
mobile: false,
})
.unwrap_or(serde_json::Value::Null),
);
setup_cmd(
self,
"Runtime.addBinding",
serde_json::json!({ "name": "__buffrPermissionRequest" }),
);
let shim_js = crate::permissions::permission_shim_js();
setup_cmd(
self,
"Page.addScriptToEvaluateOnNewDocument",
serde_json::json!({ "source": shim_js }),
);
setup_cmd(
self,
"Page.addScriptToEvaluateOnNewDocument",
serde_json::json!({ "source": crate::find::find_shim_js() }),
);
setup_cmd(
self,
"Runtime.addBinding",
serde_json::json!({ "name": "__buffrContextMenu" }),
);
setup_cmd(
self,
"Page.addScriptToEvaluateOnNewDocument",
serde_json::json!({ "source": crate::context_menu::context_menu_shim_js() }),
);
tracing::debug!(
navigate_url,
"blink-cdp: P0-3 navigating to real URL after shim setup"
);
let (nav_reply_tx, nav_reply_rx) = mpsc::channel();
self.cmd_tx
.try_send(Command::Navigate {
session_id: session_id.clone(),
url: navigate_url.to_owned(),
reply: nav_reply_tx,
})
.map_err(|_| EngineError::Other("worker channel full during open_tab".into()))?;
match nav_reply_rx.recv_timeout(Duration::from_secs(
crate::worker::CDP_RESPONSE_TIMEOUT_SECS,
)) {
Ok(Ok(_)) => {}
Ok(Err(e)) => {
tracing::warn!(navigate_url, error = %e, "blink-cdp: initial navigation error (continuing)");
}
Err(_) => {
tracing::warn!(
navigate_url,
"blink-cdp: initial navigation timed out (continuing)"
);
}
}
self.schedule_view_source_fetch_if_needed(url, &session_id);
let mut state = self.state.lock().unwrap();
let tab_id = state.mint_tab_id();
let tab = CdpTab {
id: tab_id,
target_id: target_id.clone(),
session_id: session_id.clone(),
url: navigate_url.to_owned(),
title: original_url.as_deref().unwrap_or(navigate_url).to_owned(),
zoom_level: 1.0,
};
if let Some(orig) = original_url {
state.original_urls.insert(target_id, orig);
}
match insert_idx {
Some(idx) => {
let clamped = idx.min(state.tabs.len());
state.tabs.insert(clamped, tab);
}
None => state.tabs.push(tab),
}
if make_active || state.active.is_none() {
state.active = Some(tab_id);
drop(state);
let (w, h) = self.viewport_dims();
let _ = self.cmd_tx.try_send(Command::SetActiveSession {
session_id: Some(session_id),
width: w,
height: h,
});
}
Ok(tab_id)
}
fn viewport_dims(&self) -> (u32, u32) {
use std::sync::atomic::Ordering;
let v = &self.osr_view;
(
v.width.load(Ordering::Relaxed).max(1),
v.height.load(Ordering::Relaxed).max(1),
)
}
fn run_find_js(&self, expr: &str) {
let session_id = {
let state = self.state.lock().unwrap();
match state.active_tab().map(|t| t.session_id.clone()) {
Some(s) => s,
None => {
tracing::debug!("blink-cdp: run_find_js — no active tab");
return;
}
}
};
match self.session_cmd(
&session_id,
"Runtime.evaluate",
serde_json::json!({ "expression": expr, "returnByValue": true }),
) {
Ok(value) => {
if let Some(result) = parse_find_result(&value) {
tracing::debug!(
current = result.current,
total = result.count,
"blink-cdp: find result"
);
if let Ok(mut guard) = self.find_sink.lock() {
*guard = Some(result);
}
} else {
tracing::debug!(?value, expr, "blink-cdp: find result parse failed");
if let Ok(mut guard) = self.find_sink.lock() {
*guard = Some(FindResult {
count: 0,
current: 0,
final_update: true,
});
}
}
}
Err(e) => {
tracing::debug!(error = %e, expr, "blink-cdp: Runtime.evaluate for find failed");
}
}
}
}
impl Drop for BlinkCdpEngine {
fn drop(&mut self) {
tracing::debug!("blink-cdp: Drop — shutting down worker and subprocess");
if let Err(e) = self.cmd_tx.send(Command::Shutdown) {
tracing::debug!(error = %e, "blink-cdp: Drop — Shutdown send failed (worker already gone)");
}
if let Some(handle) = self.worker.take()
&& let Err(e) = handle.join()
{
tracing::warn!("blink-cdp: Drop — worker thread panicked: {:?}", e);
}
if let Ok(mut guard) = self.subprocess.lock()
&& let Some(mut child) = guard.take()
{
let _ = child.kill();
let _ = child.wait();
tracing::debug!("blink-cdp: Drop — Chromium subprocess killed");
}
}
}
impl BrowserEngine for BlinkCdpEngine {
fn close_all_browsers(&self) {
tracing::debug!("blink-cdp: close_all_browsers");
let _ = self.cmd_tx.try_send(Command::SetActiveSession {
session_id: None,
width: 1,
height: 1,
});
let _ = self.cmd_tx.try_send(Command::Shutdown);
if let Ok(mut guard) = self.subprocess.lock()
&& let Some(mut child) = guard.take()
{
let _ = child.kill();
let _ = child.wait();
}
if let Ok(mut state) = self.state.lock() {
state.tabs.clear();
state.active = None;
state.original_urls.clear();
}
}
fn open_tab(&self, url: &str) -> Result<TabId, EngineError> {
tracing::debug!(url, "blink-cdp: open_tab");
self.open_tab_internal(url, true, None)
}
fn open_tab_background(&self, url: &str) -> Result<TabId, EngineError> {
tracing::debug!(url, "blink-cdp: open_tab_background");
self.open_tab_internal(url, false, None)
}
fn open_tab_at(&self, url: &str, insert_idx: usize) -> Result<TabId, EngineError> {
tracing::debug!(url, insert_idx, "blink-cdp: open_tab_at");
self.open_tab_internal(url, true, Some(insert_idx))
}
fn close_tab(&self, id: TabId) -> Result<bool, EngineError> {
tracing::debug!(%id, "blink-cdp: close_tab");
let (target_id, session_id, was_active) = {
let state = self.state.lock().unwrap();
let tab = state.tab_by_id(id).ok_or(EngineError::TabNotFound(id))?;
(
tab.target_id.clone(),
tab.session_id.clone(),
state.active == Some(id),
)
};
if let Err(e) = self.browser_cmd(
"Target.detachFromTarget",
DetachFromTargetParams {
session_id: session_id.clone(),
},
) {
tracing::warn!(%id, error = %e, "blink-cdp: close_tab — detachFromTarget failed (continuing)");
}
if let Err(e) = self.browser_cmd(
"Target.closeTarget",
CloseTargetParams {
target_id: target_id.clone(),
},
) {
tracing::warn!(%id, error = %e, "blink-cdp: close_tab — closeTarget failed (continuing)");
}
if let Ok(mut map) = self.perm_session_map.lock() {
map.retain(|_, sess| sess != &session_id);
}
if let Ok(mut m) = self.loading_state.lock() {
m.remove(&session_id);
}
if let Ok(mut m) = self.nav_count.lock() {
m.remove(&session_id);
}
let mut state = self.state.lock().unwrap();
state.tabs.retain(|t| t.id != id);
state.original_urls.remove(&target_id);
if was_active {
state.active = state.tabs.last().map(|t| t.id);
let new_session = state.active.and_then(|active_id| {
state
.tabs
.iter()
.find(|t| t.id == active_id)
.map(|t| t.session_id.clone())
});
drop(state);
let (w, h) = self.viewport_dims();
let _ = self.cmd_tx.try_send(Command::SetActiveSession {
session_id: new_session,
width: w,
height: h,
});
}
let remaining = self.state.lock().unwrap().tabs.len();
Ok(remaining > 0)
}
fn close_active(&self) -> Result<bool, EngineError> {
let id = self
.state
.lock()
.unwrap()
.active
.ok_or(EngineError::NoActiveTab)?;
self.close_tab(id)
}
fn select_tab(&self, id: TabId) {
tracing::debug!(%id, "blink-cdp: select_tab");
let mut state = self.state.lock().unwrap();
if let Some(tab) = state.tab_by_id(id) {
let session_id = tab.session_id.clone();
state.active = Some(id);
drop(state);
let (w, h) = self.viewport_dims();
let _ = self.cmd_tx.try_send(Command::SetActiveSession {
session_id: Some(session_id),
width: w,
height: h,
});
}
}
fn next_tab(&self) {
let (len, current_idx) = {
let state = self.state.lock().unwrap();
let len = state.tabs.len();
let idx = state
.active
.and_then(|id| state.tabs.iter().position(|t| t.id == id))
.unwrap_or(0);
(len, idx)
};
if len == 0 {
return;
}
let next_idx = (current_idx + 1) % len;
let id = self.state.lock().unwrap().tabs[next_idx].id;
self.select_tab(id);
}
fn prev_tab(&self) {
let (len, current_idx) = {
let state = self.state.lock().unwrap();
let len = state.tabs.len();
let idx = state
.active
.and_then(|id| state.tabs.iter().position(|t| t.id == id))
.unwrap_or(0);
(len, idx)
};
if len == 0 {
return;
}
let prev_idx = if current_idx == 0 {
len - 1
} else {
current_idx - 1
};
let id = self.state.lock().unwrap().tabs[prev_idx].id;
self.select_tab(id);
}
fn move_tab(&self, _from: usize, _to: usize) {
tracing::warn!("blink-cdp: move_tab not implemented in Phase 4");
}
fn duplicate_active(&self) -> Result<TabId, EngineError> {
Err(EngineError::Unimplemented {
method: "duplicate_active",
})
}
fn toggle_pin_active(&self) {
tracing::warn!("blink-cdp: toggle_pin_active not implemented in Phase 4");
}
fn set_pinned(&self, _id: TabId, _pinned: bool) {
tracing::warn!("blink-cdp: set_pinned not implemented in Phase 4");
}
fn reopen_closed_tab(&self) -> Result<Option<TabId>, EngineError> {
Err(EngineError::Unimplemented {
method: "reopen_closed_tab",
})
}
fn closed_stack_len(&self) -> usize {
0
}
fn active_tab(&self) -> Option<TabSummary> {
let state = self.state.lock().unwrap();
let loading = self.loading_state.lock().ok();
let titles = self.title_map.lock().ok();
state.active_tab().map(|t| {
let is_loading = loading
.as_ref()
.and_then(|m| m.get(&t.session_id))
.copied()
.unwrap_or(false);
let display = display_url_for(t, &state.original_urls).to_owned();
let mut summary = t.to_summary(is_loading);
summary.url = display;
if let Some(live_title) = titles
.as_ref()
.and_then(|m| m.get(&t.target_id))
.filter(|s| !s.is_empty())
{
summary.title = live_title.clone();
}
summary
})
}
fn tabs_summary(&self) -> Vec<TabSummary> {
let state = self.state.lock().unwrap();
let loading = self.loading_state.lock().ok();
let titles = self.title_map.lock().ok();
state
.tabs
.iter()
.map(|t| {
let is_loading = loading
.as_ref()
.and_then(|m| m.get(&t.session_id))
.copied()
.unwrap_or(false);
let display = display_url_for(t, &state.original_urls).to_owned();
let mut summary = t.to_summary(is_loading);
summary.url = display;
if let Some(live_title) = titles
.as_ref()
.and_then(|m| m.get(&t.target_id))
.filter(|s| !s.is_empty())
{
summary.title = live_title.clone();
}
summary
})
.collect()
}
fn tab_count(&self) -> usize {
self.state.lock().unwrap().tabs.len()
}
fn pinned_count(&self) -> usize {
0
}
fn active_index(&self) -> Option<usize> {
let state = self.state.lock().unwrap();
let active = state.active?;
state.tabs.iter().position(|t| t.id == active)
}
fn can_go_back(&self) -> bool {
let session_id = {
let state = self.state.lock().unwrap();
state.active_tab().map(|t| t.session_id.clone())
};
let Some(sess) = session_id else { return false };
self.nav_count
.lock()
.ok()
.and_then(|m| m.get(&sess).copied())
.unwrap_or(0)
>= 2
}
fn can_go_forward(&self) -> bool {
false
}
fn navigate(&self, url: &str) -> Result<(), EngineError> {
tracing::debug!(url, "blink-cdp: navigate");
let translated = self.translate_internal_url(url);
let navigate_url = translated.as_deref().unwrap_or(url);
let (session_id, target_id) = {
let state = self.state.lock().unwrap();
let tab = state.active_tab().ok_or(EngineError::NoActiveTab)?;
(tab.session_id.clone(), tab.target_id.clone())
};
{
let mut state = self.state.lock().unwrap();
if let Some(id) = state.active
&& let Some(tab) = state.tabs.iter_mut().find(|t| t.id == id)
{
tab.url = navigate_url.to_owned();
tab.title = url.to_owned();
}
if translated.is_some() {
state
.original_urls
.insert(target_id.clone(), url.to_owned());
} else {
state.original_urls.remove(&target_id);
}
}
let (reply_tx, reply_rx) = mpsc::channel();
self.cmd_tx
.try_send(Command::Navigate {
session_id: session_id.clone(),
url: navigate_url.to_owned(),
reply: reply_tx,
})
.map_err(|_| EngineError::Other("worker channel full".into()))?;
let result = reply_rx
.recv_timeout(Duration::from_secs(
crate::worker::CDP_RESPONSE_TIMEOUT_SECS,
))
.map_err(|_| EngineError::Other("navigate timed out".into()))
.and_then(|r| r.map(|_| ()).map_err(EngineError::from));
self.schedule_view_source_fetch_if_needed(url, &session_id);
result
}
fn active_tab_live_url(&self) -> String {
let state = self.state.lock().unwrap();
state
.active_tab()
.map(|t| display_url_for(t, &state.original_urls).to_owned())
.unwrap_or_default()
}
fn pump_address_changes(&self) -> bool {
let updates: Vec<(String, String, String)> = {
match self.url_update_sink.lock() {
Ok(mut sink) => sink.drain(..).collect(),
Err(_) => return false,
}
};
if updates.is_empty() {
return false;
}
let mut changed = false;
let mut state = self.state.lock().unwrap();
for (session_id, url, title) in updates {
let tab_info = state
.tabs
.iter()
.find(|t| t.session_id == session_id)
.map(|t| (t.target_id.clone(), t.url.clone()));
if let Some((target_id, old_url)) = tab_info {
let has_original = state.original_urls.contains_key(&target_id);
if !has_original && old_url != url {
tracing::debug!(
session_id,
old_url,
new_url = %url,
"pump_address_changes: updating tab url"
);
if let Some(t) = state.tabs.iter_mut().find(|t| t.target_id == target_id) {
t.url = url;
if !title.is_empty() {
t.title = title;
}
}
changed = true;
}
}
}
changed
}
fn resize(&self, width: u32, height: u32) {
use std::sync::atomic::Ordering;
self.osr_view.width.store(width, Ordering::Relaxed);
self.osr_view.height.store(height, Ordering::Relaxed);
self.osr_resize(width, height);
}
fn set_device_scale(&self, scale: f32) {
self.osr_view.set_scale(scale);
tracing::debug!(
scale,
"blink-cdp: set_device_scale (scale stored, not forwarded to CDP)"
);
}
fn set_frame_rate(&self, hz: u32) {
use std::sync::atomic::Ordering;
self.osr_view.frame_rate_hz.store(hz, Ordering::Relaxed);
}
fn notify_screen_info_changed(&self) {
}
fn osr_resize(&self, width: u32, height: u32) {
tracing::debug!(width, height, "blink-cdp: osr_resize");
let session_id = self
.state
.lock()
.unwrap()
.active_tab()
.map(|t| t.session_id.clone());
if let Some(sess) = session_id {
let _ = self.cmd_tx.try_send(Command::Resize {
session_id: sess,
width: width.max(1),
height: height.max(1),
});
}
if let Ok(mut frame) = self.osr_frame.lock() {
frame.needs_fresh = true;
}
}
fn osr_key_event(&self, event: NeutralKeyEvent) {
let session_id = self
.state
.lock()
.unwrap()
.active_tab()
.map(|t| t.session_id.clone());
let Some(session_id) = session_id else { return };
let text = if event.character != 0 {
char::from_u32(event.character as u32)
.map(|c| c.to_string())
.unwrap_or_default()
} else {
String::new()
};
let unmodified_text = if event.unmodified_character != 0 {
char::from_u32(event.unmodified_character as u32)
.map(|c| c.to_string())
.unwrap_or_default()
} else {
String::new()
};
let params = DispatchKeyEventParams {
event_type: key_event_type(event.kind),
windows_virtual_key_code: event.windows_key_code,
native_virtual_key_code: event.native_key_code,
text,
unmodified_text,
modifiers: event.modifiers,
is_system_key: event.is_system_key,
};
let _ = self
.cmd_tx
.try_send(Command::KeyEvent { session_id, params });
}
fn osr_mouse_move(&self, x: i32, y: i32, modifiers: u32) {
let session_id = self
.state
.lock()
.unwrap()
.active_tab()
.map(|t| t.session_id.clone());
let Some(session_id) = session_id else { return };
let params = DispatchMouseEventParams {
event_type: "mouseMoved",
x,
y,
button: "none",
click_count: 0,
modifiers,
delta_x: None,
delta_y: None,
};
let _ = self
.cmd_tx
.try_send(Command::MouseEvent { session_id, params });
}
fn osr_mouse_click(
&self,
x: i32,
y: i32,
button: MouseButton,
mouse_up: bool,
click_count: i32,
modifiers: u32,
) {
let session_id = self
.state
.lock()
.unwrap()
.active_tab()
.map(|t| t.session_id.clone());
let Some(session_id) = session_id else { return };
let event_type = if mouse_up {
"mouseReleased"
} else {
"mousePressed"
};
let params = DispatchMouseEventParams {
event_type,
x,
y,
button: mouse_button_str(button),
click_count,
modifiers,
delta_x: None,
delta_y: None,
};
let _ = self
.cmd_tx
.try_send(Command::MouseEvent { session_id, params });
}
fn osr_mouse_leave(&self, _modifiers: u32) {
}
fn osr_mouse_wheel(&self, x: i32, y: i32, delta_x: i32, delta_y: i32, modifiers: u32) {
let session_id = self
.state
.lock()
.unwrap()
.active_tab()
.map(|t| t.session_id.clone());
let Some(session_id) = session_id else { return };
let params = DispatchMouseEventParams {
event_type: "mouseWheel",
x,
y,
button: "none",
click_count: 0,
modifiers,
delta_x: Some(delta_x as f64),
delta_y: Some(delta_y as f64),
};
let _ = self
.cmd_tx
.try_send(Command::MouseEvent { session_id, params });
}
fn osr_focus(&self, _focused: bool) {
}
fn osr_frame(&self) -> SharedOsrFrame {
Arc::clone(&self.osr_frame)
}
fn osr_view(&self) -> SharedOsrViewState {
Arc::clone(&self.osr_view)
}
fn force_repaint_active(&self) {
}
fn osr_sleep(&self, _sleep: bool) {
}
fn osr_invalidate_view(&self) {
}
fn set_osr_wake(&self, wake: Arc<dyn Fn() + Send + Sync>) {
self.osr_view.set_wake(wake);
}
fn start_find(&self, query: &str, forward: bool) {
tracing::debug!(%query, forward, "blink-cdp: start_find");
if let Ok(mut guard) = self.find_query.lock() {
*guard = if query.is_empty() {
None
} else {
Some(query.to_owned())
};
}
let expr = find_expr(query, false, forward);
self.run_find_js(&expr);
}
fn stop_find(&self) {
tracing::debug!("blink-cdp: stop_find");
if let Ok(mut guard) = self.find_query.lock() {
*guard = None;
}
if let Ok(mut guard) = self.find_sink.lock() {
*guard = None;
}
self.run_find_js(stop_expr());
}
fn active_zoom_level(&self) -> f64 {
self.state
.lock()
.unwrap()
.active_tab()
.map(|t| t.zoom_level)
.unwrap_or(1.0)
}
fn zoom_in(&self) {
self.adjust_zoom(ZOOM_STEP);
}
fn zoom_out(&self) {
self.adjust_zoom(-ZOOM_STEP);
}
fn zoom_reset(&self) {
self.apply_zoom(1.0);
}
fn open_devtools(&self, tab: TabId) -> Result<(), buffr_engine::EngineError> {
let state = self
.state
.lock()
.map_err(|e| buffr_engine::EngineError::Other(format!("state lock poisoned: {e}")))?;
let port = state.debug_port;
let cdp_tab = state
.tabs
.iter()
.find(|t| t.id == tab)
.ok_or(buffr_engine::EngineError::TabNotFound(tab))?;
let target_id = cdp_tab.target_id.clone();
drop(state);
let url = format!(
"http://127.0.0.1:{port}/devtools/inspector.html?ws=127.0.0.1:{port}/devtools/page/{target_id}"
);
tracing::debug!(%url, "blink-cdp: open_devtools");
open::that(&url)
.map_err(|e| buffr_engine::EngineError::Other(format!("open devtools url: {e}")))?;
Ok(())
}
fn drain_context_menu_requests(&self) -> Vec<buffr_engine::ContextMenuRequest> {
match self.context_menu_sink.lock() {
Ok(mut q) => q.drain(..).collect(),
Err(_) => Vec::new(),
}
}
fn media_picture_in_picture(&self, _x: i32, _y: i32) {
let session_id = {
let state = self.state.lock().unwrap();
match state.active_tab().map(|t| t.session_id.clone()) {
Some(s) => s,
None => {
tracing::debug!("blink-cdp: media_picture_in_picture — no active tab");
return;
}
}
};
tracing::debug!("blink-cdp: media_picture_in_picture → Runtime.evaluate");
let _ = self.session_cmd(
&session_id,
"Runtime.evaluate",
serde_json::json!({
"expression": crate::pip::pip_toggle_js(),
"returnByValue": true,
}),
);
}
fn any_audio_active(&self) -> bool {
false
}
fn any_video_active(&self) -> bool {
false
}
fn popup_queue(&self) -> buffr_engine::popup::PopupQueue {
Arc::clone(&self.popup_queue)
}
fn popup_create_sink(&self) -> buffr_engine::popup::PopupCreateSink {
Arc::clone(&self.popup_create_sink)
}
fn popup_close_sink(&self) -> buffr_engine::popup::PopupCloseSink {
Arc::clone(&self.popup_close_sink)
}
fn popup_resize(&self, _browser_id: i32, _width: u32, _height: u32) {}
fn popup_close(&self, _browser_id: i32) {}
fn popup_drain_address_changes(&self) -> Vec<(i32, String)> {
Vec::new()
}
fn popup_drain_title_changes(&self) -> Vec<(i32, String)> {
Vec::new()
}
fn popup_history_back(&self, _browser_id: i32) {}
fn popup_history_forward(&self, _browser_id: i32) {}
fn popup_osr_focus(&self, _browser_id: i32, _focused: bool) {}
fn popup_osr_key_event(&self, _browser_id: i32, _event: buffr_engine::NeutralKeyEvent) {}
#[allow(clippy::too_many_arguments)]
fn popup_osr_mouse_click(
&self,
_browser_id: i32,
_x: i32,
_y: i32,
_button: buffr_engine::MouseButton,
_mouse_up: bool,
_click_count: i32,
_modifiers: u32,
) {
}
fn popup_osr_mouse_move(&self, _browser_id: i32, _x: i32, _y: i32, _modifiers: u32) {}
fn popup_osr_mouse_wheel(
&self,
_browser_id: i32,
_x: i32,
_y: i32,
_delta_x: i32,
_delta_y: i32,
_modifiers: u32,
) {
}
fn permissions_queue(&self) -> PermissionsQueue {
Arc::clone(&self.permissions_queue)
}
fn resolve_permission(&self, resolve_id: Option<&str>, outcome: PromptOutcome) {
let Some(id) = resolve_id else {
tracing::debug!("blink-cdp: resolve_permission called with no id (no-op)");
return;
};
let session_id = match self.perm_session_map.lock() {
Ok(mut map) => map.remove(id),
Err(_) => {
tracing::warn!(id, "blink-cdp: perm_session_map poisoned");
return;
}
};
let Some(session_id) = session_id else {
tracing::debug!(
id,
"blink-cdp: resolve_id not in session map (already resolved?)"
);
return;
};
let outcome_str = match outcome {
PromptOutcome::Allow { .. } => "granted",
PromptOutcome::Deny { .. } | PromptOutcome::Defer => "denied",
};
let expr = format!(
"if (window.__buffrPermissionResolve) {{ window.__buffrPermissionResolve({id:?}, {outcome_str:?}); }}"
);
tracing::debug!(
id,
outcome_str,
"blink-cdp: resolve_permission → Runtime.evaluate"
);
let _ = self.session_cmd(
&session_id,
"Runtime.evaluate",
serde_json::json!({ "expression": expr }),
);
}
fn is_hint_mode(&self) -> bool {
false
}
fn hint_status(&self) -> Option<buffr_engine::HintStatus> {
None
}
fn pump_hint_events(&self) -> bool {
false
}
fn feed_hint_key(&self, _c: char) -> Option<buffr_engine::HintAction> {
None
}
fn backspace_hint(&self) -> Option<buffr_engine::HintAction> {
None
}
fn cancel_hint(&self) {}
fn run_js(&self, code: &str) -> Result<(), buffr_engine::EngineError> {
self.run_main_frame_js(code, "")
}
fn run_main_frame_js(&self, code: &str, _url: &str) -> Result<(), buffr_engine::EngineError> {
let session_id = {
let state = self
.state
.lock()
.map_err(|e| buffr_engine::EngineError::Other(format!("lock poisoned: {e}")))?;
let tab = state
.active_tab()
.ok_or(buffr_engine::EngineError::NoActiveTab)?;
tab.session_id.clone()
};
self.session_cmd(
&session_id,
"Runtime.evaluate",
serde_json::json!({ "expression": code }),
)
.map(|_| ())
.map_err(|e| buffr_engine::EngineError::Other(e.to_string()))
}
fn show_dev_tools_at(&self, _x: i32, _y: i32) {
let tab_id = {
match self.state.lock() {
Ok(s) => s.active_tab().map(|t| t.id),
Err(_) => None,
}
};
if let Some(id) = tab_id {
if let Err(err) = self.open_devtools(id) {
tracing::debug!(error = %err, "blink-cdp: show_dev_tools_at failed");
}
} else {
tracing::debug!("blink-cdp: show_dev_tools_at — no active tab");
}
}
fn dispatch(&self, action: &buffr_modal::PageAction) {
use buffr_modal::PageAction as A;
const STEP_PX: i64 = 40;
let eval = |expr: String| {
let session_id = {
let state = self.state.lock().unwrap();
state.active_tab().map(|t| t.session_id.clone())
};
if let Some(sess) = session_id {
let _ = self.session_cmd(
&sess,
"Runtime.evaluate",
serde_json::json!({ "expression": expr }),
);
}
};
match action {
A::FindNext => {
let query = self.find_query.lock().ok().and_then(|g| g.clone());
if let Some(q) = query {
tracing::debug!(query = %q, "blink-cdp: dispatch FindNext");
let expr = find_expr(&q, false, true);
self.run_find_js(&expr);
} else {
tracing::debug!("blink-cdp: FindNext — no active find query");
}
}
A::FindPrev => {
let query = self.find_query.lock().ok().and_then(|g| g.clone());
if let Some(q) = query {
tracing::debug!(query = %q, "blink-cdp: dispatch FindPrev");
let expr = find_expr(&q, false, false);
self.run_find_js(&expr);
} else {
tracing::debug!("blink-cdp: FindPrev — no active find query");
}
}
A::HistoryBack => {
tracing::debug!("blink-cdp: dispatch HistoryBack");
eval("window.history.back();".to_owned());
}
A::HistoryForward => {
tracing::debug!("blink-cdp: dispatch HistoryForward");
eval("window.history.forward();".to_owned());
}
A::Reload => {
tracing::debug!("blink-cdp: dispatch Reload");
let session_id = {
let state = self.state.lock().unwrap();
state.active_tab().map(|t| t.session_id.clone())
};
if let Some(sess) = session_id {
let _ = self.session_cmd(
&sess,
"Page.reload",
serde_json::json!({ "ignoreCache": false }),
);
}
}
A::ReloadHard => {
tracing::debug!("blink-cdp: dispatch ReloadHard");
let session_id = {
let state = self.state.lock().unwrap();
state.active_tab().map(|t| t.session_id.clone())
};
if let Some(sess) = session_id {
let _ = self.session_cmd(
&sess,
"Page.reload",
serde_json::json!({ "ignoreCache": true }),
);
}
}
A::StopLoading => {
tracing::debug!("blink-cdp: dispatch StopLoading");
let session_id = {
let state = self.state.lock().unwrap();
state.active_tab().map(|t| t.session_id.clone())
};
if let Some(sess) = session_id {
let _ = self.session_cmd(&sess, "Page.stopLoading", serde_json::json!({}));
}
}
A::ScrollUp(n) => {
let dy = -(STEP_PX * (*n as i64));
tracing::debug!(n, dy, "blink-cdp: dispatch ScrollUp");
eval(format!("window.scrollBy(0, {dy});"));
}
A::ScrollDown(n) => {
let dy = STEP_PX * (*n as i64);
tracing::debug!(n, dy, "blink-cdp: dispatch ScrollDown");
eval(format!("window.scrollBy(0, {dy});"));
}
A::ScrollLeft(n) => {
let dx = -(STEP_PX * (*n as i64));
tracing::debug!(n, dx, "blink-cdp: dispatch ScrollLeft");
eval(format!("window.scrollBy({dx}, 0);"));
}
A::ScrollRight(n) => {
let dx = STEP_PX * (*n as i64);
tracing::debug!(n, dx, "blink-cdp: dispatch ScrollRight");
eval(format!("window.scrollBy({dx}, 0);"));
}
A::ScrollPageDown | A::ScrollFullPageDown => {
tracing::debug!("blink-cdp: dispatch ScrollPageDown");
eval("window.scrollBy(0, window.innerHeight * 0.9);".to_owned());
}
A::ScrollPageUp | A::ScrollFullPageUp => {
tracing::debug!("blink-cdp: dispatch ScrollPageUp");
eval("window.scrollBy(0, -window.innerHeight * 0.9);".to_owned());
}
A::ScrollHalfPageDown => {
tracing::debug!("blink-cdp: dispatch ScrollHalfPageDown");
eval("window.scrollBy(0, window.innerHeight * 0.5);".to_owned());
}
A::ScrollHalfPageUp => {
tracing::debug!("blink-cdp: dispatch ScrollHalfPageUp");
eval("window.scrollBy(0, -window.innerHeight * 0.5);".to_owned());
}
A::ScrollTop => {
tracing::debug!("blink-cdp: dispatch ScrollTop");
eval("window.scrollTo(0, 0);".to_owned());
}
A::ScrollBottom => {
tracing::debug!("blink-cdp: dispatch ScrollBottom");
eval("window.scrollTo(0, document.body.scrollHeight);".to_owned());
}
A::ZoomIn => self.adjust_zoom(ZOOM_STEP),
A::ZoomOut => self.adjust_zoom(-ZOOM_STEP),
A::ZoomReset => self.apply_zoom(1.0),
other => {
tracing::debug!(
action = ?other,
"blink-cdp: dispatch — action not handled by CDP backend (no-op)"
);
}
}
}
fn ime_set_composition(&self, text: &str, cursor: Option<(usize, usize)>) {
let session_id = {
let state = self.state.lock().unwrap();
match state.active_tab().map(|t| t.session_id.clone()) {
Some(s) => s,
None => {
tracing::debug!("blink-cdp: ime_set_composition — no active tab");
return;
}
}
};
let (start, end) = cursor.unwrap_or((text.len(), text.len()));
let params = serde_json::json!({
"text": text,
"selectionStart": start,
"selectionEnd": end,
});
tracing::debug!(text, start, end, "blink-cdp: ime_set_composition");
let _ = self.session_cmd(&session_id, "Input.imeSetComposition", params);
}
fn ime_commit(&self, text: &str) {
let session_id = {
let state = self.state.lock().unwrap();
match state.active_tab().map(|t| t.session_id.clone()) {
Some(s) => s,
None => {
tracing::debug!("blink-cdp: ime_commit — no active tab");
return;
}
}
};
tracing::debug!(text, "blink-cdp: ime_commit");
let _ = self.session_cmd(
&session_id,
"Input.insertText",
serde_json::json!({ "text": text }),
);
}
fn ime_cancel(&self) {
let session_id = {
let state = self.state.lock().unwrap();
match state.active_tab().map(|t| t.session_id.clone()) {
Some(s) => s,
None => {
tracing::debug!("blink-cdp: ime_cancel — no active tab");
return;
}
}
};
tracing::debug!("blink-cdp: ime_cancel");
let _ = self.session_cmd(
&session_id,
"Input.imeSetComposition",
serde_json::json!({
"text": "",
"selectionStart": 0,
"selectionEnd": 0,
}),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::subprocess::find_chromium;
#[test]
fn zoom_level_clamps_to_range() {
assert_eq!(clamp_zoom(0.0), ZOOM_MIN);
assert_eq!(clamp_zoom(-1.0), ZOOM_MIN);
assert_eq!(clamp_zoom(10.0), ZOOM_MAX);
assert_eq!(clamp_zoom(1.0), 1.0);
assert_eq!(clamp_zoom(ZOOM_MIN), ZOOM_MIN);
assert_eq!(clamp_zoom(ZOOM_MAX), ZOOM_MAX);
assert_eq!(clamp_zoom(2.5), 2.5);
}
#[test]
fn zoom_step_constant_matches_cef() {
assert!(
(ZOOM_STEP - 0.25_f64).abs() < f64::EPSILON,
"ZOOM_STEP must equal 0.25 to match the CEF backend"
);
}
#[test]
fn active_zoom_level_returns_tracked_value() {
let mut state = EngineState::new(9222);
let tab_id = state.mint_tab_id();
state.tabs.push(CdpTab {
id: tab_id,
target_id: "t1".into(),
session_id: "s1".into(),
url: "about:blank".into(),
title: "about:blank".into(),
zoom_level: 1.5,
});
state.active = Some(tab_id);
let level = state.active_tab().map(|t| t.zoom_level).unwrap_or(1.0);
assert!(
(level - 1.5_f64).abs() < f64::EPSILON,
"tracked zoom level should be 1.5, got {level}"
);
state.active = None;
let level_none = state.active_tab().map(|t| t.zoom_level).unwrap_or(1.0);
assert!(
(level_none - 1.0_f64).abs() < f64::EPSILON,
"no active tab should yield 1.0, got {level_none}"
);
}
#[test]
fn devtools_url_format_is_correct() {
let port: u16 = 9222;
let target_id = "ABCD1234-EF56-7890-ABCD-EF1234567890";
let url = format!(
"http://127.0.0.1:{port}/devtools/inspector.html?ws=127.0.0.1:{port}/devtools/page/{target_id}"
);
assert!(url.starts_with("http://127.0.0.1:9222/devtools/inspector.html"));
assert!(url.contains("ws=127.0.0.1:9222/devtools/page/"));
assert!(url.ends_with(target_id));
}
#[test]
fn open_devtools_returns_tab_not_found_for_unknown_tab() {
let state = EngineState::new(9222);
let unknown_id = TabId(99);
let result = state.tabs.iter().find(|t| t.id == unknown_id);
assert!(result.is_none(), "unknown tab should not be found");
let err = buffr_engine::EngineError::TabNotFound(unknown_id);
assert!(matches!(err, buffr_engine::EngineError::TabNotFound(_)));
}
#[test]
fn find_chromium_no_panic() {
let _result = find_chromium();
if let Some(path) = find_chromium() {
assert!(
path.exists() || !path.is_absolute(),
"resolved absolute path should exist"
);
}
}
#[test]
fn error_when_chromium_missing() {
if find_chromium().is_some() {
return;
}
let result = BlinkCdpEngine::new(
Path::new("/tmp/buffr-blink-cdp-test"),
None,
None,
None,
None,
None,
);
match result {
Err(BlinkError::ChromiumNotFound) => {} Err(other) => panic!("unexpected error: {other}"),
Ok(_) => panic!("expected error when Chromium is missing"),
}
}
#[test]
fn clamp_zoom_min_boundary() {
assert!(
(clamp_zoom(ZOOM_MIN) - ZOOM_MIN).abs() < f64::EPSILON,
"clamp(MIN) should equal MIN"
);
assert!(
(clamp_zoom(ZOOM_MIN - 0.01) - ZOOM_MIN).abs() < f64::EPSILON,
"below MIN should clamp to MIN"
);
}
#[test]
fn clamp_zoom_max_boundary() {
assert!(
(clamp_zoom(ZOOM_MAX) - ZOOM_MAX).abs() < f64::EPSILON,
"clamp(MAX) should equal MAX"
);
assert!(
(clamp_zoom(ZOOM_MAX + 0.01) - ZOOM_MAX).abs() < f64::EPSILON,
"above MAX should clamp to MAX"
);
}
#[test]
fn engine_state_mint_tab_id_monotonic() {
let mut state = EngineState::new(9999);
let id1 = state.mint_tab_id();
let id2 = state.mint_tab_id();
let id3 = state.mint_tab_id();
assert!(id1.0 < id2.0 && id2.0 < id3.0, "tab ids must increase");
}
#[test]
fn engine_state_tab_by_id_found_and_not_found() {
let mut state = EngineState::new(9999);
let id = state.mint_tab_id();
state.tabs.push(CdpTab {
id,
target_id: "t1".into(),
session_id: "s1".into(),
url: "about:blank".into(),
title: "about:blank".into(),
zoom_level: 1.0,
});
assert!(state.tab_by_id(id).is_some());
assert!(state.tab_by_id(TabId(999)).is_none());
}
#[test]
fn engine_state_tracks_active_target_id() {
let mut state = EngineState::new(8080);
let id = state.mint_tab_id();
state.tabs.push(CdpTab {
id,
target_id: "target-xyz".into(),
session_id: "sess-xyz".into(),
url: "https://example.com".into(),
title: "Example".into(),
zoom_level: 1.25,
});
state.active = Some(id);
let tab = state.active_tab().expect("active tab should be present");
assert_eq!(tab.target_id, "target-xyz");
assert_eq!(tab.session_id, "sess-xyz");
assert!((tab.zoom_level - 1.25).abs() < f64::EPSILON);
}
#[test]
fn engine_state_no_active_tab_when_none() {
let state = EngineState::new(9999);
assert!(state.active_tab().is_none());
}
fn ime_resolve_cursor(text: &str, cursor: Option<(usize, usize)>) -> (usize, usize) {
cursor.unwrap_or((text.len(), text.len()))
}
#[test]
fn ime_set_composition_payload_shape() {
let text = "こんにちは";
let (start, end) = ime_resolve_cursor(text, Some((3, 6)));
let params = serde_json::json!({
"text": text,
"selectionStart": start,
"selectionEnd": end,
});
assert_eq!(params["text"], text);
assert_eq!(params["selectionStart"], 3);
assert_eq!(params["selectionEnd"], 6);
}
#[test]
fn ime_set_composition_no_cursor_collapses_to_end() {
let text = "hello";
let (start, end) = ime_resolve_cursor(text, None);
let params = serde_json::json!({
"text": text,
"selectionStart": start,
"selectionEnd": end,
});
assert_eq!(params["selectionStart"], text.len());
assert_eq!(params["selectionEnd"], text.len());
}
#[test]
fn ime_commit_payload_shape() {
let text = "確定";
let params = serde_json::json!({ "text": text });
assert_eq!(params["text"], text);
assert!(params.get("selectionStart").is_none());
}
#[test]
fn ime_cancel_payload_shape() {
let params = serde_json::json!({
"text": "",
"selectionStart": 0,
"selectionEnd": 0,
});
assert_eq!(params["text"], "");
assert_eq!(params["selectionStart"], 0);
assert_eq!(params["selectionEnd"], 0);
}
fn data_html_prefix() -> String {
"data:text/html;base64,".to_owned()
}
#[test]
fn display_url_for_prefers_original() {
let tab = CdpTab {
id: TabId(1),
target_id: "t1".into(),
session_id: "s1".into(),
url: "data:text/html;base64,ABC".into(),
title: "buffr://new".into(),
zoom_level: 1.0,
};
let mut originals = HashMap::new();
originals.insert("t1".to_owned(), "buffr://new".to_owned());
assert_eq!(display_url_for(&tab, &originals), "buffr://new");
}
#[test]
fn display_url_for_falls_back_to_tab_url() {
let tab = CdpTab {
id: TabId(1),
target_id: "t1".into(),
session_id: "s1".into(),
url: "https://example.com".into(),
title: "Example".into(),
zoom_level: 1.0,
};
let originals: HashMap<String, String> = HashMap::new();
assert_eq!(display_url_for(&tab, &originals), "https://example.com");
}
#[test]
fn html_escape_source_escapes_special_chars() {
assert_eq!(html_escape_source("&"), "&");
assert_eq!(html_escape_source("<"), "<");
assert_eq!(html_escape_source(">"), ">");
assert_eq!(html_escape_source("\""), """);
assert_eq!(html_escape_source("'"), "'");
assert_eq!(html_escape_source("plain"), "plain");
assert_eq!(
html_escape_source("<script>alert('xss')</script>"),
"<script>alert('xss')</script>"
);
}
#[test]
fn view_source_html_error_page_on_unreachable_url() {
let html = view_source_html_sync("http://127.0.0.1:19999/no-such-server");
let text = String::from_utf8_lossy(&html);
assert!(
text.contains("<!DOCTYPE html>"),
"should be an HTML document"
);
assert!(
text.contains("view-source error") || text.contains("Error"),
"should mention an error"
);
}
#[test]
fn original_urls_cleaned_on_tab_close() {
let mut state = EngineState::new(9999);
let id = state.mint_tab_id();
state.tabs.push(CdpTab {
id,
target_id: "t-close".into(),
session_id: "s-close".into(),
url: "data:text/html;base64,X".into(),
title: "buffr://new".into(),
zoom_level: 1.0,
});
state
.original_urls
.insert("t-close".to_owned(), "buffr://new".to_owned());
assert!(state.original_urls.contains_key("t-close"));
state.tabs.retain(|t| t.id != id);
state.original_urls.remove("t-close");
assert!(!state.original_urls.contains_key("t-close"));
assert!(state.tabs.is_empty());
}
#[test]
fn buffr_newtab_url_translates_to_data_url() {
let html = buffr_engine::newtab::NEW_TAB_HTML_TEMPLATE
.as_bytes()
.to_vec();
let encoded = base64::engine::general_purpose::STANDARD.encode(&html);
let data_url = format!("{}{}", data_html_prefix(), encoded);
assert!(data_url.starts_with("data:text/html;base64,"));
let decoded = base64::engine::general_purpose::STANDARD
.decode(data_url.trim_start_matches("data:text/html;base64,"))
.expect("base64 decode should succeed");
assert_eq!(decoded, html);
}
#[test]
fn view_source_url_prefix_strip() {
let input = "view-source:https://example.com/page";
let stripped = input.strip_prefix("view-source:");
assert_eq!(stripped, Some("https://example.com/page"));
}
#[test]
fn url_update_sink_push_and_drain() {
use crate::worker::new_url_update_sink;
let sink = new_url_update_sink();
{
let mut guard = sink.lock().unwrap();
guard.push_back((
"sess-1".into(),
"https://example.com".into(),
"Example".into(),
));
guard.push_back((
"sess-2".into(),
"https://rust-lang.org".into(),
"Rust".into(),
));
}
let drained: Vec<_> = sink.lock().unwrap().drain(..).collect();
assert_eq!(drained.len(), 2);
assert_eq!(drained[0].0, "sess-1");
assert_eq!(drained[0].1, "https://example.com");
assert_eq!(drained[0].2, "Example");
assert_eq!(drained[1].0, "sess-2");
assert_eq!(drained[1].1, "https://rust-lang.org");
assert!(sink.lock().unwrap().is_empty());
}
#[test]
fn pump_address_changes_updates_tab_url() {
use crate::worker::new_url_update_sink;
let url_sink = new_url_update_sink();
url_sink.lock().unwrap().push_back((
"sess-nav".into(),
"https://example.com/page".into(),
"Example Page".into(),
));
let mut state = EngineState::new(9999);
let tab_id = state.mint_tab_id();
state.tabs.push(CdpTab {
id: tab_id,
target_id: "t-nav".into(),
session_id: "sess-nav".into(),
url: "https://example.com".into(),
title: "Example".into(),
zoom_level: 1.0,
});
state.active = Some(tab_id);
let updates: Vec<_> = url_sink.lock().unwrap().drain(..).collect();
let mut changed = false;
for (session_id, url, title) in updates {
let tab_info = state
.tabs
.iter()
.find(|t| t.session_id == session_id)
.map(|t| (t.target_id.clone(), t.url.clone()));
if let Some((target_id, old_url)) = tab_info {
let has_original = state.original_urls.contains_key(&target_id);
if !has_original && old_url != url {
if let Some(t) = state.tabs.iter_mut().find(|t| t.target_id == target_id) {
t.url = url;
if !title.is_empty() {
t.title = title;
}
}
changed = true;
}
}
}
assert!(changed, "should have detected a change");
assert_eq!(state.tabs[0].url, "https://example.com/page");
assert_eq!(state.tabs[0].title, "Example Page");
}
#[test]
fn pump_address_changes_skips_original_url_tabs() {
use crate::worker::new_url_update_sink;
let url_sink = new_url_update_sink();
url_sink.lock().unwrap().push_back((
"sess-internal".into(),
"data:text/html;base64,ABC".into(),
"".into(),
));
let mut state = EngineState::new(9999);
let tab_id = state.mint_tab_id();
state.tabs.push(CdpTab {
id: tab_id,
target_id: "t-internal".into(),
session_id: "sess-internal".into(),
url: "data:text/html;base64,ABC".into(),
title: "buffr://new".into(),
zoom_level: 1.0,
});
state.active = Some(tab_id);
state
.original_urls
.insert("t-internal".into(), "buffr://new".into());
let updates: Vec<_> = url_sink.lock().unwrap().drain(..).collect();
let mut changed = false;
for (session_id, url, title) in updates {
let tab_info = state
.tabs
.iter()
.find(|t| t.session_id == session_id)
.map(|t| (t.target_id.clone(), t.url.clone()));
if let Some((target_id, old_url)) = tab_info {
let has_original = state.original_urls.contains_key(&target_id);
if !has_original && old_url != url {
if let Some(t) = state.tabs.iter_mut().find(|t| t.target_id == target_id) {
t.url = url;
if !title.is_empty() {
t.title = title;
}
}
changed = true;
}
}
}
assert!(
!changed,
"should NOT have changed a tab with original_url stashed"
);
assert_eq!(state.tabs[0].url, "data:text/html;base64,ABC");
}
#[test]
fn view_source_translate_returns_placeholder_not_full_content() {
let placeholder = view_source_loading_data_url("https://example.com");
assert!(
placeholder.starts_with("data:text/html;base64,"),
"should be a data URL"
);
let encoded = placeholder.trim_start_matches("data:text/html;base64,");
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("valid base64");
let html = String::from_utf8_lossy(&decoded);
assert!(
html.contains("Fetching source"),
"placeholder must say 'Fetching source'"
);
assert!(
!html.contains("<pre>"),
"placeholder must NOT contain actual source content"
);
}
#[test]
fn dispatch_scroll_step_px_math() {
const STEP_PX: i64 = 40;
assert_eq!(STEP_PX * 3, 120);
assert_eq!(-(STEP_PX), -40);
assert_eq!(-(STEP_PX * 2), -80);
assert_eq!(STEP_PX * 5, 200);
}
#[test]
fn dispatch_p0_4_action_variants_exist() {
use buffr_modal::PageAction;
let _back = PageAction::HistoryBack;
let _fwd = PageAction::HistoryForward;
let _reload = PageAction::Reload;
let _hard = PageAction::ReloadHard;
let _stop = PageAction::StopLoading;
}
#[test]
fn dispatch_p0_5_scroll_variants_exist() {
use buffr_modal::PageAction;
let _su = PageAction::ScrollUp(1);
let _sd = PageAction::ScrollDown(1);
let _sl = PageAction::ScrollLeft(1);
let _sr = PageAction::ScrollRight(1);
let _spd = PageAction::ScrollPageDown;
let _spu = PageAction::ScrollPageUp;
let _sfpd = PageAction::ScrollFullPageDown;
let _sfpu = PageAction::ScrollFullPageUp;
let _shpd = PageAction::ScrollHalfPageDown;
let _shpu = PageAction::ScrollHalfPageUp;
let _st = PageAction::ScrollTop;
let _sb = PageAction::ScrollBottom;
}
#[test]
fn download_ids_stores_filename_alongside_id() {
use buffr_downloads::DownloadId;
use std::collections::HashMap;
use std::path::Path;
let mut download_ids: HashMap<String, (DownloadId, String)> = HashMap::new();
let guid = "test-guid-001".to_owned();
let filename = "report.pdf".to_owned();
let row_id = DownloadId(42);
download_ids.insert(guid.clone(), (row_id, filename.clone()));
let (id, fname) = download_ids.get(&guid).cloned().unwrap();
assert_eq!(id, row_id);
assert_eq!(fname, "report.pdf");
let download_dir = Path::new("/home/user/Downloads");
let full_path = download_dir.join(&fname);
let expected = download_dir.join("report.pdf");
assert_eq!(full_path, expected);
}
#[test]
fn download_notice_filename_populated() {
use buffr_core::{DownloadNotice, DownloadNoticeKind};
use std::path::Path;
use std::time::Instant;
let filename = "image.png".to_owned();
let download_dir = Path::new("/tmp/downloads");
let full_path = download_dir.join(&filename);
let notice = DownloadNotice {
kind: DownloadNoticeKind::Completed,
filename: filename.clone(),
path: full_path.to_string_lossy().into_owned(),
created_at: Instant::now(),
};
let expected_path = download_dir.join("image.png");
assert_eq!(notice.filename, "image.png");
assert_eq!(notice.path, expected_path.to_string_lossy().into_owned());
assert!(matches!(notice.kind, DownloadNoticeKind::Completed));
}
#[test]
fn view_source_loading_placeholder_is_valid_data_url() {
let url = "https://example.com/test";
let placeholder = view_source_loading_data_url(url);
assert!(
placeholder.starts_with("data:text/html;base64,"),
"placeholder must be a base64 data URL"
);
let b64 = placeholder.trim_start_matches("data:text/html;base64,");
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.expect("valid base64 in placeholder");
let html = String::from_utf8(decoded).expect("valid UTF-8");
assert!(html.contains("<!DOCTYPE html>"), "must be valid HTML");
assert!(html.contains("Fetching source"), "must mention fetching");
assert!(html.contains(url), "must mention the target URL");
}
#[test]
fn view_source_html_sync_error_page_on_unreachable_url() {
let html = view_source_html_sync("http://127.0.0.1:19999/no-such-server");
let text = String::from_utf8_lossy(&html);
assert!(
text.contains("<!DOCTYPE html>"),
"should be an HTML document"
);
assert!(
text.contains("view-source error") || text.contains("Error"),
"should mention an error"
);
}
#[test]
fn loading_state_tracks_session() {
let loading: Arc<Mutex<HashMap<String, bool>>> = Arc::new(Mutex::new(HashMap::new()));
loading.lock().unwrap().insert("sess-a".into(), true);
loading.lock().unwrap().insert("sess-b".into(), false);
let map = loading.lock().unwrap();
assert!(
map.get("sess-a").copied().unwrap_or(false),
"sess-a should be loading"
);
assert!(
!map.get("sess-b").copied().unwrap_or(true),
"sess-b should not be loading"
);
assert!(
!map.get("sess-c").copied().unwrap_or(false),
"unknown session should default to not loading"
);
}
#[test]
fn nav_count_increments_per_session() {
let nav: Arc<Mutex<HashMap<String, usize>>> = Arc::new(Mutex::new(HashMap::new()));
*nav.lock().unwrap().entry("sess-1".into()).or_insert(0) += 1;
*nav.lock().unwrap().entry("sess-1".into()).or_insert(0) += 1;
*nav.lock().unwrap().entry("sess-2".into()).or_insert(0) += 1;
let map = nav.lock().unwrap();
assert_eq!(map.get("sess-1").copied().unwrap_or(0), 2);
assert_eq!(map.get("sess-2").copied().unwrap_or(0), 1);
assert_eq!(
map.get("sess-3").copied().unwrap_or(0),
0,
"unknown session has count 0"
);
}
#[test]
fn can_go_back_requires_two_navigations() {
let counts: HashMap<String, usize> = [
("a".to_owned(), 0usize),
("b".to_owned(), 1usize),
("c".to_owned(), 2usize),
("d".to_owned(), 5usize),
]
.into();
let can_go_back = |sess: &str| counts.get(sess).copied().unwrap_or(0) >= 2;
assert!(!can_go_back("a"), "0 navigations → no back");
assert!(!can_go_back("b"), "1 navigation → no back");
assert!(can_go_back("c"), "2 navigations → can go back");
assert!(can_go_back("d"), "5 navigations → can go back");
assert!(!can_go_back("unknown"), "unknown session → no back");
}
#[test]
fn insert_at_index_places_tab_correctly() {
let mut state = EngineState::new(9999);
let make_tab = |n: u64, sess: &str| CdpTab {
id: TabId(n),
target_id: format!("t{n}"),
session_id: sess.to_owned(),
url: "about:blank".into(),
title: "".into(),
zoom_level: 1.0,
};
state.tabs.push(make_tab(1, "s1")); state.tabs.push(make_tab(2, "s2")); state.tabs.push(make_tab(3, "s3"));
let new_tab = make_tab(99, "s99");
let insert_idx = 1_usize;
let clamped = insert_idx.min(state.tabs.len());
state.tabs.insert(clamped, new_tab);
assert_eq!(state.tabs[0].id, TabId(1));
assert_eq!(state.tabs[1].id, TabId(99), "new tab should be at index 1");
assert_eq!(state.tabs[2].id, TabId(2));
assert_eq!(state.tabs[3].id, TabId(3));
}
#[test]
fn insert_at_index_beyond_len_appends() {
let mut v: Vec<u32> = vec![1, 2, 3];
let idx = 99_usize;
let clamped = idx.min(v.len());
v.insert(clamped, 99);
assert_eq!(v, vec![1, 2, 3, 99], "out-of-bounds insert should append");
}
#[test]
fn perm_session_map_cleaned_on_tab_close() {
let map: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new()));
map.lock()
.unwrap()
.insert("perm-1".into(), "sess-closing".into());
map.lock()
.unwrap()
.insert("perm-2".into(), "sess-closing".into());
map.lock()
.unwrap()
.insert("perm-3".into(), "sess-other".into());
let closing_session = "sess-closing".to_owned();
map.lock()
.unwrap()
.retain(|_, sess| sess != &closing_session);
let m = map.lock().unwrap();
assert!(!m.contains_key("perm-1"), "perm-1 should be cleaned up");
assert!(!m.contains_key("perm-2"), "perm-2 should be cleaned up");
assert!(
m.contains_key("perm-3"),
"perm-3 from other session should remain"
);
}
#[test]
fn popup_queue_arc_clone_shares_underlying_queue() {
let q = buffr_engine::new_popup_queue();
let q2 = Arc::clone(&q);
q.lock()
.unwrap()
.push_back("http://popup.example.com".into());
let popped = q2.lock().unwrap().pop_front();
assert_eq!(
popped.as_deref(),
Some("http://popup.example.com"),
"Arc::clone must share the same underlying queue"
);
}
#[test]
fn popup_sinks_arc_ptr_eq() {
let q = buffr_engine::new_popup_queue();
let q2 = Arc::clone(&q);
assert!(
Arc::ptr_eq(&q, &q2),
"Arc::clone must point to same allocation"
);
}
}