use corundum_framework_platform::package;
use corundum_framework_service::{ServiceSpec, ServiceState};
use crate::service::{Job, ServiceOps};
use crate::tr;
pub struct ServiceRow {
service: fn() -> ServiceSpec,
name: &'static str,
ops: ServiceOps,
is_dev: bool,
state: Option<Result<ServiceState, String>>,
job: Option<Job<Looked>>,
error: Option<(Act, String)>,
#[cfg(test)]
shown: Vec<&'static str>,
#[cfg(test)]
pressed: Option<&'static str>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Act {
Install,
Stop,
Start,
Remove,
Repair,
}
struct Looked {
acted: Option<(Act, Result<(), String>)>,
state: Result<ServiceState, String>,
}
impl ServiceRow {
pub fn new(service: fn() -> ServiceSpec) -> ServiceRow {
let mut row = ServiceRow::with_ops(service, ServiceOps::SYSTEM);
row.is_dev = package::current_exe().is_some_and(|exe| package::is_cargo_build(&exe));
row
}
pub(crate) fn with_ops(service: fn() -> ServiceSpec, ops: ServiceOps) -> ServiceRow {
ServiceRow {
service,
name: service().app_name,
ops,
is_dev: false,
state: None,
job: None,
error: None,
#[cfg(test)]
shown: Vec::new(),
#[cfg(test)]
pressed: None,
}
}
pub fn ui(&mut self, ui: &mut egui::Ui) {
#[cfg(test)]
self.shown.clear();
if self.state.is_none() && self.job.is_none() {
self.run(ui.ctx(), None);
}
if let Some(answer) = self.job.as_ref().and_then(Job::poll) {
self.job = None;
match answer {
Ok(looked) => {
self.error = looked
.acted
.and_then(|(act, done)| done.err().map(|err| (act, err)));
self.state = Some(looked.state);
}
Err(err) => self.state = Some(Err(err)),
}
}
let mut clicked = None;
let mut again = false;
ui.horizontal(|ui| {
ui.label(tr!(ui, "corundum-service-row", name = self.name));
match self.state.clone() {
None => {
ui.spinner();
}
Some(Ok(ServiceState::NotInstalled)) if self.is_dev => {
self.note(ui, "corundum-service-dev-build");
}
Some(Ok(ServiceState::NotInstalled)) => {
clicked = clicked.or(self.button(ui, "corundum-service-run", Act::Install));
}
Some(Ok(state @ (ServiceState::Running | ServiceState::Stopped))) => {
let (id, act, act_id) = if state == ServiceState::Running {
(
"corundum-service-running",
Act::Stop,
"corundum-service-stop",
)
} else {
(
"corundum-service-stopped",
Act::Start,
"corundum-service-start",
)
};
self.note(ui, id);
clicked = clicked.or(self.button(ui, act_id, act));
clicked = clicked.or(self.button(ui, "corundum-service-remove", Act::Remove));
}
Some(Ok(ServiceState::Broken { reason })) => {
self.noted("corundum-service-broken");
ui.colored_label(
ui.visuals().warn_fg_color,
tr!(ui, "corundum-service-broken", reason = reason.as_str()),
);
if !self.is_dev {
clicked =
clicked.or(self.button(ui, "corundum-service-repair", Act::Repair));
}
}
Some(Err(err)) => {
ui.colored_label(ui.visuals().error_fg_color, err);
again = self.button(ui, "corundum-service-try-again", ()).is_some();
}
Some(Ok(_)) => {}
}
});
if let Some(act) = clicked {
self.run(ui.ctx(), Some(act));
} else if again {
self.run(ui.ctx(), None);
}
if let Some((act, error)) = &self.error {
let text = if matches!(act, Act::Install | Act::Repair) {
tr!(ui, "corundum-service-failed", error = error.as_str())
} else {
error.clone()
};
ui.colored_label(ui.visuals().error_fg_color, text);
}
}
fn run(&mut self, ctx: &egui::Context, act: Option<Act>) {
let (service, ops) = (self.service, self.ops);
self.state = None;
self.job = Some(Job::spawn(ctx, "service state", move || {
let spec = service();
let acted = act.map(|act| (act, perform(&ops, act, &spec)));
Looked {
acted,
state: (ops.state)(&spec),
}
}));
}
fn button<T>(&mut self, ui: &mut egui::Ui, id: &'static str, act: T) -> Option<T> {
let clicked = ui.button(tr!(ui, id)).clicked();
#[cfg(test)]
{
self.shown.push(id);
if self.pressed == Some(id) {
self.pressed = None;
return Some(act);
}
}
clicked.then_some(act)
}
fn note(&mut self, ui: &mut egui::Ui, id: &'static str) {
self.noted(id);
ui.label(tr!(ui, id));
}
fn noted(&mut self, _id: &'static str) {
#[cfg(test)]
self.shown.push(_id);
}
#[cfg(test)]
fn press(&mut self, id: &'static str) {
self.pressed = Some(id);
}
}
fn perform(ops: &ServiceOps, act: Act, spec: &ServiceSpec) -> Result<(), String> {
match act {
Act::Install => (ops.install)(spec),
Act::Stop => (ops.stop)(spec),
Act::Start => (ops.start)(spec),
Act::Remove => (ops.uninstall)(spec),
Act::Repair => {
(ops.uninstall)(spec)?;
(ops.install)(spec)
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use corundum_framework_service::{ServiceSpec, ServiceState};
use super::*;
use crate::service::ServiceOps;
use crate::test_support::render_in;
fn demo() -> ServiceSpec {
ServiceSpec {
app_name: "demo",
description: "Demo".into(),
args: Vec::new(),
legacy_labels: Vec::new(),
post_install: None,
}
}
fn done(_: &ServiceSpec) -> Result<(), String> {
Ok(())
}
fn ops(state: fn(&ServiceSpec) -> Result<ServiceState, String>) -> ServiceOps {
ServiceOps {
state,
install: done,
uninstall: done,
stop: done,
start: done,
}
}
fn settle(row: &mut ServiceRow, ctx: &egui::Context, until: impl Fn(&ServiceRow) -> bool) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
render_in(ctx, [600.0, 400.0], |ui| row.ui(ui));
if until(row) || Instant::now() > deadline {
return;
}
std::thread::sleep(Duration::from_millis(2));
}
}
fn shown(state: fn(&ServiceSpec) -> Result<ServiceState, String>) -> Vec<&'static str> {
shown_as(state, false)
}
fn shown_as(
state: fn(&ServiceSpec) -> Result<ServiceState, String>,
is_dev: bool,
) -> Vec<&'static str> {
let mut row = ServiceRow::with_ops(demo, ops(state));
row.is_dev = is_dev;
settle(&mut row, &egui::Context::default(), |row| {
!row.shown.is_empty()
});
row.shown.clone()
}
#[test]
fn a_service_not_installed_offers_to_run_it() {
assert_eq!(
shown(|_| Ok(ServiceState::NotInstalled)),
["corundum-service-run"]
);
}
#[test]
fn a_running_service_offers_stop_and_remove() {
assert_eq!(
shown(|_| Ok(ServiceState::Running)),
[
"corundum-service-running",
"corundum-service-stop",
"corundum-service-remove"
]
);
}
#[test]
fn a_stopped_service_offers_start_and_remove() {
assert_eq!(
shown(|_| Ok(ServiceState::Stopped)),
[
"corundum-service-stopped",
"corundum-service-start",
"corundum-service-remove"
]
);
}
#[test]
fn a_broken_service_offers_repair() {
assert_eq!(
shown(|_| Ok(ServiceState::Broken {
reason: "gone".into()
})),
["corundum-service-broken", "corundum-service-repair"]
);
}
#[test]
fn a_development_build_offers_no_install() {
assert_eq!(
shown_as(|_| Ok(ServiceState::NotInstalled), true),
["corundum-service-dev-build"]
);
}
#[test]
fn a_development_build_offers_no_repair() {
assert_eq!(
shown_as(
|_| Ok(ServiceState::Broken {
reason: "gone".into()
}),
true
),
["corundum-service-broken"]
);
}
#[test]
fn a_development_build_can_still_stop_start_and_remove() {
assert_eq!(
shown_as(|_| Ok(ServiceState::Running), true),
[
"corundum-service-running",
"corundum-service-stop",
"corundum-service-remove"
]
);
assert_eq!(
shown_as(|_| Ok(ServiceState::Stopped), true),
[
"corundum-service-stopped",
"corundum-service-start",
"corundum-service-remove"
]
);
}
#[test]
fn a_failed_lookup_offers_to_try_again_and_asks_again() {
static ASKED: AtomicUsize = AtomicUsize::new(0);
fn failing(_: &ServiceSpec) -> Result<ServiceState, String> {
ASKED.fetch_add(1, Ordering::SeqCst);
Err("no manager".into())
}
let ctx = egui::Context::default();
let mut row = ServiceRow::with_ops(demo, ops(failing));
settle(&mut row, &ctx, |row| !row.shown.is_empty());
assert_eq!(row.shown, ["corundum-service-try-again"]);
assert_eq!(ASKED.load(Ordering::SeqCst), 1);
row.press("corundum-service-try-again");
settle(&mut row, &ctx, |_| ASKED.load(Ordering::SeqCst) >= 2);
assert_eq!(ASKED.load(Ordering::SeqCst), 2);
}
#[test]
fn state_is_not_asked_on_the_ui_thread() {
static RELEASED: AtomicBool = AtomicBool::new(false);
fn blocking(_: &ServiceSpec) -> Result<ServiceState, String> {
let deadline = Instant::now() + Duration::from_secs(5);
while !RELEASED.load(Ordering::SeqCst) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(2));
}
Ok(ServiceState::Running)
}
let ctx = egui::Context::default();
let mut row = ServiceRow::with_ops(demo, ops(blocking));
let started = Instant::now();
render_in(&ctx, [600.0, 400.0], |ui| row.ui(ui));
assert!(started.elapsed() < Duration::from_secs(1));
assert!(row.shown.is_empty());
RELEASED.store(true, Ordering::SeqCst);
settle(&mut row, &ctx, |row| !row.shown.is_empty());
assert_eq!(
row.shown,
[
"corundum-service-running",
"corundum-service-stop",
"corundum-service-remove"
]
);
}
#[test]
fn repair_uninstalls_then_installs_and_looks_again() {
static CALLS: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
fn record(call: &'static str) {
CALLS.lock().unwrap().push(call);
}
let ops = ServiceOps {
state: |_| {
record("state");
Ok(ServiceState::Broken {
reason: "gone".into(),
})
},
install: |_| {
record("install");
Ok(())
},
uninstall: |_| {
record("uninstall");
Ok(())
},
stop: done,
start: done,
};
let ctx = egui::Context::default();
let mut row = ServiceRow::with_ops(demo, ops);
settle(&mut row, &ctx, |row| !row.shown.is_empty());
row.press("corundum-service-repair");
settle(&mut row, &ctx, |_| CALLS.lock().unwrap().len() >= 4);
assert_eq!(
*CALLS.lock().unwrap(),
["state", "uninstall", "install", "state"]
);
}
}