1use serde::{Deserialize, Serialize};
10
11#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum BrowserPart {
15 Text { text: String },
17 Image {
20 mime_type: String,
21 #[serde(skip)]
22 bytes: Vec<u8>,
23 },
24}
25
26#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
29pub struct ProfileKey(String);
30
31impl ProfileKey {
32 pub fn of(org: &str, name: &str) -> Result<Self, String> {
33 Self::parse(&format!("{org}/{name}"))
34 }
35
36 pub fn parse(key: &str) -> Result<Self, String> {
38 let Some((org, name)) = key.split_once('/') else {
39 return Err(format!("'{key}' is not a browser profile key: the shape is <org>/<name>"));
40 };
41 segment_refusal(org, "org")?;
42 segment_refusal(name, "name")?;
43 Ok(Self(key.to_owned()))
44 }
45
46 pub fn as_str(&self) -> &str {
47 &self.0
48 }
49
50 pub fn org(&self) -> &str {
51 self.0.split_once('/').map(|(org, _)| org).unwrap_or_default()
52 }
53
54 pub fn name(&self) -> &str {
55 self.0.split_once('/').map(|(_, name)| name).unwrap_or_default()
56 }
57
58 #[cfg(feature = "test-helpers")]
61 pub fn from_parts_for_test(org: &str, name: &str) -> Self {
62 Self(format!("{org}/{name}"))
63 }
64}
65
66fn segment_refusal(segment: &str, what: &str) -> Result<(), String> {
70 let refused = segment.is_empty()
71 || segment == "."
72 || segment == ".."
73 || segment.contains(['/', '\\', '\0']);
74 if refused {
75 return Err(format!("'{segment}' cannot be a browser profile {what}"));
76 }
77 if what == "name" {
78 let allowed = segment.len() <= 64
79 && segment.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
80 if !allowed {
81 return Err(format!(
82 "'{segment}' cannot be a browser profile name: 1 to 64 characters of letters, \
83 digits, dot, dash or underscore"
84 ));
85 }
86 }
87 Ok(())
88}
89
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub struct HandOff {
98 pub id: uuid::Uuid,
99 pub reason: String,
101 pub tab: u16,
103 pub target: String,
107}
108
109#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
111pub struct InputModifiers {
112 pub alt: bool,
113 pub control: bool,
114 pub meta: bool,
115 pub shift: bool,
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum MouseAction {
122 Move,
123 Press,
124 Release,
125}
126
127#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
135#[serde(tag = "kind", rename_all = "snake_case")]
136pub enum BrowserInputEvent {
137 Mouse {
138 action: MouseAction,
139 x: f64,
140 y: f64,
141 button: i32,
143 clicks: i32,
144 modifiers: InputModifiers,
145 },
146 Wheel { x: f64, y: f64, delta_x: f64, delta_y: f64, modifiers: InputModifiers },
149 Key {
151 down: bool,
152 key: String,
153 code: String,
154 windows_vk: i32,
156 modifiers: InputModifiers,
157 },
158 Text { text: String },
160}
161
162#[derive(Clone, Debug, PartialEq, Eq)]
170pub struct BrowserFrame {
171 pub profile: u16,
173 pub tab: u16,
175 pub seq: u32,
177 pub encoded: Vec<u8>,
178}
179
180#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
182pub struct BrowserTabRow {
183 pub number: u16,
185 pub title: String,
186 pub url: String,
187 pub owner: crate::SessionSlot,
189 pub opener: Option<u16>,
193 pub state: BrowserTabState,
194 pub dialog: Option<BrowserTabDialog>,
196}
197
198#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
200pub struct BrowserTabDialog {
201 pub kind: String,
203 pub message: String,
204}
205
206#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum BrowserTabState {
210 InUse,
212 Idle,
213 Held,
215}
216
217#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
219pub struct BrowserTabList {
220 pub profile: String,
221 pub id: u16,
223 pub up: bool,
225 pub connected: bool,
227 pub closing_at_ms: Option<u64>,
229 pub rows: Vec<BrowserTabRow>,
230}
231
232#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
234pub struct BrowserViewState {
235 pub controller: Option<u64>,
237 pub viewers: u32,
239}
240
241#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
243pub struct BrowserNavState {
244 pub can_back: bool,
245 pub can_forward: bool,
246}
247
248#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
250pub struct BrowserFileAsk {
251 pub id: u64,
253 pub tab: u16,
256 pub multiple: bool,
258}
259
260#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
262pub struct BrowserUpload {
263 pub name: String,
264 pub bytes: Vec<u8>,
265}
266
267pub const MAX_UPLOAD_BYTES: u64 = 8 * 1024 * 1024;
276pub const MAX_UPLOAD_FILES: usize = 16;
277
278pub const DOWNLOAD_BACKLOG: usize = 4;
286
287#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
289pub struct BrowserDownload {
290 pub tab: Option<u16>,
295 pub name: String,
296 pub bytes: Vec<u8>,
298 pub error: Option<String>,
300}
301
302#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
304pub struct BrowserAudio {
305 pub profile: u16,
306 pub tab: u16,
307 pub seq: u32,
308 pub sample_rate: u32,
309 pub channels: u8,
310 pub samples: Vec<i16>,
312}
313
314#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(tag = "type", rename_all = "snake_case")]
317pub enum HandOffEnding {
318 Done,
320 NotNow,
323 Feedback,
326 Abandoned,
328}
329
330#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "snake_case")]
337pub enum HandOffSource {
338 Dock,
340 DockEscape,
342 Takeover,
344 Feedback,
347 Abandoned,
349 #[default]
351 Unstated,
352}
353
354#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
360pub struct HandOffAnswer {
361 pub ending: HandOffEnding,
362 #[serde(default)]
363 pub source: HandOffSource,
364 #[serde(default)]
365 pub client: Option<u64>,
366}
367
368pub const ADVERTISED_TOOLS: [&str; 32] = [
375 "browser_close",
376 "browser_resize",
377 "browser_console_messages",
378 "browser_handle_dialog",
379 "browser_emulate_media",
380 "browser_evaluate",
381 "browser_file_upload",
382 "browser_drop",
383 "browser_find",
384 "browser_fill_form",
385 "browser_press_key",
386 "browser_type",
387 "browser_navigate",
388 "browser_navigate_back",
389 "browser_network_requests",
390 "browser_network_request",
391 "browser_run_code_unsafe",
392 "browser_take_screenshot",
393 "browser_snapshot",
394 "browser_click",
395 "browser_drag",
396 "browser_hover",
397 "browser_select_option",
398 "browser_tabs",
399 "browser_wait_for",
400 "browser_click_and_capture",
401 "browser_form_state",
402 "browser_challenge_state",
403 "browser_hand_off",
404 "browser_record_start",
405 "browser_record_stop",
406 "browser_end_host",
407];
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::SessionSlot;
413
414 fn key_of_default() -> ProfileKey {
415 ProfileKey::of("Busytools", "default").expect("a key")
416 }
417
418 #[test]
419 fn a_profile_key_is_two_safe_segments() {
420 let key = ProfileKey::of("Busytools", "work").expect("a valid key");
421 assert_eq!(key.as_str(), "Busytools/work");
422 assert_eq!(key.org(), "Busytools");
423 assert_eq!(key.name(), "work");
424 assert_eq!(ProfileKey::parse("Busytools/default").expect("a parsed key"), key_of_default());
425
426 for (key, why) in [
427 ("Busytools", "one segment is not a key"),
428 ("/work", "an empty org is not a key"),
429 ("Busytools/", "an empty name is not a key"),
430 ("Busy/tools/work", "a third segment is not a key"),
431 ("Busytools/..", "a dot-dot name is not a key"),
432 ("Busytools/.", "a dot name is not a key"),
433 ("Busytools/two words", "a space is outside the name charset"),
434 ("Busytools/a/b", "a slash is outside the name charset"),
435 ] {
436 assert!(ProfileKey::parse(key).is_err(), "{why}: {key}");
437 }
438 assert!(
439 ProfileKey::of("Busy/tools", "work").is_err(),
440 "an org carrying a slash is refused"
441 );
442 }
443
444 #[test]
447 fn a_hand_off_names_the_tab_the_person_should_see() {
448 let handoff = HandOff {
449 id: uuid::Uuid::from_u128(7),
450 reason: "CAPTCHA".to_owned(),
451 tab: 3,
452 target: "page-7".to_owned(),
453 };
454 let encoded = serde_json::to_value(&handoff).expect("a hand-off encodes");
455 assert_eq!(
456 encoded,
457 serde_json::json!({ "id": "00000000-0000-0000-0000-000000000007", "reason": "CAPTCHA", "tab": 3, "target": "page-7" }),
458 );
459 assert_eq!(serde_json::from_value::<HandOff>(encoded).expect("decodes"), handoff);
460 }
461
462 #[test]
468 fn a_tab_row_crosses_with_its_owner_and_state() {
469 let row = BrowserTabRow {
470 number: 2,
471 title: "portal".to_owned(),
472 url: "https://x/".to_owned(),
473 owner: SessionSlot::worker("O", "p", "w"),
474 opener: Some(1),
475 state: BrowserTabState::Held,
476 dialog: Some(BrowserTabDialog {
477 kind: "confirm".to_owned(),
478 message: "Leave this page?".to_owned(),
479 }),
480 };
481 let encoded = serde_json::to_value(&row).expect("a row encodes");
482 assert_eq!(encoded["number"], 2);
483 assert_eq!(encoded["opener"], 1);
484 assert_eq!(encoded["state"], "held");
485 assert_eq!(encoded["dialog"]["kind"], "confirm");
486 assert_eq!(serde_json::from_value::<BrowserTabRow>(encoded).expect("decodes"), row);
487 }
488
489 #[test]
493 fn an_ending_crosses_as_its_tag() {
494 assert_eq!(
495 serde_json::to_value(HandOffEnding::Done).expect("an ending encodes"),
496 serde_json::json!({ "type": "done" }),
497 );
498 assert_eq!(
499 serde_json::from_value::<HandOffEnding>(serde_json::json!({ "type": "not_now" }))
500 .expect("an ending decodes"),
501 HandOffEnding::NotNow,
502 );
503 assert_eq!(
504 serde_json::to_value(HandOffEnding::Abandoned).expect("an ending encodes"),
505 serde_json::json!({ "type": "abandoned" }),
506 );
507 }
508
509 #[test]
513 fn an_answer_crosses_with_its_source_and_client() {
514 let answer = HandOffAnswer {
515 ending: HandOffEnding::Feedback,
516 source: HandOffSource::Feedback,
517 client: Some(7),
518 };
519 let encoded = serde_json::to_value(answer).expect("an answer encodes");
520 assert_eq!(
521 encoded,
522 serde_json::json!({
523 "ending": { "type": "feedback" },
524 "source": "feedback",
525 "client": 7,
526 }),
527 );
528 assert_eq!(serde_json::from_value::<HandOffAnswer>(encoded).expect("decodes"), answer);
529 assert_eq!(
534 serde_json::from_value::<HandOffAnswer>(serde_json::json!({
535 "ending": { "type": "done" },
536 }))
537 .expect("decodes without a source or a client"),
538 HandOffAnswer {
539 ending: HandOffEnding::Done,
540 source: HandOffSource::Unstated,
541 client: None,
542 },
543 );
544 }
545
546 #[test]
554 fn every_client_door_crosses_as_the_word_the_client_writes() {
555 for (source, word) in [
556 (HandOffSource::Dock, "dock"),
557 (HandOffSource::DockEscape, "dock_escape"),
558 (HandOffSource::Takeover, "takeover"),
559 (HandOffSource::Feedback, "feedback"),
560 ] {
561 assert_eq!(
562 serde_json::to_value(source).expect("a source encodes"),
563 serde_json::json!(word),
564 "the client writes {word} for {source:?}",
565 );
566 assert_eq!(
567 serde_json::from_value::<HandOffSource>(serde_json::json!(word))
568 .expect("the word decodes"),
569 source,
570 "{word} is the door it names",
571 );
572 }
573 }
574
575 #[test]
578 fn an_image_part_crosses_with_its_mime_and_not_its_bytes() {
579 let part = BrowserPart::Image { mime_type: "image/png".to_owned(), bytes: vec![1, 2, 3] };
580 let encoded = serde_json::to_value(&part).expect("a part encodes");
581 assert_eq!(
582 encoded,
583 serde_json::json!({ "type": "image", "mime_type": "image/png" }),
584 "the JSON form carries the tag and the mime, and never the bytes",
585 );
586 let BrowserPart::Image { mime_type, bytes } =
587 serde_json::from_value(encoded).expect("a part decodes")
588 else {
589 panic!("an image part decodes into another part")
590 };
591 assert_eq!(mime_type, "image/png");
592 assert!(bytes.is_empty(), "and a decoded JSON part carries no bytes");
593 }
594
595 #[test]
599 fn an_input_event_crosses_as_the_gesture() {
600 let click = BrowserInputEvent::Mouse {
601 action: MouseAction::Press,
602 x: 12.5,
603 y: 30.0,
604 button: 1,
605 clicks: 1,
606 modifiers: InputModifiers { meta: true, ..Default::default() },
607 };
608 let encoded = serde_json::to_value(&click).expect("an event encodes");
609 assert_eq!(encoded["kind"], "mouse");
610 assert_eq!(encoded["action"], "press");
611 assert_eq!(encoded["modifiers"]["meta"], true);
612 assert_eq!(
613 serde_json::from_value::<BrowserInputEvent>(encoded).expect("an event decodes"),
614 click,
615 );
616 let text = BrowserInputEvent::Text { text: "hi".to_owned() };
617 assert_eq!(serde_json::to_value(&text).expect("text encodes")["kind"], "text");
618 let key = BrowserInputEvent::Key {
619 down: true,
620 key: "Enter".to_owned(),
621 code: "Enter".to_owned(),
622 windows_vk: 13,
623 modifiers: InputModifiers::default(),
624 };
625 assert_eq!(serde_json::to_value(&key).expect("a key encodes")["kind"], "key");
626 }
627
628 #[test]
629 fn a_text_part_crosses_as_its_text() {
630 let encoded = serde_json::to_value(BrowserPart::Text { text: "done".to_owned() })
631 .expect("a part encodes");
632 assert_eq!(encoded, serde_json::json!({ "type": "text", "text": "done" }));
633 }
634}