diff --git a/Cargo.lock b/Cargo.lock index 794e677..87c9c74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -162,6 +162,16 @@ dependencies = [ "ureq", ] +[[package]] +name = "furst-serve" +version = "0.1.0" +dependencies = [ + "furst-read", + "scraper", + "serde", + "toml", +] + [[package]] name = "futf" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index 4bb7c5f..b2a07d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["furst-read"] +members = ["furst-read", "furst-serve"] [package] name = "furst" diff --git a/furst-read/src/fetch.rs b/furst-read/src/fetch.rs index 6f93a9a..40cd0b9 100644 --- a/furst-read/src/fetch.rs +++ b/furst-read/src/fetch.rs @@ -10,6 +10,7 @@ pub struct Page { /// The URL after redirects — the correct base for resolving links. pub url: String, pub html: String, + pub content_type: String, } pub fn get(url: &str, limit: usize) -> Result { @@ -36,7 +37,7 @@ pub fn get(url: &str, limit: usize) -> Result { .read_to_vec() .map_err(|e| format!("{url}: {e}"))?; - Ok(Page { url: final_url, html: decode(&bytes, &content_type) }) + Ok(Page { url: final_url, html: decode(&bytes, &content_type), content_type }) } /// Decode to text using, in order: the Content-Type charset, a `` diff --git a/furst-read/src/main.rs b/furst-read/src/main.rs index 58f18a9..54fc99d 100644 --- a/furst-read/src/main.rs +++ b/furst-read/src/main.rs @@ -78,7 +78,7 @@ fn run() -> Result<(), String> { let page = if stdin { let mut buf = Vec::new(); io::stdin().read_to_end(&mut buf).map_err(|e| e.to_string())?; - fetch::Page { url: url.clone(), html: fetch::decode(&buf, "") } + fetch::Page { url: url.clone(), html: fetch::decode(&buf, ""), content_type: String::new() } } else { fetch::get(&url, LIMIT)? }; diff --git a/furst-serve/Cargo.toml b/furst-serve/Cargo.toml new file mode 100644 index 0000000..65eae0f --- /dev/null +++ b/furst-serve/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "furst-serve" +version = "0.1.0" +edition = "2024" +description = "Local reader server: browse the web without a browser engine" + +[dependencies] +furst-read = { path = "../furst-read" } +scraper = "0.23" +serde = { version = "1", features = ["derive"] } +toml = { version = "0.8", default-features = false, features = ["parse"] } + +[profile.release] +opt-level = "s" diff --git a/furst-serve/src/cache.rs b/furst-serve/src/cache.rs new file mode 100644 index 0000000..9458d9c --- /dev/null +++ b/furst-serve/src/cache.rs @@ -0,0 +1,95 @@ +//! Rendered pages on disk. Re-reading an article should cost nothing, which on +//! this hardware is the difference between browsing and waiting. + +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +/// Bumped whenever the renderer changes, so old entries fall out rather than +/// being served with stale markup. +const SCHEMA: u32 = 1; + +pub struct Cache { + dir: Option, + ttl: Duration, +} + +impl Cache { + pub fn new(ttl: Duration) -> Cache { + let dir = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))) + .map(|base| base.join("furst").join("pages")); + if let Some(d) = &dir { + let _ = fs::create_dir_all(d); + } + Cache { dir, ttl } + } + + pub fn get(&self, key: &str) -> Option { + let path = self.path(key)?; + let meta = fs::metadata(&path).ok()?; + let age = SystemTime::now().duration_since(meta.modified().ok()?).ok()?; + if age > self.ttl { + let _ = fs::remove_file(&path); + return None; + } + fs::read_to_string(&path).ok() + } + + pub fn put(&self, key: &str, value: &str) { + if let Some(path) = self.path(key) { + // Write beside the target and rename, so a reader never sees a + // half-written page. + let tmp = path.with_extension("tmp"); + if fs::write(&tmp, value).is_ok() { + let _ = fs::rename(&tmp, &path); + } + } + } + + /// Number of entries removed. + pub fn clear(&self) -> usize { + let Some(dir) = &self.dir else { return 0 }; + let Ok(entries) = fs::read_dir(dir) else { return 0 }; + entries.flatten().filter(|e| fs::remove_file(e.path()).is_ok()).count() + } + + fn path(&self, key: &str) -> Option { + Some(self.dir.as_ref()?.join(format!("{:016x}", fnv1a(&format!("{SCHEMA}\u{0}{key}"))))) + } +} + +fn fnv1a(s: &str) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in s.bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x1000_0000_01b3); + } + h +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_cache(ttl: Duration) -> Cache { + let dir = std::env::temp_dir().join(format!("furst-cache-test-{}", std::process::id())); + let _ = fs::create_dir_all(&dir); + Cache { dir: Some(dir), ttl } + } + + #[test] + fn round_trips_and_expires() { + let c = temp_cache(Duration::from_secs(60)); + c.put("k", "value"); + assert_eq!(c.get("k").as_deref(), Some("value")); + assert_eq!(c.get("other"), None); + + let expired = temp_cache(Duration::ZERO); + expired.put("k2", "v"); + assert_eq!(expired.get("k2"), None, "a zero ttl must never hit"); + c.clear(); + } +} diff --git a/furst-serve/src/config.rs b/furst-serve/src/config.rs new file mode 100644 index 0000000..3479dc9 --- /dev/null +++ b/furst-serve/src/config.rs @@ -0,0 +1,66 @@ +//! The home page's links and feeds, from ~/.config/furst/home.toml. + +use std::env; +use std::fs; +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Debug, Default, Deserialize)] +pub struct Home { + #[serde(default, rename = "link")] + pub links: Vec, + #[serde(default, rename = "feed")] + pub feeds: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Entry { + pub name: String, + pub url: String, +} + +pub fn path() -> PathBuf { + env::var_os("FURST_HOME_CONFIG") + .map(PathBuf::from) + .unwrap_or_else(|| { + env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|| PathBuf::from(".")) + .join("furst") + .join("home.toml") + }) +} + +impl Home { + /// A missing or unreadable file is not an error; the home page simply has + /// nothing on it but the search box. + pub fn load() -> Home { + fs::read_to_string(path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default() + } +} + +pub const STARTER: &str = r#"# furst home page: what shows up at http://127.0.0.1:7714/ + +[[link]] +name = "Arch Wiki" +url = "https://wiki.archlinux.org/" + +[[link]] +name = "Hacker News" +url = "https://news.ycombinator.com/" + +[[link]] +name = "Lobsters" +url = "https://lobste.rs/" + +[[feed]] +name = "LWN" +url = "https://lwn.net/headlines/newrss" + +[[feed]] +name = "Phoronix" +url = "https://www.phoronix.com/rss.php" +"#; diff --git a/furst-serve/src/handler.rs b/furst-serve/src/handler.rs new file mode 100644 index 0000000..01f67ca --- /dev/null +++ b/furst-serve/src/handler.rs @@ -0,0 +1,242 @@ +//! Route dispatch. + +use std::process::{Command, Stdio}; +use std::time::Duration; + +use furst_read::urljoin::encode; +use furst_read::{extract, fetch, render}; +use scraper::Html; + +use crate::cache::Cache; +use crate::config::Home; +use crate::http::{Request, Response}; +use crate::page; + +const LIMIT: usize = 8 * 1024 * 1024; +/// Below this much extracted text a page is a listing, not an article. +pub const THIN: usize = 400; + +pub struct App { + pub cache: Cache, +} + +impl App { + pub fn new(ttl: Duration) -> App { + App { cache: Cache::new(ttl) } + } + + pub fn handle(&self, req: &Request) -> Response { + match req.path.as_str() { + "/" => self.home(), + "/read" => self.read(req), + "/go" => self.go(req), + _ => Response::status(404, page::error("Not found", "No such page.", None)), + } + } + + fn home(&self) -> Response { + let home = Home::load(); + let mut body = String::from("

furst

"); + + if home.links.is_empty() && home.feeds.is_empty() { + body.push_str(&page::notice( + "", + &format!( + "Nothing pinned yet. Put links and feeds in {}, \ + or search above.", + page::escape(&crate::config::path().display().to_string()) + ), + )); + } + + if !home.links.is_empty() { + body.push_str("

Links

    "); + for l in &home.links { + body.push_str(&format!( + "
  • {}{}
  • ", + page::read_link(&l.url), + page::escape(&l.name), + page::escape(&host_of(&l.url)) + )); + } + body.push_str("
"); + } + + if !home.feeds.is_empty() { + body.push_str("

Feeds

    "); + for f in &home.feeds { + body.push_str(&format!( + "
  • {}{}
  • ", + encode(&f.url), + page::escape(&f.name), + page::escape(&host_of(&f.url)) + )); + } + body.push_str("
"); + } + + Response::html(page::shell("furst", "", "", &body)) + } + + fn read(&self, req: &Request) -> Response { + let Some(url) = req.param("u").filter(|u| !u.is_empty()) else { + return Response::redirect("/"); + }; + // Anything we cannot fetch ourselves is the desktop's problem. + if !url.starts_with("http://") && !url.starts_with("https://") { + return Response::redirect(url); + } + + let key = format!("read:{url}"); + if req.param("fresh").is_none() + && let Some(hit) = self.cache.get(&key) + { + return Response::html(hit); + } + + let page = match fetch::get(url, LIMIT) { + Ok(p) => p, + Err(e) => { + return Response::status(502, page::error("Could not fetch", &e, Some(url))); + } + }; + // A PDF or an image is not something to extract an article from. + if !page.content_type.is_empty() && !page.content_type.contains("html") { + return Response::redirect(&page.url); + } + + let (doc, base) = follow_refreshes(page); + let article = extract::extract(&doc); + let body = render::body(&doc, &article, &base, &render::Options { + images: true, + link_prefix: Some("/read?u=".to_string()), + }); + + let html = self.article_page(&article, &base, &body); + self.cache.put(&key, &html); + Response::html(html) + } + + fn article_page(&self, article: &extract::Article, base: &str, body: &str) -> String { + let mut meta = Vec::new(); + if let Some(b) = &article.byline { + meta.push(page::escape(b)); + } + if let Some(s) = &article.site { + meta.push(page::escape(s)); + } + meta.push(page::escape(&host_of(base))); + + let title = if article.title.is_empty() { host_of(base) } else { article.title.clone() }; + let mut content = format!( + "

{}

\n

{}

\n", + page::escape(&title), + meta.join(" · ") + ); + + if text_len(body) < THIN { + content.push_str(&page::notice( + "", + &format!( + "Not much of an article here. Try the original \ + or open it in the browser.", + page::escape(base), + encode(base) + ), + )); + } + content.push_str(body); + page::shell(&title, "", &page::source_actions(base), &content) + } + + fn go(&self, req: &Request) -> Response { + let Some(url) = req.param("u").filter(|u| !u.is_empty()) else { + return Response::redirect("/"); + }; + let browser = std::env::var("FURST_HEAVY_BROWSER").unwrap_or_else(|_| "firefox".to_string()); + let spawned = Command::new(&browser) + .arg(url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + + let body = match spawned { + Ok(_) => format!( + "

Opened in {}

back to the reader

", + page::escape(&browser), + page::read_link(url) + ), + Err(e) => { + return Response::status( + 502, + page::error("Could not launch a browser", &format!("{browser}: {e}"), Some(url)), + ); + } + }; + Response::html(page::shell("Opened", "", "", &body)) + } +} + +/// Static site generators leave redirect stubs behind; follow them before +/// deciding there is no article here. +fn follow_refreshes(page: fetch::Page) -> (Html, String) { + let mut base = page.url; + let mut doc = Html::parse_document(&page.html); + for _ in 0..3 { + let Some(next) = extract::meta_refresh(&doc, &base) else { break }; + if next == base { + break; + } + match fetch::get(&next, LIMIT) { + Ok(p) => { + base = p.url; + doc = Html::parse_document(&p.html); + } + Err(_) => break, + } + } + (doc, base) +} + +/// Rough text length of rendered markup, for the "is this actually an article" +/// check. Tag-stripping is enough; this never needs to be exact. +pub fn text_len(html: &str) -> usize { + let mut count = 0; + let mut in_tag = false; + for c in html.chars() { + match c { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag && !c.is_whitespace() => count += 1, + _ => {} + } + } + count +} + +pub fn host_of(url: &str) -> String { + let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url); + let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); + let hostport = authority.rsplit('@').next().unwrap_or(authority); + hostport.rsplit_once(':').map(|(h, _)| h).unwrap_or(hostport).trim_start_matches("www.").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_length_ignores_markup() { + assert_eq!(text_len("

abc

"), 3); + assert_eq!(text_len("x"), 1); + assert!(text_len("

") < THIN); + } + + #[test] + fn host_strips_scheme_port_userinfo_and_www() { + assert_eq!(host_of("https://www.ex.test:8080/a?b"), "ex.test"); + assert_eq!(host_of("https://u:p@ex.test/a"), "ex.test"); + assert_eq!(host_of("ex.test"), "ex.test"); + } +} diff --git a/furst-serve/src/http.rs b/furst-serve/src/http.rs new file mode 100644 index 0000000..8bfac71 --- /dev/null +++ b/furst-serve/src/http.rs @@ -0,0 +1,196 @@ +//! A small blocking HTTP/1.1 server. GET only, one response per connection. +//! +//! No async runtime: a fixed pool of worker threads keeps memory predictable, +//! which matters more here than concurrency does. Nothing but the loopback +//! interface is ever bound. + +use std::io::{self, BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; +use std::time::Duration; + +use furst_read::urljoin::decode; + +const MAX_REQUEST_LINE: usize = 8 * 1024; +const MAX_HEADERS: usize = 64; + +pub struct Request { + pub method: String, + pub path: String, + pub query: Vec<(String, String)>, +} + +impl Request { + pub fn param(&self, key: &str) -> Option<&str> { + self.query.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) + } +} + +pub struct Response { + pub status: u16, + pub content_type: &'static str, + pub body: Vec, + pub location: Option, +} + +impl Response { + pub fn html(body: String) -> Response { + Response { + status: 200, + content_type: "text/html; charset=utf-8", + body: body.into_bytes(), + location: None, + } + } + + pub fn redirect(to: &str) -> Response { + Response { + status: 302, + content_type: "text/plain; charset=utf-8", + body: Vec::new(), + location: Some(to.to_string()), + } + } + + pub fn status(status: u16, body: String) -> Response { + Response { status, content_type: "text/html; charset=utf-8", body: body.into_bytes(), location: None } + } +} + +pub fn serve(port: u16, workers: usize, handler: F) -> io::Result<()> +where + F: Fn(&Request) -> Response + Send + Sync + 'static, +{ + let listener = TcpListener::bind(("127.0.0.1", port))?; + let handler = Arc::new(handler); + let (tx, rx) = mpsc::channel::(); + let rx = Arc::new(Mutex::new(rx)); + + for _ in 0..workers.max(1) { + let rx = Arc::clone(&rx); + let handler = Arc::clone(&handler); + thread::spawn(move || { + loop { + // The guard is released as soon as recv returns, so workers + // take connections one at a time rather than serialising work. + let stream = rx.lock().unwrap().recv(); + match stream { + Ok(s) => handle(s, handler.as_ref()), + Err(_) => break, + } + } + }); + } + + for stream in listener.incoming().flatten() { + if tx.send(stream).is_err() { + break; + } + } + Ok(()) +} + +fn handle(mut stream: TcpStream, handler: &F) +where + F: Fn(&Request) -> Response, +{ + let _ = stream.set_read_timeout(Some(Duration::from_secs(15))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(60))); + + let response = match read_request(&stream) { + Some(req) if req.method == "GET" => handler(&req), + Some(_) => Response::status(405, "

only GET is supported

".into()), + None => return, + }; + let _ = write_response(&mut stream, &response); +} + +fn read_request(stream: &TcpStream) -> Option { + let mut reader = BufReader::new(stream.try_clone().ok()?); + + let mut line = String::new(); + if reader.read_line(&mut line).ok()? == 0 || line.len() > MAX_REQUEST_LINE { + return None; + } + let mut parts = line.split_whitespace(); + let method = parts.next()?.to_string(); + let target = parts.next()?.to_string(); + + // Headers are read only to reach the end of the request; none are used. + for _ in 0..MAX_HEADERS { + let mut header = String::new(); + match reader.read_line(&mut header) { + Ok(0) => break, + Ok(_) if header == "\r\n" || header == "\n" => break, + Ok(_) => {} + Err(_) => return None, + } + } + + let (path, query) = split_target(&target); + Some(Request { method, path, query }) +} + +fn split_target(target: &str) -> (String, Vec<(String, String)>) { + let (path, raw) = match target.split_once('?') { + Some((p, q)) => (p, q), + None => (target, ""), + }; + let query = raw + .split('&') + .filter(|p| !p.is_empty()) + .map(|pair| match pair.split_once('=') { + Some((k, v)) => (decode(k), decode(v)), + None => (decode(pair), String::new()), + }) + .collect(); + (decode(path), query) +} + +fn write_response(stream: &mut TcpStream, r: &Response) -> io::Result<()> { + let mut head = format!("HTTP/1.1 {} {}\r\n", r.status, reason(r.status)); + if let Some(location) = &r.location { + head.push_str(&format!("Location: {location}\r\n")); + } + head.push_str(&format!( + "Content-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + r.content_type, + r.body.len() + )); + stream.write_all(head.as_bytes())?; + stream.write_all(&r.body)?; + stream.flush() +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 302 => "Found", + 400 => "Bad Request", + 404 => "Not Found", + 405 => "Method Not Allowed", + 502 => "Bad Gateway", + _ => "Error", + } +} + +#[cfg(test)] +mod tests { + use super::split_target; + + #[test] + fn query_values_are_percent_decoded() { + let (path, q) = split_target("/read?u=https%3A%2F%2Fex.test%2Fa%3Fb%3D1&x=2"); + assert_eq!(path, "/read"); + assert_eq!(q[0], ("u".into(), "https://ex.test/a?b=1".into())); + assert_eq!(q[1], ("x".into(), "2".into())); + } + + #[test] + fn a_bare_path_has_no_query() { + let (path, q) = split_target("/"); + assert_eq!(path, "/"); + assert!(q.is_empty()); + } +} diff --git a/furst-serve/src/main.rs b/furst-serve/src/main.rs new file mode 100644 index 0000000..f0f5504 --- /dev/null +++ b/furst-serve/src/main.rs @@ -0,0 +1,175 @@ +mod cache; +mod config; +mod handler; +mod http; +mod page; + +use std::env; +use std::io::Write; +use std::net::TcpStream; +use std::os::unix::process::CommandExt; +use std::process::{Command, ExitCode, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use furst_read::urljoin::encode; + +const USAGE: &str = "\ +furst-serve — a local reader server: browse the web without a browser engine + +USAGE + furst-serve run the server in the foreground + furst-serve --open start it if needed, then open in the reader + furst-serve --url print the reader URL for + furst-serve --status report whether a server is listening + furst-serve --init write a starter home.toml + furst-serve --clear drop the page cache + +OPTIONS + --port default 7714, or $FURST_PORT + --ttl page cache lifetime, default 3600 + --browser browser to open with + + Pages are cached under $XDG_CACHE_HOME/furst/pages. The heavy browser used + by the 'browser' link is $FURST_HEAVY_BROWSER, default firefox. +"; + +const DEFAULT_PORT: u16 = 7714; +const BROWSERS: &[&str] = &["surf", "luakit", "vimb", "badwolf", "epiphany", "firefox"]; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("furst-serve: {e}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let args: Vec = env::args().skip(1).collect(); + if args.iter().any(|a| a == "-h" || a == "--help") { + print!("{USAGE}"); + return Ok(()); + } + + let mut port = env::var("FURST_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(DEFAULT_PORT); + let mut ttl = Duration::from_secs(3600); + let mut open: Option = None; + let mut print_url: Option = None; + let mut browser: Option = None; + let mut status = false; + let mut init = false; + let mut clear = false; + + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--open" => open = Some(it.next().ok_or("--open needs a url")?), + "--url" => print_url = Some(it.next().ok_or("--url needs a url")?), + "--browser" => browser = Some(it.next().ok_or("--browser needs a command")?), + "--port" => { + port = it.next().ok_or("--port needs a number")?.parse().map_err(|_| "bad port")? + } + "--ttl" => { + let secs: u64 = it.next().ok_or("--ttl needs seconds")?.parse().map_err(|_| "bad ttl")?; + ttl = Duration::from_secs(secs); + } + "--status" => status = true, + "--init" => init = true, + "--clear" => clear = true, + a => return Err(format!("unknown argument {a} (--help for usage)")), + } + } + + if init { + return write_starter(); + } + if clear { + let n = cache::Cache::new(ttl).clear(); + println!("removed {n} cached pages"); + return Ok(()); + } + if status { + println!( + "{}", + if listening(port) { format!("listening on 127.0.0.1:{port}") } else { "not running".into() } + ); + return Ok(()); + } + if let Some(url) = print_url { + println!("{}", reader_url(port, &url)); + return Ok(()); + } + if let Some(url) = open { + return open_in_reader(port, &url, browser); + } + + eprintln!("furst-serve: listening on http://127.0.0.1:{port}"); + let app = handler::App::new(ttl); + http::serve(port, 4, move |req| app.handle(req)).map_err(|e| format!("port {port}: {e}")) +} + +fn reader_url(port: u16, url: &str) -> String { + format!("http://127.0.0.1:{port}/read?u={}", encode(url)) +} + +fn listening(port: u16) -> bool { + TcpStream::connect_timeout( + &format!("127.0.0.1:{port}").parse().expect("loopback address"), + Duration::from_millis(300), + ) + .is_ok() +} + +/// Start the server if nothing is on the port, then hand the reader URL to a +/// browser. The server is detached, so it outlives this process. +fn open_in_reader(port: u16, url: &str, browser: Option) -> Result<(), String> { + if !listening(port) { + let exe = env::current_exe().map_err(|e| format!("current_exe: {e}"))?; + Command::new(exe) + .arg("--port") + .arg(port.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("could not start the server: {e}"))?; + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline && !listening(port) { + thread::sleep(Duration::from_millis(50)); + } + if !listening(port) { + return Err(format!("server did not come up on port {port}")); + } + } + + let cmd = browser + .or_else(|| env::var("FURST_BROWSER").ok().filter(|s| !s.is_empty())) + .or_else(|| env::var("BROWSER").ok().filter(|s| !s.is_empty())) + .or_else(|| BROWSERS.iter().find(|b| which(b)).map(|b| b.to_string())) + .ok_or("no browser found; set $FURST_BROWSER")?; + + let err = Command::new(&cmd).arg(reader_url(port, url)).exec(); + Err(format!("{cmd}: {err}")) +} + +fn write_starter() -> Result<(), String> { + let path = config::path(); + if path.exists() { + return Err(format!("{} already exists", path.display())); + } + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + std::fs::write(&path, config::STARTER).map_err(|e| format!("{}: {e}", path.display()))?; + let mut out = std::io::stdout(); + let _ = writeln!(out, "wrote {}", path.display()); + Ok(()) +} + +fn which(cmd: &str) -> bool { + env::var_os("PATH").is_some_and(|p| env::split_paths(&p).any(|d| d.join(cmd).is_file())) +} diff --git a/furst-serve/src/page.rs b/furst-serve/src/page.rs new file mode 100644 index 0000000..efdadeb --- /dev/null +++ b/furst-serve/src/page.rs @@ -0,0 +1,89 @@ +//! Page chrome. Everything the server emits shares one shell: a bar with the +//! search box and the escape hatches, then the content. + +use furst_read::render::CSS; +use furst_read::urljoin::encode; + +pub fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// The reader's own URL for an outside page. +pub fn read_link(url: &str) -> String { + format!("/read?u={}", encode(url)) +} + +pub fn shell(title: &str, query: &str, actions: &str, body: &str) -> String { + format!( + "\n\n\n\ + \n\ + {}\n\n\n\n\ +
\ + furst\ +
\ + {actions}\ +
\n
\n{body}
\n\n", + escape(title), + escape(query) + ) +} + +/// The bar's right-hand side for a page that came from somewhere else. +pub fn source_actions(url: &str) -> String { + format!( + "refresh\ + original\ + browser", + u = encode(url), + orig = escape(url) + ) +} + +pub fn notice(kind: &str, body: &str) -> String { + format!("

{body}

") +} + +pub fn error(title: &str, detail: &str, url: Option<&str>) -> String { + let mut body = format!("

{}

{}", escape(title), notice("bad", &escape(detail))); + if let Some(u) = url { + body.push_str(&format!( + "

open {0} directly · open in the browser

", + escape(u), + encode(u) + )); + } + shell(title, "", "", &body) +} + +const EXTRA: &str = "\ +body{padding:0 0 5rem}\ +main{max-width:38rem;margin:0 auto;padding:1.75rem 1.25rem 0}\ +.bar{position:sticky;top:0;z-index:9;display:flex;gap:.85rem;align-items:center;\ +padding:.55rem 1rem;background:#f2f1ed;border-bottom:1px solid #e0dfda;font-size:.85rem}\ +.bar a{color:#555;text-decoration:none;white-space:nowrap}\ +.bar a:hover{text-decoration:underline}\ +.brand{font-weight:700;color:#111 !important;letter-spacing:.02em}\ +.find{flex:1;margin:0}\ +.find input{width:100%;padding:.34rem .6rem;font:inherit;color:inherit;\ +background:#fff;border:1px solid #d8d7d2;border-radius:3px}\ +.acts{display:flex;gap:.75rem}\ +.notice{padding:.7rem .9rem;border-radius:3px;background:#f4f3ee;border:1px solid #e2e1db;font-size:.9rem}\ +.notice.bad{background:#fdf1ef;border-color:#f0d5d0}\ +.rows{list-style:none;margin:0;padding:0}\ +.rows li{margin:0 0 1.15rem;padding:0}\ +.rows .t{font-size:1.02rem;line-height:1.35;display:block}\ +.rows .u{font-size:.8rem;color:#8a8780;display:block;margin-top:.15rem}\ +.rows .s{font-size:.88rem;color:#5d5a55;display:block;margin-top:.3rem;line-height:1.5}\ +.sec{font-size:.75rem;text-transform:uppercase;letter-spacing:.09em;color:#8a8780;\ +margin:2.25rem 0 .9rem;font-weight:600}\ +.cmt{margin:0 0 .9rem;padding:.55rem 0 0;border-top:1px solid #eceae4;font-size:.93rem}\ +.cmt .h{font-size:.78rem;color:#8a8780;margin-bottom:.3rem}\ +.cmt p{margin:0 0 .6rem}\ +@media(prefers-color-scheme:dark){\ +.bar{background:#1b1b1b;border-color:#2b2b2b}\ +.bar a{color:#a9a6a0}.brand{color:#e8e6e1 !important}\ +.find input{background:#242424;border-color:#333;color:#d8d6d1}\ +.notice{background:#1d1d1d;border-color:#2f2f2f}\ +.notice.bad{background:#2a1c1a;border-color:#4a2f2a}\ +.rows .u{color:#7c7973}.rows .s{color:#a9a6a0}.sec{color:#7c7973}\ +.cmt{border-color:#262626}.cmt .h{color:#7c7973}}";