feat(gui): tines-gui skeleton (ipc client, config gen, theme)
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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))
|
||||
}
|
||||
Reference in New Issue
Block a user