1use base64::Engine as _;
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6use std::collections::HashSet;
7use std::path::PathBuf;
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::sync::{Arc, Condvar, Mutex};
10use std::time::{Duration, Instant};
11
12use logicaffeine_compile::semantics::world::JournalEntry;
13
14use super::bundle::PageBundle;
15use super::cdp::{SessionSink, Wire};
16use super::server::{RelayHub, StaticServer};
17use super::snapshot::Snapshot;
18use super::{BrowserError, COMMAND_TIMEOUT, PAGE_WORK_TIMEOUT};
19
20const RECORDER: &str = include_str!("recorder.js");
22
23const REPORT_BINDING: &str = "__logosHarnessReport";
26
27const ANOTHER_DOCUMENT: &str =
30 "<!doctype html>\n<html>\n<head>\n</head>\n<body>\n</body>\n</html>\n";
31
32const LOAD_CLOCK_MS: u64 = 1000;
35
36const HELD_TASKS_CLOCK_MS: f64 = 0.001;
40
41const START_TIMEOUT: Duration = Duration::from_secs(30);
43
44#[derive(Clone, Debug, Default)]
46pub struct OpenOptions {
47 pub init_scripts: Vec<String>,
49 pub answers: Vec<Answer>,
52 pub relay: RelayHub,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum CallKind {
61 Import,
63 Export,
65}
66
67#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub struct HostCall {
71 pub kind: CallKind,
72 pub name: String,
73 pub args: Vec<String>,
74 #[serde(default)]
75 pub result: Option<String>,
76 pub depth: u32,
78 #[serde(default)]
79 pub threw: Option<String>,
80 #[serde(default)]
84 pub texts: Vec<String>,
85}
86
87#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct ConsoleLine {
90 pub level: String,
91 pub text: String,
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub struct DomCounters {
97 pub documents: u64,
98 pub nodes: u64,
99 pub listeners: u64,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct Answer {
107 pub method: String,
108 pub path: String,
109 pub status: u16,
110 pub content_type: String,
111 pub body: Vec<u8>,
112}
113
114#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
118pub struct PageRequest {
119 pub method: String,
120 pub url: String,
121 pub body: String,
122 pub headers: Vec<String>,
124 pub timeout: i64,
125 pub aborted: bool,
126}
127
128#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
130pub struct DroppedFile {
131 pub name: String,
132 pub size: u64,
133 #[serde(rename = "type")]
134 pub mime_type: String,
135}
136
137struct Held {
139 request_id: String,
140 method: String,
141 url: String,
142}
143
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146enum Admission {
147 Harness,
149 Page,
152}
153
154enum Reply {
156 Continue,
157 Fulfill(Answer),
158}
159
160struct Parked {
162 request_id: String,
163 reply: Reply,
164}
165
166#[derive(Clone, Debug, PartialEq, Eq)]
170struct Context {
171 id: u64,
172 unique_id: String,
173}
174
175#[derive(Clone, Copy, Debug, PartialEq, Eq)]
177struct InFlight {
178 traversal: bool,
181}
182
183struct Frame {
187 context: Option<Context>,
189 commits: u32,
191 loads_at_commit: u32,
193 page_asked: bool,
196 in_flight: Option<InFlight>,
197 requested: bool,
201 planned: bool,
203 leaving_reports: u32,
206 parked: Vec<Parked>,
208 admission: Admission,
209}
210
211impl Default for Frame {
212 fn default() -> Frame {
213 Frame {
214 context: None,
215 commits: 0,
216 loads_at_commit: 0,
217 page_asked: false,
218 in_flight: None,
219 requested: false,
220 planned: false,
221 leaving_reports: 0,
222 parked: Vec::new(),
223 admission: Admission::Harness,
224 }
225 }
226}
227
228impl Frame {
229 fn leaving(&self) -> bool {
232 self.requested || self.in_flight.is_some()
233 }
234
235 fn loaded(&self, loads: u32) -> bool {
237 loads > self.loads_at_commit
238 }
239
240 fn admits(&mut self, redirect: bool) -> bool {
244 if self.admission == Admission::Harness
245 || redirect
246 || self.in_flight.as_ref().is_some_and(|n| n.traversal)
247 {
248 return true;
249 }
250 if self.leaving_reports > 0 {
251 self.leaving_reports -= 1;
252 return true;
253 }
254 false
255 }
256
257 fn committed(&mut self, loads: u32) {
260 self.commits += 1;
261 self.loads_at_commit = loads;
262 self.page_asked = self.admission == Admission::Page;
263 self.admission = Admission::Page;
264 self.resolved();
265 }
266
267 fn resolved(&mut self) {
269 self.in_flight = None;
270 self.requested = false;
271 self.planned = false;
272 self.leaving_reports = 0;
273 self.parked.clear();
274 }
275}
276
277fn form_submission(reason: &Value) -> bool {
280 matches!(
281 reason.as_str(),
282 Some("formSubmissionGet" | "formSubmissionPost")
283 )
284}
285
286#[derive(Default)]
287struct SinkState {
288 stubs: Vec<Answer>,
289 held: Vec<Held>,
290 requests: Vec<(String, String)>,
291 console: Vec<ConsoleLine>,
292 exceptions: Vec<String>,
293 navigations: Vec<String>,
294 navigation_requests: Vec<String>,
295 submissions: Vec<(Option<String>, bool)>,
296 turns: Vec<JournalEntry>,
298 loads: u32,
299 budgets_expired: u32,
300 crashed: bool,
301 frame: Frame,
302}
303
304struct PageSink {
305 session: String,
306 frame: String,
308 origin: String,
309 bundle_paths: HashSet<String>,
310 state: Mutex<SinkState>,
311 changed: Condvar,
312}
313
314impl PageSink {
315 fn local_path<'u>(&self, url: &'u str) -> Option<&'u str> {
317 let rest = url.strip_prefix(&self.origin)?;
318 Some(if rest.is_empty() { "/" } else { rest })
319 }
320
321 fn stub_matches(&self, stub: &Answer, method: &str, url: &str) -> bool {
322 if !stub.method.eq_ignore_ascii_case(method) {
323 return false;
324 }
325 match self.local_path(url) {
326 Some(path) => {
327 stub.path == path || stub.path == path.split(['?', '#']).next().unwrap_or(path)
328 }
329 None => stub.path == url,
330 }
331 }
332
333 fn fulfill(&self, wire: &Wire, request_id: &str, stub: &Answer) {
334 wire.send_detached(
335 Some(&self.session),
336 "Fetch.fulfillRequest",
337 json!({
338 "requestId": request_id,
339 "responseCode": stub.status,
340 "responseHeaders": [{ "name": "content-type", "value": stub.content_type }],
341 "body": base64::engine::general_purpose::STANDARD.encode(&stub.body),
342 }),
343 );
344 }
345
346 fn reply(&self, wire: &Wire, request_id: &str, reply: &Reply) {
347 match reply {
348 Reply::Continue => wire.send_detached(
349 Some(&self.session),
350 "Fetch.continueRequest",
351 json!({ "requestId": request_id }),
352 ),
353 Reply::Fulfill(answer) => self.fulfill(wire, request_id, answer),
354 }
355 }
356
357 fn own_frame(&self, id: &Value) -> bool {
358 id.as_str() == Some(self.frame.as_str())
359 }
360}
361
362impl SessionSink for PageSink {
363 fn event(&self, wire: &Wire, method: &str, params: &Value) {
364 let mut st = self.state.lock().unwrap();
365 match method {
366 "Fetch.requestPaused" => {
367 let request_id = params["requestId"].as_str().unwrap_or_default().to_string();
368 let url = params["request"]["url"]
369 .as_str()
370 .unwrap_or_default()
371 .to_string();
372 let verb = params["request"]["method"]
373 .as_str()
374 .unwrap_or("GET")
375 .to_string();
376 let in_bundle = self
377 .local_path(&url)
378 .map(|p| p.split(['?', '#']).next().unwrap_or(p))
379 .is_some_and(|p| self.bundle_paths.contains(p));
380 let document = params["resourceType"] == "Document";
381 let reply = if in_bundle {
382 Some(Reply::Continue)
383 } else if let Some(stub) = st
384 .stubs
385 .iter()
386 .rev()
387 .find(|s| self.stub_matches(s, &verb, &url))
388 .cloned()
389 {
390 st.requests.push((verb.clone(), url.clone()));
391 Some(Reply::Fulfill(stub))
392 } else if document {
393 Some(Reply::Fulfill(Answer {
394 method: verb,
395 path: url,
396 status: 200,
397 content_type: "text/html; charset=utf-8".to_string(),
398 body: ANOTHER_DOCUMENT.as_bytes().to_vec(),
399 }))
400 } else {
401 st.requests.push((verb.clone(), url.clone()));
402 st.held.push(Held {
403 request_id: request_id.clone(),
404 method: verb,
405 url,
406 });
407 None
408 };
409 if let Some(reply) = reply {
410 let own_document = document && self.own_frame(¶ms["frameId"]);
411 let redirect = params["redirectedRequestId"].is_string();
412 if !own_document || st.frame.admits(redirect) {
413 self.reply(wire, &request_id, &reply);
414 } else {
415 st.frame.parked.push(Parked { request_id, reply });
416 }
417 }
418 }
419 "Runtime.consoleAPICalled" => {
420 let text = params["args"]
421 .as_array()
422 .map(|args| {
423 args.iter()
424 .map(|a| match &a["value"] {
425 Value::String(s) => s.clone(),
426 Value::Null => a["description"].as_str().unwrap_or("").to_string(),
427 v => v.to_string(),
428 })
429 .collect::<Vec<_>>()
430 .join(" ")
431 })
432 .unwrap_or_default();
433 let level = params["type"].as_str().unwrap_or("log").to_string();
434 st.console.push(ConsoleLine { level, text });
435 }
436 "Runtime.exceptionThrown" => {
437 let d = ¶ms["exceptionDetails"];
438 let text = d["exception"]["description"]
439 .as_str()
440 .or_else(|| d["text"].as_str())
441 .unwrap_or("an exception with no description")
442 .to_string();
443 st.exceptions.push(text);
444 }
445 "Runtime.executionContextCreated" => {
446 let context = ¶ms["context"];
447 let aux = &context["auxData"];
448 if aux["isDefault"].as_bool() == Some(true) && self.own_frame(&aux["frameId"]) {
449 if let (Some(id), Some(unique_id)) =
450 (context["id"].as_u64(), context["uniqueId"].as_str())
451 {
452 st.frame.context = Some(Context {
453 id,
454 unique_id: unique_id.to_string(),
455 });
456 }
457 }
458 }
459 "Page.frameNavigated" => {
460 if !params["frame"]["parentId"].is_null() {
461 return;
462 }
463 st.navigations.push(
464 params["frame"]["url"]
465 .as_str()
466 .unwrap_or_default()
467 .to_string(),
468 );
469 let loads = st.loads;
470 st.frame.committed(loads);
471 }
472 "Page.frameStartedNavigating" if self.own_frame(¶ms["frameId"]) => {
473 let kind = params["navigationType"].as_str().unwrap_or_default();
474 if matches!(kind, "sameDocument" | "historySameDocument") {
475 return;
476 }
477 st.frame.in_flight = Some(InFlight {
478 traversal: kind.starts_with("history") || kind.starts_with("restore"),
479 });
480 st.frame.planned = false;
481 }
482 "Page.frameRequestedNavigation" => {
483 st.navigation_requests
484 .push(params["url"].as_str().unwrap_or_default().to_string());
485 if self.own_frame(¶ms["frameId"]) && params["disposition"] == "currentTab" {
486 if form_submission(¶ms["reason"]) {
487 st.frame.planned = true;
488 } else {
489 st.frame.requested = true;
490 }
491 }
492 }
493 "Page.frameScheduledNavigation"
494 if self.own_frame(¶ms["frameId"]) && form_submission(¶ms["reason"]) =>
495 {
496 st.frame.requested = true;
497 st.frame.planned = false;
498 }
499 "Page.frameStoppedLoading"
500 if self.own_frame(¶ms["frameId"]) && st.frame.in_flight.is_some() =>
501 {
502 st.frame.resolved();
503 }
504 "Runtime.bindingCalled" if params["name"] == REPORT_BINDING => {
505 let report: Value = params["payload"]
506 .as_str()
507 .and_then(|p| serde_json::from_str(p).ok())
508 .unwrap_or(Value::Null);
509 if let Some(sub) = report.get("submit") {
510 st.submissions.push((
511 sub["form"].as_str().map(str::to_string),
512 sub["prevented"].as_bool() == Some(true),
513 ));
514 }
515 if let Some(turn) = report.get("turn") {
516 st.turns.push(journal_entry(turn));
517 }
518 let from_here = report.get("leaving").is_some()
519 && st
520 .frame
521 .context
522 .as_ref()
523 .is_some_and(|c| params["executionContextId"].as_u64() == Some(c.id));
524 if from_here {
525 if st.frame.parked.is_empty() {
526 st.frame.leaving_reports += 1;
527 } else {
528 let parked = st.frame.parked.remove(0);
529 self.reply(wire, &parked.request_id, &parked.reply);
530 }
531 }
532 }
533 "Page.loadEventFired" => st.loads += 1,
534 "Emulation.virtualTimeBudgetExpired" => st.budgets_expired += 1,
535 "Inspector.targetCrashed" => st.crashed = true,
536 _ => return,
537 }
538 drop(st);
539 self.changed.notify_all();
540 }
541}
542
543#[derive(Clone, Debug)]
546struct Here {
547 context: String,
548 commits: u32,
549}
550
551enum Looked {
554 Value(Value),
555 Gone,
556}
557
558enum Settled {
561 Here,
562 Gone,
563}
564
565fn document_gone(message: &str) -> bool {
570 message.contains("Inspected target navigated or closed")
571 || message.contains("uniqueContextId not found")
572 || message.contains("Cannot find context with specified id")
573 || message.contains("Execution context was destroyed")
574}
575
576pub struct Page {
578 wire: Arc<Wire>,
579 session: String,
580 target: String,
581 context: String,
582 sink: Arc<PageSink>,
583 server: StaticServer,
584 drops: PathBuf,
587 dropped: AtomicUsize,
588 here: Mutex<Option<Here>>,
589}
590
591fn journal_entry(turn: &Value) -> JournalEntry {
594 let text = |v: &Value| v.as_str().unwrap_or_default().to_string();
595 let flat: Vec<String> = turn["changes"]
596 .as_array()
597 .into_iter()
598 .flatten()
599 .map(text)
600 .collect();
601 let failure = text(&turn["failure"]);
602 JournalEntry {
603 instance: text(&turn["instance"]),
604 action: text(&turn["action"]),
605 changes: flat
606 .chunks(3)
607 .map(|c| {
608 (
609 c[0].clone(),
610 c.get(1).cloned().unwrap_or_default(),
611 c.get(2).cloned().unwrap_or_default(),
612 )
613 })
614 .collect(),
615 failed: (!failure.is_empty()).then_some(failure),
616 }
617}
618
619fn string_field(v: &Value, field: &str, method: &str) -> Result<String, BrowserError> {
620 v[field]
621 .as_str()
622 .map(str::to_string)
623 .ok_or_else(|| BrowserError::Protocol {
624 method: method.to_string(),
625 message: format!("the reply carried no `{field}`: {v}"),
626 })
627}
628
629impl Page {
630 pub(crate) fn open(
631 wire: Arc<Wire>,
632 bundle: &PageBundle,
633 options: &OpenOptions,
634 ) -> Result<Page, BrowserError> {
635 let server = StaticServer::start(bundle.served(), options.relay.clone())
636 .map_err(|e| BrowserError::Setup(format!("the page server did not start: {e}")))?;
637 let reply = wire.send(
638 None,
639 "Target.createBrowserContext",
640 json!({ "disposeOnDetach": true }),
641 COMMAND_TIMEOUT,
642 )?;
643 let context = string_field(&reply, "browserContextId", "Target.createBrowserContext")?;
644 let reply = wire.send(
645 None,
646 "Target.createTarget",
647 json!({ "url": "about:blank", "browserContextId": context }),
648 COMMAND_TIMEOUT,
649 )?;
650 let target = string_field(&reply, "targetId", "Target.createTarget")?;
651 let reply = wire.send(
652 None,
653 "Target.attachToTarget",
654 json!({ "targetId": target, "flatten": true }),
655 COMMAND_TIMEOUT,
656 )?;
657 let session = string_field(&reply, "sessionId", "Target.attachToTarget")?;
658 let sink = Arc::new(PageSink {
659 session: session.clone(),
660 frame: target.clone(),
661 origin: server.origin().to_string(),
662 bundle_paths: bundle.paths(),
663 state: Mutex::new(SinkState {
664 stubs: options
665 .answers
666 .iter()
667 .map(|a| Answer {
668 method: a.method.to_ascii_uppercase(),
669 ..a.clone()
670 })
671 .collect(),
672 ..SinkState::default()
673 }),
674 changed: Condvar::new(),
675 });
676 wire.attach(&session, sink.clone());
677 let drops =
678 std::env::temp_dir().join(format!("logos-page-drops-{}-{target}", std::process::id()));
679 let page = Page {
680 wire,
681 session,
682 target,
683 context,
684 sink,
685 server,
686 drops,
687 dropped: AtomicUsize::new(0),
688 here: Mutex::new(None),
689 };
690
691 page.command("Page.enable", json!({}))?;
692 page.command("Runtime.enable", json!({}))?;
693 page.command(
694 "Fetch.enable",
695 json!({ "patterns": [{ "urlPattern": "*" }] }),
696 )?;
697 page.command(
698 "Emulation.setFocusEmulationEnabled",
699 json!({ "enabled": true }),
700 )?;
701 page.wire.send(
702 None,
703 "Browser.setDownloadBehavior",
704 json!({ "behavior": "deny", "browserContextId": page.context }),
705 COMMAND_TIMEOUT,
706 )?;
707 page.command("Runtime.addBinding", json!({ "name": REPORT_BINDING }))?;
708 page.command(
709 "Page.addScriptToEvaluateOnNewDocument",
710 json!({ "source": RECORDER }),
711 )?;
712 for script in &options.init_scripts {
713 page.command(
714 "Page.addScriptToEvaluateOnNewDocument",
715 json!({ "source": script }),
716 )?;
717 }
718
719 let url = format!("{}{}", page.server.origin(), bundle.entry());
720 let nav = page.command("Page.navigate", json!({ "url": url }))?;
721 if let Some(err) = nav["errorText"].as_str() {
722 return Err(BrowserError::Page(format!("loading {url} failed: {err}")));
723 }
724 page.settle()?;
725 Ok(page)
726 }
727
728 fn frame<T>(&self, read: impl FnOnce(&Frame) -> T) -> T {
729 read(&self.sink.state.lock().unwrap().frame)
730 }
731
732 fn behind(&self) -> bool {
734 let commits = self.frame(|f| f.commits);
735 self.here
736 .lock()
737 .unwrap()
738 .as_ref()
739 .map_or(true, |h| commits > h.commits)
740 }
741
742 fn catch_up(&self) -> Result<(), BrowserError> {
756 loop {
757 if self.frame(Frame::leaving) {
758 self.leave()?;
759 } else if self.behind() {
760 self.arrive()?;
761 } else {
762 return Ok(());
763 }
764 }
765 }
766
767 fn leave(&self) -> Result<(), BrowserError> {
773 let commits = self.frame(|f| f.commits);
774 self.wait_for_sink(
775 "the document the page asked for to commit, or the browser to abandon that navigation",
776 START_TIMEOUT,
777 |s| s.frame.commits > commits || !s.frame.leaving(),
778 )
779 }
780
781 fn arrive(&self) -> Result<(), BrowserError> {
788 let first = self.here.lock().unwrap().is_none();
789 let mut commits = self.frame(|f| f.commits);
790 if !first {
791 self.let_load()?;
792 }
793 let context = loop {
794 self.wait_for_sink("the page's load event", START_TIMEOUT, |s| {
795 s.frame.commits > commits || s.frame.loaded(s.loads)
796 })?;
797 let st = self.sink.state.lock().unwrap();
798 if st.frame.commits > commits {
799 commits = st.frame.commits;
800 drop(st);
801 self.let_load()?;
802 continue;
803 }
804 let Some(context) = st.frame.context.clone() else {
805 return Err(BrowserError::Page(
806 "the document committed with no execution context of its own".to_string(),
807 ));
808 };
809 break context;
810 };
811 *self.here.lock().unwrap() = Some(Here {
812 context: context.unique_id,
813 commits,
814 });
815 let is_page = match self.look(
816 "document.querySelector('script[type=\"module\"]') !== null",
817 COMMAND_TIMEOUT,
818 )? {
819 Looked::Gone => return Ok(()),
820 Looked::Value(v) => v.as_bool() == Some(true),
821 };
822 if is_page {
823 let started = self
824 .look("__logosHarness.settled()", START_TIMEOUT)
825 .map_err(|e| {
826 self.with_page_log(e, "waiting for the page's module to run `main`")
827 })?;
828 let started = match started {
829 Looked::Gone => return Ok(()),
830 Looked::Value(v) => v,
831 };
832 if started.as_str() != Some("returned") {
833 let why = match self.look("__logosHarness.mainError", COMMAND_TIMEOUT) {
834 Ok(Looked::Value(v)) => v,
835 _ => Value::Null,
836 };
837 return Err(self.with_page_log(
838 BrowserError::Page(format!(
839 "the page's module did not start: `main` {started} {why}"
840 )),
841 "starting the page",
842 ));
843 }
844 }
845 self.command(
846 "Emulation.setVirtualTimePolicy",
847 json!({ "policy": "pause" }),
848 )?;
849 self.look("__logosHarness.openGate()", COMMAND_TIMEOUT)?;
850 Ok(())
851 }
852
853 fn let_load(&self) -> Result<(), BrowserError> {
856 self.command(
857 "Emulation.setVirtualTimePolicy",
858 json!({ "policy": "pauseIfNetworkFetchesPending", "budget": LOAD_CLOCK_MS }),
859 )?;
860 Ok(())
861 }
862
863 fn await_departure(&self) -> Result<(), BrowserError> {
866 let commits = self.here.lock().unwrap().as_ref().map_or(0, |h| h.commits);
867 self.wait_for_sink(
868 "the page to leave the document it asked to leave",
869 START_TIMEOUT,
870 |s| s.frame.commits > commits || s.frame.leaving(),
871 )
872 }
873
874 fn act(&self, input: impl FnOnce() -> Result<(), BrowserError>) -> Result<(), BrowserError> {
882 self.settle()?;
883 input()?;
884 self.settle()
885 }
886
887 fn command(&self, method: &str, params: Value) -> Result<Value, BrowserError> {
890 let timeout = if method.starts_with("Input.") {
891 PAGE_WORK_TIMEOUT
892 } else {
893 COMMAND_TIMEOUT
894 };
895 self.wire.send(Some(&self.session), method, params, timeout)
896 }
897
898 fn with_page_log(&self, err: BrowserError, doing: &str) -> BrowserError {
900 let st = self.sink.state.lock().unwrap();
901 let console: Vec<String> = st
902 .console
903 .iter()
904 .map(|c| format!(" [{}] {}", c.level, c.text))
905 .collect();
906 let thrown: Vec<String> = st.exceptions.iter().map(|e| format!(" {e}")).collect();
907 BrowserError::Page(format!(
908 "{err} ({doing})\nconsole:\n{}\nexceptions:\n{}",
909 if console.is_empty() {
910 " (none)".to_string()
911 } else {
912 console.join("\n")
913 },
914 if thrown.is_empty() {
915 " (none)".to_string()
916 } else {
917 thrown.join("\n")
918 },
919 ))
920 }
921
922 fn wait_for_sink(
923 &self,
924 what: &str,
925 timeout: Duration,
926 done: impl Fn(&SinkState) -> bool,
927 ) -> Result<(), BrowserError> {
928 let deadline = Instant::now() + timeout;
929 let mut st = self.sink.state.lock().unwrap();
930 loop {
931 if st.crashed {
932 return Err(BrowserError::Page(format!(
933 "the page crashed while waiting for {what}"
934 )));
935 }
936 if done(&st) {
937 return Ok(());
938 }
939 if let Some(why) = self.wire.closed() {
940 return Err(BrowserError::Closed(why));
941 }
942 let now = Instant::now();
943 if now >= deadline {
944 drop(st);
945 return Err(self.with_page_log(
946 BrowserError::Timeout {
947 what: what.to_string(),
948 after: timeout,
949 },
950 "waiting on the page",
951 ));
952 }
953 st = self
954 .sink
955 .changed
956 .wait_timeout(st, (deadline - now).min(Duration::from_millis(250)))
957 .unwrap()
958 .0;
959 }
960 }
961
962 pub fn eval(&self, js: &str) -> Result<Value, BrowserError> {
965 self.read(js)
966 }
967
968 fn read(&self, js: &str) -> Result<Value, BrowserError> {
971 loop {
972 self.catch_up()?;
973 match self.look(js, COMMAND_TIMEOUT)? {
974 Looked::Value(v) => return Ok(v),
975 Looked::Gone => self.await_departure()?,
976 }
977 }
978 }
979
980 fn look(&self, js: &str, timeout: Duration) -> Result<Looked, BrowserError> {
984 let context = match self.here.lock().unwrap().as_ref() {
985 Some(here) => here.context.clone(),
986 None => {
987 return Err(BrowserError::Page(
988 "the harness has arrived at no document to look at".to_string(),
989 ))
990 }
991 };
992 let reply = self.wire.send(
993 Some(&self.session),
994 "Runtime.evaluate",
995 json!({ "expression": js, "returnByValue": true, "awaitPromise": true, "uniqueContextId": context }),
996 timeout,
997 );
998 let reply = match reply {
999 Err(BrowserError::Protocol { message, .. }) if document_gone(&message) => {
1000 return Ok(Looked::Gone)
1001 }
1002 other => other?,
1003 };
1004 if let Some(d) = reply.get("exceptionDetails") {
1005 let text = d["exception"]["description"]
1006 .as_str()
1007 .or_else(|| d["text"].as_str())
1008 .unwrap_or("threw");
1009 return Err(BrowserError::Page(format!(
1010 "{text}\n (evaluating `{}`)",
1011 short(js)
1012 )));
1013 }
1014 Ok(Looked::Value(reply["result"]["value"].clone()))
1015 }
1016
1017 fn drive(&self, helper: &str, args: &[&str]) -> Result<Option<Value>, BrowserError> {
1021 let args: Vec<String> = args
1022 .iter()
1023 .map(|a| serde_json::to_string(a).expect("a string serializes"))
1024 .collect();
1025 Ok(
1026 match self.look(
1027 &format!("__logosHarness.drive.{helper}({})", args.join(", ")),
1028 PAGE_WORK_TIMEOUT,
1029 )? {
1030 Looked::Value(v) => Some(v),
1031 Looked::Gone => None,
1032 },
1033 )
1034 }
1035
1036 pub fn settle(&self) -> Result<(), BrowserError> {
1058 loop {
1059 self.catch_up()?;
1060 if self.frame(|f| f.planned) {
1061 self.run_held_tasks()?;
1062 self.sink.state.lock().unwrap().frame.planned = false;
1063 continue;
1064 }
1065 match self.settle_in_place()? {
1066 Settled::Here => {
1067 if self.frame(|f| f.leaving() || f.planned) || self.behind() {
1068 continue;
1069 }
1070 return Ok(());
1071 }
1072 Settled::Gone => self.await_departure()?,
1073 }
1074 }
1075 }
1076
1077 fn settle_in_place(&self) -> Result<Settled, BrowserError> {
1081 let deadline = Instant::now() + START_TIMEOUT;
1082 let mut pause = Duration::from_millis(1);
1085 loop {
1086 let flight = match self.look("__logosHarness.inFlight()", COMMAND_TIMEOUT)? {
1087 Looked::Value(v) => v,
1088 Looked::Gone => return Ok(Settled::Gone),
1089 };
1090 let mut held = self.pending_requests();
1091 let mut waiting: Vec<String> = Vec::new();
1092 for f in flight["fetches"].as_array().into_iter().flatten() {
1093 let (method, url) = (f[0].as_str().unwrap_or(""), f[1].as_str().unwrap_or(""));
1094 match held
1095 .iter()
1096 .position(|(m, u)| m.eq_ignore_ascii_case(method) && u == url)
1097 {
1098 Some(i) => {
1099 held.swap_remove(i);
1100 }
1101 None => waiting.push(format!("{method} {url}")),
1102 }
1103 }
1104 let reads = flight["reads"].as_u64().unwrap_or(0);
1105 let (relay, opening) = self.relay_in_flight(&flight);
1106 if waiting.is_empty() && reads == 0 && relay.is_empty() {
1107 return Ok(Settled::Here);
1108 }
1109 if opening {
1110 self.run_held_tasks()?;
1111 }
1112 if Instant::now() >= deadline {
1113 return Err(self.with_page_log(
1114 BrowserError::Timeout {
1115 what: format!(
1116 "the page's answered requests to complete ({}), {reads} download(s) to be read and its relay to be through ({})",
1117 waiting.join(", "),
1118 relay.join(", ")
1119 ),
1120 after: START_TIMEOUT,
1121 },
1122 "settling the page",
1123 ));
1124 }
1125 std::thread::sleep(pause);
1126 pause = (pause * 2).min(Duration::from_millis(64));
1127 }
1128 }
1129
1130 fn run_held_tasks(&self) -> Result<(), BrowserError> {
1138 let before = self.sink.state.lock().unwrap().budgets_expired;
1139 self.command(
1140 "Emulation.setVirtualTimePolicy",
1141 json!({ "policy": "advance", "budget": HELD_TASKS_CLOCK_MS }),
1142 )?;
1143 self.wait_for_sink("the page's clock to pause again", START_TIMEOUT, |s| {
1144 s.budgets_expired > before
1145 })
1146 }
1147
1148 fn relay_in_flight(&self, flight: &Value) -> (Vec<String>, bool) {
1154 let (mut waiting, mut opening) = (Vec::new(), false);
1155 for r in flight["relay"].as_array().into_iter().flatten() {
1156 let token = r["token"].as_str().unwrap_or("");
1157 if r["connecting"].as_bool() == Some(true) {
1158 waiting.push(format!("connection {token} opening"));
1159 opening = true;
1160 continue;
1161 }
1162 let (sent, handled) = (
1163 r["sent"].as_u64().unwrap_or(0),
1164 r["handled"].as_u64().unwrap_or(0),
1165 );
1166 let (read, written) = self.server.relay().counts(token);
1167 if read < sent {
1168 waiting.push(format!(
1169 "{} of {sent} sent message(s) unread by the relay",
1170 sent - read
1171 ));
1172 }
1173 if handled < written {
1174 waiting.push(format!(
1175 "{} of {written} relayed message(s) unhandled by the page",
1176 written - handled
1177 ));
1178 }
1179 }
1180 (waiting, opening)
1181 }
1182
1183 pub fn click(&self, sel: &str) -> Result<(), BrowserError> {
1191 self.act(|| {
1192 let Some(at) = self.drive("point", &[sel])? else { return Ok(()) };
1193 let (x, y) = (at["x"].as_f64().unwrap_or(0.0), at["y"].as_f64().unwrap_or(0.0));
1194 self.command(
1195 "Input.dispatchMouseEvent",
1196 json!({ "type": "mousePressed", "x": x, "y": y, "button": "left", "buttons": 1, "clickCount": 1 }),
1197 )?;
1198 self.command(
1199 "Input.dispatchMouseEvent",
1200 json!({ "type": "mouseReleased", "x": x, "y": y, "button": "left", "buttons": 0, "clickCount": 1 }),
1201 )?;
1202 Ok(())
1203 })
1204 }
1205
1206 pub fn type_into(&self, sel: &str, text: &str) -> Result<(), BrowserError> {
1209 self.act(|| {
1210 let Some(_) = self.drive("focusForTyping", &[sel])? else {
1211 return Ok(());
1212 };
1213 if text.is_empty() {
1214 let Some(_) = self.drive("clear", &[sel])? else {
1215 return Ok(());
1216 };
1217 } else {
1218 self.command("Input.insertText", json!({ "text": text }))?;
1219 }
1220 self.drive("fire", &[sel, "change"])?;
1221 Ok(())
1222 })
1223 }
1224
1225 pub fn press(&self, key: &str) -> Result<(), BrowserError> {
1229 self.act(|| {
1230 let spec = KeySpec::named(key).ok_or_else(|| {
1231 BrowserError::Setup(format!(
1232 "`{key}` is not a key this harness can press — it knows {} and single printable characters",
1233 NAMED_KEYS.iter().map(|k| format!("`{}`", k.0)).collect::<Vec<_>>().join(", ")
1234 ))
1235 })?;
1236 let mut down = json!({
1237 "type": if spec.text.is_some() { "keyDown" } else { "rawKeyDown" },
1238 "key": spec.key,
1239 "code": spec.code,
1240 "windowsVirtualKeyCode": spec.vk,
1241 "nativeVirtualKeyCode": spec.vk,
1242 });
1243 if let Some(t) = &spec.text {
1244 down["text"] = json!(t);
1245 down["unmodifiedText"] = json!(t);
1246 }
1247 self.command("Input.dispatchKeyEvent", down)?;
1248 self.command(
1249 "Input.dispatchKeyEvent",
1250 json!({ "type": "keyUp", "key": spec.key, "code": spec.code, "windowsVirtualKeyCode": spec.vk, "nativeVirtualKeyCode": spec.vk }),
1251 )?;
1252 Ok(())
1253 })
1254 }
1255
1256 pub fn press_in(&self, sel: &str, key: &str) -> Result<(), BrowserError> {
1258 self.focus(sel)?;
1259 self.press(key)
1260 }
1261
1262 pub fn focus(&self, sel: &str) -> Result<(), BrowserError> {
1264 self.act(|| {
1265 self.drive("focus", &[sel])?;
1266 Ok(())
1267 })
1268 }
1269
1270 pub fn blur(&self, sel: &str) -> Result<(), BrowserError> {
1272 self.act(|| {
1273 self.drive("blur", &[sel])?;
1274 Ok(())
1275 })
1276 }
1277
1278 pub fn hover(&self, sel: &str) -> Result<(), BrowserError> {
1284 self.act(|| {
1285 let Some(_) = self.drive("point", &[sel])? else {
1286 return Ok(());
1287 };
1288 self.drive("hover", &[sel])?;
1289 Ok(())
1290 })
1291 }
1292
1293 pub fn choose(&self, sel: &str, value: &str) -> Result<(), BrowserError> {
1296 self.act(|| {
1297 self.drive("choose", &[sel, value])?;
1298 Ok(())
1299 })
1300 }
1301
1302 pub fn submit(&self, sel: &str) -> Result<(), BrowserError> {
1306 self.act(|| {
1307 self.drive("submit", &[sel])?;
1308 Ok(())
1309 })
1310 }
1311
1312 pub fn drop_file(
1320 &self,
1321 sel: &str,
1322 name: &str,
1323 content: &[u8],
1324 ) -> Result<Option<DroppedFile>, BrowserError> {
1325 if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) {
1326 return Err(BrowserError::Setup(format!(
1327 "{name:?} is not a file name a browser can be handed — a dropped file is named by one \
1328 path segment"
1329 )));
1330 }
1331 let dir = self
1332 .drops
1333 .join(self.dropped.fetch_add(1, Ordering::SeqCst).to_string());
1334 std::fs::create_dir_all(&dir)
1335 .and_then(|()| std::fs::write(dir.join(name), content))
1336 .map_err(|e| {
1337 BrowserError::Setup(format!(
1338 "the file {name:?} could not be written for the drop: {e}"
1339 ))
1340 })?;
1341 let before = self
1342 .read("__logosHarness.drops.length")?
1343 .as_u64()
1344 .unwrap_or(0) as usize;
1345 self.act(|| {
1346 let Some(at) = self.drive("point", &[sel])? else {
1347 return Ok(());
1348 };
1349 let (x, y) = (
1350 at["x"].as_f64().unwrap_or(0.0),
1351 at["y"].as_f64().unwrap_or(0.0),
1352 );
1353 let path = dir.join(name).to_string_lossy().into_owned();
1354 let data = json!({ "items": [], "files": [path], "dragOperationsMask": 1 });
1355 for kind in ["dragEnter", "dragOver", "drop"] {
1356 self.command(
1357 "Input.dispatchDragEvent",
1358 json!({ "type": kind, "x": x, "y": y, "data": data }),
1359 )?;
1360 }
1361 Ok(())
1362 })?;
1363 let drops: Vec<Option<DroppedFile>> =
1364 serde_json::from_value(self.read("__logosHarness.drops")?).map_err(|e| {
1365 BrowserError::Page(format!("the page's drop record did not parse: {e}"))
1366 })?;
1367 Ok(drops.into_iter().nth(before).flatten())
1368 }
1369
1370 pub fn exports(&self) -> Result<Vec<String>, BrowserError> {
1372 let v = self.read("__logosHarness.exports")?;
1373 Ok(serde_json::from_value(v).unwrap_or_default())
1374 }
1375
1376 pub fn mount(&self, component: &str, parent: Option<&str>) -> Result<(), BrowserError> {
1380 self.act(|| {
1381 let component = serde_json::to_string(component).expect("a string serializes");
1382 let parent = serde_json::to_string(&parent).expect("an optional string serializes");
1383 self.look(
1384 &format!("__logosMount({component}, {parent})"),
1385 COMMAND_TIMEOUT,
1386 )?;
1387 Ok(())
1388 })
1389 }
1390
1391 pub fn reload(&self) -> Result<(), BrowserError> {
1394 self.catch_up()?;
1395 let commits = {
1396 let mut st = self.sink.state.lock().unwrap();
1397 st.frame.admission = Admission::Harness;
1398 st.frame.commits
1399 };
1400 self.command("Page.reload", json!({}))?;
1401 self.wait_for_sink("the reloaded document to commit", START_TIMEOUT, |s| {
1402 s.frame.commits > commits
1403 })?;
1404 self.settle()
1405 }
1406
1407 pub fn storage(&self) -> Result<Vec<(String, String, String)>, BrowserError> {
1410 let v = self.read("__logosHarness.storage()")?;
1411 serde_json::from_value(v.clone()).map_err(|e| {
1412 BrowserError::Page(format!(
1413 "the page's storage did not parse ({e}): {}",
1414 short(&v.to_string())
1415 ))
1416 })
1417 }
1418
1419 pub fn published(&self) -> Result<Vec<(String, String)>, BrowserError> {
1422 Ok(self
1423 .import_trace()?
1424 .into_iter()
1425 .filter(|c| c.kind == CallKind::Import && c.name == "dom_publish")
1426 .filter_map(|c| match c.texts.as_slice() {
1427 [topic, message] => Some((topic.clone(), message.clone())),
1428 _ => None,
1429 })
1430 .collect())
1431 }
1432
1433 pub fn route_ops(&self) -> Result<Vec<String>, BrowserError> {
1437 let mut ops: Vec<String> = serde_json::from_value(self.read("__logosHarness.routeOps")?)
1438 .map_err(|e| {
1439 BrowserError::Page(format!("the page's navigation log did not parse: {e}"))
1440 })?;
1441 if self.frame(|f| f.page_asked) {
1442 let here = self.read("location.pathname + location.search")?;
1443 ops.insert(0, format!("load {}", here.as_str().unwrap_or_default()));
1444 }
1445 Ok(ops)
1446 }
1447
1448 pub fn snapshot(&self) -> Result<Snapshot, BrowserError> {
1450 let v = self.read("__logosHarness.snapshot()")?;
1451 serde_json::from_value(v.clone()).map_err(|e| {
1452 BrowserError::Page(format!(
1453 "the page's snapshot did not parse ({e}): {}",
1454 short(&v.to_string())
1455 ))
1456 })
1457 }
1458
1459 pub fn import_trace(&self) -> Result<Vec<HostCall>, BrowserError> {
1461 let v = self.read("__logosHarness.trace")?;
1462 serde_json::from_value(v)
1463 .map_err(|e| BrowserError::Page(format!("the host-call trace did not parse: {e}")))
1464 }
1465
1466 pub fn trace_len(&self) -> Result<usize, BrowserError> {
1468 Ok(self
1469 .read("__logosHarness.trace.length")?
1470 .as_u64()
1471 .unwrap_or(0) as usize)
1472 }
1473
1474 pub fn advance(&self, ms: u64) -> Result<(), BrowserError> {
1483 self.act(|| {
1484 if ms > 0 {
1485 let before = self.sink.state.lock().unwrap().budgets_expired;
1486 self.command(
1487 "Emulation.setVirtualTimePolicy",
1488 json!({ "policy": "advance", "budget": ms }),
1489 )?;
1490 self.wait_for_sink(
1491 &format!("{ms}ms of virtual time to pass"),
1492 START_TIMEOUT,
1493 |s| s.budgets_expired > before,
1494 )?;
1495 }
1496 self.run_held_tasks()
1497 })
1498 }
1499
1500 pub fn answer(
1504 &self,
1505 method: &str,
1506 path: &str,
1507 status: u16,
1508 content_type: &str,
1509 body: &[u8],
1510 ) -> Result<(), BrowserError> {
1511 let stub = Answer {
1512 method: method.to_ascii_uppercase(),
1513 path: path.to_string(),
1514 status,
1515 content_type: content_type.to_string(),
1516 body: body.to_vec(),
1517 };
1518 self.act(|| {
1519 let answered: Vec<Held> = {
1520 let mut st = self.sink.state.lock().unwrap();
1521 let (now, still): (Vec<Held>, Vec<Held>) = st
1522 .held
1523 .drain(..)
1524 .partition(|h| self.sink.stub_matches(&stub, &h.method, &h.url));
1525 st.held = still;
1526 now
1527 };
1528 for held in &answered {
1529 self.command(
1530 "Fetch.fulfillRequest",
1531 json!({
1532 "requestId": held.request_id,
1533 "responseCode": stub.status,
1534 "responseHeaders": [{ "name": "content-type", "value": stub.content_type }],
1535 "body": base64::engine::general_purpose::STANDARD.encode(&stub.body),
1536 }),
1537 )?;
1538 }
1539 self.sink.state.lock().unwrap().stubs.push(stub.clone());
1540 for held in &answered {
1541 let url = serde_json::to_string(&held.url).expect("a string serializes");
1542 let read = self
1543 .look(
1544 &format!("__logosHarness.whenFetched({url})"),
1545 COMMAND_TIMEOUT,
1546 )
1547 .map_err(|e| {
1548 self.with_page_log(e, "waiting for the page to read its answer")
1549 })?;
1550 if let Looked::Gone = read {
1551 break;
1552 }
1553 }
1554 Ok(())
1555 })
1556 }
1557
1558 pub fn pending_requests(&self) -> Vec<(String, String)> {
1560 let st = self.sink.state.lock().unwrap();
1561 st.held
1562 .iter()
1563 .map(|h| (h.method.clone(), h.url.clone()))
1564 .collect()
1565 }
1566
1567 pub fn page_requests(&self) -> Result<Vec<PageRequest>, BrowserError> {
1570 let v = self.read("__logosHarness.requests")?;
1571 serde_json::from_value(v.clone()).map_err(|e| {
1572 BrowserError::Page(format!(
1573 "the page's request table did not parse ({e}): {}",
1574 short(&v.to_string())
1575 ))
1576 })
1577 }
1578
1579 pub fn requests(&self) -> Vec<(String, String)> {
1581 self.sink.state.lock().unwrap().requests.clone()
1582 }
1583
1584 pub fn console(&self) -> Vec<ConsoleLine> {
1586 self.sink.state.lock().unwrap().console.clone()
1587 }
1588
1589 pub fn exceptions(&self) -> Vec<String> {
1591 self.sink.state.lock().unwrap().exceptions.clone()
1592 }
1593
1594 pub fn navigations(&self) -> Vec<String> {
1596 self.sink.state.lock().unwrap().navigations.clone()
1597 }
1598
1599 pub fn navigation_requests(&self) -> Vec<String> {
1602 self.sink.state.lock().unwrap().navigation_requests.clone()
1603 }
1604
1605 pub fn journal(&self) -> Vec<JournalEntry> {
1608 self.sink.state.lock().unwrap().turns.clone()
1609 }
1610
1611 pub fn submissions(&self) -> Vec<(Option<String>, bool)> {
1614 self.sink.state.lock().unwrap().submissions.clone()
1615 }
1616
1617 pub fn clipboard(&self) -> Result<Vec<String>, BrowserError> {
1619 let v = self.read("__logosHarness.clipboard")?;
1620 Ok(serde_json::from_value(v).unwrap_or_default())
1621 }
1622
1623 pub fn notifications(&self) -> Result<Vec<String>, BrowserError> {
1625 let v = self.read("__logosHarness.notifications.map((n) => n.title)")?;
1626 Ok(serde_json::from_value(v).unwrap_or_default())
1627 }
1628
1629 pub fn downloads(&self) -> Result<Vec<(String, Option<String>)>, BrowserError> {
1632 let v = self.read("__logosHarness.downloads")?;
1633 Ok(v.as_array()
1634 .map(|a| {
1635 a.iter()
1636 .map(|d| {
1637 (
1638 d["name"].as_str().unwrap_or("").to_string(),
1639 d["content"].as_str().map(str::to_string),
1640 )
1641 })
1642 .collect()
1643 })
1644 .unwrap_or_default())
1645 }
1646
1647 pub fn prerendered_fate(&self) -> Result<(usize, Vec<String>), BrowserError> {
1650 let v = self.read("__logosHarness.prerenderedFate()")?;
1651 let replaced = v["replaced"]
1652 .as_array()
1653 .map(|a| {
1654 a.iter()
1655 .filter_map(|p| p.as_str().map(str::to_string))
1656 .collect()
1657 })
1658 .unwrap_or_default();
1659 Ok((v["total"].as_u64().unwrap_or(0) as usize, replaced))
1660 }
1661
1662 pub fn dom_counters(&self) -> Result<DomCounters, BrowserError> {
1664 self.catch_up()?;
1665 self.command("HeapProfiler.collectGarbage", json!({}))?;
1666 let c = self.command("Memory.getDOMCounters", json!({}))?;
1667 Ok(DomCounters {
1668 documents: c["documents"].as_u64().unwrap_or(0),
1669 nodes: c["nodes"].as_u64().unwrap_or(0),
1670 listeners: c["jsEventListeners"].as_u64().unwrap_or(0),
1671 })
1672 }
1673}
1674
1675impl Drop for Page {
1676 fn drop(&mut self) {
1677 let _ = std::fs::remove_dir_all(&self.drops);
1678 self.wire.detach(&self.session);
1679 let _ = self.wire.send(
1680 None,
1681 "Target.closeTarget",
1682 json!({ "targetId": self.target }),
1683 Duration::from_secs(5),
1684 );
1685 self.wire.send_detached(
1686 None,
1687 "Target.disposeBrowserContext",
1688 json!({ "browserContextId": self.context }),
1689 );
1690 }
1691}
1692
1693fn short(s: &str) -> String {
1694 const MAX: usize = 400;
1695 if s.len() <= MAX {
1696 return s.to_string();
1697 }
1698 let mut end = MAX;
1699 while !s.is_char_boundary(end) {
1700 end -= 1;
1701 }
1702 format!("{}…", &s[..end])
1703}
1704
1705const NAMED_KEYS: &[(&str, &str, &str, u32, Option<&str>)] = &[
1707 ("Enter", "Enter", "Enter", 13, Some("\r")),
1708 ("Escape", "Escape", "Escape", 27, None),
1709 ("Tab", "Tab", "Tab", 9, None),
1710 ("Backspace", "Backspace", "Backspace", 8, None),
1711 ("Delete", "Delete", "Delete", 46, None),
1712 ("ArrowUp", "ArrowUp", "ArrowUp", 38, None),
1713 ("ArrowDown", "ArrowDown", "ArrowDown", 40, None),
1714 ("ArrowLeft", "ArrowLeft", "ArrowLeft", 37, None),
1715 ("ArrowRight", "ArrowRight", "ArrowRight", 39, None),
1716 ("Home", "Home", "Home", 36, None),
1717 ("End", "End", "End", 35, None),
1718 ("PageUp", "PageUp", "PageUp", 33, None),
1719 ("PageDown", "PageDown", "PageDown", 34, None),
1720 ("Space", " ", "Space", 32, Some(" ")),
1721];
1722
1723struct KeySpec {
1725 key: String,
1726 code: String,
1727 vk: u32,
1728 text: Option<String>,
1729}
1730
1731impl KeySpec {
1732 fn named(name: &str) -> Option<KeySpec> {
1733 if let Some((_, key, code, vk, text)) =
1734 NAMED_KEYS.iter().find(|k| k.0.eq_ignore_ascii_case(name))
1735 {
1736 return Some(KeySpec {
1737 key: key.to_string(),
1738 code: code.to_string(),
1739 vk: *vk,
1740 text: text.map(str::to_string),
1741 });
1742 }
1743 let mut chars = name.chars();
1744 let (Some(c), None) = (chars.next(), chars.next()) else {
1745 return None;
1746 };
1747 if c.is_control() {
1748 return None;
1749 }
1750 let (code, vk) = if c.is_ascii_alphabetic() {
1751 (
1752 format!("Key{}", c.to_ascii_uppercase()),
1753 c.to_ascii_uppercase() as u32,
1754 )
1755 } else if c.is_ascii_digit() {
1756 (format!("Digit{c}"), c as u32)
1757 } else {
1758 (String::new(), 0)
1759 };
1760 Some(KeySpec {
1761 key: c.to_string(),
1762 code,
1763 vk,
1764 text: Some(c.to_string()),
1765 })
1766 }
1767}