Avoid idle clock and screenshot polling
Update visible shell clocks at their displayed time boundaries and receive recorder state through events, with elapsed updates only while recording. Arm screenshot expiry at the earliest pending deadline and cancel abandoned requests when their IPC client disconnects. Cache desktop clock formats across shell updates. Transfer owned screenshot readback pixels to the encoder and borrow the canvas for PNG encoding; both paths previously copied a full image. Close the recorder's stdout reader before waiting for its child when a status line is rejected. Validation: 910 Rust tests passed in the release workspace suite, with 44 ignored. The six screenshot-script tests passed. A private nested release test passed with a visible desktop clock, notification and modal animations, and three real PNG captures. The recorder shutdown and readback ownership regressions failed before their fixes and pass afterwards. The isolated clock settings check dropped from 30,000 allocations to zero over 10,000 rebuilds. Borrowing the PNG canvas removed 33,177,600 allocated bytes for a 3840x2160 capture while producing identical PNG bytes. Direct DRM and laptop power checks remain pending.
This commit is contained in:
1 parent
49e54f9a63
commit
90e5c3543d
7 files changed
+1562
-161
No files matched your search
@@ -345,6 +345,7 @@ enum RequestState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct Request {
|
struct Request {
|
||||||
|
owner: u64,
|
||||||
response_id: u64,
|
response_id: u64,
|
||||||
reply: ReplySender<Response>,
|
reply: ReplySender<Response>,
|
||||||
state: RequestState,
|
state: RequestState,
|
||||||
@@ -374,6 +375,8 @@ pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
|||||||
pub(crate) struct Coordinator {
|
pub(crate) struct Coordinator {
|
||||||
requests: std::collections::HashMap<u64, Request>,
|
requests: std::collections::HashMap<u64, Request>,
|
||||||
next_id: u64,
|
next_id: u64,
|
||||||
|
deadline_changed: Option<Box<dyn Fn(Option<Instant>)>>,
|
||||||
|
notified_deadline: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Coordinator {
|
impl Coordinator {
|
||||||
@@ -381,11 +384,29 @@ impl Coordinator {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_deadline_observer(&mut self, observer: impl Fn(Option<Instant>) + 'static) {
|
||||||
|
self.notified_deadline = self.next_deadline();
|
||||||
|
observer(self.notified_deadline);
|
||||||
|
self.deadline_changed = Some(Box::new(observer));
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn admit(
|
pub(crate) fn admit(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
owner: u64,
|
||||||
response_id: u64,
|
response_id: u64,
|
||||||
reply: ReplySender<Response>,
|
reply: ReplySender<Response>,
|
||||||
specs: Vec<PartSpec>,
|
specs: Vec<PartSpec>,
|
||||||
|
) -> Result<u64, String> {
|
||||||
|
self.admit_at(owner, response_id, reply, specs, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admit_at(
|
||||||
|
&mut self,
|
||||||
|
owner: u64,
|
||||||
|
response_id: u64,
|
||||||
|
reply: ReplySender<Response>,
|
||||||
|
specs: Vec<PartSpec>,
|
||||||
|
now: Instant,
|
||||||
) -> Result<u64, String> {
|
) -> Result<u64, String> {
|
||||||
if specs.is_empty() {
|
if specs.is_empty() {
|
||||||
return Err("No outputs are enabled".into());
|
return Err("No outputs are enabled".into());
|
||||||
@@ -416,14 +437,16 @@ impl Coordinator {
|
|||||||
self.requests.insert(
|
self.requests.insert(
|
||||||
id,
|
id,
|
||||||
Request {
|
Request {
|
||||||
|
owner,
|
||||||
response_id,
|
response_id,
|
||||||
reply,
|
reply,
|
||||||
state: RequestState::Collecting,
|
state: RequestState::Collecting,
|
||||||
parts: specs.into_iter().map(|spec| (spec, PartState::Pending)).collect(),
|
parts: specs.into_iter().map(|spec| (spec, PartState::Pending)).collect(),
|
||||||
received: 0,
|
received: 0,
|
||||||
deadline: Instant::now() + REQUEST_TIMEOUT,
|
deadline: now + REQUEST_TIMEOUT,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
self.notify_deadline_change();
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,36 +478,22 @@ impl Coordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let frame = match result {
|
let frame = match result {
|
||||||
Ok(ref buffer) => {
|
Ok(buffer) => OutputFrame {
|
||||||
let mut pixels = Vec::new();
|
preserve_alpha: spec.preserve_alpha,
|
||||||
if pixels.try_reserve_exact(buffer.pixels.len()).is_err() {
|
width: buffer.width,
|
||||||
None
|
height: buffer.height,
|
||||||
} else {
|
stride: buffer.stride,
|
||||||
pixels.extend_from_slice(&buffer.pixels);
|
pixels: buffer.pixels,
|
||||||
Some(OutputFrame {
|
transform: spec.transform,
|
||||||
preserve_alpha: spec.preserve_alpha,
|
location: spec.location,
|
||||||
width: buffer.width,
|
scale: spec.scale,
|
||||||
height: buffer.height,
|
logical_width: spec.logical_width,
|
||||||
stride: buffer.stride,
|
logical_height: spec.logical_height,
|
||||||
pixels,
|
},
|
||||||
transform: spec.transform,
|
Err(error) => {
|
||||||
location: spec.location,
|
self.reject_inner(request, &error);
|
||||||
scale: spec.scale,
|
return Action::None;
|
||||||
logical_width: spec.logical_width,
|
|
||||||
logical_height: spec.logical_height,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(_) => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(frame) = frame else {
|
|
||||||
let error = match result {
|
|
||||||
Err(error) => error,
|
|
||||||
Ok(_) => "Screenshot capture could not be allocated".to_string(),
|
|
||||||
};
|
|
||||||
self.reject_inner(request, &error);
|
|
||||||
return Action::None;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(entry) = self.requests.get_mut(&request) else {
|
let Some(entry) = self.requests.get_mut(&request) else {
|
||||||
@@ -516,7 +525,7 @@ impl Coordinator {
|
|||||||
Err(_) => Action::None,
|
Err(_) => Action::None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let entry = self.requests.remove(&request).expect("checked above");
|
let entry = self.remove_request(request).expect("checked above");
|
||||||
match result {
|
match result {
|
||||||
Ok(path) => {
|
Ok(path) => {
|
||||||
reply(&entry, Ok(&path));
|
reply(&entry, Ok(&path));
|
||||||
@@ -536,8 +545,7 @@ impl Coordinator {
|
|||||||
// Answers every request that outlived its deadline. The returned ids let the
|
// Answers every request that outlived its deadline. The returned ids let the
|
||||||
// caller drop readbacks that would otherwise publish into a request that no
|
// caller drop readbacks that would otherwise publish into a request that no
|
||||||
// longer exists.
|
// longer exists.
|
||||||
pub(crate) fn expire(&mut self) -> Vec<u64> {
|
pub(crate) fn expire_at(&mut self, now: Instant) -> Vec<u64> {
|
||||||
let now = Instant::now();
|
|
||||||
let expired: Vec<u64> = self
|
let expired: Vec<u64> = self
|
||||||
.requests
|
.requests
|
||||||
.iter()
|
.iter()
|
||||||
@@ -550,13 +558,22 @@ impl Coordinator {
|
|||||||
expired
|
expired
|
||||||
}
|
}
|
||||||
|
|
||||||
// The remaining time before the earliest deadline, so the caller can sleep
|
// Absolute deadlines do not drift when unrelated work wakes the loop.
|
||||||
// exactly that long instead of polling on a fixed tick.
|
pub(crate) fn next_deadline(&self) -> Option<Instant> {
|
||||||
pub(crate) fn next_deadline(&self) -> Option<Duration> {
|
self.requests.values().map(|entry| entry.deadline).min()
|
||||||
self.requests
|
}
|
||||||
.values()
|
|
||||||
.map(|entry| entry.deadline.saturating_duration_since(Instant::now()))
|
pub(crate) fn terminate_owner(&mut self, owner: u64) -> Vec<u64> {
|
||||||
.min()
|
let ids: Vec<u64> = self
|
||||||
|
.requests
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, entry)| entry.owner == owner)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
for id in &ids {
|
||||||
|
self.reject_inner(*id, "Screenshot cancelled: the client disconnected");
|
||||||
|
}
|
||||||
|
ids
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn terminate_all(&mut self) -> Vec<u64> {
|
pub(crate) fn terminate_all(&mut self) -> Vec<u64> {
|
||||||
@@ -572,10 +589,28 @@ impl Coordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn reject_inner(&mut self, request: u64, error: &str) {
|
fn reject_inner(&mut self, request: u64, error: &str) {
|
||||||
if let Some(entry) = self.requests.remove(&request) {
|
if let Some(entry) = self.remove_request(request) {
|
||||||
reply(&entry, Err(error));
|
reply(&entry, Err(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All terminal paths pass through here, including readback/encode failure,
|
||||||
|
// owner closure, cancellation and timeout, so none can leave a stale timer.
|
||||||
|
fn remove_request(&mut self, request: u64) -> Option<Request> {
|
||||||
|
let entry = self.requests.remove(&request)?;
|
||||||
|
self.notify_deadline_change();
|
||||||
|
Some(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notify_deadline_change(&mut self) {
|
||||||
|
let deadline = self.next_deadline();
|
||||||
|
if deadline != self.notified_deadline {
|
||||||
|
self.notified_deadline = deadline;
|
||||||
|
if let Some(observer) = &self.deadline_changed {
|
||||||
|
observer(deadline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reply(entry: &Request, result: Result<&Path, &str>) {
|
fn reply(entry: &Request, result: Result<&Path, &str>) {
|
||||||
@@ -918,7 +953,7 @@ mod completion {
|
|||||||
let (reply, received) = sync_channel(8);
|
let (reply, received) = sync_channel(8);
|
||||||
let mut coordinator = Coordinator::new();
|
let mut coordinator = Coordinator::new();
|
||||||
coordinator
|
coordinator
|
||||||
.admit(42, reply.clone(), (0..parts).map(|_| spec(2, 2)).collect())
|
.admit(1, 42, reply.clone(), (0..parts).map(|_| spec(2, 2)).collect())
|
||||||
.expect("admitted");
|
.expect("admitted");
|
||||||
Self { coordinator, received }
|
Self { coordinator, received }
|
||||||
}
|
}
|
||||||
@@ -928,6 +963,23 @@ mod completion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_readback_moves_its_pixels_to_the_encoder() {
|
||||||
|
let mut harness = Harness::new(1);
|
||||||
|
let id = harness.coordinator.next_id;
|
||||||
|
let mut captured = buffer(2, 2);
|
||||||
|
captured.pixels.fill(137);
|
||||||
|
let allocation = captured.pixels.as_ptr();
|
||||||
|
|
||||||
|
let Action::Encode { frames, .. } = harness.coordinator.on_part(id, 0, Ok(captured)) else {
|
||||||
|
panic!("completed readback was not queued for encoding");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
assert_eq!(frames[0].pixels.as_ptr(), allocation, "readback pixels were copied");
|
||||||
|
assert_eq!(frames[0].pixels, vec![137; 16]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn part_failure_terminates_the_request_with_an_error() {
|
fn part_failure_terminates_the_request_with_an_error() {
|
||||||
let mut harness = Harness::new(2);
|
let mut harness = Harness::new(2);
|
||||||
@@ -1105,7 +1157,7 @@ mod completion {
|
|||||||
let id = harness.coordinator.next_id;
|
let id = harness.coordinator.next_id;
|
||||||
harness.coordinator.requests.get_mut(&id).expect("tracked").deadline = Instant::now() - Duration::from_secs(1);
|
harness.coordinator.requests.get_mut(&id).expect("tracked").deadline = Instant::now() - Duration::from_secs(1);
|
||||||
|
|
||||||
assert_eq!(harness.coordinator.expire(), vec![id]);
|
assert_eq!(harness.coordinator.expire_at(Instant::now()), vec![id]);
|
||||||
let response = harness.response().expect("the caller is answered");
|
let response = harness.response().expect("the caller is answered");
|
||||||
assert_eq!(response.error.as_ref().unwrap().code, "screenshot_failed");
|
assert_eq!(response.error.as_ref().unwrap().code, "screenshot_failed");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1126,10 +1178,14 @@ mod completion {
|
|||||||
fn a_live_request_is_not_expired_early() {
|
fn a_live_request_is_not_expired_early() {
|
||||||
let mut harness = Harness::new(1);
|
let mut harness = Harness::new(1);
|
||||||
let id = harness.coordinator.next_id;
|
let id = harness.coordinator.next_id;
|
||||||
assert!(harness.coordinator.expire().is_empty(), "not yet due");
|
assert!(harness.coordinator.expire_at(Instant::now()).is_empty(), "not yet due");
|
||||||
assert!(harness.coordinator.is_live(id));
|
assert!(harness.coordinator.is_live(id));
|
||||||
assert!(harness.response().is_none(), "still unanswered, not failed");
|
assert!(harness.response().is_none(), "still unanswered, not failed");
|
||||||
let remaining = harness.coordinator.next_deadline().expect("still armed");
|
let remaining = harness
|
||||||
|
.coordinator
|
||||||
|
.next_deadline()
|
||||||
|
.expect("still armed")
|
||||||
|
.saturating_duration_since(Instant::now());
|
||||||
assert!(
|
assert!(
|
||||||
remaining <= REQUEST_TIMEOUT && remaining > REQUEST_TIMEOUT - Duration::from_secs(1),
|
remaining <= REQUEST_TIMEOUT && remaining > REQUEST_TIMEOUT - Duration::from_secs(1),
|
||||||
"the timer re-arms to roughly the full timeout, got {remaining:?}"
|
"the timer re-arms to roughly the full timeout, got {remaining:?}"
|
||||||
@@ -1142,17 +1198,17 @@ mod completion {
|
|||||||
let mut coordinator = Coordinator::new();
|
let mut coordinator = Coordinator::new();
|
||||||
for _ in 0..MAX_OUTSTANDING {
|
for _ in 0..MAX_OUTSTANDING {
|
||||||
coordinator
|
coordinator
|
||||||
.admit(42, reply.clone(), vec![spec(2, 2)])
|
.admit(1, 42, reply.clone(), vec![spec(2, 2)])
|
||||||
.expect("within the request cap");
|
.expect("within the request cap");
|
||||||
}
|
}
|
||||||
for entry in coordinator.requests.values_mut() {
|
for entry in coordinator.requests.values_mut() {
|
||||||
entry.deadline = Instant::now() - Duration::from_secs(1);
|
entry.deadline = Instant::now() - Duration::from_secs(1);
|
||||||
}
|
}
|
||||||
assert_eq!(coordinator.expire().len(), MAX_OUTSTANDING);
|
assert_eq!(coordinator.expire_at(Instant::now()).len(), MAX_OUTSTANDING);
|
||||||
assert_eq!(coordinator.next_deadline(), None);
|
assert_eq!(coordinator.next_deadline(), None);
|
||||||
// The cap is usable again rather than permanently degraded.
|
// The cap is usable again rather than permanently degraded.
|
||||||
coordinator
|
coordinator
|
||||||
.admit(42, reply, vec![spec(2, 2)])
|
.admit(1, 42, reply, vec![spec(2, 2)])
|
||||||
.expect("a slot was released");
|
.expect("a slot was released");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1162,10 +1218,10 @@ mod completion {
|
|||||||
let mut coordinator = Coordinator::new();
|
let mut coordinator = Coordinator::new();
|
||||||
for _ in 0..MAX_OUTSTANDING {
|
for _ in 0..MAX_OUTSTANDING {
|
||||||
coordinator
|
coordinator
|
||||||
.admit(42, reply.clone(), vec![spec(2, 2)])
|
.admit(1, 42, reply.clone(), vec![spec(2, 2)])
|
||||||
.expect("within the request cap");
|
.expect("within the request cap");
|
||||||
}
|
}
|
||||||
let overflow = coordinator.admit(42, reply.clone(), vec![spec(2, 2)]);
|
let overflow = coordinator.admit(1, 42, reply.clone(), vec![spec(2, 2)]);
|
||||||
assert!(overflow.is_err(), "outstanding requests are bounded");
|
assert!(overflow.is_err(), "outstanding requests are bounded");
|
||||||
|
|
||||||
let huge = PartSpec {
|
let huge = PartSpec {
|
||||||
@@ -1176,11 +1232,172 @@ mod completion {
|
|||||||
};
|
};
|
||||||
let mut fresh = Coordinator::new();
|
let mut fresh = Coordinator::new();
|
||||||
assert!(
|
assert!(
|
||||||
fresh.admit(42, reply.clone(), vec![huge]).is_err(),
|
fresh.admit(1, 42, reply.clone(), vec![huge]).is_err(),
|
||||||
"the byte budget is checked before any readback allocates"
|
"the byte budget is checked before any readback allocates"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_deadlines(coordinator: &mut Coordinator) -> std::rc::Rc<std::cell::RefCell<Vec<Option<Instant>>>> {
|
||||||
|
let changes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||||
|
let observed = changes.clone();
|
||||||
|
coordinator.set_deadline_observer(move |deadline| observed.borrow_mut().push(deadline));
|
||||||
|
changes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deadline_admission_after_long_idle_and_earliest_removal_rearm_exactly() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let (reply, _received) = sync_channel(8);
|
||||||
|
let mut coordinator = Coordinator::new();
|
||||||
|
let changes = observe_deadlines(&mut coordinator);
|
||||||
|
assert_eq!(&*changes.borrow(), &[None]);
|
||||||
|
|
||||||
|
// An idle period does not establish a polling epoch. Every request gets
|
||||||
|
// exactly the timeout measured from its own admission.
|
||||||
|
let late = now + Duration::from_secs(3600);
|
||||||
|
let first = coordinator
|
||||||
|
.admit_at(1, 42, reply.clone(), vec![spec(2, 2)], late)
|
||||||
|
.unwrap();
|
||||||
|
let second = coordinator
|
||||||
|
.admit_at(2, 43, reply.clone(), vec![spec(2, 2)], late + Duration::from_secs(5))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
changes.borrow().len(),
|
||||||
|
2,
|
||||||
|
"a later request does not rearm the earliest timer"
|
||||||
|
);
|
||||||
|
assert!(coordinator.admit_at(1, 44, reply, Vec::new(), late).is_err());
|
||||||
|
assert_eq!(changes.borrow().len(), 2, "rejected admission does not touch the timer");
|
||||||
|
assert_eq!(coordinator.terminate_owner(1), vec![first]);
|
||||||
|
assert!(coordinator.is_live(second));
|
||||||
|
assert_eq!(coordinator.terminate_owner(99), Vec::<u64>::new());
|
||||||
|
coordinator.reject(second, "encoding queue is full");
|
||||||
|
assert_eq!(
|
||||||
|
&*changes.borrow(),
|
||||||
|
&[
|
||||||
|
None,
|
||||||
|
Some(late + REQUEST_TIMEOUT),
|
||||||
|
Some(late + Duration::from_secs(5) + REQUEST_TIMEOUT),
|
||||||
|
None,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deadline_survives_readback_and_encoding_then_disarms_on_completion() {
|
||||||
|
for result in [Ok(PathBuf::from("/tmp/shot.png")), Err("encoder failed".into())] {
|
||||||
|
let mut harness = Harness::new(2);
|
||||||
|
let changes = observe_deadlines(&mut harness.coordinator);
|
||||||
|
let id = harness.coordinator.next_id;
|
||||||
|
let deadline = harness.coordinator.next_deadline();
|
||||||
|
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
|
||||||
|
assert!(matches!(
|
||||||
|
harness.coordinator.on_part(id, 1, Ok(buffer(2, 2))),
|
||||||
|
Action::Encode { .. }
|
||||||
|
));
|
||||||
|
assert_eq!(&*changes.borrow(), &[deadline], "encoding keeps the original deadline");
|
||||||
|
harness.coordinator.on_encoded(id, result);
|
||||||
|
assert_eq!(&*changes.borrow(), &[deadline, None]);
|
||||||
|
harness.coordinator.on_part(id, 0, Err("late readback".into()));
|
||||||
|
harness.coordinator.on_encoded(id, Err("late encode".into()));
|
||||||
|
assert_eq!(
|
||||||
|
changes.borrow().len(),
|
||||||
|
2,
|
||||||
|
"late results cannot rearm an empty coordinator"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deadline_cancellations_disarm_collecting_and_encoding_requests() {
|
||||||
|
for encoding in [false, true] {
|
||||||
|
for cancellation in [
|
||||||
|
"output was removed",
|
||||||
|
"session locked",
|
||||||
|
"privacy changed",
|
||||||
|
"client closed",
|
||||||
|
] {
|
||||||
|
// Topology failures arrive as parts while collecting. Once
|
||||||
|
// encoding begins its snapshotted frames remain valid.
|
||||||
|
if encoding && cancellation == "output was removed" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut harness = Harness::new(1);
|
||||||
|
let changes = observe_deadlines(&mut harness.coordinator);
|
||||||
|
let id = harness.coordinator.next_id;
|
||||||
|
if encoding {
|
||||||
|
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
|
||||||
|
}
|
||||||
|
match cancellation {
|
||||||
|
"output was removed" => {
|
||||||
|
harness.coordinator.on_part(id, 0, Err(cancellation.into()));
|
||||||
|
}
|
||||||
|
"session locked" => {
|
||||||
|
harness.coordinator.terminate_all();
|
||||||
|
}
|
||||||
|
"privacy changed" => {
|
||||||
|
harness.coordinator.terminate_all_with_reason(cancellation);
|
||||||
|
}
|
||||||
|
"client closed" => {
|
||||||
|
harness.coordinator.terminate_owner(1);
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
assert_eq!(changes.borrow().last(), Some(&None), "{cancellation} must disarm");
|
||||||
|
assert_eq!(changes.borrow().len(), 2);
|
||||||
|
assert!(harness.response().unwrap().error.is_some());
|
||||||
|
assert!(!harness.coordinator.is_live(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deadline_expiry_moves_to_next_request_and_frees_encoding_slots() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let (reply, received) = sync_channel(8);
|
||||||
|
let mut coordinator = Coordinator::new();
|
||||||
|
let changes = observe_deadlines(&mut coordinator);
|
||||||
|
let first = coordinator
|
||||||
|
.admit_at(1, 42, reply.clone(), vec![spec(2, 2)], now)
|
||||||
|
.unwrap();
|
||||||
|
let second = coordinator
|
||||||
|
.admit_at(2, 43, reply.clone(), vec![spec(2, 2)], now + Duration::from_secs(5))
|
||||||
|
.unwrap();
|
||||||
|
coordinator.on_part(first, 0, Ok(buffer(2, 2)));
|
||||||
|
assert!(
|
||||||
|
coordinator
|
||||||
|
.expire_at(now + REQUEST_TIMEOUT - Duration::from_nanos(1))
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(coordinator.expire_at(now + REQUEST_TIMEOUT), vec![first]);
|
||||||
|
assert!(coordinator.is_live(second));
|
||||||
|
assert_eq!(
|
||||||
|
coordinator.expire_at(now + REQUEST_TIMEOUT + Duration::from_secs(5)),
|
||||||
|
vec![second]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
&*changes.borrow(),
|
||||||
|
&[
|
||||||
|
None,
|
||||||
|
Some(now + REQUEST_TIMEOUT),
|
||||||
|
Some(now + REQUEST_TIMEOUT + Duration::from_secs(5)),
|
||||||
|
None,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(received.try_iter().count(), 2, "both timeouts answer once");
|
||||||
|
assert!(matches!(
|
||||||
|
coordinator.on_encoded(first, Ok(PathBuf::from("/tmp/late.png"))),
|
||||||
|
Action::DiscardFile(_)
|
||||||
|
));
|
||||||
|
coordinator
|
||||||
|
.admit_at(3, 44, reply, vec![spec(2, 2)], now + Duration::from_secs(3600))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
changes.borrow().last(),
|
||||||
|
Some(&Some(now + Duration::from_secs(3600) + REQUEST_TIMEOUT))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_sink_publishes_once_and_then_rejects_duplicates() {
|
fn a_sink_publishes_once_and_then_rejects_duplicates() {
|
||||||
let (sender, receiver) = loop_channel();
|
let (sender, receiver) = loop_channel();
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ fn encode_png(canvas: &Canvas) -> Result<Vec<u8>, String> {
|
|||||||
if width == 0 || height == 0 {
|
if width == 0 || height == 0 {
|
||||||
return Err("Screenshot is empty".into());
|
return Err("Screenshot is empty".into());
|
||||||
}
|
}
|
||||||
let image = image::RgbaImage::from_raw(width, height, canvas.pixels.clone())
|
let image = image::ImageBuffer::<image::Rgba<u8>, _>::from_raw(width, height, canvas.pixels.as_slice())
|
||||||
.ok_or("Screenshot buffer does not match its dimensions")?;
|
.ok_or("Screenshot buffer does not match its dimensions")?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let encoder = image::codecs::png::PngEncoder::new_with_quality(
|
let encoder = image::codecs::png::PngEncoder::new_with_quality(
|
||||||
@@ -210,7 +210,11 @@ mod tests {
|
|||||||
request: 1,
|
request: 1,
|
||||||
frames: vec![frame(4, 3)],
|
frames: vec![frame(4, 3)],
|
||||||
};
|
};
|
||||||
let canvas = compose(&job.frames).unwrap();
|
let mut canvas = compose(&job.frames).unwrap();
|
||||||
|
for (index, pixel) in canvas.pixels.chunks_exact_mut(4).enumerate() {
|
||||||
|
pixel[..3].copy_from_slice(&[index as u8, 137, 241]);
|
||||||
|
}
|
||||||
|
|
||||||
let png = encode_png(&canvas).expect("png");
|
let png = encode_png(&canvas).expect("png");
|
||||||
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n", "png signature");
|
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n", "png signature");
|
||||||
|
|
||||||
@@ -218,6 +222,7 @@ mod tests {
|
|||||||
.expect("decodes back")
|
.expect("decodes back")
|
||||||
.to_rgba8();
|
.to_rgba8();
|
||||||
assert_eq!(decoded.dimensions(), (4, 3));
|
assert_eq!(decoded.dimensions(), (4, 3));
|
||||||
|
assert_eq!(decoded.as_raw(), &canvas.pixels, "encoding changed the pixel data");
|
||||||
// The opaque alpha we force is preserved through the round trip.
|
// The opaque alpha we force is preserved through the round trip.
|
||||||
assert_eq!(decoded.get_pixel(0, 0)[3], 255);
|
assert_eq!(decoded.get_pixel(0, 0)[3], 255);
|
||||||
}
|
}
|
||||||
|
|||||||
+187
-29
@@ -1,11 +1,13 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
|
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::mpsc::{SyncSender, sync_channel};
|
use std::sync::mpsc::{SyncSender, sync_channel};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
use std::{env, fs, io, thread};
|
use std::{env, fs, io, thread};
|
||||||
|
|
||||||
use ferese_core::LayoutMode;
|
use ferese_core::LayoutMode;
|
||||||
@@ -13,7 +15,7 @@ use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
|||||||
use ferese_layout::Direction;
|
use ferese_layout::Direction;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use smithay::output::Output;
|
use smithay::output::Output;
|
||||||
use smithay::reexports::calloop::{EventLoop, LoopSignal, channel, timer};
|
use smithay::reexports::calloop::{EventLoop, LoopHandle, LoopSignal, RegistrationToken, channel, timer};
|
||||||
use smithay::utils::Transform;
|
use smithay::utils::Transform;
|
||||||
|
|
||||||
use crate::Ferese;
|
use crate::Ferese;
|
||||||
@@ -34,10 +36,6 @@ const _: () = assert!(
|
|||||||
"the result queue must outsize the job queue plus the in-flight job"
|
"the result queue must outsize the job queue plus the in-flight job"
|
||||||
);
|
);
|
||||||
const SWEEP_INTERVAL: Duration = crate::handlers::screenshot_worker::SWEEP_INTERVAL;
|
const SWEEP_INTERVAL: Duration = crate::handlers::screenshot_worker::SWEEP_INTERVAL;
|
||||||
// Only consulted when no request is outstanding, so it bounds how late a newly
|
|
||||||
// admitted request can be noticed. With a request pending the timer re-arms to
|
|
||||||
// the exact remaining time instead.
|
|
||||||
const DEADLINE_IDLE: Duration = Duration::from_secs(1);
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct IpcCall {
|
struct IpcCall {
|
||||||
@@ -110,6 +108,11 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
|||||||
let call = match event {
|
let call = match event {
|
||||||
IpcEvent::Call(call) => call,
|
IpcEvent::Call(call) => call,
|
||||||
IpcEvent::Closed(owner) => {
|
IpcEvent::Closed(owner) => {
|
||||||
|
for request in state.screenshot.terminate_owner(owner) {
|
||||||
|
state
|
||||||
|
.pending_screencopies
|
||||||
|
.retain(|capture| capture.request_id() != Some(request));
|
||||||
|
}
|
||||||
let captured = state.input_capture.active();
|
let captured = state.input_capture.active();
|
||||||
state.input_capture.remove_owner(owner);
|
state.input_capture.remove_owner(owner);
|
||||||
if captured && !state.input_capture.active() {
|
if captured && !state.input_capture.active() {
|
||||||
@@ -191,11 +194,11 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
|||||||
tracing::warn!(%error, "cannot queue configuration reload");
|
tracing::warn!(%error, "cannot queue configuration reload");
|
||||||
}
|
}
|
||||||
} else if call.request.command == "screenshot-window" {
|
} else if call.request.command == "screenshot-window" {
|
||||||
state.start_window_screenshot(call.request, call.response);
|
state.start_window_screenshot(call.owner, call.request, call.response);
|
||||||
} else if call.request.command == "screenshot" {
|
} else if call.request.command == "screenshot" {
|
||||||
// Deferred: this path answers the caller itself, exactly
|
// Deferred: this path answers the caller itself, exactly
|
||||||
// once, whenever the request finishes or is terminated.
|
// once, whenever the request finishes or is terminated.
|
||||||
state.start_screenshot(call.request, call.response);
|
state.start_screenshot(call.owner, call.request, call.response);
|
||||||
} else {
|
} else {
|
||||||
let response = state.handle_ipc_request(call.owner, call.request);
|
let response = state.handle_ipc_request(call.owner, call.request);
|
||||||
let _ = call.response.send(response);
|
let _ = call.response.send(response);
|
||||||
@@ -236,23 +239,15 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
|||||||
timer::TimeoutAction::ToDuration(SWEEP_INTERVAL)
|
timer::TimeoutAction::ToDuration(SWEEP_INTERVAL)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Readbacks are published by the render path, so a request whose outputs
|
// Admission and every terminal coordinator transition update this one-shot
|
||||||
// are not redrawn would otherwise wait forever. This re-arms to the exact
|
// source. No deadline source is registered when there are no requests.
|
||||||
// remaining time whenever a request is outstanding, and idles otherwise.
|
let deadline_timer = DeadlineTimer::new(event_loop.handle(), |state: &mut Ferese, now| {
|
||||||
event_loop
|
for request in state.screenshot.expire_at(now) {
|
||||||
.handle()
|
state
|
||||||
.insert_source(timer::Timer::from_duration(DEADLINE_IDLE), |_, _, state| {
|
.pending_screencopies
|
||||||
// Abandoned requests have already been answered, so their
|
.retain(|capture| capture.request_id() != Some(request));
|
||||||
// readbacks are simply dropped: they are not failed, because
|
}
|
||||||
// that would publish into a request that no longer exists.
|
});
|
||||||
for request in state.screenshot.expire() {
|
|
||||||
state
|
|
||||||
.pending_screencopies
|
|
||||||
.retain(|capture| capture.request_id() != Some(request));
|
|
||||||
}
|
|
||||||
let next = state.screenshot.next_deadline().unwrap_or(DEADLINE_IDLE);
|
|
||||||
timer::TimeoutAction::ToDuration(next)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
tracing::info!(path = %path.display(), "Ferese IPC is accepting connections");
|
tracing::info!(path = %path.display(), "Ferese IPC is accepting connections");
|
||||||
|
|
||||||
@@ -262,6 +257,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
|||||||
Ok(ScreenshotInit {
|
Ok(ScreenshotInit {
|
||||||
parts,
|
parts,
|
||||||
worker,
|
worker,
|
||||||
|
deadline_timer,
|
||||||
_guard: guard,
|
_guard: guard,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -269,9 +265,62 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
|||||||
pub(crate) struct ScreenshotInit {
|
pub(crate) struct ScreenshotInit {
|
||||||
pub(crate) parts: PartSender,
|
pub(crate) parts: PartSender,
|
||||||
pub(crate) worker: Worker,
|
pub(crate) worker: Worker,
|
||||||
|
pub(crate) deadline_timer: DeadlineTimer<Ferese>,
|
||||||
pub(crate) _guard: IpcSocketGuard,
|
pub(crate) _guard: IpcSocketGuard,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The timer callback clears its registration before expiring requests, allowing
|
||||||
|
// the coordinator observer to arm the next deadline during the same dispatch.
|
||||||
|
// Keeping absolute Instants also avoids pushing a timeout later on each rearm.
|
||||||
|
pub(crate) struct DeadlineTimer<Data: 'static> {
|
||||||
|
handle: LoopHandle<'static, Data>,
|
||||||
|
armed: Rc<RefCell<Option<(Instant, RegistrationToken)>>>,
|
||||||
|
expire: fn(&mut Data, Instant),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Data: 'static> DeadlineTimer<Data> {
|
||||||
|
fn new(handle: LoopHandle<'static, Data>, expire: fn(&mut Data, Instant)) -> Self {
|
||||||
|
Self {
|
||||||
|
handle,
|
||||||
|
armed: Rc::new(RefCell::new(None)),
|
||||||
|
expire,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update(&self, deadline: Option<Instant>) {
|
||||||
|
if self.armed.borrow().as_ref().map(|(deadline, _)| *deadline) == deadline {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((_, token)) = self.armed.borrow_mut().take() {
|
||||||
|
self.handle.remove(token);
|
||||||
|
}
|
||||||
|
let Some(deadline) = deadline else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let armed = self.armed.clone();
|
||||||
|
let expire = self.expire;
|
||||||
|
// calloop's Timer registers only in its in-memory wheel and always
|
||||||
|
// returns Ok; a failure here would break that infrastructure invariant.
|
||||||
|
let token = self
|
||||||
|
.handle
|
||||||
|
.insert_source(timer::Timer::from_deadline(deadline), move |_, _, state| {
|
||||||
|
armed.borrow_mut().take();
|
||||||
|
expire(state, Instant::now());
|
||||||
|
timer::TimeoutAction::Drop
|
||||||
|
})
|
||||||
|
.expect("could not register screenshot deadline timer");
|
||||||
|
*self.armed.borrow_mut() = Some((deadline, token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Data: 'static> Drop for DeadlineTimer<Data> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some((_, token)) = self.armed.borrow_mut().take() {
|
||||||
|
self.handle.remove(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal) {
|
fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal) {
|
||||||
let active_connections = Arc::new(AtomicUsize::new(0));
|
let active_connections = Arc::new(AtomicUsize::new(0));
|
||||||
let connection_ids = AtomicU64::new(1);
|
let connection_ids = AtomicU64::new(1);
|
||||||
@@ -777,7 +826,7 @@ impl Ferese {
|
|||||||
|
|
||||||
// Screenshot replies are deferred: this either answers the caller now, or
|
// Screenshot replies are deferred: this either answers the caller now, or
|
||||||
// hands the reply to the coordinator, which answers exactly once.
|
// hands the reply to the coordinator, which answers exactly once.
|
||||||
pub(crate) fn start_screenshot(&mut self, request: Request, response: SyncSender<Response>) {
|
pub(crate) fn start_screenshot(&mut self, owner: u64, request: Request, response: SyncSender<Response>) {
|
||||||
// A macro rather than a closure: the message may be a borrowed str or an
|
// A macro rather than a closure: the message may be a borrowed str or an
|
||||||
// owned String, and Response::error already accepts either.
|
// owned String, and Response::error already accepts either.
|
||||||
macro_rules! reject {
|
macro_rules! reject {
|
||||||
@@ -849,7 +898,7 @@ impl Ferese {
|
|||||||
// admit stores the reply only on success, so a clone survives the
|
// admit stores the reply only on success, so a clone survives the
|
||||||
// rejection path and every caller is answered exactly once.
|
// rejection path and every caller is answered exactly once.
|
||||||
let fallback = response.clone();
|
let fallback = response.clone();
|
||||||
let id = match self.screenshot.admit(request.id, response, specs) {
|
let id = match self.screenshot.admit(owner, request.id, response, specs) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
let _ = fallback.try_send(Response::error(request.id, "screenshot_rejected", message));
|
let _ = fallback.try_send(Response::error(request.id, "screenshot_rejected", message));
|
||||||
@@ -878,7 +927,7 @@ impl Ferese {
|
|||||||
crate::backends::direct::render_on(self, &outputs);
|
crate::backends::direct::render_on(self, &outputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_window_screenshot(&mut self, request: Request, response: SyncSender<Response>) {
|
fn start_window_screenshot(&mut self, owner: u64, request: Request, response: SyncSender<Response>) {
|
||||||
let reject = |message: String| {
|
let reject = |message: String| {
|
||||||
let _ = response.try_send(Response::error(request.id, "window_capture_failed", message));
|
let _ = response.try_send(Response::error(request.id, "window_capture_failed", message));
|
||||||
};
|
};
|
||||||
@@ -936,7 +985,7 @@ impl Ferese {
|
|||||||
buffer_height: size.h,
|
buffer_height: size.h,
|
||||||
};
|
};
|
||||||
let fallback = response.clone();
|
let fallback = response.clone();
|
||||||
let capture = match self.screenshot.admit(request.id, response, vec![spec]) {
|
let capture = match self.screenshot.admit(owner, request.id, response, vec![spec]) {
|
||||||
Ok(capture) => capture,
|
Ok(capture) => capture,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = fallback.try_send(Response::error(request.id, "screenshot_rejected", error));
|
let _ = fallback.try_send(Response::error(request.id, "screenshot_rejected", error));
|
||||||
@@ -1392,6 +1441,115 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DeadlineState {
|
||||||
|
fired: usize,
|
||||||
|
next: Option<Instant>,
|
||||||
|
timer: Option<DeadlineTimer<DeadlineState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deadline_loop() -> (EventLoop<'static, DeadlineState>, DeadlineState) {
|
||||||
|
let event_loop = EventLoop::try_new().unwrap();
|
||||||
|
let timer = DeadlineTimer::new(event_loop.handle(), |state: &mut DeadlineState, _| {
|
||||||
|
state.fired += 1;
|
||||||
|
state.timer.as_ref().unwrap().update(state.next.take());
|
||||||
|
});
|
||||||
|
(
|
||||||
|
event_loop,
|
||||||
|
DeadlineState {
|
||||||
|
timer: Some(timer),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screenshot_deadline_idle_has_no_timer_and_admission_arms_immediately() {
|
||||||
|
let (mut event_loop, mut state) = deadline_loop();
|
||||||
|
let timer = state.timer.as_ref().unwrap();
|
||||||
|
timer.update(None);
|
||||||
|
assert!(timer.armed.borrow().is_none(), "idle registers no timeout source");
|
||||||
|
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||||
|
assert_eq!(state.fired, 0);
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(3600);
|
||||||
|
let timer = state.timer.as_ref().unwrap();
|
||||||
|
timer.update(Some(deadline));
|
||||||
|
assert_eq!(
|
||||||
|
timer.armed.borrow().as_ref().map(|(deadline, _)| *deadline),
|
||||||
|
Some(deadline)
|
||||||
|
);
|
||||||
|
timer.update(None);
|
||||||
|
assert!(timer.armed.borrow().is_none());
|
||||||
|
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||||
|
assert_eq!(state.fired, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screenshot_deadline_replacement_and_cancellation_remove_old_sources() {
|
||||||
|
let (mut event_loop, mut state) = deadline_loop();
|
||||||
|
let due = Instant::now() - Duration::from_secs(1);
|
||||||
|
let future = Instant::now() + Duration::from_secs(3600);
|
||||||
|
let timer = state.timer.as_ref().unwrap();
|
||||||
|
timer.update(Some(due));
|
||||||
|
let original = timer.armed.borrow().as_ref().unwrap().1;
|
||||||
|
timer.update(Some(due));
|
||||||
|
assert_eq!(
|
||||||
|
timer.armed.borrow().as_ref().unwrap().1,
|
||||||
|
original,
|
||||||
|
"unchanged deadline keeps its source"
|
||||||
|
);
|
||||||
|
timer.update(Some(future));
|
||||||
|
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||||
|
assert_eq!(state.fired, 0, "the replaced due timer must never fire");
|
||||||
|
state.timer.as_ref().unwrap().update(Some(due));
|
||||||
|
state.timer.as_ref().unwrap().update(None);
|
||||||
|
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||||
|
assert_eq!(state.fired, 0, "cancelling the due timer removes its wakeup");
|
||||||
|
assert!(state.timer.as_ref().unwrap().armed.borrow().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screenshot_deadline_drop_removes_a_registered_due_source() {
|
||||||
|
let (mut event_loop, mut state) = deadline_loop();
|
||||||
|
state
|
||||||
|
.timer
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.update(Some(Instant::now() - Duration::from_secs(1)));
|
||||||
|
drop(state.timer.take());
|
||||||
|
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||||
|
assert_eq!(state.fired, 0, "dropping the observer removes its timer callback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screenshot_deadline_expiry_can_rearm_during_dispatch_then_returns_to_idle() {
|
||||||
|
let (mut event_loop, mut state) = deadline_loop();
|
||||||
|
let due = Instant::now() - Duration::from_secs(1);
|
||||||
|
let future = Instant::now() + Duration::from_secs(3600);
|
||||||
|
state.next = Some(future);
|
||||||
|
state.timer.as_ref().unwrap().update(Some(due));
|
||||||
|
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||||
|
assert_eq!(state.fired, 1);
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.timer
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.armed
|
||||||
|
.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.map(|(deadline, _)| *deadline),
|
||||||
|
Some(future)
|
||||||
|
);
|
||||||
|
state.timer.as_ref().unwrap().update(Some(due));
|
||||||
|
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||||
|
assert_eq!(state.fired, 2);
|
||||||
|
assert!(state.timer.as_ref().unwrap().armed.borrow().is_none());
|
||||||
|
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||||
|
assert_eq!(state.fired, 2, "an empty queue has no repeat wakeup");
|
||||||
|
}
|
||||||
|
|
||||||
fn request(version: u32, kind: &str) -> Request {
|
fn request(version: u32, kind: &str) -> Request {
|
||||||
Request {
|
Request {
|
||||||
version,
|
version,
|
||||||
|
|||||||
@@ -767,6 +767,9 @@ impl Ferese {
|
|||||||
};
|
};
|
||||||
state.config_worker = Some(crate::reload::Worker::new(event_loop)?);
|
state.config_worker = Some(crate::reload::Worker::new(event_loop)?);
|
||||||
let screenshot = crate::ipc::init(event_loop)?;
|
let screenshot = crate::ipc::init(event_loop)?;
|
||||||
|
state
|
||||||
|
.screenshot
|
||||||
|
.set_deadline_observer(move |deadline| screenshot.deadline_timer.update(deadline));
|
||||||
state.screenshot_parts = Some(screenshot.parts);
|
state.screenshot_parts = Some(screenshot.parts);
|
||||||
state.screenshot_worker = Some(screenshot.worker);
|
state.screenshot_worker = Some(screenshot.worker);
|
||||||
state._ipc_socket = Some(screenshot._guard);
|
state._ipc_socket = Some(screenshot._guard);
|
||||||
|
|||||||
@@ -0,0 +1,749 @@
|
|||||||
|
//! Visible clock labels, driven by realtime boundaries and timezone changes.
|
||||||
|
use std::ffi::{CString, OsString};
|
||||||
|
use std::io;
|
||||||
|
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use cosmic::iced::{Subscription, futures};
|
||||||
|
use ferese_config::desktop::Clock;
|
||||||
|
use jiff::{Timestamp, Zoned, tz::TimeZone};
|
||||||
|
|
||||||
|
const SECOND: i128 = 1_000_000_000;
|
||||||
|
const MINUTE: i128 = 60 * SECOND;
|
||||||
|
const UNNAMED_ZONE: &str = "ferese/unnamed-system-zone";
|
||||||
|
// Fractional formats keep the previous sampling rate; nanosecond formats must
|
||||||
|
// not turn into a nanosecond timer that continuously rebuilds the shell.
|
||||||
|
const FRACTION_SAMPLE: i128 = SECOND / 2;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Labels {
|
||||||
|
pub bar: String,
|
||||||
|
pub desktop: (String, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||||
|
struct Format {
|
||||||
|
time: String,
|
||||||
|
date: Option<String>,
|
||||||
|
zone: Option<String>,
|
||||||
|
lowercase: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||||
|
struct Settings {
|
||||||
|
bar: bool,
|
||||||
|
desktop: Option<Arc<Format>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Service {
|
||||||
|
desktop: Option<Arc<Format>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service {
|
||||||
|
pub fn new(clock: &Clock) -> Self {
|
||||||
|
let mut service = Self::default();
|
||||||
|
service.configure(clock);
|
||||||
|
service
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn configure(&mut self, clock: &Clock) {
|
||||||
|
if !clock.enabled {
|
||||||
|
self.desktop = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let date = clock.show_date.then_some(clock.date_format.as_str());
|
||||||
|
let zone = clock.time_zone.as_deref().filter(|zone| !zone.is_empty());
|
||||||
|
if self.desktop.as_ref().is_some_and(|format| {
|
||||||
|
format.time == clock.time_format
|
||||||
|
&& format.date.as_deref() == date
|
||||||
|
&& format.zone.as_deref() == zone
|
||||||
|
&& format.lowercase == clock.lowercase
|
||||||
|
}) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.desktop = Some(Arc::new(Format {
|
||||||
|
time: clock.time_format.clone(),
|
||||||
|
date: date.map(str::to_owned),
|
||||||
|
zone: zone.map(str::to_owned),
|
||||||
|
lowercase: clock.lowercase,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscription(&self, bar: bool, desktop_visible: bool) -> Subscription<Labels> {
|
||||||
|
self.settings(bar, desktop_visible)
|
||||||
|
.map_or_else(Subscription::none, |settings| Subscription::run_with(settings, stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(&self, bar: bool, desktop_visible: bool) -> Option<Settings> {
|
||||||
|
let desktop = if desktop_visible { self.desktop.clone() } else { None };
|
||||||
|
if !bar && desktop.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Settings { bar, desktop })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(settings: &Settings) -> impl futures::Stream<Item = Labels> + use<> {
|
||||||
|
let settings = settings.clone();
|
||||||
|
let (send, receive) = tokio::sync::mpsc::channel(1);
|
||||||
|
let stop = match UnixStream::pair() {
|
||||||
|
Ok((stop, cancel)) => {
|
||||||
|
match std::thread::Builder::new().name("ferese-clock".into()).spawn(move || {
|
||||||
|
if let Err(error) = watch(&settings, send, cancel) {
|
||||||
|
eprintln!("ferese-shell: clock subscription: {error}");
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Ok(_) => Some(stop),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("ferese-shell: clock worker: {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("ferese-shell: clock cancellation socket: {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Dropping the stream closes the socket and wakes the worker's poll.
|
||||||
|
futures::stream::unfold((receive, stop), |(mut receive, stop)| async move {
|
||||||
|
Some((receive.recv().await?, (receive, stop)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn watch(settings: &Settings, send: tokio::sync::mpsc::Sender<Labels>, cancel: UnixStream) -> io::Result<()> {
|
||||||
|
let timer =
|
||||||
|
owned_fd(unsafe { libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) })?;
|
||||||
|
let (mut system, mut desktop, mut paths) = zones(settings);
|
||||||
|
let mut timezone = ZoneWatch::new(&paths)?;
|
||||||
|
let mut previous = None;
|
||||||
|
loop {
|
||||||
|
let now = Timestamp::now();
|
||||||
|
let labels = labels(settings, now, &system, &desktop);
|
||||||
|
if previous.as_ref() != Some(&labels) {
|
||||||
|
previous = Some(labels.clone());
|
||||||
|
if send.blocking_send(labels).is_err() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if arm(&timer, deadline(settings, now, &system, &desktop))? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut fds = [
|
||||||
|
libc::pollfd {
|
||||||
|
fd: timer.as_raw_fd(),
|
||||||
|
events: libc::POLLIN,
|
||||||
|
revents: 0,
|
||||||
|
},
|
||||||
|
libc::pollfd {
|
||||||
|
fd: timezone.fd.as_raw_fd(),
|
||||||
|
events: libc::POLLIN,
|
||||||
|
revents: 0,
|
||||||
|
},
|
||||||
|
libc::pollfd {
|
||||||
|
fd: cancel.as_raw_fd(),
|
||||||
|
events: libc::POLLIN,
|
||||||
|
revents: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
loop {
|
||||||
|
if unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as _, -1) } >= 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
if error.kind() != io::ErrorKind::Interrupted {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fds[2].revents != 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if fds[0].revents != 0 {
|
||||||
|
read_timer(&timer)?;
|
||||||
|
}
|
||||||
|
if fds[1].revents != 0 && timezone.changed()? {
|
||||||
|
jiff::tz::db().reset();
|
||||||
|
(system, desktop, paths) = zones(settings);
|
||||||
|
timezone = ZoneWatch::new(&paths)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owned_fd(fd: libc::c_int) -> io::Result<OwnedFd> {
|
||||||
|
if fd < 0 {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arm(timer: &OwnedFd, deadline: i128) -> io::Result<bool> {
|
||||||
|
let spec = libc::itimerspec {
|
||||||
|
it_interval: libc::timespec { tv_sec: 0, tv_nsec: 0 },
|
||||||
|
it_value: libc::timespec {
|
||||||
|
tv_sec: deadline.div_euclid(SECOND) as _,
|
||||||
|
tv_nsec: deadline.rem_euclid(SECOND) as _,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let flags = libc::TFD_TIMER_ABSTIME | libc::TFD_TIMER_CANCEL_ON_SET;
|
||||||
|
if unsafe { libc::timerfd_settime(timer.as_raw_fd(), flags, &spec, std::ptr::null_mut()) } < 0 {
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
if error.raw_os_error() == Some(libc::ECANCELED) {
|
||||||
|
// settime can apply the new deadline yet report an unread clock
|
||||||
|
// cancellation. Drain it and resample before waiting again.
|
||||||
|
read_timer(timer)?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_timer(timer: &OwnedFd) -> io::Result<()> {
|
||||||
|
let mut expirations = 0u64;
|
||||||
|
let result = unsafe { libc::read(timer.as_raw_fd(), (&mut expirations as *mut u64).cast(), 8) };
|
||||||
|
if result < 0 {
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
// A wall-clock adjustment cancels the timer. Recompute labels/deadline
|
||||||
|
// just as after expiration; CLOCK_REALTIME also advances during suspend.
|
||||||
|
if !matches!(error.raw_os_error(), Some(libc::ECANCELED | libc::EAGAIN | libc::EINTR)) {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zones(settings: &Settings) -> (TimeZone, TimeZone, Vec<PathBuf>) {
|
||||||
|
let mut paths = vec![PathBuf::from("/etc/localtime"), PathBuf::from("/etc/timezone")];
|
||||||
|
// Load the file directly: Jiff's system timezone cache lasts five minutes.
|
||||||
|
let system = match std::env::var("TZ") {
|
||||||
|
Ok(zone) if zone.is_empty() => TimeZone::UTC,
|
||||||
|
Ok(zone) => named_zone(zone.trim_start_matches(':'), &mut paths).unwrap_or_else(TimeZone::system),
|
||||||
|
Err(_) => file_zone(Path::new("/etc/localtime"), &mut paths).unwrap_or_else(TimeZone::system),
|
||||||
|
};
|
||||||
|
let desktop = settings
|
||||||
|
.desktop
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|format| format.zone.as_ref())
|
||||||
|
.and_then(|zone| named_zone(zone, &mut paths))
|
||||||
|
.unwrap_or_else(|| system.clone());
|
||||||
|
(system, desktop, paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn named_zone(name: &str, paths: &mut Vec<PathBuf>) -> Option<TimeZone> {
|
||||||
|
// Jiff treats UTC case-insensitively and gives it the Etc/UTC identity.
|
||||||
|
if name.eq_ignore_ascii_case("UTC") {
|
||||||
|
return Some(TimeZone::UTC);
|
||||||
|
}
|
||||||
|
for root in ["/usr/share/zoneinfo", "/usr/share/lib/zoneinfo", "/etc/zoneinfo"] {
|
||||||
|
let path = Path::new(root).join(name);
|
||||||
|
if let Some(zone) = file_zone(&path, paths) {
|
||||||
|
return Some(zone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_zone(Path::new(name), paths)
|
||||||
|
.or_else(|| TimeZone::posix(name).ok())
|
||||||
|
.or_else(|| TimeZone::get(name).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zone_identifier(path: &Path) -> Option<&str> {
|
||||||
|
path.to_str()
|
||||||
|
.and_then(|path| path.split_once("zoneinfo/").map(|(_, name)| name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_zone(path: &Path, paths: &mut Vec<PathBuf>) -> Option<TimeZone> {
|
||||||
|
let data = std::fs::read(path).ok()?;
|
||||||
|
let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned());
|
||||||
|
let name = zone_identifier(path)
|
||||||
|
.or_else(|| zone_identifier(&resolved))
|
||||||
|
.unwrap_or(UNNAMED_ZONE);
|
||||||
|
let zone = TimeZone::tzif(name, &data).ok()?;
|
||||||
|
paths.push(path.to_owned());
|
||||||
|
paths.push(resolved);
|
||||||
|
Some(zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn labels(settings: &Settings, now: Timestamp, system: &TimeZone, desktop: &TimeZone) -> Labels {
|
||||||
|
let bar = if settings.bar {
|
||||||
|
ferese_theme::calendar::format_bar_time(&now.to_zoned(system.clone()))
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let desktop = settings
|
||||||
|
.desktop
|
||||||
|
.as_ref()
|
||||||
|
.map(|format| {
|
||||||
|
let now = now.to_zoned(desktop.clone());
|
||||||
|
let mut broken = jiff::fmt::strtime::BrokenDownTime::from(&now);
|
||||||
|
if desktop.iana_name() == Some(UNNAMED_ZONE) {
|
||||||
|
// Copied localtime files have no IANA name. Preserve Jiff's
|
||||||
|
// %Q offset fallback while retaining their DST/abbreviation.
|
||||||
|
broken.set_iana_time_zone(None);
|
||||||
|
}
|
||||||
|
let time = broken.to_string(&format.time).unwrap_or_default();
|
||||||
|
let date = format
|
||||||
|
.date
|
||||||
|
.as_ref()
|
||||||
|
.map(|date| broken.to_string(date).unwrap_or_default())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if format.lowercase {
|
||||||
|
(time.to_lowercase(), date.to_lowercase())
|
||||||
|
} else {
|
||||||
|
(time, date)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Labels { bar, desktop }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deadline(settings: &Settings, now: Timestamp, system: &TimeZone, desktop: &TimeZone) -> i128 {
|
||||||
|
let mut next = i128::MAX;
|
||||||
|
if settings.bar {
|
||||||
|
next = next.min(zone_deadline(&now.to_zoned(system.clone()), MINUTE));
|
||||||
|
}
|
||||||
|
if let Some(format) = &settings.desktop {
|
||||||
|
let precision =
|
||||||
|
format_precision(&format.time).min(format.date.as_deref().map(format_precision).unwrap_or(MINUTE));
|
||||||
|
next = next.min(zone_deadline(&now.to_zoned(desktop.clone()), precision));
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zone_deadline(now: &Zoned, precision: i128) -> i128 {
|
||||||
|
let stamp = now.timestamp().as_nanosecond();
|
||||||
|
let next = if precision == MINUTE {
|
||||||
|
stamp + MINUTE - i128::from(now.second()) * SECOND - i128::from(now.subsec_nanosecond())
|
||||||
|
} else {
|
||||||
|
(stamp.div_euclid(precision) + 1) * precision
|
||||||
|
};
|
||||||
|
now.time_zone()
|
||||||
|
.following(now.timestamp())
|
||||||
|
.next()
|
||||||
|
.map_or(next, |transition| next.min(transition.timestamp().as_nanosecond()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_precision(format: &str) -> i128 {
|
||||||
|
let mut precision = MINUTE;
|
||||||
|
let mut chars = format.chars();
|
||||||
|
while let Some(character) = chars.next() {
|
||||||
|
if character != '%' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(mut directive) = chars.next() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if directive == '%' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
while matches!(directive, '_' | '-' | '0' | '^' | '#' | ':' | '.') || directive.is_ascii_digit() {
|
||||||
|
let Some(next) = chars.next() else {
|
||||||
|
return precision;
|
||||||
|
};
|
||||||
|
directive = next;
|
||||||
|
}
|
||||||
|
precision = precision.min(match directive {
|
||||||
|
'f' | 'N' => FRACTION_SAMPLE,
|
||||||
|
'S' | 's' | 'T' | 'c' | 'r' | 'X' => SECOND,
|
||||||
|
_ => MINUTE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
precision
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ZoneWatch {
|
||||||
|
fd: OwnedFd,
|
||||||
|
names: Vec<(libc::c_int, OsString)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZoneWatch {
|
||||||
|
fn new(paths: &[PathBuf]) -> io::Result<Self> {
|
||||||
|
let fd = owned_fd(unsafe { libc::inotify_init1(libc::IN_CLOEXEC | libc::IN_NONBLOCK) })?;
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for path in paths {
|
||||||
|
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(parent) = CString::new(parent.as_os_str().as_bytes()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mask = libc::IN_CLOSE_WRITE
|
||||||
|
| libc::IN_MOVED_TO
|
||||||
|
| libc::IN_CREATE
|
||||||
|
| libc::IN_DELETE
|
||||||
|
| libc::IN_ATTRIB
|
||||||
|
| libc::IN_MOVE_SELF
|
||||||
|
| libc::IN_DELETE_SELF;
|
||||||
|
let wd = unsafe { libc::inotify_add_watch(fd.as_raw_fd(), parent.as_ptr(), mask) };
|
||||||
|
if wd >= 0 {
|
||||||
|
names.push((wd, name.to_owned()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self { fd, names })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changed(&self) -> io::Result<bool> {
|
||||||
|
let mut buffer = [0u8; 8192];
|
||||||
|
let mut changed = false;
|
||||||
|
loop {
|
||||||
|
let size = unsafe { libc::read(self.fd.as_raw_fd(), buffer.as_mut_ptr().cast(), buffer.len()) };
|
||||||
|
if size < 0 {
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
if error.kind() == io::ErrorKind::WouldBlock {
|
||||||
|
return Ok(changed);
|
||||||
|
}
|
||||||
|
if error.kind() == io::ErrorKind::Interrupted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
if size == 0 {
|
||||||
|
return Ok(changed);
|
||||||
|
}
|
||||||
|
let mut offset = 0;
|
||||||
|
while offset + std::mem::size_of::<libc::inotify_event>() <= size as usize {
|
||||||
|
let event = unsafe { (buffer.as_ptr().add(offset) as *const libc::inotify_event).read_unaligned() };
|
||||||
|
offset += std::mem::size_of::<libc::inotify_event>();
|
||||||
|
let end = offset + event.len as usize;
|
||||||
|
if end > size as usize {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let name = &buffer[offset..end];
|
||||||
|
let name = &name[..name.iter().position(|byte| *byte == 0).unwrap_or(name.len())];
|
||||||
|
changed |= event.mask
|
||||||
|
& (libc::IN_Q_OVERFLOW | libc::IN_IGNORED | libc::IN_MOVE_SELF | libc::IN_DELETE_SELF)
|
||||||
|
!= 0
|
||||||
|
|| self
|
||||||
|
.names
|
||||||
|
.iter()
|
||||||
|
.any(|(wd, watched)| *wd == event.wd && watched.as_bytes() == name);
|
||||||
|
offset = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn settings(time: Option<&str>, date: Option<&str>) -> Settings {
|
||||||
|
Settings {
|
||||||
|
bar: true,
|
||||||
|
desktop: time.map(|time| {
|
||||||
|
Arc::new(Format {
|
||||||
|
time: time.into(),
|
||||||
|
date: date.map(str::to_owned),
|
||||||
|
zone: None,
|
||||||
|
lowercase: false,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subscription_rebuilds_and_style_changes_keep_the_cached_format() {
|
||||||
|
let mut clock = Clock {
|
||||||
|
enabled: true,
|
||||||
|
time_zone: Some("Asia/Kathmandu".into()),
|
||||||
|
..Clock::default()
|
||||||
|
};
|
||||||
|
let mut service = Service::new(&clock);
|
||||||
|
let first = service.settings(true, true).unwrap();
|
||||||
|
let format = first.desktop.as_ref().unwrap();
|
||||||
|
for _ in 0..100 {
|
||||||
|
let current = service.settings(true, true).unwrap();
|
||||||
|
assert!(Arc::ptr_eq(format, current.desktop.as_ref().unwrap()));
|
||||||
|
assert_eq!(first, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.time_size += 8.;
|
||||||
|
clock.margin_x += 10;
|
||||||
|
service.configure(&clock);
|
||||||
|
assert!(Arc::ptr_eq(format, service.desktop.as_ref().unwrap()));
|
||||||
|
assert!(service.settings(true, false).unwrap().desktop.is_none());
|
||||||
|
assert!(service.settings(false, false).is_none());
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
format,
|
||||||
|
service.settings(false, true).unwrap().desktop.as_ref().unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_changes_replace_subscription_identity_without_mutating_the_worker_settings() {
|
||||||
|
let mut clock = Clock {
|
||||||
|
enabled: true,
|
||||||
|
..Clock::default()
|
||||||
|
};
|
||||||
|
let mut service = Service::new(&clock);
|
||||||
|
let initial = service.settings(true, true).unwrap();
|
||||||
|
clock.time_format = "%H:%M:%S".into();
|
||||||
|
service.configure(&clock);
|
||||||
|
let seconds = service.settings(true, true).unwrap();
|
||||||
|
assert_ne!(initial, seconds);
|
||||||
|
assert_eq!(initial.desktop.as_ref().unwrap().time, "%-I:%M %p");
|
||||||
|
|
||||||
|
clock.show_date = false;
|
||||||
|
service.configure(&clock);
|
||||||
|
let no_date = service.settings(true, true).unwrap();
|
||||||
|
assert_ne!(seconds, no_date);
|
||||||
|
assert!(no_date.desktop.as_ref().unwrap().date.is_none());
|
||||||
|
clock.date_format = "%Y".into();
|
||||||
|
service.configure(&clock);
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
no_date.desktop.as_ref().unwrap(),
|
||||||
|
service.desktop.as_ref().unwrap()
|
||||||
|
));
|
||||||
|
|
||||||
|
clock.time_zone = Some("UTC".into());
|
||||||
|
service.configure(&clock);
|
||||||
|
assert_ne!(no_date, service.settings(true, true).unwrap());
|
||||||
|
clock.lowercase = !clock.lowercase;
|
||||||
|
let previous = service.settings(true, true).unwrap();
|
||||||
|
service.configure(&clock);
|
||||||
|
assert_ne!(previous, service.settings(true, true).unwrap());
|
||||||
|
clock.enabled = false;
|
||||||
|
service.configure(&clock);
|
||||||
|
assert!(service.settings(false, true).is_none());
|
||||||
|
assert!(service.settings(true, true).unwrap().desktop.is_none());
|
||||||
|
clock.enabled = true;
|
||||||
|
service.configure(&clock);
|
||||||
|
assert!(service.settings(false, true).unwrap().desktop.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_subscription_uses_visible_outputs_formats_and_hidden_date() {
|
||||||
|
let mut clock = Clock {
|
||||||
|
enabled: true,
|
||||||
|
time_format: "%H:%M".into(),
|
||||||
|
date_format: "%S%.f".into(),
|
||||||
|
..Clock::default()
|
||||||
|
};
|
||||||
|
assert_eq!(Service::new(&clock).subscription(false, false).units(), 0);
|
||||||
|
assert_eq!(Service::new(&clock).subscription(true, false).units(), 1);
|
||||||
|
let hidden = Service::new(&clock).settings(true, false).unwrap();
|
||||||
|
assert!(hidden.desktop.is_none());
|
||||||
|
let now: Timestamp = "2026-10-04T12:34:56.123Z".parse().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
deadline(&hidden, now, &TimeZone::UTC, &TimeZone::UTC),
|
||||||
|
"2026-10-04T12:35:00Z".parse::<Timestamp>().unwrap().as_nanosecond()
|
||||||
|
);
|
||||||
|
let visible = Service::new(&clock).settings(true, true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
deadline(&visible, now, &TimeZone::UTC, &TimeZone::UTC),
|
||||||
|
"2026-10-04T12:34:56.5Z".parse::<Timestamp>().unwrap().as_nanosecond()
|
||||||
|
);
|
||||||
|
clock.show_date = false;
|
||||||
|
let hidden_date = Service::new(&clock).settings(true, true).unwrap();
|
||||||
|
assert_ne!(hidden_date, visible);
|
||||||
|
assert_eq!(
|
||||||
|
deadline(&hidden_date, now, &TimeZone::UTC, &TimeZone::UTC),
|
||||||
|
"2026-10-04T12:35:00Z".parse::<Timestamp>().unwrap().as_nanosecond()
|
||||||
|
);
|
||||||
|
clock.time_format = "%S".into();
|
||||||
|
assert_ne!(Service::new(&clock).settings(true, true).unwrap(), hidden_date);
|
||||||
|
clock.enabled = false;
|
||||||
|
assert_eq!(Service::new(&clock).subscription(false, true).units(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_zone_labels_match_configured_clock_including_aliases_and_case() {
|
||||||
|
let now: Timestamp = "2026-10-04T12:34:56.123Z".parse().unwrap();
|
||||||
|
for zone in ["America/New_York", "Etc/UTC", "Asia/Kathmandu", "UTC", "utc"] {
|
||||||
|
let clock = Clock {
|
||||||
|
enabled: true,
|
||||||
|
time_format: "%H:%M:%S%.3f %Q %Z".into(),
|
||||||
|
date_format: "%a %F".into(),
|
||||||
|
time_zone: Some(zone.into()),
|
||||||
|
..Clock::default()
|
||||||
|
};
|
||||||
|
let settings = Service::new(&clock).settings(true, true).unwrap();
|
||||||
|
let desktop = named_zone(zone, &mut Vec::new()).unwrap_or_else(|| panic!("zone {zone} unavailable"));
|
||||||
|
let actual = labels(&settings, now, &TimeZone::UTC, &desktop).desktop;
|
||||||
|
assert_eq!(actual, clock.labels(&now.to_zoned(TimeZone::UTC)).unwrap(), "{zone}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn localtime_replacement_loads_new_zone_without_system_cache() {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
named_zone("America/New_York", &mut paths).unwrap();
|
||||||
|
let eastern = std::fs::read(paths.first().unwrap()).unwrap();
|
||||||
|
paths.clear();
|
||||||
|
named_zone("Asia/Kathmandu", &mut paths).unwrap();
|
||||||
|
let nepal = std::fs::read(paths.first().unwrap()).unwrap();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("localtime");
|
||||||
|
std::fs::write(&path, eastern).unwrap();
|
||||||
|
let watcher = ZoneWatch::new(std::slice::from_ref(&path)).unwrap();
|
||||||
|
let now: Timestamp = "2026-10-04T12:34:56Z".parse().unwrap();
|
||||||
|
let first = file_zone(&path, &mut Vec::new()).unwrap();
|
||||||
|
let replacement = dir.path().join("replacement");
|
||||||
|
std::fs::write(&replacement, nepal).unwrap();
|
||||||
|
std::fs::rename(replacement, &path).unwrap();
|
||||||
|
assert!(watcher.changed().unwrap());
|
||||||
|
let second = file_zone(&path, &mut Vec::new()).unwrap();
|
||||||
|
let bar_settings = settings(None, None);
|
||||||
|
assert_eq!(labels(&bar_settings, now, &first, &first).bar, "4 oct, 8:34 am");
|
||||||
|
assert_eq!(labels(&bar_settings, now, &second, &second).bar, "4 oct, 6:19 pm");
|
||||||
|
let settings = settings(Some("%Q %:Q %Z %z"), None);
|
||||||
|
assert_eq!(
|
||||||
|
labels(&settings, now, &first, &first).desktop.0,
|
||||||
|
"-0400 -04:00 EDT -0400"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
labels(&settings, now, &second, &second).desktop.0,
|
||||||
|
"+0545 +05:45 +0545 +0545"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visible_format_precision_handles_seconds_escapes_and_sampled_fractions() {
|
||||||
|
for format in ["%-I:%M %p", "%a, %b %-d", "%%S %%f", "%:z %:::z", "fixed text"] {
|
||||||
|
assert_eq!(format_precision(format), MINUTE, "{format}");
|
||||||
|
}
|
||||||
|
for format in ["%S", "%_3S", "%s", "%T", "%c", "%r", "%X"] {
|
||||||
|
assert_eq!(format_precision(format), SECOND, "{format}");
|
||||||
|
}
|
||||||
|
for format in ["%f", "%N", "%.f", "%.3f", "%9f", "%1f"] {
|
||||||
|
assert_eq!(format_precision(format), FRACTION_SAMPLE, "{format}");
|
||||||
|
}
|
||||||
|
let now: Timestamp = "2026-10-04T12:34:56.123Z".parse().unwrap();
|
||||||
|
let minute = "2026-10-04T12:35:00Z".parse::<Timestamp>().unwrap().as_nanosecond();
|
||||||
|
assert_eq!(
|
||||||
|
deadline(&settings(None, None), now, &TimeZone::UTC, &TimeZone::UTC),
|
||||||
|
minute
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
deadline(
|
||||||
|
&settings(Some("%H:%M"), Some("%S")),
|
||||||
|
now,
|
||||||
|
&TimeZone::UTC,
|
||||||
|
&TimeZone::UTC
|
||||||
|
),
|
||||||
|
"2026-10-04T12:34:57Z".parse::<Timestamp>().unwrap().as_nanosecond()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
deadline(
|
||||||
|
&settings(Some("%H:%M:%S%.3f"), None),
|
||||||
|
now,
|
||||||
|
&TimeZone::UTC,
|
||||||
|
&TimeZone::UTC
|
||||||
|
),
|
||||||
|
"2026-10-04T12:34:56.5Z".parse::<Timestamp>().unwrap().as_nanosecond()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boundaries_handle_midnight_dst_and_offsets_with_seconds() {
|
||||||
|
for (stamp, expected) in [
|
||||||
|
("2026-10-04T23:59:59.9Z", "2026-10-05T00:00:00Z"),
|
||||||
|
("2026-10-04T12:34:00Z", "2026-10-04T12:35:00Z"),
|
||||||
|
] {
|
||||||
|
let now: Timestamp = stamp.parse().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
zone_deadline(&now.to_zoned(TimeZone::UTC), MINUTE),
|
||||||
|
expected.parse::<Timestamp>().unwrap().as_nanosecond()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let zone = TimeZone::get("America/New_York").unwrap();
|
||||||
|
for (stamp, expected, label) in [
|
||||||
|
("2026-03-08T06:59:59Z", "2026-03-08T07:00:00Z", "8 mar, 3:00 am"),
|
||||||
|
("2026-11-01T05:59:59Z", "2026-11-01T06:00:00Z", "1 nov, 1:00 am"),
|
||||||
|
] {
|
||||||
|
let now: Timestamp = stamp.parse().unwrap();
|
||||||
|
let expected: Timestamp = expected.parse().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
zone_deadline(&now.to_zoned(zone.clone()), MINUTE),
|
||||||
|
expected.as_nanosecond()
|
||||||
|
);
|
||||||
|
assert_eq!(labels(&settings(None, None), expected, &zone, &zone).bar, label);
|
||||||
|
}
|
||||||
|
let zone = TimeZone::fixed(jiff::tz::Offset::from_seconds(30).unwrap());
|
||||||
|
let now: Timestamp = "2026-10-04T12:34:20Z".parse().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
zone_deadline(&now.to_zoned(zone), MINUTE),
|
||||||
|
"2026-10-04T12:34:30Z".parse::<Timestamp>().unwrap().as_nanosecond()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn realtime_jumps_recompute_boundaries_and_timezone_labels() {
|
||||||
|
let settings = settings(Some("%H:%M %Z"), None);
|
||||||
|
let initial: Timestamp = "2026-10-04T12:34:56Z".parse().unwrap();
|
||||||
|
let earlier: Timestamp = "2026-10-04T11:20:01Z".parse().unwrap();
|
||||||
|
let later: Timestamp = "2026-10-04T15:40:01Z".parse().unwrap();
|
||||||
|
let first = labels(&settings, initial, &TimeZone::UTC, &TimeZone::UTC);
|
||||||
|
for (now, expected) in [(earlier, "2026-10-04T11:21:00Z"), (later, "2026-10-04T15:41:00Z")] {
|
||||||
|
assert_ne!(labels(&settings, now, &TimeZone::UTC, &TimeZone::UTC), first);
|
||||||
|
assert_eq!(
|
||||||
|
deadline(&settings, now, &TimeZone::UTC, &TimeZone::UTC),
|
||||||
|
expected.parse::<Timestamp>().unwrap().as_nanosecond()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let zone = TimeZone::get("Asia/Kathmandu").unwrap();
|
||||||
|
assert_ne!(labels(&settings, initial, &zone, &zone), first);
|
||||||
|
assert_eq!(labels(&settings, initial, &zone, &zone).desktop.0, "18:19 +0545");
|
||||||
|
assert_eq!(
|
||||||
|
labels(&settings, initial, &TimeZone::UTC, &TimeZone::UTC),
|
||||||
|
labels(
|
||||||
|
&settings,
|
||||||
|
initial + jiff::SignedDuration::from_secs(1),
|
||||||
|
&TimeZone::UTC,
|
||||||
|
&TimeZone::UTC
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timezone_watch_filters_unrelated_files_and_survives_atomic_replacement() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("localtime");
|
||||||
|
std::fs::write(&path, b"old").unwrap();
|
||||||
|
let watcher = ZoneWatch::new(std::slice::from_ref(&path)).unwrap();
|
||||||
|
std::fs::write(dir.path().join("unrelated"), b"data").unwrap();
|
||||||
|
assert!(!watcher.changed().unwrap());
|
||||||
|
let replacement = dir.path().join("replacement");
|
||||||
|
std::fs::write(&replacement, b"new").unwrap();
|
||||||
|
std::fs::rename(replacement, &path).unwrap();
|
||||||
|
assert!(watcher.changed().unwrap());
|
||||||
|
assert!(!watcher.changed().unwrap());
|
||||||
|
std::fs::write(&path, b"updated").unwrap();
|
||||||
|
assert!(watcher.changed().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_timer_and_subscription_drop_wake_without_recurring_poll() {
|
||||||
|
let timer =
|
||||||
|
owned_fd(unsafe { libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) })
|
||||||
|
.unwrap();
|
||||||
|
arm(&timer, Timestamp::now().as_nanosecond() - SECOND).unwrap();
|
||||||
|
let mut poll = libc::pollfd {
|
||||||
|
fd: timer.as_raw_fd(),
|
||||||
|
events: libc::POLLIN,
|
||||||
|
revents: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(unsafe { libc::poll(&mut poll, 1, 1000) }, 1);
|
||||||
|
read_timer(&timer).unwrap();
|
||||||
|
// Reading an already drained timer is harmless (EAGAIN).
|
||||||
|
read_timer(&timer).unwrap();
|
||||||
|
|
||||||
|
let (stop, cancel) = UnixStream::pair().unwrap();
|
||||||
|
let (send, mut receive) = tokio::sync::mpsc::channel(1);
|
||||||
|
let (done_send, done_receive) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
done_send.send(watch(&settings(None, None), send, cancel)).unwrap();
|
||||||
|
});
|
||||||
|
assert!(receive.blocking_recv().is_some());
|
||||||
|
drop(stop);
|
||||||
|
done_receive.recv_timeout(Duration::from_secs(2)).unwrap().unwrap();
|
||||||
|
assert!(receive.blocking_recv().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use bar::*;
|
|||||||
use desktop_widgets::*;
|
use desktop_widgets::*;
|
||||||
use surfaces::*;
|
use surfaces::*;
|
||||||
|
|
||||||
|
mod clock;
|
||||||
mod compositor_ipc;
|
mod compositor_ipc;
|
||||||
mod config;
|
mod config;
|
||||||
mod control;
|
mod control;
|
||||||
@@ -169,6 +170,7 @@ struct FereseShell {
|
|||||||
snapshot: ShellSnapshot,
|
snapshot: ShellSnapshot,
|
||||||
overview_active: bool,
|
overview_active: bool,
|
||||||
clock: String,
|
clock: String,
|
||||||
|
clock_service: clock::Service,
|
||||||
desktop_clock: (String, String),
|
desktop_clock: (String, String),
|
||||||
outputs: Vec<OutputSurfaces>,
|
outputs: Vec<OutputSurfaces>,
|
||||||
notifications: notifications::Center,
|
notifications: notifications::Center,
|
||||||
@@ -239,7 +241,9 @@ enum Message {
|
|||||||
WallpaperLoaded(Result<image::Handle, String>),
|
WallpaperLoaded(Result<image::Handle, String>),
|
||||||
Event(Event, window::Id),
|
Event(Event, window::Id),
|
||||||
NativeSurface(window::Id, Result<(Connection, wl_surface::WlSurface), String>),
|
NativeSurface(window::Id, Result<(Connection, wl_surface::WlSurface), String>),
|
||||||
Tick,
|
ClockChanged(clock::Labels),
|
||||||
|
RecorderEvent(recording::Event),
|
||||||
|
RecorderElapsed,
|
||||||
ControlReady,
|
ControlReady,
|
||||||
ActivateWorkspace(u64),
|
ActivateWorkspace(u64),
|
||||||
ToggleOverview,
|
ToggleOverview,
|
||||||
@@ -327,6 +331,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
note_inflight: Vec::new(),
|
note_inflight: Vec::new(),
|
||||||
note_saving: false,
|
note_saving: false,
|
||||||
note_error: None,
|
note_error: None,
|
||||||
|
clock_service: clock::Service::new(&config.desktop_widgets.clock),
|
||||||
config,
|
config,
|
||||||
wallpaper: None,
|
wallpaper: None,
|
||||||
control: ShellControl::connect()
|
control: ShellControl::connect()
|
||||||
@@ -378,7 +383,16 @@ impl cosmic::Application for FereseShell {
|
|||||||
)) => Some(Message::Event(event, id)),
|
)) => Some(Message::Event(event, id)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}),
|
}),
|
||||||
cosmic::iced::time::every(Duration::from_millis(500)).map(|_| Message::Tick),
|
self.clock_service
|
||||||
|
.subscription(
|
||||||
|
self.outputs.iter().any(|output| !output.hidden),
|
||||||
|
self.outputs.iter().any(|output| {
|
||||||
|
output.clock.is_some() && self.config.desktop_widgets.clock.on_output(output.name.as_deref())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.map(Message::ClockChanged),
|
||||||
|
self.recorder.subscription().map(Message::RecorderEvent),
|
||||||
|
self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed),
|
||||||
self.status_service.subscription().map(Message::StatusUpdated),
|
self.status_service.subscription().map(Message::StatusUpdated),
|
||||||
if self.config.desktop_widgets.clock.enabled
|
if self.config.desktop_widgets.clock.enabled
|
||||||
|| self
|
|| self
|
||||||
@@ -729,21 +743,16 @@ impl cosmic::Application for FereseShell {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::Event(event, id) => self.handle_event(event, id),
|
Message::Event(event, id) => self.handle_event(event, id),
|
||||||
Message::Tick => {
|
Message::ClockChanged(labels) => {
|
||||||
self.recorder.poll();
|
self.clock = labels.bar;
|
||||||
self.clock = current_time();
|
self.desktop_clock = labels.desktop;
|
||||||
|
|
||||||
if self.config.desktop_widgets.clock.enabled {
|
|
||||||
self.desktop_clock = self
|
|
||||||
.config
|
|
||||||
.desktop_widgets
|
|
||||||
.clock
|
|
||||||
.labels(&Zoned::now())
|
|
||||||
.unwrap_or_default();
|
|
||||||
}
|
|
||||||
|
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
Message::RecorderEvent(event) => {
|
||||||
|
self.recorder.handle(event);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::RecorderElapsed => Task::none(),
|
||||||
Message::ControlReady => {
|
Message::ControlReady => {
|
||||||
let mut reload_task = Task::none();
|
let mut reload_task = Task::none();
|
||||||
if let Some(control) = &self.control {
|
if let Some(control) = &self.control {
|
||||||
@@ -895,6 +904,7 @@ impl FereseShell {
|
|||||||
|| old.bar_margin_horizontal != theme.bar_margin_horizontal
|
|| old.bar_margin_horizontal != theme.bar_margin_horizontal
|
||||||
|| old.bar_window_gap != theme.bar_window_gap;
|
|| old.bar_window_gap != theme.bar_window_gap;
|
||||||
self.notifications.configure(config.notifications.clone());
|
self.notifications.configure(config.notifications.clone());
|
||||||
|
self.clock_service.configure(&config.desktop_widgets.clock);
|
||||||
self.config = config;
|
self.config = config;
|
||||||
self.desktop_clock = self
|
self.desktop_clock = self
|
||||||
.config
|
.config
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
//! Encoding and portal negotiation run in the separate ferese-record process.
|
//! Encoding and portal negotiation run in the separate ferese-record process.
|
||||||
use std::io::{BufRead, BufReader, Read};
|
use std::io::{BufRead, BufReader, Read};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{ChildStdin, Command, Stdio};
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||||
use std::sync::mpsc;
|
use std::sync::Arc;
|
||||||
use std::sync::mpsc::Receiver;
|
use std::time::{Duration, Instant};
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -19,21 +18,40 @@ pub enum State {
|
|||||||
Saved(PathBuf),
|
Saved(PathBuf),
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
#[derive(Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
struct Update {
|
pub struct Update {
|
||||||
state: String,
|
state: String,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
path: Option<PathBuf>,
|
path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
enum Event {
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum Event {
|
||||||
Update(Update),
|
Update(Update),
|
||||||
Exited,
|
Exited,
|
||||||
}
|
}
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Events(Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Event>>>);
|
||||||
|
|
||||||
|
impl std::hash::Hash for Events {
|
||||||
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||||
|
std::ptr::hash(Arc::as_ptr(&self.0), state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Events {
|
||||||
|
fn stream(&self) -> impl cosmic::iced::futures::Stream<Item = Event> + use<> {
|
||||||
|
cosmic::iced::futures::stream::unfold(self.0.clone(), |receiver| async move {
|
||||||
|
let event = receiver.lock().await.recv().await?;
|
||||||
|
Some((event, receiver))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Recorder {
|
pub struct Recorder {
|
||||||
pub state: State,
|
pub state: State,
|
||||||
control: Option<ChildStdin>,
|
control: Option<ChildStdin>,
|
||||||
updates: Option<Receiver<Event>>,
|
updates: Option<Events>,
|
||||||
}
|
}
|
||||||
impl Recorder {
|
impl Recorder {
|
||||||
pub fn label(&self) -> &'static str {
|
pub fn label(&self) -> &'static str {
|
||||||
@@ -76,32 +94,11 @@ impl Recorder {
|
|||||||
{
|
{
|
||||||
Ok(mut child) => {
|
Ok(mut child) => {
|
||||||
self.control = child.stdin.take();
|
self.control = child.stdin.take();
|
||||||
let output = child.stdout.take().unwrap();
|
let (send, receive) = tokio::sync::mpsc::channel(8);
|
||||||
let (send, receive) = mpsc::sync_channel(8);
|
self.updates = Some(Events(Arc::new(tokio::sync::Mutex::new(receive))));
|
||||||
self.updates = Some(receive);
|
|
||||||
self.state = State::Selecting;
|
self.state = State::Selecting;
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || read_events(child, send));
|
||||||
let mut output = BufReader::new(output);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
match output.by_ref().take(65537).read_line(&mut line) {
|
|
||||||
Ok(0) | Err(_) => break,
|
|
||||||
Ok(_) if line.len() > 65536 => break,
|
|
||||||
Ok(_) => {
|
|
||||||
if let Ok(update) = serde_json::from_str::<Update>(&line)
|
|
||||||
&& send.send(Event::Update(update)).is_err()
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = child.wait();
|
|
||||||
let _ = send.send(Event::Exited);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let message = format!("Cannot start recorder: {error}");
|
let message = format!("Cannot start recorder: {error}");
|
||||||
@@ -119,47 +116,101 @@ impl Recorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn poll(&mut self) {
|
pub fn subscription(&self) -> cosmic::iced::Subscription<Event> {
|
||||||
let mut done = false;
|
self.updates
|
||||||
if let Some(updates) = &self.updates {
|
.as_ref()
|
||||||
while let Ok(event) = updates.try_recv() {
|
.map_or_else(cosmic::iced::Subscription::none, |events| {
|
||||||
match event {
|
cosmic::iced::Subscription::run_with(events.clone(), Events::stream)
|
||||||
Event::Update(update) => match update.state.as_str() {
|
})
|
||||||
"selecting" if self.control.is_some() => self.state = State::Selecting,
|
}
|
||||||
"recording" if self.control.is_some() => self.state = State::Recording(Instant::now()),
|
|
||||||
"saving" => self.state = State::Saving,
|
pub fn elapsed_subscription(&self) -> cosmic::iced::Subscription<()> {
|
||||||
"saved" => {
|
fn stream(deadline: &Instant) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
|
||||||
self.state = update
|
let deadline = *deadline;
|
||||||
.path
|
cosmic::iced::futures::stream::once(async move {
|
||||||
.map(State::Saved)
|
tokio::time::sleep_until(deadline.into()).await;
|
||||||
.unwrap_or_else(|| State::Error("Recorder returned no saved file".into()))
|
})
|
||||||
}
|
|
||||||
"cancelled" => self.state = State::Idle,
|
|
||||||
"error" => {
|
|
||||||
self.state = State::Error(update.message.unwrap_or_else(|| "Recording failed".into()))
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
},
|
|
||||||
Event::Exited => {
|
|
||||||
if matches!(self.state, State::Selecting | State::Recording(_) | State::Saving) {
|
|
||||||
self.state = State::Error("The recorder stopped unexpectedly".into());
|
|
||||||
}
|
|
||||||
done = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if done {
|
self.elapsed_deadline(Instant::now())
|
||||||
|
.map_or_else(cosmic::iced::Subscription::none, |deadline| {
|
||||||
|
cosmic::iced::Subscription::run_with(deadline, stream)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn elapsed_deadline(&self, now: Instant) -> Option<Instant> {
|
||||||
|
let State::Recording(start) = self.state else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(start + Duration::from_secs(now.saturating_duration_since(start).as_secs() + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle(&mut self, event: Event) {
|
||||||
|
if self.apply(event) {
|
||||||
match &self.state {
|
match &self.state {
|
||||||
State::Saved(path) => notify("Recording saved", path.display().to_string()),
|
State::Saved(path) => notify("Recording saved", path.display().to_string()),
|
||||||
State::Error(message) => notify("Recording failed", message.clone()),
|
State::Error(message) => notify("Recording failed", message.clone()),
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
self.control.take();
|
|
||||||
self.updates.take();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply(&mut self, event: Event) -> bool {
|
||||||
|
match event {
|
||||||
|
Event::Update(update) => match update.state.as_str() {
|
||||||
|
"selecting" if self.control.is_some() => self.state = State::Selecting,
|
||||||
|
"recording" if self.control.is_some() => {
|
||||||
|
if !matches!(self.state, State::Recording(_)) {
|
||||||
|
self.state = State::Recording(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"saving" => self.state = State::Saving,
|
||||||
|
"saved" => {
|
||||||
|
self.state = update
|
||||||
|
.path
|
||||||
|
.map(State::Saved)
|
||||||
|
.unwrap_or_else(|| State::Error("Recorder returned no saved file".into()))
|
||||||
|
}
|
||||||
|
"cancelled" => self.state = State::Idle,
|
||||||
|
"error" => self.state = State::Error(update.message.unwrap_or_else(|| "Recording failed".into())),
|
||||||
|
_ => (),
|
||||||
|
},
|
||||||
|
Event::Exited => {
|
||||||
|
if matches!(self.state, State::Selecting | State::Recording(_) | State::Saving) {
|
||||||
|
self.state = State::Error("The recorder stopped unexpectedly".into());
|
||||||
|
}
|
||||||
|
self.control.take();
|
||||||
|
self.updates.take();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_events(mut child: Child, send: tokio::sync::mpsc::Sender<Event>) {
|
||||||
|
let mut output = BufReader::new(child.stdout.take().expect("piped recorder output"));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
match output.by_ref().take(65537).read_line(&mut line) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(_) if line.len() > 65536 => break,
|
||||||
|
Ok(_) => {
|
||||||
|
if let Ok(update) = serde_json::from_str::<Update>(&line)
|
||||||
|
&& send.blocking_send(Event::Update(update)).is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rejected status line may leave the child blocked writing into stdout.
|
||||||
|
// Close our reader before waiting so that writer can observe the disconnect.
|
||||||
|
drop(output);
|
||||||
|
let _ = child.wait();
|
||||||
|
let _ = send.blocking_send(Event::Exited);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the normal notification center for results, without an extra recording menu.
|
// Use the normal notification center for results, without an extra recording menu.
|
||||||
@@ -195,3 +246,211 @@ fn notify(title: &'static str, body: String) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use cosmic::iced::futures::{FutureExt, StreamExt};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversized_status_closes_stdout_before_waiting_for_exit() {
|
||||||
|
let child = Command::new("head")
|
||||||
|
.args(["-c", "16777216", "/dev/zero"])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let pid = child.id();
|
||||||
|
let (send, mut receive) = tokio::sync::mpsc::channel(8);
|
||||||
|
let (done_send, done_receive) = std::sync::mpsc::channel();
|
||||||
|
let worker = std::thread::spawn(move || {
|
||||||
|
read_events(child, send);
|
||||||
|
done_send.send(()).unwrap();
|
||||||
|
});
|
||||||
|
let done = done_receive.recv_timeout(Duration::from_secs(2));
|
||||||
|
|
||||||
|
if done.is_err() {
|
||||||
|
// Reap a stuck test child before reporting a failure. The reader
|
||||||
|
// still owns Child, so this PID cannot have been reused.
|
||||||
|
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.join().unwrap();
|
||||||
|
assert!(
|
||||||
|
done.is_ok(),
|
||||||
|
"reader kept the output pipe open while waiting for a blocked writer"
|
||||||
|
);
|
||||||
|
assert!(matches!(receive.try_recv(), Ok(Event::Exited)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(state: &str, path: Option<PathBuf>) -> Event {
|
||||||
|
Event::Update(Update {
|
||||||
|
state: state.into(),
|
||||||
|
message: None,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session() -> (Recorder, std::process::Child, tokio::sync::mpsc::Sender<Event>) {
|
||||||
|
// A real control pipe lets stop() exercise EOF without a recorder,
|
||||||
|
// portal, encoder, or D-Bus service.
|
||||||
|
let mut child = Command::new("cat")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let (send, receive) = tokio::sync::mpsc::channel(8);
|
||||||
|
let recorder = Recorder {
|
||||||
|
state: State::Selecting,
|
||||||
|
control: child.stdin.take(),
|
||||||
|
updates: Some(Events(Arc::new(tokio::sync::Mutex::new(receive)))),
|
||||||
|
};
|
||||||
|
(recorder, child, send)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_recording_events_stop_by_eof_and_keep_terminal_result() {
|
||||||
|
let (mut recorder, mut child, send) = session();
|
||||||
|
assert_eq!(recorder.subscription().units(), 1);
|
||||||
|
assert_eq!(recorder.elapsed_subscription().units(), 0);
|
||||||
|
recorder.apply(update("selecting", None));
|
||||||
|
assert!(matches!(recorder.state, State::Selecting));
|
||||||
|
recorder.apply(update("recording", None));
|
||||||
|
let State::Recording(start) = recorder.state else {
|
||||||
|
panic!("recording event was lost")
|
||||||
|
};
|
||||||
|
assert_eq!(recorder.elapsed_subscription().units(), 1);
|
||||||
|
recorder.apply(update("recording", None));
|
||||||
|
assert!(matches!(recorder.state, State::Recording(same) if same == start));
|
||||||
|
recorder.stop();
|
||||||
|
assert!(matches!(recorder.state, State::Saving));
|
||||||
|
assert!(recorder.control.is_none());
|
||||||
|
assert!(recorder.busy());
|
||||||
|
assert_eq!(recorder.elapsed_subscription().units(), 0);
|
||||||
|
assert!(child.wait().unwrap().success());
|
||||||
|
recorder.apply(update("selecting", None));
|
||||||
|
recorder.apply(update("recording", None));
|
||||||
|
assert!(matches!(recorder.state, State::Saving));
|
||||||
|
recorder.apply(update("saving", None));
|
||||||
|
recorder.apply(update("saved", Some("display.webm".into())));
|
||||||
|
assert!(recorder.apply(Event::Exited));
|
||||||
|
assert!(matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("display.webm")));
|
||||||
|
assert!(!recorder.busy());
|
||||||
|
assert_eq!(recorder.subscription().units(), 0);
|
||||||
|
assert!(send.try_send(Event::Exited).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelling_selection_uses_eof_and_leaves_no_elapsed_or_event_timer() {
|
||||||
|
let (mut recorder, mut child, send) = session();
|
||||||
|
recorder.stop();
|
||||||
|
assert!(matches!(recorder.state, State::Saving));
|
||||||
|
assert!(child.wait().unwrap().success());
|
||||||
|
recorder.apply(update("cancelled", None));
|
||||||
|
assert!(recorder.apply(Event::Exited));
|
||||||
|
assert!(matches!(recorder.state, State::Idle));
|
||||||
|
assert_eq!(recorder.subscription().units(), 0);
|
||||||
|
assert_eq!(recorder.elapsed_subscription().units(), 0);
|
||||||
|
assert!(send.try_send(Event::Exited).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn elapsed_deadlines_only_run_while_recording_and_align_to_start() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut recorder = Recorder::default();
|
||||||
|
for state in [
|
||||||
|
State::Idle,
|
||||||
|
State::Selecting,
|
||||||
|
State::Saving,
|
||||||
|
State::Saved("video.webm".into()),
|
||||||
|
State::Error("failed".into()),
|
||||||
|
] {
|
||||||
|
recorder.state = state;
|
||||||
|
assert_eq!(recorder.elapsed_deadline(start), None);
|
||||||
|
}
|
||||||
|
recorder.state = State::Recording(start);
|
||||||
|
assert_eq!(
|
||||||
|
recorder.elapsed_deadline(start + Duration::from_millis(999)),
|
||||||
|
Some(start + Duration::from_secs(1))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
recorder.elapsed_deadline(start + Duration::from_secs(1)),
|
||||||
|
Some(start + Duration::from_secs(2))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
recorder.elapsed_deadline(start + Duration::from_millis(5200)),
|
||||||
|
Some(start + Duration::from_secs(6))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn final_recording_results_survive_exit_and_release_session() {
|
||||||
|
for terminal in ["saved", "cancelled", "error"] {
|
||||||
|
let mut recorder = Recorder {
|
||||||
|
state: State::Saving,
|
||||||
|
..Recorder::default()
|
||||||
|
};
|
||||||
|
let (send, receive) = tokio::sync::mpsc::channel(8);
|
||||||
|
recorder.updates = Some(Events(Arc::new(tokio::sync::Mutex::new(receive))));
|
||||||
|
recorder.apply(update(terminal, Some("video.webm".into())));
|
||||||
|
assert!(recorder.busy());
|
||||||
|
assert!(recorder.apply(Event::Exited));
|
||||||
|
assert!(!recorder.busy());
|
||||||
|
match terminal {
|
||||||
|
"saved" => {
|
||||||
|
assert!(matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("video.webm")))
|
||||||
|
}
|
||||||
|
"cancelled" => assert!(matches!(recorder.state, State::Idle)),
|
||||||
|
_ => assert!(matches!(recorder.state, State::Error(ref message) if message == "Recording failed")),
|
||||||
|
}
|
||||||
|
assert!(send.try_send(Event::Exited).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_path_and_unexpected_exit_remain_errors() {
|
||||||
|
let mut recorder = Recorder::default();
|
||||||
|
recorder.apply(update("saved", None));
|
||||||
|
assert!(matches!(recorder.state, State::Error(ref message) if message == "Recorder returned no saved file"));
|
||||||
|
for state in [State::Selecting, State::Recording(Instant::now()), State::Saving] {
|
||||||
|
recorder.state = state;
|
||||||
|
recorder.apply(Event::Exited);
|
||||||
|
assert!(
|
||||||
|
matches!(recorder.state, State::Error(ref message) if message == "The recorder stopped unexpectedly")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn late_start_events_cannot_restart_stopped_recorder() {
|
||||||
|
let mut recorder = Recorder {
|
||||||
|
state: State::Saving,
|
||||||
|
..Recorder::default()
|
||||||
|
};
|
||||||
|
recorder.apply(update("selecting", None));
|
||||||
|
recorder.apply(update("recording", None));
|
||||||
|
assert!(matches!(recorder.state, State::Saving));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_stream_wakes_without_polling_and_keeps_bounded_order() {
|
||||||
|
let (send, receive) = tokio::sync::mpsc::channel(8);
|
||||||
|
let events = Events(Arc::new(tokio::sync::Mutex::new(receive)));
|
||||||
|
let stream = events.stream();
|
||||||
|
cosmic::iced::futures::pin_mut!(stream);
|
||||||
|
assert!(stream.next().now_or_never().is_none());
|
||||||
|
for _ in 0..8 {
|
||||||
|
send.try_send(update("saving", None)).unwrap();
|
||||||
|
}
|
||||||
|
assert!(send.try_send(Event::Exited).is_err());
|
||||||
|
for _ in 0..8 {
|
||||||
|
assert!(
|
||||||
|
matches!(stream.next().now_or_never(), Some(Some(Event::Update(Update { state, .. }))) if state == "saving")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
send.try_send(Event::Exited).unwrap();
|
||||||
|
assert!(matches!(stream.next().now_or_never(), Some(Some(Event::Exited))));
|
||||||
|
drop(send);
|
||||||
|
assert!(matches!(stream.next().now_or_never(), Some(None)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user