use corundum_framework_platform::package;
use corundum_framework_service::ServiceSpec;
use super::{FirstRunPage, PageAction};
use crate::service::{Job, ServiceOps};
use crate::store;
use crate::tr;
pub struct ServicePage {
services: Vec<fn() -> ServiceSpec>,
question: String,
reason: String,
ops: ServiceOps,
is_dev: bool,
phase: Phase,
#[cfg(test)]
shown: Vec<&'static str>,
#[cfg(test)]
pressed: Option<&'static str>,
}
enum Phase {
Asking,
Installing(Job<Result<(), String>>),
Failed(String),
Answered,
}
impl ServicePage {
pub fn new(
services: Vec<fn() -> ServiceSpec>,
question: String,
reason: String,
) -> ServicePage {
let mut page = ServicePage::with_ops(services, question, reason, ServiceOps::SYSTEM);
page.is_dev = package::current_exe().is_some_and(|exe| package::is_cargo_build(&exe));
page
}
pub(crate) fn with_ops(
services: Vec<fn() -> ServiceSpec>,
question: String,
reason: String,
ops: ServiceOps,
) -> ServicePage {
ServicePage {
services,
question,
reason,
ops,
is_dev: false,
phase: Phase::Asking,
#[cfg(test)]
shown: Vec::new(),
#[cfg(test)]
pressed: None,
}
}
fn install(&mut self, ctx: &egui::Context) {
let services = self.services.clone();
let install = self.ops.install;
let named = services.len() > 1;
self.phase = Phase::Installing(Job::spawn(ctx, "service install", move || {
for service in services {
let spec = service();
install(&spec).map_err(|err| {
if named {
format!("{}: {err}", spec.app_name)
} else {
err
}
})?;
}
Ok(())
}));
}
fn answer(&mut self, ctx: &egui::Context, yes: bool) -> PageAction {
store::update(ctx, |stored| stored.services_asked = Some(yes));
self.phase = Phase::Answered;
PageAction::Next
}
fn button(&mut self, ui: &mut egui::Ui, id: &'static str) -> bool {
let clicked = ui.button(tr!(ui, id)).clicked();
#[cfg(test)]
{
self.shown.push(id);
if self.pressed == Some(id) {
self.pressed = None;
return true;
}
}
clicked
}
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);
}
}
impl FirstRunPage for ServicePage {
fn title(&self, _ctx: &egui::Context) -> String {
self.question.clone()
}
fn ui(&mut self, ui: &mut egui::Ui) -> PageAction {
#[cfg(test)]
self.shown.clear();
ui.label(&self.reason);
ui.add_space(12.0);
if self.is_dev {
self.noted("corundum-service-dev-build");
ui.label(tr!(ui, "corundum-service-dev-build"));
return PageAction::Stay;
}
if let Phase::Installing(job) = &self.phase
&& let Some(answer) = job.poll()
{
match answer.and_then(|installed| installed) {
Ok(()) => return self.answer(ui.ctx(), true),
Err(err) => self.phase = Phase::Failed(err),
}
}
match &self.phase {
Phase::Installing(_) => {
ui.spinner();
PageAction::Stay
}
Phase::Failed(error) => {
let error = error.clone();
self.noted("corundum-service-failed");
ui.colored_label(
ui.visuals().error_fg_color,
tr!(ui, "corundum-service-failed", error = error.as_str()),
);
ui.add_space(8.0);
let (mut again, mut without) = (false, false);
ui.horizontal(|ui| {
again = self.button(ui, "corundum-service-try-again");
without = self.button(ui, "corundum-service-continue-without");
});
if again {
self.install(ui.ctx());
} else if without {
return self.answer(ui.ctx(), false);
}
PageAction::Stay
}
Phase::Asking | Phase::Answered => {
let (mut yes, mut no) = (false, false);
ui.horizontal(|ui| {
yes = self.button(ui, "corundum-service-yes");
no = self.button(ui, "corundum-service-no");
});
if yes {
self.install(ui.ctx());
} else if no {
return self.answer(ui.ctx(), false);
}
PageAction::Stay
}
}
}
fn can_continue(&self) -> bool {
self.is_dev || matches!(self.phase, Phase::Answered)
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use corundum_framework_service::{ServiceSpec, ServiceState};
use super::*;
use crate::service::ServiceOps;
use crate::store;
use crate::test_support::render_in;
fn first() -> ServiceSpec {
spec("first")
}
fn second() -> ServiceSpec {
spec("second")
}
fn spec(app_name: &'static str) -> ServiceSpec {
ServiceSpec {
app_name,
description: app_name.into(),
args: Vec::new(),
legacy_labels: Vec::new(),
post_install: None,
}
}
fn done(_: &ServiceSpec) -> Result<(), String> {
Ok(())
}
fn ops(install: fn(&ServiceSpec) -> Result<(), String>) -> ServiceOps {
ServiceOps {
state: |_| Ok(ServiceState::NotInstalled),
install,
uninstall: done,
stop: done,
start: done,
}
}
fn page(install: fn(&ServiceSpec) -> Result<(), String>, is_dev: bool) -> ServicePage {
let mut page = ServicePage::with_ops(
vec![first, second],
"Keep it running?".into(),
"So it syncs.".into(),
ops(install),
);
page.is_dev = is_dev;
page
}
fn settle(
page: &mut ServicePage,
ctx: &egui::Context,
until: impl Fn(&ServicePage) -> bool,
) -> PageAction {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let mut action = PageAction::Stay;
render_in(ctx, [600.0, 400.0], |ui| {
let asked = page.ui(ui);
if asked != PageAction::Stay {
action = asked;
}
});
if action != PageAction::Stay || until(page) || Instant::now() > deadline {
return action;
}
std::thread::sleep(Duration::from_millis(2));
}
}
#[test]
fn yes_installs_each_service_and_moves_on() {
static INSTALLED: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
let ctx = egui::Context::default();
let mut page = page(
|spec| {
INSTALLED.lock().unwrap().push(spec.app_name);
Ok(())
},
false,
);
settle(&mut page, &ctx, |_| true);
assert!(page.shown.contains(&"corundum-service-yes"));
page.press("corundum-service-yes");
assert_eq!(settle(&mut page, &ctx, |_| false), PageAction::Next);
assert_eq!(*INSTALLED.lock().unwrap(), ["first", "second"]);
assert_eq!(store::state(&ctx).services_asked, Some(true));
assert!(page.can_continue());
}
#[test]
fn no_registers_nothing_and_moves_on() {
static INSTALLS: AtomicUsize = AtomicUsize::new(0);
let ctx = egui::Context::default();
let mut page = page(
|_| {
INSTALLS.fetch_add(1, Ordering::SeqCst);
Ok(())
},
false,
);
page.press("corundum-service-no");
assert_eq!(settle(&mut page, &ctx, |_| false), PageAction::Next);
assert_eq!(INSTALLS.load(Ordering::SeqCst), 0);
assert_eq!(store::state(&ctx).services_asked, Some(false));
}
#[test]
fn a_failed_install_offers_try_again_and_continue() {
static INSTALLS: AtomicUsize = AtomicUsize::new(0);
let ctx = egui::Context::default();
let mut page = page(
|_| {
INSTALLS.fetch_add(1, Ordering::SeqCst);
Err("refused".into())
},
false,
);
page.press("corundum-service-yes");
let offered = |page: &ServicePage| page.shown.contains(&"corundum-service-try-again");
assert_eq!(settle(&mut page, &ctx, offered), PageAction::Stay);
assert!(page.shown.contains(&"corundum-service-failed"));
assert!(page.shown.contains(&"corundum-service-continue-without"));
assert_eq!(INSTALLS.load(Ordering::SeqCst), 1);
assert!(!page.can_continue());
page.press("corundum-service-try-again");
settle(&mut page, &ctx, |page| {
INSTALLS.load(Ordering::SeqCst) == 2 && offered(page)
});
assert_eq!(INSTALLS.load(Ordering::SeqCst), 2);
assert!(offered(&page));
page.press("corundum-service-continue-without");
assert_eq!(settle(&mut page, &ctx, |_| false), PageAction::Next);
assert_eq!(store::state(&ctx).services_asked, Some(false));
}
#[test]
fn it_cannot_continue_until_answered() {
let ctx = egui::Context::default();
let mut page = page(done, false);
settle(&mut page, &ctx, |_| true);
assert!(!page.can_continue());
page.press("corundum-service-no");
settle(&mut page, &ctx, |_| false);
assert!(page.can_continue());
}
#[test]
fn a_cargo_build_registers_nothing() {
static INSTALLS: AtomicUsize = AtomicUsize::new(0);
let ctx = egui::Context::default();
let mut page = page(
|_| {
INSTALLS.fetch_add(1, Ordering::SeqCst);
Ok(())
},
true,
);
assert_eq!(settle(&mut page, &ctx, |_| true), PageAction::Stay);
assert_eq!(page.shown, ["corundum-service-dev-build"]);
assert!(page.can_continue());
page.press("corundum-service-yes");
settle(&mut page, &ctx, |_| true);
assert_eq!(INSTALLS.load(Ordering::SeqCst), 0);
assert_eq!(store::state(&ctx).services_asked, None);
}
}