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 | |
| 11 | use oxedyne_fe2o3_net::http::local::{ |
| 12 | Listen, |
| 13 | LocalServer, |
| 14 | }; |
| 15 | |
| 16 | use oxedyne_fe2o3_core::prelude::*; |
| 17 | |
| 18 | use 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. |
| 25 | const INDEX_HTML: &str = include_str!("../../fe2o3_austenite/web/pearl-reader/index.html"); |
| 26 | const JDAT_JS: &str = include_str!("../../fe2o3_austenite/web/pearl-reader/jdat.js"); |
| 27 | const PEARL_JS: &str = include_str!("../../fe2o3_austenite/web/pearl-reader/pearl.js"); |
| 28 | const 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. |
| 32 | pub struct Shell { |
| 33 | doc_name: String, // path segment the document answers at, e.g. "opened.prl" |
| 34 | doc_bytes: Vec<u8>, |
| 35 | } |
| 36 | |
| 37 | impl 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. |
| 69 | pub 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)] |
| 85 | mod 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 | } |