tuna_web/
lib.rs

1use anyhow::Result;
2use nanoserde::{DeJson, SerJson};
3use std::{
4    net::{TcpListener, TcpStream},
5    str::FromStr,
6};
7use tiny_http::{Header, Response as HttpResponse, Server};
8use tuna::Tuneable;
9use tungstenite::{accept, WebSocket};
10
11use include_dir::{include_dir, Dir};
12
13static PROJECT_DIR: Dir = include_dir!("html");
14
15fn content_type(url: &str) -> Option<Header> {
16    if url.ends_with(".js") {
17        return Header::from_str("Content-Type: application/javascript; charset=UTF=8").ok();
18    }
19
20    if url.ends_with(".css") {
21        return Header::from_str("Content-Type: text/css; charset=UTF=8").ok();
22    }
23
24    if url.ends_with(".html") {
25        return Header::from_str("Content-Type: text/html; charset=UTF=8").ok();
26    }
27
28    None
29}
30
31#[derive(DeJson, SerJson, Debug)]
32enum TunaMessage {
33    ListAll,
34    Tuneables(tuna::TunaState),
35    Delta((String, String, Tuneable)),
36    Ok((String, String)),
37}
38
39struct TunaClient {
40    websocket: WebSocket<TcpStream>,
41}
42
43impl TunaClient {
44    fn new(stream: TcpStream) -> Result<Self> {
45        let websocket = accept(stream)?;
46
47        Ok(Self { websocket })
48    }
49
50    fn poll(&mut self) -> bool {
51        let msg = self.websocket.read_message().unwrap();
52
53        if msg.is_text() {
54            let contents = msg.into_text().unwrap();
55            let message: TunaMessage = match DeJson::deserialize_json(&contents) {
56                Ok(v) => v,
57                Err(e) => {
58                    log::error!("failed deserialization: {}, {}", e, contents);
59                    return true;
60                }
61            };
62
63            match message {
64                TunaMessage::ListAll => {
65                    let state = tuna::TUNA_STATE.read();
66                    let res = TunaMessage::Tuneables((*state).clone());
67
68                    let response = SerJson::serialize_json(&res);
69                    self.websocket
70                        .write_message(tungstenite::Message::Text(response))
71                        .unwrap();
72                }
73
74                TunaMessage::Delta((category, name, tuneable)) => {
75                    tuneable.apply_to(&category, &name);
76
77                    let response = SerJson::serialize_json(&TunaMessage::Ok((category, name)));
78                    self.websocket
79                        .write_message(tungstenite::Message::Text(response))
80                        .unwrap();
81                }
82                TunaMessage::Tuneables(_) | TunaMessage::Ok((_, _)) => {
83                    panic!("unexpected message kind")
84                }
85            }
86        } else if msg.is_close() {
87            return false;
88        } else {
89            log::error!("received non-string message: {:?}", msg);
90        }
91
92        true
93    }
94}
95
96/// The server to tuna web. Will deal with both serving of HTTP content and the
97/// websockets used for management.
98pub struct TunaServer {
99    server: TcpListener,
100    http_server: Server,
101}
102
103impl TunaServer {
104    /// Create a new Tuna Web server. Will serve HTTP on the specified port, and
105    /// websocket traffic on the subsequent port (`port + 1`).
106    pub fn new(port: u16) -> anyhow::Result<Self> {
107        let http_server = Server::http(("0.0.0.0", port))
108            .map_err(|e| anyhow::format_err!("http server error: {}", e))?;
109
110        let server = TcpListener::bind(("127.0.0.1", port + 1))?;
111
112        server.set_nonblocking(true)?;
113
114        Ok(Self {
115            server,
116            http_server,
117        })
118    }
119
120    /// Update the HTTP server, draining the request queue before returning.
121    pub fn work_http(&mut self) {
122        loop {
123            match self.http_server.try_recv() {
124                Ok(Some(req)) => {
125                    log::debug!("request: {:#?}", req);
126                    let response = match req.url() {
127                        "/" => HttpResponse::from_string(
128                            PROJECT_DIR
129                                .get_file("index.html")
130                                .unwrap()
131                                .contents_utf8()
132                                .unwrap(),
133                        )
134                        .with_status_code(200)
135                        .with_header(content_type(".html").unwrap()),
136                        _ => match PROJECT_DIR.get_file(&req.url()[1..]) {
137                            Some(contents) => {
138                                HttpResponse::from_string(contents.contents_utf8().unwrap())
139                                    .with_status_code(200)
140                                    .with_header(content_type(req.url()).unwrap())
141                            }
142                            _ => HttpResponse::from_string("not found").with_status_code(404),
143                        },
144                    };
145
146                    let _ = req.respond(response);
147                }
148                Ok(None) => {
149                    break;
150                }
151                Err(e) => {
152                    log::error!("Http Error: {:?}", e);
153                    break;
154                }
155            }
156        }
157    }
158
159    /// Update the websocket server, draining the connection queue before returning.
160    pub fn work_websocket(&mut self) {
161        loop {
162            match self.server.accept() {
163                Ok((stream, addr)) => {
164                    log::debug!("New Tuna client from: {:?}", addr);
165
166                    match TunaClient::new(stream) {
167                        Ok(mut client) => {
168                            std::thread::spawn(move || loop {
169                                if !client.poll() {
170                                    break;
171                                }
172                            });
173                        }
174                        Err(e) => log::error!("failed to accept client: {}", e),
175                    }
176                }
177                Err(e) if e.kind() != std::io::ErrorKind::WouldBlock => {
178                    log::error!("Error during accept: {:?}", e);
179                    break;
180                }
181                _ => {
182                    break;
183                }
184            }
185        }
186    }
187    /// Update all connections and servers in one go. Note that the clients will
188    /// not be polled here as they are blocking.
189    pub fn loop_once(&mut self) {
190        self.work_http();
191        self.work_websocket();
192    }
193}