use anyhow::{Context, Result, bail};
use omochi_hub::dashboards::{Panel, Reference};
use serde::Deserialize;
use serde_json::{Value, json};
use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
process::Stdio,
time::Duration,
};
use tokio::process::{Child, Command};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Document {
version: u32,
id: String,
services: BTreeMap<String, Service>,
dashboard: Dashboard,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Service {
command: Option<Vec<String>>,
utility: Option<Vec<String>>,
cwd: Option<String>,
#[serde(default)]
env: BTreeMap<String, String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Dashboard {
name: String,
rows: Vec<Row>,
panels: BTreeMap<String, Definition>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Row {
panels: Vec<String>,
height: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Definition {
title: String,
service: String,
surface: Option<String>,
}
pub struct ServiceCommand {
argv: Vec<String>,
cwd: PathBuf,
env: BTreeMap<String, String>,
}
pub struct Composition {
pub services: Vec<ServiceCommand>,
pub preset: Value,
}
fn text(value: &str, max: usize) -> Result<String> {
if value.trim().is_empty() || value.encode_utf16().count() > max {
bail!("invalid text (expected 1..{max} characters)");
}
Ok(value.trim().to_owned())
}
pub fn load(file: &Path, node: &str) -> Result<Composition> {
let file = file
.canonicalize()
.with_context(|| format!("could not read {}", file.display()))?;
let source = std::fs::read_to_string(&file)?;
parse(&source, file.parent().unwrap(), node)
}
fn parse(source: &str, root: &Path, node: &str) -> Result<Composition> {
if !omochi_protocol::session::valid_node_id(node) {
bail!("invalid local node identity");
}
let doc: Document = toml::from_str(source)?;
if doc.version != 1 {
bail!("composition.version must be 1");
}
let id = text(&doc.id, 100)?;
let name = text(&doc.dashboard.name, 200)?;
if doc.services.is_empty() || doc.services.len() > 256 {
bail!("composition requires 1..256 services");
}
let mut services = Vec::new();
for (id, service) in &doc.services {
if !omochi_protocol::session::valid_service_id(id) {
bail!("invalid service id: {id}");
}
let argv = match (&service.command, &service.utility) {
(Some(argv), None) => argv.clone(),
(None, Some(utility)) => {
if !utility.first().is_some_and(|s| {
matches!(s.as_str(), "button" | "watch" | "process" | "tail" | "http")
}) {
bail!("invalid utility mode for {id}");
}
let executable = std::env::current_exe()?.with_file_name("omochi-utils");
let mut argv = vec![
executable.to_string_lossy().into_owned(),
utility[0].clone(),
"--service-id".into(),
id.clone(),
];
argv.extend_from_slice(&utility[1..]);
argv
}
_ => bail!("service {id} requires exactly one of command or utility"),
};
if argv.is_empty() || argv.iter().any(|a| a.is_empty() || a.contains('\0')) {
bail!("service {id} command must be a non-empty array of strings");
}
let cwd = match &service.cwd {
Some(cwd) => root.join(text(cwd, 4096)?),
None => root.to_owned(),
};
let mut env = service.env.clone();
if env
.iter()
.any(|(k, v)| k.is_empty() || k.contains(['=', '\0']) || v.contains('\0'))
{
bail!("invalid environment for {id}");
}
env.insert("OMOCHI_SERVICE".into(), id.clone());
services.push(ServiceCommand { argv, cwd, env });
}
if doc.dashboard.rows.is_empty() || doc.dashboard.panels.is_empty() {
bail!("dashboard needs rows and panels");
}
let mut panels = Vec::new();
let mut placed = BTreeSet::new();
let mut y = 0;
for row in doc.dashboard.rows {
let count = row.panels.len();
if !(1..=12).contains(&count) {
bail!("rows must contain 1..12 panels");
}
let height = match row.height.as_deref().unwrap_or("medium") {
"small" => 4,
"medium" => 6,
"large" => 9,
_ => bail!("invalid row height"),
};
let mut x = 0;
for (column, key) in row.panels.into_iter().enumerate() {
if !placed.insert(key.clone()) {
bail!("panel {key} placed twice");
}
let panel = doc
.dashboard
.panels
.get(&key)
.with_context(|| format!("unknown panel {key}"))?;
let service = text(&panel.service, 64)?;
if !doc.services.contains_key(&service) {
bail!("undeclared service {service}");
}
let w = (12 / count + usize::from(column < 12 % count)) as u32;
panels.push(Panel {
id: text(&key, 100)?,
title: text(&panel.title, 200)?,
reference: Reference {
node_id: node.into(),
service_id: service,
surface_id: text(panel.surface.as_deref().unwrap_or("main"), 4096)?,
},
x,
y,
w,
h: height,
});
x += w;
}
y += height;
}
if placed.len() != doc.dashboard.panels.len() {
bail!("every panel must be placed in a row");
}
let input = omochi_hub::dashboards::validate(json!({"name":name,"panels":panels}))
.map_err(|e| anyhow::anyhow!(e.1))?;
Ok(Composition {
services,
preset: json!({"id":id,"name":input.name,"panels":input.panels}),
})
}
pub struct Supervisor {
children: Vec<Child>,
groups: Vec<i32>,
}
impl Supervisor {
pub fn start(services: &[ServiceCommand], runtime: &Path) -> Result<Self> {
if runtime.file_name().and_then(|s| s.to_str()) != Some("omochi") {
bail!(
"up requires a runtime directory ending in /omochi to preserve the SDK XDG_RUNTIME_DIR contract"
);
}
let mut supervisor = Self {
children: vec![],
groups: vec![],
};
for service in services {
let mut command = Command::new(&service.argv[0]);
command
.args(&service.argv[1..])
.current_dir(&service.cwd)
.envs(&service.env)
.env(
"XDG_RUNTIME_DIR",
runtime
.parent()
.context("runtime directory needs a parent")?,
)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.process_group(0);
let child = command
.spawn()
.with_context(|| format!("could not start {}", service.argv[0]))?;
supervisor.groups.push(child.id().unwrap() as i32);
supervisor.children.push(child);
}
Ok(supervisor)
}
fn signal(&self, signal: i32) {
for &group in &self.groups {
unsafe {
libc::kill(-group, signal);
}
}
}
pub async fn close(&mut self) {
self.signal(libc::SIGTERM);
let _ = tokio::time::timeout(Duration::from_secs(1), async {
for child in &mut self.children {
let _ = child.wait().await;
}
})
.await;
self.signal(libc::SIGKILL);
for child in &mut self.children {
let _ = child.wait().await;
}
self.groups.clear();
}
}
impl Drop for Supervisor {
fn drop(&mut self) {
self.signal(libc::SIGKILL);
}
}
pub fn open_browser(url: &str) {
let executable = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
match Command::new(executable)
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(mut child) => {
tokio::spawn(async move {
let _ = child.wait().await;
});
}
Err(error) => eprintln!("[omochi-ui] could not open browser: {error}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCE: &str = r#"
version = 1
id = "work"
[services.test]
command = ["echo", "hello"]
[dashboard]
name = "Work"
rows = [{panels = ["one", "two"], height = "small"}]
[dashboard.panels.one]
title = "One"
service = "test"
[dashboard.panels.two]
title = "Two"
service = "test"
surface = "other"
"#;
#[test]
fn semantic_rows_preserve_references_and_reject_invalid_compositions() {
let node = "a".repeat(64);
let c = parse(SOURCE, Path::new("/tmp"), &node).unwrap();
assert_eq!(c.preset["panels"][1]["x"], 6);
assert_eq!(c.preset["panels"][1]["reference"]["nodeId"], node);
assert_eq!(c.services[0].env["OMOCHI_SERVICE"], "test");
assert!(
parse(
&SOURCE.replace("\"one\", \"two\"", "\"one\", \"one\""),
Path::new("/tmp"),
&node
)
.is_err()
);
assert!(
parse(
&SOURCE.replace("service = \"test\"", "service = \"missing\""),
Path::new("/tmp"),
&node
)
.is_err()
);
assert!(
parse(
&SOURCE.replace("version = 1", "version = 1\nunknown = true"),
Path::new("/tmp"),
&node
)
.is_err()
);
}
}