Skip to main content

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}