use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Duration;
#[cfg(desktop)]
const DEV_ENV: &str = "FRUS_DEV";
#[cfg(android)]
pub(crate) const DEV_EXTRA: &str = "frus_dev";
#[cfg(desktop)]
const LOCATION_ENV: &str = "FRUS_RESTORE_LOCATION";
#[cfg(desktop)]
const WINDOW_ENV: &str = "FRUS_RESTORE_WINDOW";
#[cfg(desktop)]
const STATES_ENV: &str = "FRUS_RESTORE_STATES";
#[cfg(android)]
const STATES_FILE: &str = "frus-run-states";
static SAVE_TO: Mutex<Option<PathBuf>> = Mutex::new(None);
pub(crate) type Placement = [i32; 4];
pub(crate) const LOG_MARK: &str = "frus-dev ";
pub(crate) struct DevChannel {
sink: Sink,
location: Option<String>,
window: Option<Placement>,
}
enum Sink {
#[cfg_attr(not(desktop), allow(dead_code))]
Socket(TcpStream),
#[cfg_attr(not(android), allow(dead_code))]
Log,
}
impl Drop for DevChannel {
fn drop(&mut self) {
if let Sink::Socket(stream) = &self.sink {
let _ = stream.shutdown(std::net::Shutdown::Both);
}
}
}
impl DevChannel {
#[cfg_attr(not(desktop), allow(dead_code))]
pub(crate) fn connect(address: &str) -> Option<Self> {
let address: SocketAddr = address.trim().parse().ok()?;
let Ok(stream) = TcpStream::connect_timeout(&address, Duration::from_secs(1)) else {
log::warn!(
"frus run's channel at {address} could not be reached: a restart will start over"
);
return None;
};
let _ = stream.set_nodelay(true);
if let Ok(reading) = stream.try_clone() {
std::thread::spawn(move || {
for line in BufReader::new(reading).lines().map_while(Result::ok) {
if let Some(path) = line.strip_prefix("save ") {
if let Ok(mut slot) = SAVE_TO.lock() {
*slot = Some(PathBuf::from(path.trim()));
}
crate::dev_commands::wake();
} else if let Some(command) = line.strip_prefix("do ") {
crate::dev_commands::push_command(command);
}
}
});
}
Some(Self::to(Sink::Socket(stream)))
}
pub(crate) fn save_if_asked(&mut self, snapshot: impl FnOnce() -> String) {
let Some(path) = SAVE_TO.lock().ok().and_then(|mut slot| slot.take()) else {
return;
};
let written = std::fs::write(&path, snapshot()).is_ok();
if let Sink::Socket(stream) = &mut self.sink {
let _ = stream.write_all(if written { b"saved\n" } else { b"unsaved\n" });
}
}
#[cfg_attr(not(android), allow(dead_code))]
pub(crate) fn through_log() -> Self {
Self::to(Sink::Log)
}
fn to(sink: Sink) -> Self {
Self {
sink,
location: None,
window: None,
}
}
pub(crate) fn report(&mut self, location: Option<String>, window: Option<Placement>) -> bool {
let mut lines = String::new();
if location.is_some() && location != self.location {
lines.push_str(&format!(
"location {}\n",
location.as_deref().unwrap_or("/")
));
self.location = location;
}
if window.is_some() && window != self.window {
if let Some([x, y, w, h]) = window {
lines.push_str(&format!("window {x} {y} {w} {h}\n"));
}
self.window = window;
}
if lines.is_empty() {
return true;
}
match &mut self.sink {
Sink::Socket(stream) => stream.write_all(lines.as_bytes()).is_ok(),
Sink::Log => {
for line in lines.lines() {
log::info!(target: "frus::dev", "{LOG_MARK}{line}");
}
true
}
}
}
}
#[cfg(desktop)]
pub(crate) fn restored_states() -> Option<String> {
let path = std::env::var_os(STATES_ENV)?;
let states = std::fs::read_to_string(&path).ok();
let _ = std::fs::remove_file(&path);
states
}
#[cfg(android)]
pub(crate) fn restored_states(storage: &std::path::Path) -> Option<String> {
let path = storage.join(STATES_FILE);
let states = std::fs::read_to_string(&path).ok();
let _ = std::fs::remove_file(&path);
states
}
#[cfg(android)]
pub(crate) fn keep_states(storage: &std::path::Path, snapshot: &str) {
let _ = std::fs::write(storage.join(STATES_FILE), snapshot);
}
#[cfg(desktop)]
pub(crate) fn address() -> Option<String> {
std::env::var(DEV_ENV).ok().filter(|a| !a.is_empty())
}
#[cfg(desktop)]
pub(crate) fn restore_link() -> Option<String> {
let location = std::env::var(LOCATION_ENV).ok()?;
Some(as_link(&location))
}
#[cfg_attr(not(desktop), allow(dead_code))]
pub(crate) fn as_link(location: &str) -> String {
format!("frus:///{}", location.trim_start_matches('/'))
}
#[cfg(desktop)]
pub(crate) fn restore_window() -> Option<Placement> {
parse_placement(&std::env::var(WINDOW_ENV).ok()?)
}
#[cfg_attr(not(desktop), allow(dead_code))]
pub(crate) fn parse_placement(text: &str) -> Option<Placement> {
let numbers: Vec<i32> = text
.split(',')
.map(|n| n.trim().parse().ok())
.collect::<Option<_>>()?;
match numbers.as_slice() {
&[x, y, w, h] if w > 0 && h > 0 => Some([x, y, w, h]),
_ => None,
}
}
#[cfg(test)]
mod tests {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use super::*;
use crate::link::location_of_link;
#[test]
fn a_location_survives_the_trip_through_a_link() {
for location in ["/", "/products/3", "/search?q=shoes&sort=price", "/a/b/c"] {
assert_eq!(
location_of_link(&as_link(location)).as_deref(),
Some(location)
);
}
}
#[test]
fn placements_are_read_strictly() {
assert_eq!(
parse_placement("120,80,1280,800"),
Some([120, 80, 1280, 800])
);
assert_eq!(
parse_placement("-8, -8, 800, 600"),
Some([-8, -8, 800, 600])
);
assert_eq!(parse_placement("1,2,0,600"), None);
assert_eq!(parse_placement("1,2,3"), None);
assert_eq!(parse_placement("a,b,c,d"), None);
}
#[test]
fn the_channel_reports_changes_only() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap().to_string();
let mut channel = DevChannel::connect(&address).unwrap();
let (peer, _) = listener.accept().unwrap();
assert!(channel.report(Some("/".into()), Some([0, 0, 800, 600])));
assert!(channel.report(Some("/".into()), Some([0, 0, 800, 600])));
assert!(channel.report(Some("/products/3".into()), Some([0, 0, 800, 600])));
assert!(channel.report(None, Some([10, 20, 800, 600])));
drop(channel);
peer.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let lines: Vec<String> = BufReader::new(peer).lines().map_while(Result::ok).collect();
assert_eq!(
lines,
[
"location /",
"window 0 0 800 600",
"location /products/3",
"window 10 20 800 600"
]
);
}
#[test]
fn commands_from_the_socket_are_queued() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap().to_string();
let _channel = DevChannel::connect(&address).unwrap();
let (mut peer, _) = listener.accept().unwrap();
crate::dev_commands::take_commands();
peer.write_all(b"do outlines\ndo screenshot shot.png\n")
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut got = Vec::new();
while got.len() < 2 && std::time::Instant::now() < deadline {
got.extend(crate::dev_commands::take_commands());
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(got, ["outlines", "screenshot shot.png"]);
}
#[test]
fn nobody_listening_is_no_channel() {
let unused = TcpListener::bind("127.0.0.1:0").unwrap();
let address = unused.local_addr().unwrap().to_string();
drop(unused);
assert!(DevChannel::connect(&address).is_none());
assert!(DevChannel::connect("not an address").is_none());
}
}