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
+133
View File
@@ -0,0 +1,133 @@
//! Fleet/node layout on disk and the service configs the launcher writes.
//!
//! The launcher owns per-fleet state under
//! `$XDG_STATE_HOME|~/.local/state/tines-console/`, and each node's
//! `config.json` is exactly the service's [`tines::config::Config`] schema —
//! the service rejects unknown keys, so the generated config must match it
//! byte-for-byte (pinned by `tests/config.rs`).
use std::fs::{self, Permissions};
use std::io::{self, Read};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
/// The per-fleet state dir, created `0700` on first use:
/// `$XDG_STATE_HOME|~/.local/state` + `tines-console/fleets/<fleet>`.
pub fn fleet_dir(fleet: &str) -> PathBuf {
let base = state_home();
let dir = base.join("tines-console").join("fleets").join(fleet);
if let Err(e) = fs::create_dir_all(&dir) {
eprintln!("tines-gui: cannot create fleet dir {}: {e}", dir.display());
}
// create_dir_all honors the umask; the fleets carry node sockets and
// logs, so they are private by design.
if let Err(e) = fs::set_permissions(&dir, Permissions::from_mode(0o700)) {
eprintln!("tines-gui: cannot chmod fleet dir {}: {e}", dir.display());
}
dir
}
/// A node's dir inside a fleet: `<fleet_dir>/<node>`. Not created here —
/// [`NodeConfig::write`] creates it when a config lands.
pub fn node_dir(fleet: &str, node: &str) -> PathBuf {
fleet_dir(fleet).join(node)
}
/// A fresh random node id: 32 lowercase hex chars from 16 bytes of
/// `/dev/urandom`.
pub fn generate_node_id() -> String {
let mut buf = [0u8; 16];
let mut f =
std::fs::File::open("/dev/urandom").expect("/dev/urandom is available on this platform");
f.read_exact(&mut buf)
.expect("reading 16 bytes from /dev/urandom");
buf.iter().map(|b| format!("{b:02x}")).collect()
}
/// A node's service config, serializing to exactly the service schema.
#[derive(Debug, Clone, serde::Serialize)]
pub struct NodeConfig {
/// This node's id (32 lowercase hex chars).
pub node_id: String,
/// bt: the peer; bus: the allowlist of one, `None` for any peer.
#[serde(skip_serializing_if = "Option::is_none")]
pub peer: Option<String>,
/// Absolute path to the LogStore log inside the container.
pub log_path: String,
/// Absolute path to the IPC socket inside the container.
pub socket_path: String,
/// `"bt"` or `"bus"`.
pub transport: String,
/// bus only: the bus daemon's socket inside the container.
#[serde(skip_serializing_if = "Option::is_none")]
pub bus_socket: Option<String>,
/// `"system"` when the host clock is trustworthy (the container's is).
#[serde(skip_serializing_if = "Option::is_none")]
pub trusted_clock: Option<String>,
}
impl NodeConfig {
/// A bt-mode config: one peer over the BLE transport.
pub fn bt(
node_id: String,
peer: Option<String>,
log_path: String,
socket_path: String,
) -> Self {
NodeConfig {
node_id,
peer,
log_path,
socket_path,
transport: "bt".into(),
bus_socket: None,
trusted_clock: Some("system".into()),
}
}
/// A bus-mode config: peers are every node on the bus daemon (or the
/// allowlisted one).
pub fn bus(
node_id: String,
peer: Option<String>,
log_path: String,
socket_path: String,
bus_socket: String,
) -> Self {
NodeConfig {
node_id,
peer,
log_path,
socket_path,
transport: "bus".into(),
bus_socket: Some(bus_socket),
trusted_clock: Some("system".into()),
}
}
/// Write this config to `<node_dir(fleet, node)>/config.json` (creating
/// the node dir, `0700`) and return the path.
pub fn write(&self, fleet: &str, node: &str) -> io::Result<PathBuf> {
let dir = node_dir(fleet, node);
fs::create_dir_all(&dir)?;
fs::set_permissions(&dir, Permissions::from_mode(0o700))?;
let path = dir.join("config.json");
let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
fs::write(&path, json)?;
Ok(path)
}
}
/// `$XDG_STATE_HOME` when set to an absolute path, else `~/.local/state`.
fn state_home() -> PathBuf {
if let Some(dir) = std::env::var_os("XDG_STATE_HOME") {
let p = PathBuf::from(dir);
if p.is_absolute() {
return p;
}
}
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join(".local/state")
}
+198
View File
@@ -0,0 +1,198 @@
//! An async, queue-based client for the `tines` service IPC.
//!
//! The wire protocol is the service's own: `[u32 LE len][JSON]`. The client
//! tags frames with a monotonically increasing message id **on the
//! client-side queue only** — `request` enqueues `[id, JSON body]` and the
//! worker thread writes `[len][JSON]` to the socket, so the service-side
//! protocol is untouched. The service answers one request per connection in
//! order, so the worker pairs responses with the outstanding ids FIFO; the
//! GUI polls [`IpcClient::try_recv`] for `(id, response)` pairs.
//!
//! The worker owns the socket and re-dials forever: a dead service never
//! blocks `request` (requests queue), and [`IpcClient::reconnect`] forces a
//! re-dial without dropping queued requests. If the connection dies between
//! a write and its response, the request is replayed after the re-dial —
//! observe is the dominant verb here, and re-observing beats losing data.
use std::collections::VecDeque;
use std::fmt;
use std::io::{self, Read, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::Duration;
use tines::{Request, Response};
/// Errors the client can report.
#[derive(Debug)]
pub enum ClientError {
/// A socket-level failure (including a dead worker thread).
Io(io::Error),
/// A request failed to serialize.
Json(serde_json::Error),
}
impl fmt::Display for ClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ClientError::Io(e) => write!(f, "ipc client io: {e}"),
ClientError::Json(e) => write!(f, "ipc client json: {e}"),
}
}
}
impl std::error::Error for ClientError {}
/// Shared state between the handle and the worker thread.
struct Shared {
/// Set by [`IpcClient::reconnect`]; the worker redials and clears it.
force_reconnect: AtomicBool,
}
/// A queue-based client for one service socket.
pub struct IpcClient {
out: Sender<(u64, Vec<u8>)>,
rx: Receiver<(u64, Response)>,
next_id: Arc<AtomicU64>,
shared: Arc<Shared>,
_socket: PathBuf,
}
impl IpcClient {
/// Spawn the worker thread and return the client. The socket is dialed
/// on the worker thread, so this succeeds even while the service is
/// down.
pub fn spawn(socket: PathBuf) -> io::Result<IpcClient> {
let (out_tx, out_rx) = mpsc::channel();
let (resp_tx, resp_rx) = mpsc::channel();
let next_id = Arc::new(AtomicU64::new(0));
let shared = Arc::new(Shared {
force_reconnect: AtomicBool::new(false),
});
let worker_shared = Arc::clone(&shared);
let worker_socket = socket.clone();
thread::Builder::new()
.name("tines-gui-ipc".into())
.spawn(move || worker(worker_socket, out_rx, resp_tx, worker_shared))?;
Ok(IpcClient {
out: out_tx,
rx: resp_rx,
next_id,
shared,
_socket: socket,
})
}
/// Encode and queue a request; returns the message id it will be
/// answered under.
pub fn request(&self, req: Request) -> Result<u64, ClientError> {
let body = serde_json::to_vec(&req).map_err(ClientError::Json)?;
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
self.out
.send((id, body))
.map_err(|e| ClientError::Io(io::Error::new(io::ErrorKind::BrokenPipe, e)))?;
Ok(id)
}
/// Nonblocking: the next completed `(message id, response)`, if any.
pub fn try_recv(&self) -> Option<(u64, Response)> {
self.rx.try_recv().ok()
}
/// Force the worker to drop its current connection and re-dial. Queued
/// requests are kept and replayed after the new dial.
pub fn reconnect(&self) {
self.shared.force_reconnect.store(true, Ordering::Relaxed);
}
}
/// Write one `[u32 LE len][body]` frame to the stream.
fn write_frame(stream: &mut UnixStream, body: &[u8]) -> io::Result<()> {
let len = (body.len() as u32).to_le_bytes();
stream.write_all(&len)?;
stream.write_all(body)?;
stream.flush()
}
/// Read one `[u32 LE len][body]` frame from the stream.
fn read_frame(stream: &mut UnixStream) -> io::Result<Vec<u8>> {
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf)?;
let len = u32::from_le_bytes(len_buf) as usize;
let mut buf = vec![0u8; len];
stream.read_exact(&mut buf)?;
Ok(buf)
}
/// The worker loop: dial, serve requests in order, redial forever.
fn worker(
socket: PathBuf,
out: Receiver<(u64, Vec<u8>)>,
resp: Sender<(u64, Response)>,
shared: Arc<Shared>,
) {
let mut backlog: VecDeque<(u64, Vec<u8>)> = VecDeque::new();
loop {
// Dial (keeping the queue drained while we wait for the service).
let mut stream = loop {
match UnixStream::connect(&socket) {
Ok(s) => break s,
Err(_) => {
while let Ok(x) = out.try_recv() {
backlog.push_back(x);
}
thread::sleep(Duration::from_millis(50));
}
}
};
// Serve this connection until it dies or reconnect() asks.
loop {
if shared.force_reconnect.swap(false, Ordering::Relaxed) {
break;
}
let (id, body) = if let Some(x) = backlog.pop_front() {
x
} else {
match out.recv_timeout(Duration::from_millis(50)) {
Ok(x) => x,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
};
// A reconnect() may have landed while this request waited in the
// channel: redial first rather than write on a connection that
// may already be dead.
if shared.force_reconnect.swap(false, Ordering::Relaxed) {
backlog.push_front((id, body));
break;
}
if write_frame(&mut stream, &body).is_err() {
backlog.push_front((id, body));
break;
}
match read_frame(&mut stream) {
Ok(buf) => {
let response = serde_json::from_slice::<Response>(&buf).unwrap_or_else(|e| {
Response::Error {
message: format!("unparseable service response: {e}"),
}
});
let _ = resp.send((id, response));
}
Err(_) => {
// The connection died mid-exchange; replay the request
// on the next dial rather than dropping it.
backlog.push_front((id, body));
break;
}
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
//! The tines fleet console.
//!
//! A launcher plus one window per container: each window drives a running
//! `tines` service over its Unix-socket IPC. This crate holds the pieces
//! shared by those windows — the IPC client ([`ipc_client`]), the
//! config-generation half of the launcher ([`config`]), and the omarchy
//! theme mechanism ([`theme`]).
#![deny(missing_docs)]
pub mod config;
pub mod ipc_client;
pub mod theme;
+9
View File
@@ -0,0 +1,9 @@
//! The console binary. Task 8 wires the real UI; for now the launcher
//! sentence is the whole program.
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
println!("tines-gui: launcher");
}
}
+110
View File
@@ -0,0 +1,110 @@
//! The omarchy theme: the live palette plus a watch that repaints on change.
//!
//! Reads `~/.local/state/omarchy/current/theme/colors.toml` for the three
//! keys the console uses — `background`, `foreground`, `accent`. Omarchy
//! rewrites the `current` directory in place on theme change, so the watch
//! is on the directory (recursive), not the file.
use std::path::PathBuf;
use egui::Color32;
use notify::Watcher;
/// The three palette colors the console paints with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OmarchyTheme {
/// The window background.
pub background: Color32,
/// The window foreground (text).
pub foreground: Color32,
/// The accent (highlights, focus).
pub accent: Color32,
}
/// The app's own named fallback — used only when the live theme is missing
/// or unreadable. Deliberately not the omarchy palette: the console must be
/// visibly on fallback, not approximating the real theme.
pub const FALLBACK: OmarchyTheme = OmarchyTheme {
background: Color32::from_rgb(0x16, 0x18, 0x1d),
foreground: Color32::from_rgb(0xd7, 0xdb, 0xe2),
accent: Color32::from_rgb(0x7a, 0xa2, 0xf7),
};
impl OmarchyTheme {
/// Load the live theme; [`FALLBACK`] when it is missing or malformed.
pub fn load() -> OmarchyTheme {
match read_theme_file() {
Some(t) => t,
None => FALLBACK,
}
}
/// Watch the omarchy `current` theme dir (recursively) and call
/// `on_change` whenever it changes — the caller repaints from there.
/// Returns an error only if the watcher cannot be installed.
pub fn watch<F>(on_change: F) -> notify::Result<()>
where
F: FnMut() + Send + 'static,
{
// notify's callback is Fn, ours is FnMut: share it through a mutex.
let on_change = std::sync::Mutex::new(on_change);
let mut watcher =
notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if res.is_ok()
&& let Ok(mut f) = on_change.lock()
{
f();
}
})?;
watcher.watch(&current_dir(), notify::RecursiveMode::Recursive)?;
// The watcher must outlive the call: park it on a thread.
std::thread::spawn(move || {
std::thread::park();
let _ = watcher;
});
Ok(())
}
}
/// `~/.local/state/omarchy/current` — the live-theme indirection.
fn current_dir() -> PathBuf {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
home.join(".local/state/omarchy/current")
}
fn read_theme_file() -> Option<OmarchyTheme> {
let text = std::fs::read_to_string(current_dir().join("theme/colors.toml")).ok()?;
let mut background = None;
let mut foreground = None;
let mut accent = None;
for line in text.lines() {
let Some((key, value)) = line.split_once('=') else {
continue; // blank lines and comments carry no key
};
let parsed = parse_hex(value);
match key.trim() {
"background" => background = parsed,
"foreground" => foreground = parsed,
"accent" => accent = parsed,
_ => {}
}
}
Some(OmarchyTheme {
background: background?,
foreground: foreground?,
accent: accent?,
})
}
fn parse_hex(s: &str) -> Option<Color32> {
let s = s.trim().trim_matches('"').strip_prefix('#')?;
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some(Color32::from_rgb(r, g, b))
}