forge_browser/control.rs
1//! The host's control lines to the server, on stdout.
2//!
3//! **The input pipe runs the other way.** The server writes pointer
4//! gestures on the host's stdin; the host writes the things a viewer has to
5//! answer here - a page's file chooser today - as one JSON line per event on
6//! its stdout, which the server reads beside the child's frames. The log
7//! stays on stderr, so neither stream is parsed for the other's content.
8
9use std::io::Write as _;
10
11use serde::{Deserialize, Serialize};
12
13/// Where a JavaScript dialog stands. **The host holds one until it is
14/// answered**, so these are the facts there are: it was raised, someone
15/// answered it, or the page took it away without an answer (a navigation, or
16/// the tab closing). **`Closed` is not `Answered`**: nobody decided.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum JsDialogState {
20 Shown,
21 Answered,
22 Closed,
23}
24
25/// One thing the host needs the server to route.
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(tag = "kind", rename_all = "snake_case")]
28pub enum HostEvent {
29 /// A tab the host created: the page the server asked for, or the popup a
30 /// page opened under `opener`.
31 TabCreated {
32 tab: u16,
33 opener: Option<u16>,
34 url: String,
35 /// The server command's id, when one asked for this tab; a popup
36 /// carries none.
37 #[serde(default)]
38 request: Option<u64>,
39 },
40 /// A tab the host closed.
41 TabClosed { tab: u16 },
42 /// A page's file chooser: the client's picker answers with the files'
43 /// bytes, and the host is handed server-local paths.
44 FileDialog { id: u64, tab: u16, multiple: bool },
45 /// A page's JavaScript dialog. **The host holds it until it is answered**:
46 /// a `shown` line is followed by `answered` when the agent's answer
47 /// arrives, or by `closed` when the page takes the dialog away itself,
48 /// and by nothing until one of those.
49 JsDialog { tab: u16, dialog: String, message: String, state: JsDialogState },
50 /// A download finished on the server: the tab it started from, the file's
51 /// name, its path beside the profile, and how many bytes it holds.
52 Download { tab: u16, name: String, path: String, size: u64 },
53 /// A recording answered: `started` says whether the writer opened, and a
54 /// later event with it false carries how the take ended.
55 Recording { started: bool, seconds: u64, bytes: u64, capped: bool, error: Option<String> },
56}
57
58/// Write one event line for the server.
59pub fn emit(event: &HostEvent) {
60 let Ok(line) = serde_json::to_string(event) else { return };
61 let mut out = std::io::stdout().lock();
62 // **A server that stopped reading is a broken pipe, not a panic.** The
63 // host keeps painting its ring for whoever is watching; the event is
64 // lost with the connection.
65 let _ = writeln!(out, "{line}");
66 let _ = out.flush();
67}
68
69/// The event one line names, or `None` for a line the server cannot read.
70pub fn parse(line: &str) -> Option<HostEvent> {
71 serde_json::from_str(line).ok()
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 /// **The line and the reader agree.** A chooser's request carries the
79 /// dialog id the answer comes back under, or the client's files cannot
80 /// be matched to the dialog that asked.
81 #[test]
82 fn a_file_dialog_round_trips_its_line() {
83 let event = HostEvent::FileDialog { id: 7, tab: 2, multiple: true };
84 let line = serde_json::to_string(&event).expect("a line");
85 assert_eq!(parse(&line), Some(event));
86 assert!(parse("").is_none());
87 assert!(parse("{\"kind\":\"other\"}").is_none());
88 }
89
90 /// **A dialog's raise and its answer are two lines of one shape**, so a
91 /// server can tell a held dialog from one someone answered.
92 #[test]
93 fn a_js_dialog_round_trips_its_line() {
94 for state in [JsDialogState::Shown, JsDialogState::Answered, JsDialogState::Closed] {
95 let event = HostEvent::JsDialog {
96 tab: 2,
97 dialog: "confirm".to_owned(),
98 message: "are you sure?".to_owned(),
99 state,
100 };
101 let line = serde_json::to_string(&event).expect("a line");
102 assert!(line.contains("js_dialog"), "the server matches the kind: {line}");
103 assert_eq!(parse(&line), Some(event));
104 }
105 assert!(
106 serde_json::to_string(&HostEvent::JsDialog {
107 tab: 2,
108 dialog: "confirm".to_owned(),
109 message: String::new(),
110 state: JsDialogState::Answered,
111 })
112 .expect("a line")
113 .contains("\"answered\""),
114 "the state is spelled in the line",
115 );
116 }
117
118 /// **A tab's birth and end are named on the control line**, with the tab
119 /// number and, for a popup, the tab that opened it: the server files a
120 /// tab under its opener's session from this line alone.
121 #[test]
122 fn a_tab_created_and_closed_round_trip_their_lines() {
123 let created = HostEvent::TabCreated {
124 tab: 2,
125 opener: Some(1),
126 url: "https://example.com/".to_owned(),
127 request: Some(9),
128 };
129 let line = serde_json::to_string(&created).expect("a line");
130 assert!(line.contains("tab_created"), "the server matches the kind: {line}");
131 assert_eq!(parse(&line), Some(created));
132
133 let popup = HostEvent::TabCreated {
134 tab: 3,
135 opener: None,
136 url: "about:blank".to_owned(),
137 request: None,
138 };
139 let line = serde_json::to_string(&popup).expect("a line");
140 assert_eq!(parse(&line), Some(popup), "a requested tab has no opener");
141
142 let closed = HostEvent::TabClosed { tab: 2 };
143 let line = serde_json::to_string(&closed).expect("a line");
144 assert_eq!(parse(&line), Some(closed));
145 }
146}