Add session-owned Pomodoro cycles and controls
Add configurable work and break intervals, pause/resume, CLI and activity-popup controls, and silent completion alerts. Use CLOCK_BOOTTIME deadlines so running timers include suspend without consuming paused time. Refs #31
This commit is contained in:
1 parent
53ba599918
commit
2c9bb85f2c
18 files changed
+1209
-16
No files matched your search
@@ -1,5 +1,7 @@
|
||||
//! Session broker: verified process identities, one-shot policy deadlines and
|
||||
//! bounded long-poll subscribers. Process inspection runs on IPC workers.
|
||||
pub(crate) mod deadline;
|
||||
mod pomodoro;
|
||||
mod publication;
|
||||
use ferese_activities::{Data, Id, Owner, Source, State, Store, Template, Value};
|
||||
use ferese_ipc::{
|
||||
@@ -10,7 +12,7 @@ use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Rules {
|
||||
@@ -21,12 +23,15 @@ pub(crate) type SharedRules = Arc<RwLock<Arc<Rules>>>;
|
||||
|
||||
pub(crate) struct Engine {
|
||||
pub store: Store,
|
||||
pomodoro: Option<pomodoro::Timer>,
|
||||
pomodoro_settings: ferese_activities::pomodoro::Settings,
|
||||
alerts: Option<pomodoro::Alerts>,
|
||||
pub rules: SharedRules,
|
||||
pub monitor: Option<crate::ipc::activity_owner::Monitor>,
|
||||
process_enabled: bool,
|
||||
process_sender: Option<smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>>,
|
||||
process_watcher: Option<crate::activities_process::Watcher>,
|
||||
pub timer: Option<crate::ipc::DeadlineTimer<crate::Ferese>>,
|
||||
pub timer: Option<deadline::Deadline>,
|
||||
revision: u64,
|
||||
cached_snapshot: Option<Arc<Snapshot>>,
|
||||
publisher: Option<publication::Publisher>,
|
||||
@@ -40,6 +45,9 @@ impl Default for Engine {
|
||||
fn default() -> Self {
|
||||
let settings = ferese_config::activities::Settings::default();
|
||||
Self {
|
||||
pomodoro: None,
|
||||
pomodoro_settings: Default::default(),
|
||||
alerts: None,
|
||||
store: Store::new(ferese_ipc::activities::now_ms() ^ u64::from(std::process::id()) << 32),
|
||||
rules: Arc::new(RwLock::new(Arc::new(Rules {
|
||||
enabled: false,
|
||||
@@ -109,6 +117,7 @@ impl Engine {
|
||||
matcher: ferese_config::activities::Matcher,
|
||||
) {
|
||||
self.store.limits = settings.limits();
|
||||
self.pomodoro_settings = settings.pomodoro.clone();
|
||||
self.process_enabled = settings.sources.process_watch;
|
||||
*self.rules.write().expect("activity rules") = Arc::new(Rules {
|
||||
enabled: settings.sources.shell_hook,
|
||||
@@ -145,7 +154,16 @@ impl Engine {
|
||||
revision: self.revision,
|
||||
now_ms: ferese_ipc::activities::now_ms(),
|
||||
limits: self.store.limits,
|
||||
activities: self.store.entries().cloned().collect(),
|
||||
activities: self
|
||||
.store
|
||||
.entries()
|
||||
.cloned()
|
||||
.chain(self.pomodoro.iter().map(|timer| timer.activity()))
|
||||
.collect(),
|
||||
pomodoro: self.pomodoro.as_ref().map(|timer| ferese_ipc::activities::Pomodoro {
|
||||
id: timer.id.clone(),
|
||||
timer: timer.session.clone(),
|
||||
}),
|
||||
windows: self
|
||||
.windows
|
||||
.iter()
|
||||
@@ -218,7 +236,9 @@ impl Engine {
|
||||
self.schedule();
|
||||
}
|
||||
pub fn tick(&mut self) {
|
||||
self.store.advance(ferese_ipc::activities::now_ms());
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
self.store.advance(now);
|
||||
self.advance_pomodoro(now);
|
||||
self.changed();
|
||||
}
|
||||
pub fn schedule(&self) {
|
||||
@@ -234,11 +254,15 @@ impl Engine {
|
||||
}
|
||||
if let Some(timer) = &self.timer {
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
timer.update(
|
||||
if let Err(error) = timer.update(
|
||||
self.store
|
||||
.next_deadline(now)
|
||||
.map(|deadline| Instant::now() + Duration::from_millis(deadline.saturating_sub(now))),
|
||||
);
|
||||
.into_iter()
|
||||
.chain(self.pomodoro.as_ref().and_then(|timer| timer.session.deadline))
|
||||
.min(),
|
||||
) {
|
||||
tracing::error!(%error, "Activity deadline could not be armed");
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn publish_verified(
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Activity deadlines share CLOCK_BOOTTIME with the published countdowns. A
|
||||
//! monotonic calloop timer would otherwise keep waiting for time spent asleep.
|
||||
use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction, RegistrationToken, generic::Generic};
|
||||
use std::{
|
||||
io,
|
||||
os::fd::{AsRawFd, FromRawFd, OwnedFd},
|
||||
};
|
||||
|
||||
pub(crate) struct Deadline {
|
||||
fd: OwnedFd,
|
||||
armed: std::cell::Cell<Option<u64>>,
|
||||
handle: LoopHandle<'static, crate::Ferese>,
|
||||
token: RegistrationToken,
|
||||
}
|
||||
impl Deadline {
|
||||
pub fn new(handle: LoopHandle<'static, crate::Ferese>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// SAFETY: timerfd_create returns a new owned descriptor or -1.
|
||||
let fd = unsafe { libc::timerfd_create(libc::CLOCK_BOOTTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) };
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error().into());
|
||||
}
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||
let token = handle.insert_source(
|
||||
Generic::new(fd.try_clone()?, Interest::READ, Mode::Level),
|
||||
|_, fd, state| {
|
||||
let mut expirations = 0_u64;
|
||||
// SAFETY: expirations is a writable eight-byte timerfd counter.
|
||||
let result = unsafe { libc::read(fd.as_raw_fd(), (&raw mut expirations).cast(), 8) };
|
||||
if result < 0 {
|
||||
let error = io::Error::last_os_error();
|
||||
if error.kind() != io::ErrorKind::WouldBlock {
|
||||
return Err(error);
|
||||
}
|
||||
} else {
|
||||
state.activities.tick();
|
||||
}
|
||||
Ok(PostAction::Continue)
|
||||
},
|
||||
)?;
|
||||
Ok(Self {
|
||||
fd,
|
||||
armed: std::cell::Cell::new(None),
|
||||
handle,
|
||||
token,
|
||||
})
|
||||
}
|
||||
pub fn update(&self, deadline: Option<u64>) -> io::Result<()> {
|
||||
if self.armed.get() == deadline {
|
||||
return Ok(());
|
||||
}
|
||||
let ms = deadline.unwrap_or(0);
|
||||
let spec = libc::itimerspec {
|
||||
it_interval: libc::timespec { tv_sec: 0, tv_nsec: 0 },
|
||||
it_value: libc::timespec {
|
||||
tv_sec: (ms / 1_000) as _,
|
||||
tv_nsec: ((ms % 1_000) * 1_000_000) as _,
|
||||
},
|
||||
};
|
||||
// SAFETY: spec is initialized; null old_value is permitted.
|
||||
if unsafe {
|
||||
libc::timerfd_settime(
|
||||
self.fd.as_raw_fd(),
|
||||
libc::TFD_TIMER_ABSTIME,
|
||||
&spec,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
} < 0
|
||||
{
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
self.armed.set(deadline);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Deadline {
|
||||
fn drop(&mut self) {
|
||||
self.handle.remove(self.token);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn deadline_uses_boottime_and_disarming_leaves_no_periodic_wakeups() {
|
||||
let events = smithay::reexports::calloop::EventLoop::<crate::Ferese>::try_new().unwrap();
|
||||
let deadline = Deadline::new(events.handle()).unwrap();
|
||||
let info = std::fs::read_to_string(format!("/proc/self/fdinfo/{}", deadline.fd.as_raw_fd())).unwrap();
|
||||
assert!(
|
||||
info.lines()
|
||||
.any(|line| line.split_whitespace().collect::<Vec<_>>() == ["clockid:", "7"]),
|
||||
"{info}"
|
||||
);
|
||||
deadline.update(Some(ferese_ipc::activities::now_ms() + 20)).unwrap();
|
||||
let mut pollfd = libc::pollfd {
|
||||
fd: deadline.fd.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
// SAFETY: pollfd is a valid array of one descriptor.
|
||||
assert_eq!(unsafe { libc::poll(&raw mut pollfd, 1, 500) }, 1);
|
||||
let mut counter = 0_u64;
|
||||
assert_eq!(
|
||||
unsafe { libc::read(deadline.fd.as_raw_fd(), (&raw mut counter).cast(), 8) },
|
||||
8
|
||||
);
|
||||
assert_eq!(counter, 1);
|
||||
deadline.update(None).unwrap();
|
||||
assert_eq!(unsafe { libc::poll(&raw mut pollfd, 1, 0) }, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
use super::*;
|
||||
use ferese_activities::pomodoro::{Session, Status};
|
||||
use ferese_ipc::activities::{PomodoroAction, PomodoroRequest};
|
||||
|
||||
pub(super) struct Timer {
|
||||
pub id: Id,
|
||||
pub session: Session,
|
||||
output: Option<String>,
|
||||
created_at: u64,
|
||||
}
|
||||
impl Timer {
|
||||
pub fn activity(&self) -> ferese_activities::Activity {
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
let value = if let Some(deadline) = self.session.deadline {
|
||||
Value::Countdown {
|
||||
remaining_ms: deadline.saturating_sub(now),
|
||||
sampled_at: now,
|
||||
}
|
||||
} else {
|
||||
Value::PausedCountdown {
|
||||
remaining_ms: self.session.remaining_ms,
|
||||
}
|
||||
};
|
||||
ferese_activities::Activity {
|
||||
id: self.id.clone(),
|
||||
data: Data {
|
||||
source: Source::Timer,
|
||||
output: self.output.clone(),
|
||||
priority: 100,
|
||||
icon: "timer".into(),
|
||||
title: format!(
|
||||
"Pomodoro · {}{}",
|
||||
self.session.phase.label(),
|
||||
match self.session.status {
|
||||
Status::Paused => " · Paused",
|
||||
Status::Ready => " · Ready",
|
||||
Status::Finished => " · Finished",
|
||||
_ => "",
|
||||
}
|
||||
),
|
||||
value,
|
||||
template: Template::Timer,
|
||||
},
|
||||
state: State::Running,
|
||||
created_at: self.created_at,
|
||||
eligible_at: self.created_at,
|
||||
refreshed_at: self.created_at,
|
||||
lease_deadline: None,
|
||||
result_deadline: None,
|
||||
result_message: None,
|
||||
flash_on_fail: false,
|
||||
result_flash_ms: 0,
|
||||
group: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Engine {
|
||||
pub fn pomodoro_control(
|
||||
&mut self,
|
||||
request: PomodoroRequest,
|
||||
output: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if request.version != ferese_ipc::activities::VERSION {
|
||||
return Err("Unsupported activity schema".into());
|
||||
}
|
||||
if let Some(id) = &request.session
|
||||
&& self.pomodoro.as_ref().is_none_or(|timer| &timer.id != id)
|
||||
{
|
||||
return Err("Pomodoro session changed; refresh its controls".into());
|
||||
}
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
if !matches!(request.action, PomodoroAction::Stop | PomodoroAction::Reset) && self.advance_pomodoro(now) {
|
||||
self.changed();
|
||||
}
|
||||
match request.action {
|
||||
PomodoroAction::Status => {}
|
||||
PomodoroAction::Start | PomodoroAction::Reset => {
|
||||
if request.action == PomodoroAction::Start
|
||||
&& self
|
||||
.pomodoro
|
||||
.as_ref()
|
||||
.is_some_and(|timer| timer.session.status != Status::Finished)
|
||||
{
|
||||
return Err("A Pomodoro session already exists; resume or reset it".into());
|
||||
}
|
||||
let session = Session::start(self.pomodoro_settings.clone(), now)?;
|
||||
// Verify the long-lived host, never the short-lived CLI, as owner.
|
||||
let (owner, _pidfd, _) =
|
||||
crate::ipc::activity_owner::inspect(std::process::id()).map_err(|error| error.to_string())?;
|
||||
let id = Id {
|
||||
owner,
|
||||
local: "pomodoro".into(),
|
||||
generation: self.revision,
|
||||
session: self.store.session(),
|
||||
};
|
||||
self.pomodoro = Some(Timer {
|
||||
id,
|
||||
session,
|
||||
output,
|
||||
created_at: now,
|
||||
});
|
||||
}
|
||||
PomodoroAction::Stop => {
|
||||
self.pomodoro = None;
|
||||
}
|
||||
action => {
|
||||
let timer = self.pomodoro.as_mut().ok_or("No Pomodoro session; start one first")?;
|
||||
match action {
|
||||
PomodoroAction::Pause => timer.session.pause(now)?,
|
||||
PomodoroAction::Resume => timer.session.resume(now)?,
|
||||
PomodoroAction::Next => timer.session.next(now)?,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
if request.action != PomodoroAction::Status {
|
||||
self.changed();
|
||||
}
|
||||
let snapshot = self.snapshot();
|
||||
serde_json::to_value(&snapshot.pomodoro).map_err(|error| error.to_string())
|
||||
}
|
||||
pub(super) fn advance_pomodoro(&mut self, now: u64) -> bool {
|
||||
let Some(timer) = &mut self.pomodoro else {
|
||||
return false;
|
||||
};
|
||||
let Some(completion) = timer.session.advance(now) else {
|
||||
return false;
|
||||
};
|
||||
if timer.session.settings.notifications {
|
||||
let title = format!("Pomodoro: {} complete", completion.phase.label());
|
||||
let body = completion.next.map_or_else(
|
||||
|| "Your Pomodoro session is finished.".into(),
|
||||
|phase| {
|
||||
format!(
|
||||
"{} {}.",
|
||||
phase.label(),
|
||||
if timer.session.status == Status::Running {
|
||||
"has started"
|
||||
} else {
|
||||
"is ready"
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
let alerts = self.alerts.get_or_insert_with(Alerts::new);
|
||||
if alerts.sender.try_send((title, body)).is_err() {
|
||||
tracing::warn!("Pomodoro alert queue unavailable");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
pub fn pomodoro_remove_output(&mut self, output: &str, fallback: Option<&str>) {
|
||||
if let Some(timer) = &mut self.pomodoro
|
||||
&& timer.output.as_deref() == Some(output)
|
||||
{
|
||||
timer.output = fallback.map(str::to_owned);
|
||||
}
|
||||
}
|
||||
pub fn pomodoro_attach_output(&mut self, output: &str) -> bool {
|
||||
if let Some(timer) = &mut self.pomodoro
|
||||
&& timer.output.is_none()
|
||||
{
|
||||
timer.output = Some(output.into());
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification delivery runs off the compositor thread and is attempted once.
|
||||
/// Reconnection never replays an alert; the notification server applies DND and
|
||||
/// lock-screen privacy, just as it does for other applications.
|
||||
pub(super) struct Alerts {
|
||||
sender: std::sync::mpsc::SyncSender<(String, String)>,
|
||||
}
|
||||
impl Alerts {
|
||||
fn new() -> Self {
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel::<(String, String)>(16);
|
||||
std::thread::spawn(move || {
|
||||
while let Ok((title, body)) = receiver.recv() {
|
||||
let result = (|| -> zbus::Result<()> {
|
||||
let connection = zbus::blocking::connection::Builder::session()?
|
||||
.method_timeout(Duration::from_secs(2))
|
||||
.build()?;
|
||||
let mut hints = std::collections::HashMap::<&str, zbus::zvariant::Value<'_>>::new();
|
||||
hints.insert("suppress-sound", zbus::zvariant::Value::Bool(true));
|
||||
let reply = connection.call_method(
|
||||
Some("org.freedesktop.Notifications"),
|
||||
"/org/freedesktop/Notifications",
|
||||
Some("org.freedesktop.Notifications"),
|
||||
"Notify",
|
||||
&("Ferese", 0_u32, "", title, body, Vec::<String>::new(), hints, -1_i32),
|
||||
)?;
|
||||
let _: u32 = reply.body().deserialize()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(%error,"Pomodoro completion alert could not be delivered");
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { sender }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn control(engine: &mut Engine, action: PomodoroAction) -> Result<serde_json::Value, String> {
|
||||
engine.pomodoro_control(
|
||||
PomodoroRequest {
|
||||
version: ferese_ipc::activities::VERSION,
|
||||
action,
|
||||
session: None,
|
||||
},
|
||||
Some("test".into()),
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn completion_uses_the_standard_notification_service_without_sound() {
|
||||
const TEST: &str =
|
||||
"activities::pomodoro::tests::completion_uses_the_standard_notification_service_without_sound";
|
||||
if !crate::startup_tests::private_runtime(TEST) {
|
||||
return;
|
||||
}
|
||||
use std::io::BufRead;
|
||||
struct Bus(std::process::Child);
|
||||
impl Drop for Bus {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
let mut bus = Bus(std::process::Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap());
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(bus.0.stdout.take().unwrap())
|
||||
.read_line(&mut address)
|
||||
.unwrap();
|
||||
// SAFETY: private_runtime runs only this test in a separate process;
|
||||
// set the bus before starting the notification service or alert worker.
|
||||
unsafe {
|
||||
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", address.trim());
|
||||
}
|
||||
struct Notifications(std::sync::mpsc::Sender<(String, String, bool)>);
|
||||
#[zbus::interface(name = "org.freedesktop.Notifications")]
|
||||
impl Notifications {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn notify(
|
||||
&self,
|
||||
_app_name: &str,
|
||||
_replaces_id: u32,
|
||||
_app_icon: &str,
|
||||
summary: &str,
|
||||
body: &str,
|
||||
_actions: Vec<String>,
|
||||
hints: std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
_expire_timeout: i32,
|
||||
) -> u32 {
|
||||
let silent = hints
|
||||
.get("suppress-sound")
|
||||
.and_then(|value| bool::try_from(value).ok())
|
||||
.unwrap_or(false);
|
||||
self.0.send((summary.into(), body.into(), silent)).unwrap();
|
||||
1
|
||||
}
|
||||
}
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let _service = zbus::blocking::connection::Builder::address(address.trim())
|
||||
.unwrap()
|
||||
.serve_at("/org/freedesktop/Notifications", Notifications(sender))
|
||||
.unwrap()
|
||||
.name("org.freedesktop.Notifications")
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
let mut engine = Engine::default();
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
|
||||
engine.advance_pomodoro(deadline);
|
||||
let (title, body, silent) = receiver.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
assert_eq!(title, "Pomodoro: Work complete");
|
||||
assert_eq!(body, "Short break is ready.");
|
||||
assert!(silent);
|
||||
engine.advance_pomodoro(deadline + 1);
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn host_owns_the_single_session_and_stale_popup_controls_are_rejected() {
|
||||
let mut engine = Engine::default();
|
||||
let first = control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
let first: ferese_ipc::activities::Pomodoro = serde_json::from_value(first).unwrap();
|
||||
assert_eq!(first.id.owner.pid, std::process::id());
|
||||
assert!(control(&mut engine, PomodoroAction::Start).is_err());
|
||||
engine.remove_connection(10);
|
||||
engine.owner_exit(Owner { pid: 999_999, birth: 0 });
|
||||
assert_eq!(engine.snapshot().pomodoro.as_ref().unwrap().id, first.id);
|
||||
assert_eq!(engine.snapshot().activities.len(), 1);
|
||||
control(&mut engine, PomodoroAction::Reset).unwrap();
|
||||
assert_ne!(engine.snapshot().pomodoro.as_ref().unwrap().id, first.id);
|
||||
assert!(
|
||||
engine
|
||||
.pomodoro_control(
|
||||
PomodoroRequest {
|
||||
version: ferese_ipc::activities::VERSION,
|
||||
action: PomodoroAction::Stop,
|
||||
session: Some(first.id)
|
||||
},
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
control(&mut engine, PomodoroAction::Stop).unwrap();
|
||||
assert!(engine.snapshot().pomodoro.is_none());
|
||||
assert!(engine.snapshot().activities.is_empty());
|
||||
assert!(Engine::default().snapshot().pomodoro.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn completions_are_delivered_once_and_cancellation_does_not_alert() {
|
||||
let mut engine = Engine::default();
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(16);
|
||||
engine.alerts = Some(Alerts { sender });
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
|
||||
assert!(engine.advance_pomodoro(deadline));
|
||||
assert!(!engine.advance_pomodoro(deadline + 1));
|
||||
let (title, body) = receiver.try_recv().unwrap();
|
||||
assert_eq!(title, "Pomodoro: Work complete");
|
||||
assert_eq!(body, "Short break is ready.");
|
||||
assert!(receiver.try_recv().is_err());
|
||||
control(&mut engine, PomodoroAction::Reset).unwrap();
|
||||
engine.pomodoro.as_mut().unwrap().session.deadline = Some(1);
|
||||
control(&mut engine, PomodoroAction::Stop).unwrap();
|
||||
assert!(receiver.try_recv().is_err());
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
engine.pomodoro.as_mut().unwrap().session.deadline = Some(1);
|
||||
control(&mut engine, PomodoroAction::Reset).unwrap();
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn paused_snapshot_is_constant_and_output_loss_routes_the_timer() {
|
||||
let mut engine = Engine::default();
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
control(&mut engine, PomodoroAction::Pause).unwrap();
|
||||
let snapshot = engine.snapshot();
|
||||
let activity = &snapshot.activities[0];
|
||||
assert!(activity.data.title.contains("Paused"));
|
||||
assert_eq!(
|
||||
activity.data.value.remaining_ms(0),
|
||||
activity.data.value.remaining_ms(u64::MAX)
|
||||
);
|
||||
engine.pomodoro_remove_output("test", None);
|
||||
assert!(engine.pomodoro_attach_output("new"));
|
||||
engine.changed();
|
||||
assert_eq!(engine.snapshot().activities[0].data.output.as_deref(), Some("new"));
|
||||
}
|
||||
#[test]
|
||||
fn disabled_alerts_and_schema_errors_have_no_side_effects() {
|
||||
let mut engine = Engine::default();
|
||||
engine.pomodoro_settings.notifications = false;
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
engine.alerts = Some(Alerts { sender });
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
assert!(
|
||||
engine
|
||||
.pomodoro_control(
|
||||
PomodoroRequest {
|
||||
version: 99,
|
||||
action: PomodoroAction::Stop,
|
||||
session: None
|
||||
},
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
|
||||
engine.advance_pomodoro(deadline);
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -232,6 +232,33 @@ pub(crate) fn init(
|
||||
Err(error) => Response::error(call.request.id, "activity_rejected", error),
|
||||
};
|
||||
let _ = call.response.try_send(response);
|
||||
} else if call.request.command == "pomodoro" {
|
||||
let result = validate_request(&call.request)
|
||||
.map_err(|error| error.message)
|
||||
.and_then(|()| {
|
||||
if state.session_lock.active() {
|
||||
return Err("Session locked".into());
|
||||
}
|
||||
let request: ferese_ipc::activities::PomodoroRequest =
|
||||
serde_json::from_value(call.request.args).map_err(|error| error.to_string())?;
|
||||
let output = state
|
||||
.outputs_json()
|
||||
.as_array()
|
||||
.and_then(|outputs| {
|
||||
outputs
|
||||
.iter()
|
||||
.find(|output| output["focused"] == true)
|
||||
.or_else(|| outputs.first())
|
||||
})
|
||||
.and_then(|output| output["name"].as_str())
|
||||
.map(str::to_owned);
|
||||
state.activities.pomodoro_control(request, output)
|
||||
});
|
||||
let response = match result {
|
||||
Ok(value) => Response::success(call.request.id, value),
|
||||
Err(error) => Response::error(call.request.id, "pomodoro_rejected", error),
|
||||
};
|
||||
let _ = call.response.try_send(response);
|
||||
} else if call.request.command == "activity-get" {
|
||||
if state.session_lock.active() {
|
||||
let _ = call.response.try_send(Response::error(
|
||||
@@ -415,7 +442,7 @@ pub(crate) fn init(
|
||||
})?;
|
||||
let worker = Worker::spawn(encoded);
|
||||
|
||||
let activity_timer = DeadlineTimer::new(event_loop.handle(), |state: &mut Ferese, _| state.activities.tick());
|
||||
let activity_timer = crate::activities::deadline::Deadline::new(event_loop.handle())?;
|
||||
let (rules_send, rules_receive) = sync_channel(1);
|
||||
let monitor = activity_owner::Monitor::new(sender.clone());
|
||||
let state_monitor = monitor.clone();
|
||||
|
||||
@@ -103,6 +103,8 @@ impl Ferese {
|
||||
.find(|candidate| *candidate != output)
|
||||
.map(|output| output.name());
|
||||
self.activities.store.remove_output(&output.name(), fallback.as_deref());
|
||||
self.activities
|
||||
.pomodoro_remove_output(&output.name(), fallback.as_deref());
|
||||
self.activities.changed();
|
||||
self.display_presentation.remove_output(output);
|
||||
self.output_redraw_pending.retain(|pending| pending != output);
|
||||
|
||||
@@ -168,7 +168,7 @@ impl Ferese {
|
||||
self.desktop_transition.as_mut().unwrap().publishing = true;
|
||||
let activity_output = self.space.outputs().next().map(|output| output.name());
|
||||
if let Some(output) = activity_output
|
||||
&& self.activities.store.attach_output(&output)
|
||||
&& (self.activities.store.attach_output(&output) | self.activities.pomodoro_attach_output(&output))
|
||||
{
|
||||
self.activities.changed();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user