Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_pearlite/src/shell.rs

6.4 KiB, 19 runs

created by r1870400018:58218, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Phase 2: a loopback browser-shell that serves the existing `fe2o3_austenite` `web/pearl-reader/`
2//! assets, plus the caller's own `.prl`, and opens the OS default browser at it.
3//!
4//! The server itself is `fe2o3_net`'s std-only [`LocalServer`](oxedyne_fe2o3_net::http::local::LocalServer)
5//! -- one thread per connection, GET only, no TLS, no async runtime, bound to `127.0.0.1` alone. This
6//! module is now just the reader's fixed routes: the four embedded assets and the one document, wired up
7//! and handed to that server. The std-only server was born here (embedding tokio and rustls to serve four
8//! files on loopback made no sense); it has since moved into `fe2o3_net` so the dev-preview script and any
9//! other caller can share it rather than each growing its own.
10
11use oxedyne_fe2o3_net::http::local::{
12 Listen,
13 LocalServer,
14};
15
16use oxedyne_fe2o3_core::prelude::*;
17
18use std::{
19 borrow::Cow,
20 net::TcpListener,
21};
22
23// Embedded verbatim from `fe2o3_austenite/web/pearl-reader/`, so the native shell serves exactly the one
24// browser-side reader the web build does -- no second implementation to keep in step with the first.
25const INDEX_HTML: &str = include_str!("../../fe2o3_austenite/web/pearl-reader/index.html");
26const JDAT_JS: &str = include_str!("../../fe2o3_austenite/web/pearl-reader/jdat.js");
27const PEARL_JS: &str = include_str!("../../fe2o3_austenite/web/pearl-reader/pearl.js");
28const AUTHORING_JS: &str = include_str!("../../fe2o3_austenite/web/pearl-reader/authoring.js");
29
30/// A loopback shell: the fixed reader assets, plus one caller-named `.prl` served at its own path so the
31/// served `index.html`'s `?doc=` query can point straight at it.
32pub struct Shell {
33 doc_name: String, // path segment the document answers at, e.g. "opened.prl"
34 doc_bytes: Vec<u8>,
35}
36
37impl Shell {
38 pub fn new(doc_name: String, doc_bytes: Vec<u8>) -> Self {
39 Self { doc_name, doc_bytes }
40 }
41
42 /// Binds a loopback listener. `port` 0 asks the OS for an ephemeral one; the caller reads back what
43 /// was chosen with `listener.local_addr()`. Never binds anything but `127.0.0.1`.
44 pub fn bind(port: u16) -> Outcome<TcpListener> {
45 LocalServer::bind(Listen::Loopback(port))
46 }
47
48 /// Serves forever on `listener`, delegating to the shared std-only server. The reader's four assets
49 /// and the one document are the fixed routes; there is no directory root, so nothing else is served.
50 pub fn serve(self, listener: TcpListener) -> Outcome<()> {
51 LocalServer::new()
52 .route("/", Cow::Borrowed(INDEX_HTML.as_bytes()), "text/html; charset=utf-8")
53 .route("/index.html", Cow::Borrowed(INDEX_HTML.as_bytes()), "text/html; charset=utf-8")
54 .route("/jdat.js", Cow::Borrowed(JDAT_JS.as_bytes()), "text/javascript; charset=utf-8")
55 .route("/pearl.js", Cow::Borrowed(PEARL_JS.as_bytes()), "text/javascript; charset=utf-8")
56 .route("/authoring.js", Cow::Borrowed(AUTHORING_JS.as_bytes()), "text/javascript; charset=utf-8")
57 .route(
58 &fmt!("/{}", self.doc_name),
59 Cow::Owned(self.doc_bytes),
60 "text/plain; charset=utf-8",
61 )
62 .serve(listener)
63 }
64}
65
66/// Opens the OS default browser at `url`. A failure to find or spawn a browser is an error the caller
67/// logs, not a reason to stop the server -- the shell is up regardless, and the URL is printable for the
68/// caller to open by hand.
69pub fn open_browser(url: &str) -> Outcome<()> {
70 let mut cmd = if cfg!(target_os = "macos") {
71 std::process::Command::new("open")
72 } else if cfg!(target_os = "windows") {
73 let mut c = std::process::Command::new("cmd");
74 c.args(["/C", "start", ""]);
75 c
76 } else {
77 std::process::Command::new("xdg-open")
78 };
79 cmd.arg(url);
80 res!(cmd.spawn(), IO, System, Init);
81 Ok(())
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 use std::{
89 io::{
90 Read,
91 Write,
92 },
93 net::TcpStream,
94 thread,
95 time::Duration,
96 };
97
98 /// One raw GET over loopback, read to connection close (every response here sets `Connection:
99 /// close`), returned as the response text -- head and body together, since a small fixed asset needs
100 /// no separate parse to check.
101 fn get(port: u16, path: &str) -> String {
102 let mut stream = TcpStream::connect(("127.0.0.1", port))
103 .expect("connecting to the Pearlite test shell");
104 let req = fmt!("GET {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", path);
105 stream.write_all(req.as_bytes()).expect("writing the test request");
106 let mut buf = Vec::new();
107 stream.read_to_end(&mut buf).expect("reading the test response");
108 String::from_utf8_lossy(&buf).into_owned()
109 }
110
111 // The whole Phase 2 gate: the shell starts on loopback, serves the reader's own index.html and
112 // pearl.js byte for byte, serves the caller's document at its own path, and 404s an unknown one --
113 // everything a headless check can confirm without a real browser.
114 #[test]
115 fn test_shell_serves_index_pearl_js_and_the_document_00() -> Outcome<()> {
116 let doc_text = "pearl 1\nnot a real document, just fixture bytes for the shell test\n";
117 let shell = Shell::new("opened.prl".to_string(), doc_text.as_bytes().to_vec());
118 let listener = res!(Shell::bind(0));
119 let port = res!(listener.local_addr(), IO, Network).port();
120 thread::spawn(move || {
121 let _ = shell.serve(listener);
122 });
123 // The listener is already bound before this thread starts; the sleep is a generous margin for
124 // the accept loop to be running, not something the assertions below depend on for correctness.
125 thread::sleep(Duration::from_millis(50));
126
127 let index = get(port, "/");
128 assert!(index.starts_with("HTTP/1.1 200"), "index did not answer 200: {}", head_of(&index));
129 assert!(index.contains("Pearl Reader"), "index body is not the pearl-reader page");
130 assert!(index.contains("pearl.js"), "index does not reference pearl.js");
131
132 let js = get(port, "/pearl.js");
133 assert!(js.starts_with("HTTP/1.1 200"), "pearl.js did not answer 200: {}", head_of(&js));
134 assert!(js.contains("Pearl"), "pearl.js body does not look like the reader script");
135
136 let jdat = get(port, "/jdat.js");
137 assert!(jdat.starts_with("HTTP/1.1 200"), "jdat.js did not answer 200");
138
139 let doc = get(port, "/opened.prl");
140 assert!(doc.starts_with("HTTP/1.1 200"), "the document did not answer 200: {}", head_of(&doc));
141 assert!(doc.ends_with(doc_text), "the served document bytes did not match what was given");
142
143 let missing = get(port, "/nope");
144 assert!(missing.starts_with("HTTP/1.1 404"), "an unknown path should 404: {}", head_of(&missing));
145 Ok(())
146 }
147
148 fn head_of(s: &str) -> &str {
149 s.lines().next().unwrap_or("")
150 }
151}