pub mod cloudstore;
pub mod profile;
pub mod service;
pub mod wnf;
pub mod worker;
pub use wnf::DndState;
const RECOVERY_FILE: &str = "dnd-recovery.toml";
#[derive(serde::Serialize, serde::Deserialize)]
struct Recovery {
entries: Vec<RecoveryEntry>,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct RecoveryEntry {
path: String,
blob_hex: String,
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn recovery_path() -> Option<std::path::PathBuf> {
crate::config::app_dir().map(|d| d.join(RECOVERY_FILE))
}
fn to_hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn from_hex(s: &str) -> Option<Vec<u8>> {
if s.len() % 2 != 0 {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
fn persist_recovery(saved: &[(String, Vec<u8>)]) {
let Some(p) = recovery_path() else { return };
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let rec = Recovery {
entries: saved
.iter()
.map(|(path, blob)| RecoveryEntry {
path: path.clone(),
blob_hex: to_hex(blob),
})
.collect(),
};
if let Ok(text) = toml::to_string_pretty(&rec) {
let _ = std::fs::write(
&p,
format!(
"# Written while this app has Do Not Disturb switched on.\n\
# If it is still here at startup, the app was killed and these\n\
# original values are restored. Safe to delete when not running.\n\n{text}"
),
);
}
}
fn clear_recovery() {
if let Some(p) = recovery_path() {
let _ = std::fs::remove_file(p);
}
}
pub fn recover_if_needed() -> Option<DndOutcome> {
let p = recovery_path()?;
let text = std::fs::read_to_string(&p).ok()?;
let rec: Recovery = toml::from_str(&text).ok()?;
let mut wrote = 0;
let mut expect = DndState::Off;
for e in &rec.entries {
if let Some(blob) = from_hex(&e.blob_hex) {
let mut restored = blob.clone();
if let Some(name) = profile::read(&blob) {
expect = state_for_profile(&name);
if let Ok(fresh) = profile::write(&blob, &name, now_unix()) {
restored = fresh;
}
}
if cloudstore::write_blob(&e.path, &restored) {
wrote += 1;
}
}
}
let _ = std::fs::remove_file(&p);
if wrote == 0 {
return Some(DndOutcome::Failed(
"could not restore after an unclean exit",
));
}
Some(DndController::settle(Some(expect)))
}
fn state_for_profile(profile_id: &str) -> DndState {
match profile_id {
id if id == profile::UNRESTRICTED => DndState::Off,
id if id == profile::PRIORITY_ONLY => DndState::PriorityOnly,
_ => DndState::Off,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DndOutcome {
Applied(DndState),
Unverified,
AlreadyCorrect,
Failed(&'static str),
}
#[derive(Default)]
pub struct DndController {
saved: Vec<(String, Vec<u8>)>,
engaged: bool,
}
impl DndController {
pub fn new() -> Self {
Self::default()
}
pub fn engage(&mut self) -> DndOutcome {
if self.engaged {
return DndOutcome::AlreadyCorrect;
}
if wnf::query().is_some_and(DndState::is_on) {
return DndOutcome::AlreadyCorrect;
}
self.apply(profile::PRIORITY_ONLY, Some(DndState::PriorityOnly), true)
}
pub fn release(&mut self) -> DndOutcome {
if !self.engaged {
return DndOutcome::AlreadyCorrect;
}
let outcome = if !self.saved.is_empty() {
let saved = std::mem::take(&mut self.saved);
let mut wrote = 0;
for (path, blob) in &saved {
let restored = profile::read(blob)
.and_then(|name| profile::write(blob, &name, now_unix()).ok())
.unwrap_or_else(|| blob.clone());
if cloudstore::write_blob(path, &restored) {
wrote += 1;
}
}
if wrote == 0 {
DndOutcome::Failed("could not restore the previous DND state")
} else {
let expect = profile::read(&saved[0].1)
.as_deref()
.map(state_for_profile)
.unwrap_or(DndState::Off);
Self::settle(Some(expect))
}
} else {
self.apply(profile::UNRESTRICTED, Some(DndState::Off), false)
};
self.engaged = false;
self.saved.clear();
clear_recovery();
outcome
}
fn apply(&mut self, profile_id: &str, expect: Option<DndState>, remember: bool) -> DndOutcome {
let targets = cloudstore::discover();
if targets.is_empty() {
return DndOutcome::Failed("quiet-hours settings not found in the registry");
}
let mut wrote = 0usize;
for t in &targets {
let new_blob = match profile::write(&t.blob, profile_id, now_unix()) {
Ok(b) => b,
Err(_) => continue,
};
if cloudstore::write_blob(&t.path, &new_blob) {
if remember && !self.saved.iter().any(|(p, _)| p == &t.path) {
self.saved.push((t.path.clone(), t.blob.clone()));
}
wrote += 1;
}
}
if wrote == 0 {
return DndOutcome::Failed("could not write the quiet-hours setting");
}
if remember {
self.engaged = true;
persist_recovery(&self.saved);
}
let outcome = Self::settle(expect);
if let (DndOutcome::Failed(_), true) = (&outcome, remember) {
self.rollback();
}
outcome
}
fn settle(expect: Option<DndState>) -> DndOutcome {
if service::restart_notification_service() == 0 {
return DndOutcome::Failed("could not restart the notification service");
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut last = None;
loop {
last = wnf::query().or(last);
if let (Some(got), Some(want)) = (last, expect) {
if got == want {
return DndOutcome::Applied(got);
}
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
match (last, expect) {
(None, _) => DndOutcome::Unverified,
(Some(_), Some(_)) => DndOutcome::Failed("Windows did not accept the DND change"),
(Some(got), None) => DndOutcome::Applied(got),
}
}
fn rollback(&mut self) {
for (path, blob) in std::mem::take(&mut self.saved) {
let _ = cloudstore::write_blob(&path, &blob);
}
self.engaged = false;
}
}
impl Drop for DndController {
fn drop(&mut self) {
if self.engaged {
let _ = self.release();
}
}
}