feat(gui): tines-gui skeleton (ipc client, config gen, theme)

This commit is contained in:
2026-10-24 23:56:43 -07:00
parent 7b30550454
commit ca4ec4c5dd
10 changed files with 4667 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
//! The GUI's generated configs must deserialize as the service's `Config`
//! byte-for-byte: unknown keys are fatal on the service side.
use tines_gui::config::{NodeConfig, generate_node_id};
#[test]
fn generated_config_parses_as_service_config() {
// bt: transport bt, a peer, no bus socket.
let bt = NodeConfig::bt(
generate_node_id(),
Some(generate_node_id()),
"/tmp/fleet/log".into(),
"/tmp/fleet/node.sock".into(),
);
let json = serde_json::to_string(&bt).unwrap();
let parsed: tines::config::Config =
serde_json::from_str(&json).expect("bt config must parse as the service config");
assert_eq!(parsed.node_id, bt.node_id);
assert_eq!(parsed.peer, bt.peer);
assert_eq!(parsed.bus_socket, None);
assert_eq!(parsed.validate(), Ok(()));
// bus: transport bus, no peer, a bus socket.
let bus = NodeConfig::bus(
generate_node_id(),
None,
"/tmp/fleet/log".into(),
"/tmp/fleet/node.sock".into(),
"/tmp/fleet/bus.sock".into(),
);
let json = serde_json::to_string(&bus).unwrap();
let parsed: tines::config::Config =
serde_json::from_str(&json).expect("bus config must parse as the service config");
assert_eq!(parsed.node_id, bus.node_id);
assert_eq!(parsed.peer, None);
assert_eq!(parsed.bus_socket, Some("/tmp/fleet/bus.sock".to_string()));
assert_eq!(parsed.validate(), Ok(()));
}
#[test]
fn node_id_is_32_hex_chars() {
let id = generate_node_id();
assert_eq!(id.len(), 32, "node id must be 32 chars, got {id:?}");
assert!(
id.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"node id must be lowercase hex, got {id:?}"
);
}
#[test]
fn fleet_dir_is_0700() {
use std::os::unix::fs::PermissionsExt;
// Point XDG_STATE_HOME at a scratch dir so the test never touches the
// real ~/.local/state, and fleet_dir's creation is what we inspect.
let scratch = std::env::temp_dir().join(format!("tines-gui-state-{}", std::process::id()));
std::fs::remove_dir_all(&scratch).ok();
std::fs::create_dir_all(&scratch).unwrap();
// SAFETY: this is the only test in this binary touching fleet_dir, and
// the test binary dies right after; the env change cannot leak to other
// tests in other binaries.
unsafe { std::env::set_var("XDG_STATE_HOME", &scratch) };
let dir = tines_gui::config::fleet_dir("fleet-a");
let meta = std::fs::metadata(&dir).expect("fleet dir must be created");
assert!(meta.is_dir());
assert_eq!(
meta.permissions().mode() & 0o777,
0o700,
"fleet dir must be 0700"
);
std::fs::remove_dir_all(&scratch).ok();
}
+133
View File
@@ -0,0 +1,133 @@
//! The IPC client against a fake service speaking the real wire framing
//! (`[u32 LE len][JSON]`), from a test-local UnixListener.
use std::io::{Read, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant};
use tines::{Request, Response};
use tines_gui::ipc_client::IpcClient;
fn scratch_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tines-gui-ipc-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn read_frame(stream: &mut UnixStream) -> Vec<u8> {
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf).unwrap();
let len = u32::from_le_bytes(len_buf) as usize;
let mut buf = vec![0u8; len];
stream.read_exact(&mut buf).unwrap();
buf
}
fn write_frame(stream: &mut UnixStream, body: &[u8]) {
let len = (body.len() as u32).to_le_bytes();
stream.write_all(&len).unwrap();
stream.write_all(body).unwrap();
stream.flush().unwrap();
}
/// Answer one Observe request with an Observe response, then end the
/// connection (EOF).
fn answer_once(socket: &Path) {
// A restarted server finds the previous server's socket path still on
// disk (Linux leaves the file behind when a bound listener drops), so
// it removes the stale path first — exactly what a real service does.
let _ = std::fs::remove_file(socket);
let (mut stream, _) = UnixListener::bind(socket)
.unwrap()
.accept()
.expect("server accepts");
let body = read_frame(&mut stream);
// Request-agnostic: the client-side tests only assert the id pairing,
// so any well-formed request gets an Observe-shaped response.
let _: Request = serde_json::from_slice(&body).expect("well-formed request");
write_frame(
&mut stream,
&serde_json::to_vec(&Response::Observe { id: "00".into() }).unwrap(),
);
// Dropping the stream here closes the connection: the client sees EOF.
}
#[test]
fn client_roundtrips_observe() {
let dir = scratch_dir("rt");
let socket = dir.join("s.sock");
let server_socket = socket.clone();
let server = thread::spawn(move || answer_once(&server_socket));
let client = IpcClient::spawn(socket.clone()).expect("client spawns");
let id = client
.request(Request::Observe {
topic: "ab".into(),
payload: "cd".into(),
to: None,
lifetime_ms: None,
})
.expect("request queues");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some((got_id, resp)) = client.try_recv() {
assert_eq!(got_id, id, "response must carry the request id");
assert!(
matches!(resp, Response::Observe { .. }),
"expected Observe response, got {resp:?}"
);
break;
}
assert!(Instant::now() < deadline, "no response arrived");
thread::sleep(Duration::from_millis(20));
}
server.join().unwrap();
}
#[test]
fn client_survives_server_restart() {
let dir = scratch_dir("restart");
let socket = dir.join("s.sock");
// First server: one round trip, then it goes away.
let server_socket = socket.clone();
let first = thread::spawn(move || answer_once(&server_socket));
let client = IpcClient::spawn(socket.clone()).expect("client spawns");
let first_id = client
.request(Request::Events)
.expect("first request queues");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some((got, _)) = client.try_recv() {
assert_eq!(got, first_id);
break;
}
assert!(Instant::now() < deadline, "no response to first request");
thread::sleep(Duration::from_millis(20));
}
first.join().unwrap();
// Restart: a fresh listener on the same path answers the next request.
client.reconnect();
let server_socket = socket.clone();
let second = thread::spawn(move || answer_once(&server_socket));
let second_id = client
.request(Request::Events)
.expect("second request queues");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some((got, _)) = client.try_recv() {
assert_eq!(got, second_id, "ids keep increasing across restarts");
break;
}
assert!(Instant::now() < deadline, "no response after restart");
thread::sleep(Duration::from_millis(20));
}
second.join().unwrap();
}