1#![cfg(feature = "d2b")]
8
9use dig2browser::browser::{
10 BrowserPreference, BrowserProfile, LaunchConfig, StealthBrowser, StealthConfig, StealthPage,
11};
12use dig2browser::detect::args::RendererSandboxMode;
13use dig2browser::stealth::LocaleProfile;
14use hatchery_core::{
15 decode_browser_input, BrowserFrameRegion, BrowserFrameTrace, BrowserInput, BrowserMouseButton, BrowserStreamEvent,
16};
17use serde_json::json;
18use std::path::PathBuf;
19use std::sync::Arc;
20use std::time::Duration;
21use tokio::sync::{mpsc, oneshot, Mutex};
22
23const DEFAULT_URL: &str = "about:blank";
24const CAPTURE_EVERY: Duration = Duration::from_millis(250);
25const META_EVERY: Duration = Duration::from_millis(1000);
26const VIEW_W: u32 = 1280;
27const VIEW_H: u32 = 720;
28
29pub struct BrowserSessionHandle {
30 pub session: String,
31 input_tx: mpsc::UnboundedSender<BrowserInput>,
32 kick_tx: mpsc::UnboundedSender<()>,
34 stop_tx: Option<oneshot::Sender<()>>,
35 join: Option<tokio::task::JoinHandle<()>>,
36}
37
38impl BrowserSessionHandle {
39 pub fn push_input(&self, input: BrowserInput) {
40 let _ = self.input_tx.send(input);
41 }
42
43 pub fn kick_frame(&self) {
44 let _ = self.kick_tx.send(());
45 }
46
47 pub async fn stop(mut self) {
48 if let Some(tx) = self.stop_tx.take() {
49 let _ = tx.send(());
50 }
51 if let Some(join) = self.join.take() {
52 let _ = join.await;
53 }
54 }
55}
56
57#[derive(Clone, Default)]
60pub struct BrowserEventSink {
61 inner: Arc<Mutex<Option<mpsc::UnboundedSender<BrowserStreamEvent>>>>,
62}
63
64impl BrowserEventSink {
65 pub async fn set(&self, tx: mpsc::UnboundedSender<BrowserStreamEvent>) {
66 *self.inner.lock().await = Some(tx);
67 }
68
69 pub async fn clear(&self) {
70 *self.inner.lock().await = None;
71 }
72
73 pub async fn send(&self, ev: BrowserStreamEvent) {
74 let mut guard = self.inner.lock().await;
75 let live = guard.is_some();
76 match &ev {
77 BrowserStreamEvent::Frame { session, bytes, .. } => {
78 tracing::debug!(%session, bytes = bytes.len(), sink_live = live, "d2b frame → sink");
79 }
80 BrowserStreamEvent::Started { session, url, .. } => {
81 tracing::debug!(%session, %url, sink_live = live, "d2b started → sink");
82 }
83 _ => {}
84 }
85 if let Some(tx) = guard.as_ref() {
86 if tx.send(ev).is_err() {
87 tracing::warn!("d2b sink send failed (bridge rx dropped)");
88 }
89 } else {
90 tracing::warn!("d2b sink unbound; event dropped");
91 }
92 }
93}
94
95pub struct BrowserSessionMap {
97 inner: Mutex<std::collections::BTreeMap<String, BrowserSessionHandle>>,
98 sink: BrowserEventSink,
99}
100
101impl Default for BrowserSessionMap {
102 fn default() -> Self {
103 Self {
104 inner: Mutex::new(std::collections::BTreeMap::new()),
105 sink: BrowserEventSink::default(),
106 }
107 }
108}
109
110impl BrowserSessionMap {
111 pub fn new() -> Self {
112 Self::default()
113 }
114
115 pub fn sink(&self) -> BrowserEventSink {
116 self.sink.clone()
117 }
118
119 pub async fn bind_bridge(&self, tx: mpsc::UnboundedSender<BrowserStreamEvent>) {
121 self.sink.set(tx).await;
122 let guard = self.inner.lock().await;
123 for h in guard.values() {
124 h.kick_frame();
125 }
126 }
127
128 pub async fn start(
129 &self,
130 node_id: impl Into<String>,
131 session: String,
132 url: String,
133 profile_dir: PathBuf,
134 event_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
135 ) -> Result<(), String> {
136 hatchery_core::validate_browser_session_id(&session)?;
137 self.sink.set(event_tx).await;
139 let node_id = node_id.into();
140 let mut guard = self.inner.lock().await;
141 if guard.contains_key(&session) {
142 self.sink
144 .send(BrowserStreamEvent::Started {
145 node_id,
146 session: session.clone(),
147 url: url.clone(),
148 })
149 .await;
150 if let Some(h) = guard.get(&session) {
151 h.kick_frame();
152 if !url.trim().is_empty() {
153 h.push_input(BrowserInput::Navigate { url });
154 }
155 }
156 return Ok(());
157 }
158 let handle = spawn_session(
159 node_id,
160 session.clone(),
161 if url.trim().is_empty() {
162 DEFAULT_URL.to_owned()
163 } else {
164 url
165 },
166 profile_dir,
167 self.sink.clone(),
168 )?;
169 guard.insert(session, handle);
170 Ok(())
171 }
172
173 pub async fn input(&self, session: &str, bytes: &[u8]) -> Result<(), String> {
174 let input = decode_browser_input(bytes)?;
175 let guard = self.inner.lock().await;
176 let Some(handle) = guard.get(session) else {
177 return Err("browser session not found".into());
178 };
179 handle.push_input(input);
180 Ok(())
181 }
182
183 pub async fn stop(&self, session: &str) -> Result<(), String> {
184 let mut guard = self.inner.lock().await;
185 let Some(handle) = guard.remove(session) else {
186 return Err("browser session not found".into());
187 };
188 drop(guard);
189 handle.stop().await;
190 Ok(())
191 }
192
193 pub async fn stop_all(&self) {
194 let mut guard = self.inner.lock().await;
195 let handles: Vec<_> = std::mem::take(&mut *guard).into_values().collect();
196 drop(guard);
197 for h in handles {
198 h.stop().await;
199 }
200 }
201}
202
203fn spawn_session(
204 node_id: String,
205 session: String,
206 start_url: String,
207 profile_dir: PathBuf,
208 sink: BrowserEventSink,
209) -> Result<BrowserSessionHandle, String> {
210 let (input_tx, input_rx) = mpsc::unbounded_channel();
211 let (kick_tx, kick_rx) = mpsc::unbounded_channel();
212 let (stop_tx, stop_rx) = oneshot::channel();
213 let session_for_task = session.clone();
214 let sink_err = sink.clone();
215 let join = tokio::spawn(async move {
216 if let Err(err) = run_session(
217 node_id.clone(),
218 session_for_task.clone(),
219 start_url,
220 profile_dir,
221 input_rx,
222 kick_rx,
223 stop_rx,
224 sink.clone(),
225 )
226 .await
227 {
228 sink_err
229 .send(BrowserStreamEvent::Error {
230 node_id: node_id.clone(),
231 session: session_for_task.clone(),
232 message: err,
233 })
234 .await;
235 }
236 sink_err
237 .send(BrowserStreamEvent::Stopped {
238 node_id,
239 session: session_for_task,
240 reason: "closed".into(),
241 })
242 .await;
243 });
244 Ok(BrowserSessionHandle {
245 session,
246 input_tx,
247 kick_tx,
248 stop_tx: Some(stop_tx),
249 join: Some(join),
250 })
251}
252
253async fn run_session(
254 node_id: String,
255 session: String,
256 start_url: String,
257 profile_dir: PathBuf,
258 mut input_rx: mpsc::UnboundedReceiver<BrowserInput>,
259 mut kick_rx: mpsc::UnboundedReceiver<()>,
260 mut stop_rx: oneshot::Receiver<()>,
261 sink: BrowserEventSink,
262) -> Result<(), String> {
263 std::fs::create_dir_all(&profile_dir).map_err(|e| e.to_string())?;
264 let shared = shared::profile().is_some_and(|p| p == profile_dir);
267 let (owned, mut page) = if shared {
268 (None, shared::open_page(&profile_dir).await?)
269 } else {
270 ensure_profile_accept_languages(&profile_dir);
271 let browser = StealthBrowser::launch_with(launch_config(&profile_dir), stealth_config())
272 .await
273 .map_err(|e| e.to_string())?;
274 let page = browser.new_page("about:blank").await.map_err(|e| e.to_string())?;
275 (Some(browser), page)
276 };
277 let _ = page.set_viewport(VIEW_W, VIEW_H, 1.0).await;
278 apply_browser_locale(&page).await;
279 let _ = page.cdp_call("Page.bringToFront", None).await;
281 let _ = page
282 .cdp_call("Emulation.setFocusEmulationEnabled", Some(json!({ "enabled": true })))
283 .await;
284 let _ = page.cdp_call("Page.navigate", Some(json!({ "url": start_url }))).await;
285 sink
286 .send(BrowserStreamEvent::Started {
287 node_id: node_id.clone(),
288 session: session.clone(),
289 url: start_url.clone(),
290 })
291 .await;
292 let mut force_kick = true;
294
295 let opener_id = page
296 .cdp_call("Target.getTargetInfo", None)
297 .await
298 .ok()
299 .and_then(|v| v["targetInfo"]["targetId"].as_str().map(str::to_owned))
300 .unwrap_or_default();
301
302 let (sc_tx, mut sc_rx) = mpsc::channel::<Vec<u8>>(4);
305 let mut screencast = match screencast::start(&profile_dir, &page, sc_tx).await {
306 Ok(h) => Some(h),
307 Err(e) => {
308 tracing::warn!(%session, error = %e, "d2b screencast unavailable; polling");
309 None
310 }
311 };
312
313 let (mut popup_rx, popup_watch) = match popups::start(&profile_dir, opener_id.clone()).await {
316 Ok(v) => v,
317 Err(e) => {
318 tracing::warn!(%session, error = %e, "d2b popup watch unavailable");
319 let (_t, r) = mpsc::unbounded_channel();
320 (r, tokio::spawn(async {}))
321 }
322 };
323 let mut parent_page: Option<StealthPage> = None;
325 let mut popup_id: Option<String> = None;
326 let mut seen_popup: std::collections::HashSet<String> = std::collections::HashSet::new();
328
329 let mut tick = tokio::time::interval(if screencast.is_some() { META_EVERY } else { CAPTURE_EVERY });
330 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
331 let mut last_hash: u64 = 0;
332 let (mut url, mut title) = (start_url.clone(), String::new());
333 let mut input_n: u64 = 0;
334 let mut pending: Option<(u64, std::time::Instant, u32, std::time::Instant, u32)> = None;
336 let mut last_frame: Option<Vec<u8>> = None;
337 let mut delta = delta::Delta::default();
338 let mut meta_fail: u32 = 0;
341 macro_rules! restore_parent {
342 () => {
343 if let Some(p) = parent_page.take() {
344 page = p;
345 popup_id = None;
346 let _ = page.cdp_call("Page.bringToFront", None).await;
347 if let Some(h) = screencast.take() { h.abort(); }
348 let (tx, rx) = mpsc::channel::<Vec<u8>>(4);
349 sc_rx = rx;
350 screencast = screencast::start(&profile_dir, &page, tx).await.ok();
351 delta = delta::Delta::default();
352 last_frame = None;
353 last_hash = 0;
354 url.clear();
355 title.clear();
356 force_kick = true;
357 tick.reset_immediately();
358 }
359 };
360 }
361 loop {
362 let frame: Option<(Vec<u8>, &'static str)> = tokio::select! {
363 _ = &mut stop_rx => break,
364 _ = kick_rx.recv() => {
365 force_kick = true;
366 tick.reset_immediately();
367 None
368 }
369 pe = popup_rx.recv() => {
370 match pe {
371 Some(popups::Ev::Opened { target_id, url: purl }) => {
372 if popup_id.as_deref() == Some(target_id.as_str()) || !seen_popup.insert(target_id.clone()) {
373 None
374 } else if parent_page.is_some() {
375 tracing::info!(%session, %target_id, "d2b popup ignored (already in one)");
377 None
378 } else {
379 tracing::info!(%session, %target_id, url = %purl, "d2b popup opened; switching stream");
380 match attach_target(shared, &owned, &profile_dir, &target_id).await {
381 Ok(child) => {
382 let _ = child.set_viewport(VIEW_W, VIEW_H, 1.0).await;
383 apply_browser_locale(&child).await;
384 let _ = child.cdp_call("Page.bringToFront", None).await;
385 let _ = child
386 .cdp_call("Emulation.setFocusEmulationEnabled", Some(json!({ "enabled": true })))
387 .await;
388 let old = std::mem::replace(&mut page, child);
389 parent_page = Some(old);
390 popup_id = Some(target_id);
391 if let Some(h) = screencast.take() { h.abort(); }
392 let (tx, rx) = mpsc::channel::<Vec<u8>>(4);
393 sc_rx = rx;
394 screencast = screencast::start(&profile_dir, &page, tx).await.ok();
395 delta = delta::Delta::default();
396 last_frame = None;
397 last_hash = 0;
398 force_kick = true;
399 tick.reset_immediately();
400 None
401 }
402 Err(e) => {
403 tracing::warn!(%session, error = %e, "d2b popup attach failed");
404 None
405 }
406 }
407 }
408 }
409 Some(popups::Ev::Closed { target_id }) => {
410 seen_popup.remove(&target_id);
411 if popup_id.as_deref() == Some(target_id.as_str()) {
412 tracing::info!(%session, %target_id, "d2b popup closed; restoring parent");
413 restore_parent!();
414 }
415 None
416 }
417 None => None,
418 }
419 }
420 f = sc_rx.recv(), if screencast.is_some() => f.map(|b| (b, "screencast")),
421 _ = tick.tick() => {
422 if screencast.is_some() {
423 match tokio::time::timeout(Duration::from_millis(1500), page_meta(&page)).await {
425 Ok(Ok((u, ti))) => {
426 meta_fail = 0;
427 let changed = u != url || ti != title;
428 url = u; title = ti;
429 if force_kick || last_frame.is_none() {
430 match tokio::time::timeout(Duration::from_secs(3), capture_frame(&page)).await {
433 Ok(Ok(bytes)) => { force_kick = false; Some((bytes, "poll")) }
434 Ok(Err(e)) => { tracing::warn!(%session, error = %e, "d2b force_kick capture failed"); None }
435 Err(_) => { tracing::warn!(%session, "d2b force_kick capture timeout"); None }
436 }
437 } else if changed {
438 last_frame.clone().map(|b| (b, "meta"))
439 } else { None }
440 }
441 _ if popup_id.is_some() && { meta_fail += 1; meta_fail >= 3 } => {
442 meta_fail = 0;
443 tracing::info!(%session, "d2b popup unresponsive; restoring parent");
445 if let Some(id) = popup_id.clone() { seen_popup.remove(&id); }
446 restore_parent!();
447 None
448 }
449 _ => None,
450 }
451 } else {
452 if let Ok(Ok((u, ti))) = tokio::time::timeout(Duration::from_secs(3), page_meta(&page)).await { url = u; title = ti; }
453 match tokio::time::timeout(Duration::from_secs(5), capture_frame(&page)).await {
454 Ok(Ok(bytes)) => {
455 let hash = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut h); h.finish() };
456 if hash != last_hash { last_hash = hash; Some((bytes, "poll")) } else { None }
457 }
458 _ => None,
459 }
460 }
461 }
462 input = input_rx.recv() => {
463 let Some(input) = input else { break };
464 let received = std::time::Instant::now();
465 input_n += 1;
466 let queue_ms = 0u32;
467 if matches!(&input, BrowserInput::Navigate { url } if url.is_empty()) {
468 last_frame.clone().map(|b| (b, "replay"))
470 } else {
471 let input = if matches!(&input, BrowserInput::Navigate { url } if !url.is_empty())
474 && popup_id.is_some()
475 {
476 if let Some(id) = popup_id.clone() {
477 tracing::info!(%session, %id, "d2b navigate closes popup first");
478 let _ = page.cdp_call("Target.closeTarget", Some(json!({ "targetId": id }))).await;
479 seen_popup.remove(&id);
480 restore_parent!();
481 }
482 input
483 } else {
484 input
485 };
486 let copy = matches!(&input, BrowserInput::Key { key } if keys::is_copy(key));
487 let sel = if copy { keys::selection(&page).await } else { None };
488 if let Some(text) = sel.filter(|t| !t.is_empty()) {
489 sink.send(BrowserStreamEvent::Clipboard { node_id: node_id.clone(), session: session.clone(), text }).await;
490 }
491 match apply_input(&page, &input).await {
492 Err(err) => { tracing::warn!(%session, error = %err, "browser input failed"); None }
493 Ok(()) => {
494 let cdp_ms = received.elapsed().as_millis() as u32;
495 pending = Some((input_n, received, cdp_ms, std::time::Instant::now(), queue_ms));
496 if screencast.is_none() {
497 last_hash = 0;
498 tick.reset_immediately();
499 }
500 None
501 }
502 }
503 }
504 }
505 };
506 let Some((bytes, source)) = frame else { continue };
507 let (bytes, region) = if source == "screencast" {
510 match delta.encode(&bytes) {
511 delta::Out::Unchanged => continue,
512 delta::Out::Full => (bytes, None),
513 delta::Out::Region(b, r) => (b, Some(r)),
514 }
515 } else if source == "replay" || source == "meta" {
516 (delta.full_jpeg().unwrap_or(bytes), None)
517 } else {
518 (bytes, None)
519 };
520 if region.is_none() && (source == "screencast" || source == "poll") {
521 last_frame = Some(bytes.clone());
522 }
523 let trace = if source == "screencast" || source == "poll" {
524 pending.take().map(|(n, received, cdp_ms, dispatched, queue_ms)| BrowserFrameTrace {
525 input_n: n,
526 queue_ms,
527 cdp_ms,
528 to_frame_ms: dispatched.elapsed().as_millis() as u32,
529 node_ms: received.elapsed().as_millis() as u32,
530 source: if region.is_some() { "screencast-delta".to_owned() } else { source.to_owned() },
531 })
532 } else { None };
533 sink
534 .send(BrowserStreamEvent::Frame {
535 node_id: node_id.clone(),
536 session: session.clone(),
537 bytes,
538 width: VIEW_W,
539 height: VIEW_H,
540 url: url.clone(),
541 title: title.clone(),
542 trace,
543 region,
544 })
545 .await;
546 }
547 popup_watch.abort();
548 if let Some(h) = screencast { h.abort(); }
549 if let Some(id) = popup_id.take() {
551 let _ = page.cdp_call("Target.closeTarget", Some(json!({ "targetId": id }))).await;
552 if let Some(parent) = parent_page.take() {
553 page = parent;
554 }
555 }
556 match owned {
557 Some(browser) => { let _ = browser.close().await; }
558 None => { let _ = page.cdp_call("Page.close", None).await; }
559 }
560 Ok(())
561}
562
563async fn attach_target(
564 shared_profile: bool,
565 owned: &Option<StealthBrowser>,
566 profile_dir: &std::path::Path,
567 target_id: &str,
568) -> Result<StealthPage, String> {
569 if shared_profile {
570 shared::attach(profile_dir, target_id).await
571 } else {
572 let b = owned.as_ref().ok_or("no browser for attach")?;
573 b.attach_page(target_id).await.map_err(|e| e.to_string())
574 }
575}
576
577
578fn launch_config(profile_dir: &std::path::Path) -> LaunchConfig {
579 let extra_args = vec![
583 "--force-device-scale-factor=1".into(),
584 "--disable-dev-shm-usage".into(),
585 "--disable-backgrounding-occluded-windows".into(),
587 "--disable-renderer-backgrounding".into(),
588 "--disable-background-timer-throttling".into(),
589 ];
590 LaunchConfig {
591 headless: std::env::var("HATCHERY_D2B_HEADFUL").map(|v| v != "1").unwrap_or(true)
593 || std::env::var_os("DISPLAY").is_none(),
594 window_size: (VIEW_W, VIEW_H),
595 profile: BrowserProfile::Persistent(profile_dir.to_path_buf()),
596 browser_pref: BrowserPreference::ChromeOnly,
597 renderer_sandbox: RendererSandboxMode::CompatibilityDisabled,
598 extra_args,
599 ..LaunchConfig::default()
600 }
601}
602
603fn browser_accept_languages() -> Option<String> {
606 std::env::var("HATCHERY_BROWSER_ACCEPT_LANGUAGES")
607 .ok()
608 .map(|v| v.trim().to_owned())
609 .filter(|v| !v.is_empty())
610}
611
612fn browser_language_tags() -> Vec<String> {
614 let Some(raw) = browser_accept_languages() else { return Vec::new() };
615 raw.split(',')
616 .map(|p| p.split(';').next().unwrap_or(p).trim().to_owned())
617 .filter(|t| !t.is_empty())
618 .collect()
619}
620
621fn ensure_profile_accept_languages(profile_dir: &std::path::Path) {
624 let Some(al) = browser_accept_languages() else { return };
625 let pref = profile_dir.join("Default").join("Preferences");
626 let Ok(raw) = std::fs::read_to_string(&pref) else {
627 let _ = std::fs::create_dir_all(profile_dir.join("Default"));
629 let seed = serde_json::json!({ "intl": { "accept_languages": al } });
630 let _ = std::fs::write(&pref, seed.to_string());
631 return;
632 };
633 let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&raw) else { return };
634 let obj = v.as_object_mut().unwrap();
635 let intl = obj
636 .entry("intl".to_owned())
637 .or_insert_with(|| json!({}))
638 .as_object_mut();
639 let Some(intl) = intl else { return };
640 intl.insert("accept_languages".into(), json!(al));
641 if let Ok(out) = serde_json::to_string(&v) {
642 let _ = std::fs::write(&pref, out);
643 }
644}
645
646fn stealth_config() -> StealthConfig {
651 let mut cfg = StealthConfig::default();
652 let lang = std::env::var("HATCHERY_BROWSER_LANG").ok().filter(|v| !v.is_empty());
653 let tz = std::env::var("HATCHERY_BROWSER_TZ").ok().filter(|v| !v.is_empty());
654 if lang.is_none() && tz.is_none() {
655 return cfg;
656 }
657 if let Some(ref t) = tz {
658 std::env::set_var("TZ", t);
660 }
661 cfg.locale = LocaleProfile {
662 locale: lang.unwrap_or_else(|| cfg.locale.locale.clone()),
663 timezone: tz.or(cfg.locale.timezone),
664 };
665 cfg
666}
667
668async fn apply_browser_locale(page: &StealthPage) {
673 if let Ok(lang) = std::env::var("HATCHERY_BROWSER_LANG") {
674 if !lang.is_empty() {
675 let _ = page
676 .cdp_call("Emulation.setLocaleOverride", Some(json!({ "locale": lang })))
677 .await;
678 }
679 }
680 if let Ok(tz) = std::env::var("HATCHERY_BROWSER_TZ") {
681 if !tz.is_empty() {
682 let _ = page
683 .cdp_call(
684 "Emulation.setTimezoneOverride",
685 Some(json!({ "timezoneId": tz })),
686 )
687 .await;
688 }
689 }
690 if let Some(al) = browser_accept_languages() {
691 let _ = page.cdp_call("Network.enable", None).await;
693 let _ = page
694 .cdp_call(
695 "Network.setExtraHTTPHeaders",
696 Some(json!({ "headers": { "Accept-Language": al } })),
697 )
698 .await;
699 }
700 let tags = browser_language_tags();
701 if tags.is_empty() {
702 return;
703 }
704 let primary = tags.first().cloned().unwrap_or_else(|| "en-US".into());
705 let tags_json = serde_json::to_string(&tags).unwrap_or_else(|_| "[]".into());
706 let src = format!(
707 r#"(function(){{
708 const tags = {tags_json};
709 try {{
710 Object.defineProperty(Navigator.prototype, 'languages', {{
711 get: () => Object.freeze(tags.slice()), configurable: true
712 }});
713 Object.defineProperty(Navigator.prototype, 'language', {{
714 get: () => {primary:?}, configurable: true
715 }});
716 }} catch (_) {{}}
717}})();"#
718 );
719 let _ = page
720 .cdp_call(
721 "Page.addScriptToEvaluateOnNewDocument",
722 Some(json!({ "source": src })),
723 )
724 .await;
725 let _ = page
726 .eval(&format!(
727 "(() => {{ try {{ Object.defineProperty(navigator,'languages',{{get:()=>Object.freeze({tags_json}),configurable:true}}); Object.defineProperty(navigator,'language',{{get:()=>{primary:?},configurable:true}}); }} catch(_){{}} return [navigator.language, navigator.languages]; }})()"
728 ))
729 .await;
730}
731
732pub mod shared {
736 use super::*;
737 use std::sync::OnceLock;
738
739 pub fn profile() -> Option<PathBuf> {
740 std::env::var_os("HATCHERY_BROWSER_PROFILE")
741 .filter(|v| !v.is_empty())
742 .map(PathBuf::from)
743 }
744
745 fn cell() -> &'static Mutex<Option<StealthBrowser>> {
746 static CELL: OnceLock<Mutex<Option<StealthBrowser>>> = OnceLock::new();
747 CELL.get_or_init(|| Mutex::new(None))
748 }
749
750 fn kill_stale(dir: &std::path::Path) {
752 let want = format!("--user-data-dir={}", dir.display());
753 let Ok(rd) = std::fs::read_dir("/proc") else { return };
754 for entry in rd.flatten() {
755 let Ok(pid) = entry.file_name().to_string_lossy().parse::<i32>() else { continue };
756 let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else { continue };
757 let args: Vec<&[u8]> = raw.split(|b| *b == 0).collect();
758 if args.iter().any(|a| *a == want.as_bytes()) {
759 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
760 }
761 }
762 std::thread::sleep(Duration::from_millis(500));
763 for f in ["SingletonLock", "SingletonSocket", "SingletonCookie"] {
764 let _ = std::fs::remove_file(dir.join(f));
765 }
766 }
767
768 pub async fn open_page(dir: &std::path::Path) -> Result<StealthPage, String> {
769 let mut g = cell().lock().await;
770 let mut last = String::new();
771 for _ in 0..2 {
772 if g.is_none() {
773 kill_stale(dir);
774 ensure_profile_accept_languages(dir);
775 let b = StealthBrowser::launch_with(launch_config(dir), stealth_config())
776 .await
777 .map_err(|e| e.to_string())?;
778 *g = Some(b);
779 }
780 let b = g.as_ref().expect("browser set");
781 match new_window_page(b, dir).await {
782 Ok(p) => return Ok(p),
783 Err(e) => {
784 tracing::warn!(error = %e, "shared browser unusable; relaunching");
785 last = e;
786 if let Some(old) = g.take() { let _ = old.close().await; }
787 }
788 }
789 }
790 Err(last)
791 }
792
793 pub async fn attach(dir: &std::path::Path, target_id: &str) -> Result<StealthPage, String> {
795 let g = cell().lock().await;
796 let b = g.as_ref().ok_or("shared browser not running")?;
797 let _ = dir;
799 b.attach_page(target_id).await.map_err(|e| e.to_string())
800 }
801
802 async fn new_window_page(b: &StealthBrowser, dir: &std::path::Path) -> Result<StealthPage, String> {
804 use futures_util::{SinkExt, StreamExt};
805 use tokio_tungstenite::tungstenite::Message;
806 let port = super::screencast::devtools_port(dir).ok_or("devtools port not found")?;
807 let path = browser_ws_path(port).await?;
808 let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}{path}"))
809 .await
810 .map_err(|e| e.to_string())?;
811 ws.send(Message::text(json!({ "id": 1, "method": "Target.createTarget",
812 "params": { "url": "about:blank", "newWindow": true } }).to_string()))
813 .await
814 .map_err(|e| e.to_string())?;
815 let target = tokio::time::timeout(Duration::from_secs(10), async {
816 while let Some(Ok(m)) = ws.next().await {
817 let Message::Text(t) = m else { continue };
818 let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) else { continue };
819 if v["id"] == 1 {
820 return v["result"]["targetId"].as_str().map(str::to_owned).ok_or_else(|| t.to_string());
821 }
822 }
823 Err("browser endpoint closed".to_string())
824 })
825 .await
826 .map_err(|_| "createTarget timed out".to_string())??;
827 let _ = ws.close(None).await;
828 b.attach_page(&target).await.map_err(|e| e.to_string())
829 }
830
831 async fn browser_ws_path(port: u16) -> Result<String, String> {
833 use tokio::io::{AsyncReadExt, AsyncWriteExt};
834 let mut s = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.map_err(|e| e.to_string())?;
835 s.write_all(b"GET /json/version HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").await.map_err(|e| e.to_string())?;
837 let mut buf = Vec::new();
838 let body = tokio::time::timeout(Duration::from_secs(5), async {
839 let mut chunk = [0u8; 4096];
840 loop {
841 let n = s.read(&mut chunk).await.map_err(|e| e.to_string())?;
842 if n == 0 { return Err("devtools closed".to_string()) }
843 buf.extend_from_slice(&chunk[..n]);
844 let text = String::from_utf8_lossy(&buf).into_owned();
845 if let Some((head, body)) = text.split_once("\r\n\r\n") {
846 let len = head.lines()
847 .find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").and_then(|v| v.trim().parse::<usize>().ok()))
848 .unwrap_or(0);
849 if body.len() >= len { return Ok(body.to_owned()) }
850 }
851 }
852 })
853 .await
854 .map_err(|_| "devtools /json/version timed out".to_string())??;
855 let body = body.as_str();
856 let v: serde_json::Value = serde_json::from_str(body).map_err(|e| e.to_string())?;
857 let url = v["webSocketDebuggerUrl"].as_str().ok_or("no webSocketDebuggerUrl")?;
858 url.find("/devtools/browser").map(|i| url[i..].to_owned()).ok_or_else(|| "bad webSocketDebuggerUrl".into())
859 }
860}
861
862async fn page_meta(page: &StealthPage) -> Result<(String, String), String> {
863 let v = page.eval("[location.href, document.title]").await.map_err(|e| e.to_string())?;
864 let a = v.as_array().cloned().unwrap_or_default();
865 Ok((
866 a.first().and_then(|x| x.as_str()).unwrap_or_default().to_owned(),
867 a.get(1).and_then(|x| x.as_str()).unwrap_or_default().to_owned(),
868 ))
869}
870
871mod delta {
874 use super::BrowserFrameRegion;
875 use image::{codecs::jpeg::JpegEncoder, ImageReader, RgbImage};
876
877 const TILE: u32 = 32;
878 #[derive(Default)]
879 pub struct Delta { prev: Option<RgbImage> }
880 pub enum Out { Unchanged, Full, Region(Vec<u8>, BrowserFrameRegion) }
881
882 impl Delta {
883 pub fn encode(&mut self, jpeg: &[u8]) -> Out {
884 let Ok(img) = ImageReader::new(std::io::Cursor::new(jpeg)).with_guessed_format().map_err(|_| ()).and_then(|r| r.decode().map_err(|_| ())) else { return Out::Full };
885 let cur = img.to_rgb8();
886 let Some(prev) = self.prev.as_ref().filter(|p| p.dimensions() == cur.dimensions()) else {
887 self.prev = Some(cur);
888 return Out::Full;
889 };
890 let (w, h) = cur.dimensions();
891 let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);
892 let (pr, cr) = (prev.as_raw(), cur.as_raw());
893 for ty in (0..h).step_by(TILE as usize) {
894 for tx in (0..w).step_by(TILE as usize) {
895 let tw = TILE.min(w - tx); let th = TILE.min(h - ty);
896 let mut changed = false;
897 for yy in ty..ty + th {
898 let a = ((yy * w + tx) * 3) as usize; let b = a + (tw * 3) as usize;
899 if pr[a..b].iter().zip(&cr[a..b]).any(|(p, c)| p.abs_diff(*c) > 24) { changed = true; break; }
901 }
902 if changed { x0 = x0.min(tx); y0 = y0.min(ty); x1 = x1.max(tx + tw); y1 = y1.max(ty + th); }
903 }
904 }
905 if x0 == u32::MAX { return Out::Unchanged; }
906 let (rw, rh) = (x1 - x0, y1 - y0);
907 if (rw * rh) as u64 * 2 > (w * h) as u64 {
908 self.prev = Some(cur);
909 return Out::Full;
910 }
911 let crop = image::imageops::crop_imm(&cur, x0, y0, rw, rh).to_image();
912 let mut out = Vec::new();
913 if JpegEncoder::new_with_quality(&mut out, 70).encode_image(&crop).is_err() { self.prev = Some(cur); return Out::Full; }
914 let mut next = prev.clone();
916 image::imageops::replace(&mut next, &crop, x0 as i64, y0 as i64);
917 self.prev = Some(next);
918 Out::Region(out, BrowserFrameRegion { x: x0, y: y0, w: rw, h: rh, src_w: w, src_h: h })
919 }
920
921 pub fn full_jpeg(&self) -> Option<Vec<u8>> {
923 let img = self.prev.as_ref()?;
924 let mut out = Vec::new();
925 JpegEncoder::new_with_quality(&mut out, 60).encode_image(img).ok()?;
926 Some(out)
927 }
928 }
929}
930
931mod popups {
933 use super::*;
934 use futures_util::{SinkExt, StreamExt};
935 use tokio_tungstenite::tungstenite::Message;
936
937 pub enum Ev {
938 Opened { target_id: String, url: String },
940 Closed { target_id: String },
942 }
943
944 pub async fn start(
947 profile_dir: &std::path::Path,
948 opener_id: String,
949 ) -> Result<(mpsc::UnboundedReceiver<Ev>, tokio::task::JoinHandle<()>), String> {
950 let port = screencast::devtools_port(profile_dir).ok_or("devtools port not found")?;
951 let path = browser_ws_path(port).await?;
952 let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}{path}"))
953 .await
954 .map_err(|e| e.to_string())?;
955 let (tx, rx) = mpsc::unbounded_channel();
956 ws.send(Message::text(
959 json!({ "id": 1, "method": "Target.setDiscoverTargets", "params": { "discover": true } })
960 .to_string(),
961 ))
962 .await
963 .map_err(|e| e.to_string())?;
964 ws.send(Message::text(json!({ "id": 2, "method": "Target.getTargets" }).to_string()))
965 .await
966 .map_err(|e| e.to_string())?;
967 let join = tokio::spawn(async move {
968 while let Some(Ok(msg)) = ws.next().await {
969 let Message::Text(text) = msg else { continue };
970 let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
971 if v["id"] == 2 {
972 if let Some(arr) = v["result"]["targetInfos"].as_array() {
973 for t in arr {
974 maybe_open(&tx, &opener_id, t);
975 }
976 }
977 continue;
978 }
979 match v["method"].as_str() {
980 Some("Target.targetCreated") => {
981 maybe_open(&tx, &opener_id, &v["params"]["targetInfo"]);
982 }
983 Some("Target.targetDestroyed") => {
984 if let Some(id) = v["params"]["targetId"].as_str() {
985 let _ = tx.send(Ev::Closed {
986 target_id: id.to_owned(),
987 });
988 }
989 }
990 Some("Target.targetInfoChanged") => {
991 maybe_open(&tx, &opener_id, &v["params"]["targetInfo"]);
993 }
994 _ => {}
995 }
996 }
997 });
998 Ok((rx, join))
999 }
1000
1001 fn maybe_open(tx: &mpsc::UnboundedSender<Ev>, opener_id: &str, info: &serde_json::Value) {
1002 let ty = info["type"].as_str().unwrap_or("");
1003 if ty != "page" && ty != "webview" {
1004 return;
1005 }
1006 let Some(tid) = info["targetId"].as_str() else { return };
1007 let opener = info["openerId"].as_str().unwrap_or("");
1008 if opener != opener_id {
1009 return;
1010 }
1011 let url = info["url"].as_str().unwrap_or("").to_owned();
1012 let _ = tx.send(Ev::Opened {
1013 target_id: tid.to_owned(),
1014 url,
1015 });
1016 }
1017
1018 async fn browser_ws_path(port: u16) -> Result<String, String> {
1019 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1020 let mut s = tokio::net::TcpStream::connect(("127.0.0.1", port))
1021 .await
1022 .map_err(|e| e.to_string())?;
1023 s.write_all(b"GET /json/version HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
1024 .await
1025 .map_err(|e| e.to_string())?;
1026 let mut buf = Vec::new();
1027 let body = tokio::time::timeout(Duration::from_secs(5), async {
1028 let mut chunk = [0u8; 4096];
1029 loop {
1030 let n = s.read(&mut chunk).await.map_err(|e| e.to_string())?;
1031 if n == 0 {
1032 return Err("devtools closed".to_string());
1033 }
1034 buf.extend_from_slice(&chunk[..n]);
1035 let text = String::from_utf8_lossy(&buf).into_owned();
1036 if let Some((head, body)) = text.split_once("\r\n\r\n") {
1037 let len = head
1038 .lines()
1039 .find_map(|l| {
1040 l.to_ascii_lowercase()
1041 .strip_prefix("content-length:")
1042 .and_then(|v| v.trim().parse::<usize>().ok())
1043 })
1044 .unwrap_or(0);
1045 if body.len() >= len {
1046 return Ok(body.to_owned());
1047 }
1048 }
1049 }
1050 })
1051 .await
1052 .map_err(|_| "devtools /json/version timed out".to_string())??;
1053 let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
1054 let url = v["webSocketDebuggerUrl"]
1055 .as_str()
1056 .ok_or("no webSocketDebuggerUrl")?;
1057 url.find("/devtools/browser")
1058 .map(|i| url[i..].to_owned())
1059 .ok_or_else(|| "bad webSocketDebuggerUrl".into())
1060 }
1061}
1062
1063mod screencast {
1065 use super::*;
1066 use futures_util::{SinkExt, StreamExt};
1067 use tokio_tungstenite::tungstenite::Message;
1068
1069 pub(super) fn devtools_port(profile_dir: &std::path::Path) -> Option<u16> {
1072 if let Ok(s) = std::fs::read_to_string(profile_dir.join("DevToolsActivePort")) {
1073 if let Some(p) = s.lines().next().and_then(|l| l.trim().parse().ok()) { return Some(p); }
1074 }
1075 let want = format!("--user-data-dir={}", profile_dir.display());
1076 for entry in std::fs::read_dir("/proc").ok()?.flatten() {
1077 let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else { continue };
1078 let args: Vec<String> = raw.split(|b| *b == 0).map(|a| String::from_utf8_lossy(a).into_owned()).collect();
1079 if !args.iter().any(|a| a == &want) { continue; }
1080 if let Some(p) = args.iter().find_map(|a| a.strip_prefix("--remote-debugging-port=").and_then(|v| v.parse().ok())) {
1081 return Some(p);
1082 }
1083 }
1084 None
1085 }
1086
1087 const BUDGET: usize = 40 * 1024;
1089
1090 pub async fn start(
1091 profile_dir: &std::path::Path,
1092 page: &StealthPage,
1093 out: mpsc::Sender<Vec<u8>>,
1094 ) -> Result<tokio::task::JoinHandle<()>, String> {
1095 let port = devtools_port(profile_dir).ok_or("devtools port not found")?;
1096 let info = page.cdp_call("Target.getTargetInfo", None).await.map_err(|e| e.to_string())?;
1097 let target = info["targetInfo"]["targetId"].as_str().ok_or("no target id")?.to_owned();
1098 let url = format!("ws://127.0.0.1:{port}/devtools/page/{target}");
1099 let (ws, _) = tokio_tungstenite::connect_async(&url).await.map_err(|e| e.to_string())?;
1100 let (mut tx, mut rx) = ws.split();
1101 let mut quality: u32 = 50;
1102 let mut max_w: u32 = VIEW_W;
1103 let mut id: u64 = 1;
1104 let start_msg = |id: u64, q: u32, w: u32| json!({ "id": id, "method": "Page.startScreencast",
1105 "params": { "format": "jpeg", "quality": q, "maxWidth": w, "maxHeight": w * VIEW_H / VIEW_W, "everyNthFrame": 1 } }).to_string();
1106 tx.send(Message::text(json!({ "id": id, "method": "Page.enable" }).to_string())).await.map_err(|e| e.to_string())?;
1107 id += 1;
1108 tx.send(Message::text(start_msg(id, quality, max_w))).await.map_err(|e| e.to_string())?;
1109 Ok(tokio::spawn(async move {
1110 while let Some(Ok(msg)) = rx.next().await {
1111 let Message::Text(text) = msg else { continue };
1112 let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
1113 if v["method"] != "Page.screencastFrame" { continue; }
1114 let sid = v["params"]["sessionId"].clone();
1115 id += 1;
1117 let _ = tx.send(Message::text(json!({ "id": id, "method": "Page.screencastFrameAck", "params": { "sessionId": sid } }).to_string())).await;
1118 let Some(b64) = v["params"]["data"].as_str() else { continue };
1119 use base64::Engine;
1120 let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64) else { continue };
1121 if bytes.len() > BUDGET {
1122 if quality > 25 { quality -= 15 } else if max_w > 640 { max_w = max_w * 3 / 4 } else { continue }
1124 id += 1;
1125 let _ = tx.send(Message::text(json!({ "id": id, "method": "Page.stopScreencast" }).to_string())).await;
1126 id += 1;
1127 let _ = tx.send(Message::text(start_msg(id, quality, max_w))).await;
1128 continue;
1129 }
1130 let _ = out.try_send(bytes);
1132 }
1133 }))
1134 }
1135}
1136
1137async fn capture_frame(page: &StealthPage) -> Result<Vec<u8>, String> {
1138 const FRAME_BUDGET: usize = 40 * 1024;
1142 let mut bytes = Vec::new();
1143 'fit: for scale in [1.0f64, 0.75, 0.5] {
1144 for q in [60u32, 45, 32, 22, 14] {
1145 let mut params = json!({ "format": "jpeg", "quality": q });
1146 if scale < 1.0 {
1147 params["clip"] = json!({ "x": 0, "y": 0, "width": VIEW_W, "height": VIEW_H, "scale": scale });
1148 }
1149 let v = page.cdp_call("Page.captureScreenshot", Some(params)).await.map_err(|e| e.to_string())?;
1150 let b64 = v.get("data").and_then(|x| x.as_str()).ok_or("no jpeg data")?;
1151 use base64::Engine;
1152 bytes = base64::engine::general_purpose::STANDARD.decode(b64).map_err(|e| e.to_string())?;
1153 if bytes.len() <= FRAME_BUDGET {
1154 break 'fit;
1155 }
1156 }
1157 }
1158 if bytes.len() > FRAME_BUDGET {
1159 return Err(format!("frame too large ({} bytes)", bytes.len()));
1160 }
1161 Ok(bytes)
1162}
1163
1164async fn apply_input(page: &StealthPage, input: &BrowserInput) -> Result<(), String> {
1165 match input {
1166 BrowserInput::Click { x, y, button } => {
1167 let b = match button {
1170 BrowserMouseButton::Left => "left",
1171 BrowserMouseButton::Right => "right",
1172 BrowserMouseButton::Middle => "middle",
1173 };
1174 tracing::info!(x = *x, y = *y, button = b, "d2b click");
1175 page.cdp_call(
1176 "Input.dispatchMouseEvent",
1177 Some(json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none" })),
1178 )
1179 .await
1180 .map_err(|e| e.to_string())?;
1181 for ty in ["mousePressed", "mouseReleased"] {
1182 page.cdp_call(
1183 "Input.dispatchMouseEvent",
1184 Some(json!({ "type": ty, "x": x, "y": y, "button": b, "buttons": if ty == "mousePressed" { 1 } else { 0 }, "clickCount": 1 })),
1185 )
1186 .await
1187 .map_err(|e| e.to_string())?;
1188 }
1189 }
1190 BrowserInput::MouseDown { x, y, button } | BrowserInput::MouseUp { x, y, button } => {
1191 let down = matches!(input, BrowserInput::MouseDown { .. });
1192 let b = match button {
1193 BrowserMouseButton::Left => "left",
1194 BrowserMouseButton::Right => "right",
1195 BrowserMouseButton::Middle => "middle",
1196 };
1197 let count = mouse::click_count(down, *x, *y);
1198 if down {
1199 page.cdp_call("Input.dispatchMouseEvent", Some(json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none" })))
1200 .await
1201 .map_err(|e| e.to_string())?;
1202 }
1203 page.cdp_call(
1204 "Input.dispatchMouseEvent",
1205 Some(json!({ "type": if down { "mousePressed" } else { "mouseReleased" }, "x": x, "y": y,
1206 "button": b, "buttons": if down { mouse::bit(b) } else { 0 }, "clickCount": count })),
1207 )
1208 .await
1209 .map_err(|e| e.to_string())?;
1210 mouse::set_held(if down { mouse::bit(b) } else { 0 });
1211 }
1212 BrowserInput::MouseMove { x, y } => {
1213 let held = mouse::held();
1214 let b = match held { 1 => "left", 2 => "right", 4 => "middle", _ => "none" };
1215 page.cdp_call("Input.dispatchMouseEvent", Some(json!({ "type": "mouseMoved", "x": x, "y": y, "button": b, "buttons": held })))
1216 .await
1217 .map_err(|e| e.to_string())?;
1218 }
1219 BrowserInput::Scroll { x, y, dx, dy } => {
1220 page.wheel(*x, *y, *dx, *dy)
1221 .await
1222 .map_err(|e| e.to_string())?
1223 }
1224 BrowserInput::Key { key } => keys::press(page, key).await?,
1225 BrowserInput::Text { text } => {
1226 match page
1228 .cdp_call("Input.insertText", Some(json!({ "text": text })))
1229 .await
1230 {
1231 Ok(_) => {}
1232 Err(_) => {
1233 for ch in text.chars() {
1234 page.key_press(&ch.to_string())
1235 .await
1236 .map_err(|e| e.to_string())?;
1237 }
1238 }
1239 }
1240 }
1241 BrowserInput::Navigate { url } if url.is_empty() => {}
1242 BrowserInput::Navigate { url } => {
1243 tracing::info!(%url, "d2b navigate");
1247 let _ = page.cdp_call("Page.bringToFront", None).await;
1248 match tokio::time::timeout(
1249 Duration::from_secs(5),
1250 page.cdp_call("Page.navigate", Some(json!({ "url": url }))),
1251 )
1252 .await
1253 {
1254 Ok(Ok(r)) => {
1255 if let Some(err) = r.get("errorText").and_then(|v| v.as_str()) {
1256 if !err.is_empty() {
1257 return Err(format!("navigate: {err}"));
1258 }
1259 }
1260 }
1261 Ok(Err(e)) => return Err(e.to_string()),
1262 Err(_) => tracing::warn!(%url, "d2b navigate timed out; continuing"),
1263 }
1264 }
1265 }
1266 Ok(())
1267}
1268
1269mod mouse {
1271 use std::sync::atomic::{AtomicU32, Ordering};
1272 use std::sync::Mutex;
1273 static HELD: AtomicU32 = AtomicU32::new(0);
1274 static LAST: Mutex<Option<(std::time::Instant, f64, f64, u32)>> = Mutex::new(None);
1275
1276 pub fn bit(b: &str) -> u32 { match b { "left" => 1, "right" => 2, "middle" => 4, _ => 0 } }
1277 pub fn held() -> u32 { HELD.load(Ordering::Relaxed) }
1278 pub fn set_held(v: u32) { HELD.store(v, Ordering::Relaxed) }
1279
1280 pub fn click_count(down: bool, x: f64, y: f64) -> u32 {
1282 let mut g = LAST.lock().unwrap_or_else(|e| e.into_inner());
1283 let now = std::time::Instant::now();
1284 let prev = g.filter(|(t, px, py, _)| now.duration_since(*t).as_millis() < 500 && (px - x).abs() < 6.0 && (py - y).abs() < 6.0);
1285 if !down { return prev.map(|p| p.3).unwrap_or(1) }
1286 let n = prev.map(|p| p.3 % 3 + 1).unwrap_or(1);
1287 *g = Some((now, x, y, n));
1288 n
1289 }
1290}
1291
1292pub fn shared_map() -> Arc<BrowserSessionMap> {
1294 use std::sync::OnceLock;
1295 static MAP: OnceLock<Arc<BrowserSessionMap>> = OnceLock::new();
1296 MAP.get_or_init(|| Arc::new(BrowserSessionMap::new())).clone()
1297}
1298
1299mod keys {
1303 use super::*;
1304
1305 pub struct Chord<'a> { pub mods: u32, pub key: &'a str }
1306
1307 pub fn parse(combo: &str) -> Chord<'_> {
1308 let mut mods = 0;
1309 let mut rest = combo;
1310 loop {
1311 let Some((head, tail)) = rest.split_once('+') else { break };
1312 if tail.is_empty() { break }
1313 let bit = match head { "Alt" => 1, "Control" | "Ctrl" => 2, "Meta" => 4, "Shift" => 8, _ => break };
1314 mods |= bit;
1315 rest = tail;
1316 }
1317 Chord { mods, key: rest }
1318 }
1319
1320 pub fn def(key: &str) -> (String, u32, Option<String>) {
1322 let named = |code: &str, vk: u32, text: Option<&str>| (code.to_owned(), vk, text.map(str::to_owned));
1323 match key {
1324 "Backspace" => named("Backspace", 8, None),
1325 "Tab" => named("Tab", 9, None),
1326 "Enter" => named("Enter", 13, Some("\r")),
1327 "Escape" => named("Escape", 27, None),
1328 "PageUp" => named("PageUp", 33, None),
1329 "PageDown" => named("PageDown", 34, None),
1330 "End" => named("End", 35, None),
1331 "Home" => named("Home", 36, None),
1332 "ArrowLeft" => named("ArrowLeft", 37, None),
1333 "ArrowUp" => named("ArrowUp", 38, None),
1334 "ArrowRight" => named("ArrowRight", 39, None),
1335 "ArrowDown" => named("ArrowDown", 40, None),
1336 "Insert" => named("Insert", 45, None),
1337 "Delete" => named("Delete", 46, None),
1338 k if k.len() > 1 && k.starts_with('F') && k[1..].parse::<u32>().is_ok_and(|n| (1..=12).contains(&n)) => {
1339 let n: u32 = k[1..].parse().unwrap_or(1);
1340 (k.to_owned(), 111 + n, None)
1341 }
1342 k => {
1343 let mut it = k.chars();
1344 match (it.next(), it.next()) {
1345 (Some(c), None) if c.is_ascii_alphabetic() => {
1346 let u = c.to_ascii_uppercase();
1347 (format!("Key{u}"), u as u32, Some(k.to_owned()))
1348 }
1349 (Some(c), None) if c.is_ascii_digit() => (format!("Digit{c}"), c as u32, Some(k.to_owned())),
1350 (Some(' '), None) => ("Space".into(), 32, Some(" ".into())),
1351 (Some(_), None) => (String::new(), 0, Some(k.to_owned())),
1352 _ => (String::new(), 0, None),
1353 }
1354 }
1355 }
1356 }
1357
1358 pub fn commands(mods: u32, key: &str) -> Vec<&'static str> {
1359 if mods & 6 == 0 { return vec![] }
1360 match (key.to_ascii_lowercase().as_str(), mods & 8 != 0) {
1361 ("a", _) => vec!["selectAll"],
1362 ("c", _) => vec!["copy"],
1363 ("x", _) => vec!["cut"],
1364 ("v", _) => vec!["paste"],
1365 ("z", false) => vec!["undo"],
1366 ("z", true) | ("y", _) => vec!["redo"],
1367 _ => vec![],
1368 }
1369 }
1370
1371 pub fn is_copy(combo: &str) -> bool {
1372 let c = parse(combo);
1373 c.mods & 6 != 0 && matches!(c.key, "c" | "C" | "x" | "X" | "Insert")
1374 }
1375
1376 pub async fn selection(page: &StealthPage) -> Option<String> {
1378 let v = page.eval("(() => { const a = document.activeElement; let t = ''; \
1379 if (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA') && typeof a.selectionStart === 'number') t = a.value.slice(a.selectionStart, a.selectionEnd); \
1380 if (!t) t = String(window.getSelection() || ''); return t.slice(0, 100000); })()").await.ok()?;
1381 v.as_str().map(str::to_owned)
1382 }
1383
1384 enum Nav { Reload, Back, Forward }
1385
1386 fn nav(c: &Chord<'_>) -> Option<Nav> {
1387 match (c.key, c.mods) {
1388 ("BrowserRefresh", _) | ("F5", _) => Some(Nav::Reload),
1389 ("r" | "R", m) if m & 6 != 0 => Some(Nav::Reload),
1390 ("BrowserBack", _) => Some(Nav::Back),
1391 ("ArrowLeft", 1) => Some(Nav::Back),
1392 ("BrowserForward", _) => Some(Nav::Forward),
1393 ("ArrowRight", 1) => Some(Nav::Forward),
1394 _ => None,
1395 }
1396 }
1397
1398 async fn history(page: &StealthPage, step: i64) -> Result<(), String> {
1399 let h = page.cdp_call("Page.getNavigationHistory", None).await.map_err(|e| e.to_string())?;
1400 let i = h["currentIndex"].as_i64().unwrap_or(0) + step;
1401 let entries = h["entries"].as_array().cloned().unwrap_or_default();
1402 let Some(e) = usize::try_from(i).ok().and_then(|i| entries.get(i)) else { return Ok(()) };
1403 page.cdp_call("Page.navigateToHistoryEntry", Some(json!({ "entryId": e["id"] })))
1404 .await
1405 .map_err(|e| e.to_string())?;
1406 Ok(())
1407 }
1408
1409 pub async fn press(page: &StealthPage, combo: &str) -> Result<(), String> {
1410 let c = parse(combo);
1411 if matches!(c.key, "Shift" | "Control" | "Alt" | "Meta" | "CapsLock" | "") { return Ok(()) }
1412 if let Some(n) = nav(&c) {
1413 return match n {
1414 Nav::Reload => page.cdp_call("Page.reload", None).await.map(|_| ()).map_err(|e| e.to_string()),
1415 Nav::Back => history(page, -1).await,
1416 Nav::Forward => history(page, 1).await,
1417 };
1418 }
1419 let (code, vk, text) = def(c.key);
1420 let typed = if c.mods & 7 == 0 { text } else { None };
1421 let mut down = json!({
1422 "type": if typed.is_some() { "keyDown" } else { "rawKeyDown" },
1423 "modifiers": c.mods, "key": c.key, "code": code,
1424 "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
1425 });
1426 if let Some(t) = &typed { down["text"] = json!(t); down["unmodifiedText"] = json!(t); }
1427 let cmds = commands(c.mods, c.key);
1428 if !cmds.is_empty() { down["commands"] = json!(cmds); }
1429 page.cdp_call("Input.dispatchKeyEvent", Some(down)).await.map_err(|e| e.to_string())?;
1430 page.cdp_call("Input.dispatchKeyEvent", Some(json!({
1431 "type": "keyUp", "modifiers": c.mods, "key": c.key, "code": code,
1432 "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
1433 }))).await.map_err(|e| e.to_string())?;
1434 Ok(())
1435 }
1436
1437 #[cfg(test)]
1438 mod tests {
1439 use super::*;
1440 #[test]
1441 fn chords_parse() {
1442 let c = parse("Control+Shift+a"); assert_eq!((c.mods, c.key), (10, "a"));
1443 let c = parse("Control++"); assert_eq!((c.mods, c.key), (2, "+"));
1444 let c = parse("Backspace"); assert_eq!((c.mods, c.key), (0, "Backspace"));
1445 assert_eq!(def("Delete").1, 46);
1446 assert_eq!(def("a").0, "KeyA");
1447 assert_eq!(commands(2, "a"), vec!["selectAll"]);
1448 }
1449 }
1450}