Implement native inhibition and acknowledged session-ending confirmation

This commit is contained in:
Samuel Onoja committed 2026-09-30 02:38:54 +01:00
1 parent cc441c049e
commit 29a71e9974
27 files changed
+1915 -142

No files matched your search

+1 -2
View File
@@ -105,8 +105,7 @@ impl CompositorHandler for Ferese {
self.backdrop_generation = self.backdrop_generation.wrapping_add(1); self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
if self.idle_inhibitors.remove(surface).is_some() { if self.idle_inhibitors.remove(surface).is_some() {
self.idle_notifier_state self.refresh_idle_inhibition();
.set_is_inhibited(!self.idle_inhibitors.is_empty());
} }
if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == surface) if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == surface)
{ {
+1 -2
View File
@@ -126,8 +126,7 @@ impl IdleInhibitHandler for Ferese {
self.idle_inhibitors.remove(&surface); self.idle_inhibitors.remove(&surface);
} }
} }
self.idle_notifier_state self.refresh_idle_inhibition();
.set_is_inhibited(!self.idle_inhibitors.is_empty());
} }
} }
+137 -4
View File
@@ -122,10 +122,33 @@ pub(crate) fn init(
IpcEvent::Call(call) => call, IpcEvent::Call(call) => call,
IpcEvent::Closed(owner) => { IpcEvent::Closed(owner) => {
state.portal_shortcuts.remove(owner); state.portal_shortcuts.remove(owner);
state.portal_session.remove(owner);
state.refresh_idle_inhibition();
return; return;
} }
}; };
if call.request.command == "screenshot-window" { if call.request.command == "session-watch" {
if let Err(error) = validate_request(&call.request) {
let _ = call.response.send(Response::error(
call.request.id,
error.code,
error.message,
));
} else if let Some(since) = call.request.args["since"].as_u64() {
state.portal_session.watch(
call.owner,
call.request.id,
since,
call.response,
);
} else {
let _ = call.response.send(Response::error(
call.request.id,
"invalid_argument",
"Missing session revision",
));
}
} else if call.request.command == "screenshot-window" {
state.start_window_screenshot(call.request, call.response); state.start_window_screenshot(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
@@ -322,8 +345,34 @@ fn serve_connection(
tracing::warn!("disconnecting IPC client because the request queue is full"); tracing::warn!("disconnecting IPC client because the request queue is full");
return; return;
} }
let Ok(response) = receiver.recv() else { let response = loop {
return; match receiver.recv_timeout(Duration::from_secs(1)) {
Ok(response) => break response,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
let mut byte = 0_u8;
// SAFETY: the live stream owns this fd and byte is writable.
let result = unsafe {
libc::recv(
std::os::fd::AsRawFd::as_raw_fd(&stream),
(&mut byte as *mut u8).cast(),
1,
libc::MSG_PEEK | libc::MSG_DONTWAIT,
)
};
if result == 0 {
return;
}
if result < 0
&& !matches!(
io::Error::last_os_error().kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
)
{
return;
}
}
}
}; };
let exit_accepted = exit_requested && response.error.is_none(); let exit_accepted = exit_requested && response.error.is_none();
if let Err(error) = write_frame(&mut stream, &response) { if let Err(error) = write_frame(&mut stream, &response) {
@@ -342,6 +391,13 @@ fn serve_connection(
} }
} }
fn u32_arg(args: &Value, key: &str) -> Result<u32, CommandError> {
args[key]
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.ok_or_else(|| CommandError::new("invalid_argument", format!("Missing or invalid {key}")))
}
fn discard_undelivered_screenshot(response: &Response) { fn discard_undelivered_screenshot(response: &Response) {
if response.error.is_none() if response.error.is_none()
&& let Some(path) = response && let Some(path) = response
@@ -357,7 +413,18 @@ fn discard_undelivered_screenshot(response: &Response) {
impl Ferese { impl Ferese {
fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response { fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response {
if self.session_lock.active && request.command != "portal-shortcuts-poll" { if self.session_lock.active
&& !matches!(
request.command.as_str(),
"portal-shortcuts-poll"
| "get-session-state"
| "portal-inhibit"
| "portal-monitor-register"
| "portal-monitor-ack"
| "logind-session-ending"
| "cancel-session-end"
)
{
return Response::error( return Response::error(
request.id, request.id,
"session_locked", "session_locked",
@@ -381,6 +448,72 @@ impl Ferese {
args: &Value, args: &Value,
) -> Result<Value, CommandError> { ) -> Result<Value, CommandError> {
match command { match command {
"portal-inhibit" => {
let inhibition = serde_json::from_value(args.clone())
.map_err(|error| CommandError::new("invalid_argument", error.to_string()))?;
self.portal_session
.register(owner, inhibition)
.map_err(|error| CommandError::new("invalid_argument", error))?;
self.refresh_idle_inhibition();
return Ok(json!({}));
}
"get-session-state" => {
return Ok(self.portal_session.snapshot());
}
"portal-monitor-register" => {
self.portal_session
.register_monitor(owner)
.map_err(|error| CommandError::new("invalid_argument", error))?;
let mut result = self.portal_session.snapshot();
result["monitor-owner"] = json!(owner);
return Ok(result);
}
"portal-monitor-ack" => {
let token = u32_arg(args, "token")?;
self.portal_session
.acknowledge(owner, token)
.map_err(|error| CommandError::new("invalid_argument", error))?;
return Ok(json!({}));
}
"begin-session-end" => {
self.portal_session.begin_owned_query(owner);
return Ok(self.portal_session.snapshot());
}
"cancel-session-end" => {
self.portal_session.cancel_query(u32_arg(args, "token")?);
return Ok(json!({}));
}
"validate-session-end" => {
self.portal_session
.validate_end(
u32_arg(args, "token")?,
u32_arg(args, "inhibitor-revision")?,
args["force"].as_bool().ok_or_else(|| {
CommandError::new("invalid_argument", "Missing force confirmation")
})?,
)
.map_err(|error| CommandError::new("inhibited", error))?;
return Ok(json!({}));
}
"commit-session-end" => {
self.portal_session
.commit_end(
u32_arg(args, "token")?,
u32_arg(args, "inhibitor-revision")?,
args["force"].as_bool().ok_or_else(|| {
CommandError::new("invalid_argument", "Missing force confirmation")
})?,
)
.map_err(|error| CommandError::new("inhibited", error))?;
return Ok(json!({}));
}
"logind-session-ending" => {
let ending = args["ending"].as_bool().ok_or_else(|| {
CommandError::new("invalid_argument", "Missing shutdown state")
})?;
self.portal_session.set_phase(if ending { 3 } else { 1 });
return Ok(json!({}));
}
"portal-shortcuts-register" => { "portal-shortcuts-register" => {
let shortcuts = serde_json::from_value(args.clone()) let shortcuts = serde_json::from_value(args.clone())
.map_err(|error| CommandError::new("invalid_argument", error.to_string()))?; .map_err(|error| CommandError::new("invalid_argument", error.to_string()))?;
+1
View File
@@ -13,6 +13,7 @@ mod input;
mod ipc; mod ipc;
mod metrics; mod metrics;
mod overview; mod overview;
mod portal_session;
mod portal_shortcuts; mod portal_shortcuts;
mod presentation; mod presentation;
mod private_client; mod private_client;
+422
View File
@@ -0,0 +1,422 @@
use ferese_ipc::Response;
use serde::Deserialize;
use serde_json::{Value, json};
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use std::{
collections::{HashMap, HashSet, VecDeque},
sync::mpsc::SyncSender,
time::{Duration, Instant},
};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Inhibition {
flags: u32,
app: String,
reason: String,
}
struct Query {
token: u32,
owner: Option<u64>,
deadline: Instant,
pending: HashSet<u64>,
}
pub(crate) struct PortalSession {
inhibitors: HashMap<u64, Inhibition>,
monitors: HashSet<u64>,
waiters: HashMap<u64, (u64, SyncSender<Response>)>,
phase: u32,
locked: bool,
revision: u64,
inhibitor_revision: u32,
next_query: u32,
query: Option<Query>,
transitions: VecDeque<Value>,
}
impl Default for PortalSession {
fn default() -> Self {
Self {
inhibitors: HashMap::new(),
monitors: HashSet::new(),
waiters: HashMap::new(),
phase: 1,
locked: false,
revision: 0,
inhibitor_revision: 0,
next_query: 0,
query: None,
transitions: VecDeque::new(),
}
}
}
impl PortalSession {
pub(crate) fn register(&mut self, owner: u64, inhibition: Inhibition) -> Result<(), String> {
if inhibition.flags == 0
|| inhibition.flags & !15 != 0
|| inhibition.app.len() > 512
|| inhibition.reason.len() > 1024
|| inhibition
.app
.chars()
.chain(inhibition.reason.chars())
.any(char::is_control)
{
return Err("Invalid session inhibition".into());
}
if self.inhibitors.len() >= 16 && !self.inhibitors.contains_key(&owner) {
return Err("Session inhibition limit reached".into());
}
self.inhibitors.insert(owner, inhibition);
self.inhibitor_revision = self.inhibitor_revision.wrapping_add(1);
Ok(())
}
pub(crate) fn register_monitor(&mut self, owner: u64) -> Result<(), String> {
if self.monitors.len() >= 8 && !self.monitors.contains(&owner) {
return Err("Session monitor limit reached".into());
}
self.monitors.insert(owner);
if let Some(query) = &mut self.query
&& self.phase == 2
{
query.pending.insert(owner);
}
Ok(())
}
pub(crate) fn acknowledge(&mut self, owner: u64, token: u32) -> Result<(), String> {
if !self.monitors.contains(&owner) {
return Err("Unknown session monitor".into());
}
if let Some(query) = &mut self.query
&& query.token == token
&& self.phase == 2
&& query.pending.remove(&owner)
{
self.changed();
}
Ok(())
}
pub(crate) fn remove(&mut self, owner: u64) {
self.waiters.remove(&owner);
if self.phase == 2
&& self
.query
.as_ref()
.is_some_and(|query| query.owner == Some(owner))
{
self.set_phase(1);
}
if self.inhibitors.remove(&owner).is_some() {
self.inhibitor_revision = self.inhibitor_revision.wrapping_add(1);
}
let monitor_removed = self.monitors.remove(&owner);
let acknowledged = self
.query
.as_mut()
.is_some_and(|query| query.pending.remove(&owner));
if monitor_removed || acknowledged {
self.changed();
}
}
fn idle_inhibited(&self) -> bool {
self.inhibitors
.values()
.any(|inhibitor| inhibitor.flags & 8 != 0)
}
pub(crate) fn begin_query(&mut self) -> u32 {
self.next_query = self.next_query.wrapping_add(1).max(1);
let token = self.next_query;
self.query = Some(Query {
token,
owner: None,
deadline: Instant::now() + Duration::from_secs(1),
pending: self.monitors.clone(),
});
self.phase = 2;
self.changed();
token
}
pub(crate) fn begin_owned_query(&mut self, owner: u64) -> u32 {
let token = self.begin_query();
self.query.as_mut().unwrap().owner = Some(owner);
token
}
pub(crate) fn cancel_query(&mut self, token: u32) {
if self
.query
.as_ref()
.is_some_and(|query| query.token == token)
{
self.set_phase(1);
}
}
pub(crate) fn query_ready(&self) -> bool {
self.phase == 2
&& self
.query
.as_ref()
.is_some_and(|query| query.pending.is_empty() || Instant::now() >= query.deadline)
}
pub(crate) fn validate_end(
&self,
token: u32,
inhibitor_revision: u32,
force: bool,
) -> Result<(), String> {
if !self.query_ready()
|| !self
.query
.as_ref()
.is_some_and(|query| query.token == token)
{
return Err(
"Session-ending confirmation expired or applications are still responding".into(),
);
}
if self.inhibitor_revision != inhibitor_revision
|| (!force
&& self
.inhibitors
.values()
.any(|inhibitor| inhibitor.flags & 1 != 0))
{
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
}
Ok(())
}
pub(crate) fn commit_end(
&mut self,
token: u32,
inhibitor_revision: u32,
force: bool,
) -> Result<(), String> {
self.validate_end(token, inhibitor_revision, force)?;
self.set_phase(3);
Ok(())
}
pub(crate) fn set_phase(&mut self, phase: u32) {
if phase == 2 {
self.begin_query();
return;
}
if phase == 1 {
self.query = None;
}
if self.phase != phase {
self.phase = phase;
self.changed();
}
}
pub(crate) fn set_locked(&mut self, locked: bool) {
if self.locked != locked {
self.locked = locked;
self.changed();
}
}
fn transition(&self) -> Value {
json!({"screensaver-active": self.locked, "session-state": self.phase, "revision": self.revision, "query-token": self.query.as_ref().map_or(0, |query| query.token)})
}
fn changed(&mut self) {
self.revision = self.revision.wrapping_add(1);
if self.transitions.len() == 64 {
self.transitions.pop_front();
}
self.transitions.push_back(self.transition());
let snapshot = self.snapshot();
for (_, (id, response)) in self.waiters.drain() {
let _ = response.try_send(Response::success(id, snapshot.clone()));
}
}
pub(crate) fn watch(
&mut self,
owner: u64,
id: u64,
since: u64,
response: SyncSender<Response>,
) {
if since != self.revision {
let _ = response.try_send(Response::success(id, self.snapshot()));
} else {
self.waiters.insert(owner, (id, response));
}
}
pub(crate) fn snapshot(&self) -> Value {
let inhibitors = self.inhibitors.values().map(|inhibitor| json!({"flags": inhibitor.flags, "app": inhibitor.app, "reason": inhibitor.reason})).collect::<Vec<_>>();
let remaining = self.query.as_ref().map_or(0, |query| {
query
.deadline
.saturating_duration_since(Instant::now())
.as_millis() as u64
});
json!({"screensaver-active": self.locked, "session-state": self.phase, "revision": self.revision,
"inhibitors": inhibitors, "inhibitor-revision": self.inhibitor_revision,
"query-token": self.query.as_ref().map_or(0, |query| query.token), "query-ready": self.query_ready(),
"query-remaining-ms": remaining, "transitions": self.transitions, "monitor-owners": self.monitors})
}
}
impl crate::Ferese {
pub(crate) fn refresh_idle_inhibition(&mut self) {
self.idle_notifier_state.set_is_inhibited(
!self.idle_inhibitors.is_empty() || self.portal_session.idle_inhibited(),
);
}
pub(crate) fn end_portal_session(&mut self) {
let token = self.portal_session.query.as_ref().map(|query| query.token);
let result = self.loop_handle.insert_source(
Timer::from_duration(Duration::from_millis(200)),
move |_, _, state| {
if state.portal_session.phase == 3
&& state.portal_session.query.as_ref().map(|query| query.token) == token
{
state.loop_signal.stop();
}
TimeoutAction::Drop
},
);
if let Err(error) = result {
tracing::error!(%error, "failed to schedule session ending");
self.loop_signal.stop();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn inhibition(flags: u32) -> Inhibition {
Inhibition {
flags,
app: "test.app".into(),
reason: "Playing media".into(),
}
}
#[test]
fn inhibition_is_connection_scoped_and_invalid_flags_do_not_replace_it() {
let mut session = PortalSession::default();
session.register(1, inhibition(8)).unwrap();
session.register(2, inhibition(8)).unwrap();
assert!(session.register(1, inhibition(16)).is_err());
session.remove(1);
assert!(session.idle_inhibited());
session.remove(2);
assert!(!session.idle_inhibited());
}
#[test]
fn acknowledgements_are_generation_scoped_and_disconnect_releases_pending_monitor() {
let mut session = PortalSession::default();
session.register_monitor(1).unwrap();
session.register_monitor(2).unwrap();
let old = session.begin_query();
assert!(!session.query_ready());
let token = session.begin_query();
session.acknowledge(1, old).unwrap();
assert!(!session.query_ready());
session.acknowledge(1, token).unwrap();
assert!(!session.query_ready());
session.remove(2);
assert!(session.query_ready());
session.cancel_query(old);
assert!(session.query_ready());
session.cancel_query(token);
assert!(!session.query_ready());
}
#[test]
fn late_inhibitor_requires_a_new_confirmation_and_force_is_explicit() {
let mut session = PortalSession::default();
let token = session.begin_query();
let approved = session.inhibitor_revision;
session.register(1, inhibition(1)).unwrap();
assert!(session.commit_end(token, approved, true).is_err());
assert!(
session
.commit_end(token, session.inhibitor_revision, false)
.is_err()
);
session
.commit_end(token, session.inhibitor_revision, true)
.unwrap();
assert_eq!(session.snapshot()["session-state"], 3);
assert!(
session
.commit_end(token, session.inhibitor_revision, true)
.is_err()
);
}
#[test]
fn validation_keeps_query_open_and_owner_disconnect_cancels_it() {
let mut session = PortalSession::default();
let token = session.begin_owned_query(9);
session.validate_end(token, 0, false).unwrap();
assert_eq!(session.snapshot()["session-state"], 2);
session.remove(9);
assert_eq!(session.snapshot()["session-state"], 1);
assert!(session.validate_end(token, 0, false).is_err());
}
#[test]
fn old_logout_token_does_not_cancel_a_new_shutdown_query() {
let mut session = PortalSession::default();
let logout = session.begin_query();
let shutdown = session.begin_owned_query(9);
session.cancel_query(logout);
session.validate_end(shutdown, 0, false).unwrap();
}
#[test]
fn unresponsive_monitors_have_a_one_second_deadline() {
let mut session = PortalSession::default();
session.register_monitor(1).unwrap();
session.begin_query();
assert!(!session.query_ready());
session.query.as_mut().unwrap().deadline = Instant::now() - Duration::from_millis(1);
assert!(session.query_ready());
}
#[test]
fn state_watch_preserves_fast_transitions_and_stops_on_disconnect() {
let mut session = PortalSession::default();
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
session.watch(1, 10, 0, sender);
assert!(receiver.try_recv().is_err());
session.set_locked(true);
let response = receiver.try_recv().unwrap();
assert_eq!(response.result.unwrap()["screensaver-active"], true);
session.set_locked(false);
assert_eq!(
session.snapshot()["transitions"].as_array().unwrap().len(),
2
);
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
session.watch(1, 11, session.revision, sender);
session.remove(1);
assert!(receiver.recv().is_err());
}
}
+2
View File
@@ -123,6 +123,7 @@ impl SessionLockHandler for Ferese {
} }
self.cancel_logout_confirmation(); self.cancel_logout_confirmation();
self.session_lock.active = true; self.session_lock.active = true;
self.portal_session.set_locked(true);
self.session_lock.owner = Some(confirmation.ext_session_lock().clone()); self.session_lock.owner = Some(confirmation.ext_session_lock().clone());
self.session_lock.confirmation = Some(confirmation); self.session_lock.confirmation = Some(confirmation);
for capture in self.pending_screencopies.drain(..) { for capture in self.pending_screencopies.drain(..) {
@@ -165,6 +166,7 @@ impl SessionLockHandler for Ferese {
fn unlock(&mut self) { fn unlock(&mut self) {
self.session_lock = Lock::default(); self.session_lock = Lock::default();
self.portal_session.set_locked(false);
self.restore_keyboard_focus(); self.restore_keyboard_focus();
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
tracing::info!("session unlocked by lock owner"); tracing::info!("session unlocked by lock owner");
+74 -8
View File
@@ -14,7 +14,7 @@ use smithay::{
use crate::{Ferese, private_client::ClientCapabilities, state::ClientState}; use crate::{Ferese, private_client::ClientCapabilities, state::ClientState};
pub(crate) fn init_global(display: &DisplayHandle) { pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseShellManagerV1, _>(3, ()); display.create_global::<Ferese, FereseShellManagerV1, _>(4, ());
} }
pub(crate) fn config_chunks(source: &str) -> Vec<&str> { pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
@@ -137,11 +137,25 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
} }
} }
ferese_shell_v1::Request::ConfirmLogout { serial } => { ferese_shell_v1::Request::ConfirmLogout { serial } => {
if state.logout_owner.as_ref() == Some(&shell.id()) let snapshot = state.portal_session.snapshot();
&& consume_logout_confirmation(&mut state.pending_logout, serial) let token = snapshot["query-token"].as_u64().unwrap_or(0) as u32;
{ let revision = snapshot["inhibitor-revision"].as_u64().unwrap_or(0) as u32;
state.logout_owner = None; state.confirm_portal_logout(shell, serial, token, revision, false);
state.loop_signal.stop(); }
ferese_shell_v1::Request::ConfirmLogoutWithInhibitors {
serial,
inhibitor_revision,
query_token,
force,
} => {
if force <= 1 {
state.confirm_portal_logout(
shell,
serial,
query_token,
inhibitor_revision,
force == 1,
);
} }
} }
ferese_shell_v1::Request::CancelLogout { serial } => { ferese_shell_v1::Request::CancelLogout { serial } => {
@@ -183,12 +197,20 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
_data: &(), _data: &(),
) { ) {
if state.logout_owner.as_ref() == Some(&shell.id()) { if state.logout_owner.as_ref() == Some(&shell.id()) {
state.pending_logout = None; state.cancel_logout_confirmation();
state.logout_owner = None;
} }
} }
} }
fn logout_confirmation_matches(
pending: Option<u32>,
query: Option<u32>,
serial: u32,
token: u32,
) -> bool {
pending == Some(serial) && query == Some(token)
}
fn consume_logout_confirmation(pending: &mut Option<u32>, serial: u32) -> bool { fn consume_logout_confirmation(pending: &mut Option<u32>, serial: u32) -> bool {
if *pending == Some(serial) { if *pending == Some(serial) {
*pending = None; *pending = None;
@@ -199,6 +221,34 @@ fn consume_logout_confirmation(pending: &mut Option<u32>, serial: u32) -> bool {
} }
impl Ferese { impl Ferese {
fn confirm_portal_logout(
&mut self,
shell: &FereseShellV1,
serial: u32,
token: u32,
revision: u32,
force: bool,
) {
if self.logout_owner.as_ref() != Some(&shell.id())
|| !logout_confirmation_matches(self.pending_logout, self.logout_query, serial, token)
{
return;
}
match self.portal_session.commit_end(token, revision, force) {
Ok(()) => {
consume_logout_confirmation(&mut self.pending_logout, serial);
self.logout_owner = None;
self.logout_query = None;
self.end_portal_session();
}
Err(error) => {
tracing::debug!(%error, "logout requires a new confirmation");
self.cancel_logout_confirmation();
self.request_logout_confirmation();
}
}
}
pub(crate) fn request_logout_confirmation(&mut self) { pub(crate) fn request_logout_confirmation(&mut self) {
if self.session_lock.active { if self.session_lock.active {
return; return;
@@ -216,6 +266,7 @@ impl Ferese {
self.cancel_logout_confirmation(); self.cancel_logout_confirmation();
let serial = u32::from(smithay::utils::SERIAL_COUNTER.next_serial()); let serial = u32::from(smithay::utils::SERIAL_COUNTER.next_serial());
let shell = &shells[0]; let shell = &shells[0];
self.logout_query = Some(self.portal_session.begin_query());
self.pending_logout = Some(serial); self.pending_logout = Some(serial);
self.logout_owner = Some(shell.id()); self.logout_owner = Some(shell.id());
let output_name = self let output_name = self
@@ -225,6 +276,9 @@ impl Ferese {
} }
pub(crate) fn cancel_logout_confirmation(&mut self) { pub(crate) fn cancel_logout_confirmation(&mut self) {
if let Some(token) = self.logout_query.take() {
self.portal_session.cancel_query(token);
}
if let Some(serial) = self.pending_logout.take() { if let Some(serial) = self.pending_logout.take() {
for shell in self for shell in self
.shell_resources .shell_resources
@@ -507,6 +561,18 @@ fn send_request_failed(shell: &FereseShellV1, request: ferese_shell_v1::FailedRe
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn logout_serial_cannot_authorize_a_different_session_query() {
assert!(super::logout_confirmation_matches(Some(42), Some(9), 42, 9));
assert!(!super::logout_confirmation_matches(
Some(42),
Some(9),
42,
10
));
assert!(!super::logout_confirmation_matches(None, Some(9), 42, 9));
}
#[test] #[test]
fn logout_requires_the_current_confirmation_and_can_be_cancelled() { fn logout_requires_the_current_confirmation_and_can_be_cancelled() {
let mut pending = Some(42); let mut pending = Some(42);
+6
View File
@@ -231,6 +231,8 @@ pub struct Ferese {
pub socket_name: OsString, pub socket_name: OsString,
pub display_handle: DisplayHandle, pub display_handle: DisplayHandle,
pub loop_signal: LoopSignal, pub loop_signal: LoopSignal,
pub(crate) loop_handle: smithay::reexports::calloop::LoopHandle<'static, Self>,
pub(crate) portal_session: crate::portal_session::PortalSession,
pub space: Space<Window>, pub space: Space<Window>,
pub workspaces: WorkspaceSet, pub workspaces: WorkspaceSet,
pub output_workspaces: OutputWorkspaceMap, pub output_workspaces: OutputWorkspaceMap,
@@ -259,6 +261,7 @@ pub struct Ferese {
pub(crate) material_buffers: HashMap<WlSurface, crate::winit::MaterialBuffers>, pub(crate) material_buffers: HashMap<WlSurface, crate::winit::MaterialBuffers>,
pub(crate) portal_shortcuts: crate::portal_shortcuts::PortalShortcuts, pub(crate) portal_shortcuts: crate::portal_shortcuts::PortalShortcuts,
pub(crate) pending_logout: Option<u32>, pub(crate) pending_logout: Option<u32>,
pub(crate) logout_query: Option<u32>,
pub(crate) logout_owner: Option<smithay::reexports::wayland_server::backend::ObjectId>, pub(crate) logout_owner: Option<smithay::reexports::wayland_server::backend::ObjectId>,
pub(crate) blur_programs: HashMap<ErasedContextId, crate::winit::BlurProgram>, pub(crate) blur_programs: HashMap<ErasedContextId, crate::winit::BlurProgram>,
pub(crate) backdrop_generation: u64, pub(crate) backdrop_generation: u64,
@@ -448,6 +451,8 @@ impl Ferese {
socket_name, socket_name,
display_handle, display_handle,
loop_signal: event_loop.get_signal(), loop_signal: event_loop.get_signal(),
loop_handle: event_loop.handle(),
portal_session: Default::default(),
space: Space::default(), space: Space::default(),
workspaces: WorkspaceSet::new( workspaces: WorkspaceSet::new(
config.layout_mode, config.layout_mode,
@@ -483,6 +488,7 @@ impl Ferese {
material_buffers: HashMap::new(), material_buffers: HashMap::new(),
portal_shortcuts: crate::portal_shortcuts::PortalShortcuts::default(), portal_shortcuts: crate::portal_shortcuts::PortalShortcuts::default(),
pending_logout: None, pending_logout: None,
logout_query: None,
logout_owner: None, logout_owner: None,
blur_programs: HashMap::new(), blur_programs: HashMap::new(),
backdrop_generation: 0, backdrop_generation: 0,
+19 -3
View File
@@ -41,6 +41,12 @@ standard file, print and application dialogs. Install both backends.
Closing a session or losing its compositor connection releases its shortcuts; Closing a session or losing its compositor connection releases its shortcuts;
shortcuts do not activate on the lock screen. Saved choices are offered for shortcuts do not activate on the lock screen. Saved choices are offered for
future sessions, with explicit approval before registering them. future sessions, with explicit approval before registering them.
- **Inhibit:** connection-scoped logout, suspend and idle inhibitors, backed by
logind descriptors and compositor idle inhibition. Native session monitors
report lock state and Running/QueryEnd/Ending transitions. Ending queries wait
for acknowledgements for up to one second. Power confirmations list blockers
and recheck them before acting; authentication failure cancels the query.
UserSwitch is recorded, but Ferese currently has no user-switch operation.
- **ScreenCast:** monitor sharing with a native picker and PipeWire streams. - **ScreenCast:** monitor sharing with a native picker and PipeWire streams.
See [screen sharing](screen-sharing.md) for lifetime and recording details. See [screen sharing](screen-sharing.md) for lifetime and recording details.
@@ -70,11 +76,11 @@ Matching D-Bus signatures does not establish complete feature parity.
| Background | Native application state, per-instance consent and XDG autostart | | Background | Native application state, per-instance consent and XDG autostart |
| Usb | Native consent; standard frontend handles enumeration and device descriptors | | Usb | Native consent; standard frontend handles enumeration and device descriptors |
| GlobalShortcuts | Native sessions, configurable keyboard triggers, conflict checks and activation/release signals | | GlobalShortcuts | Native sessions, configurable keyboard triggers, conflict checks and activation/release signals |
| RemoteDesktop | Missing authorized input injection and EIS transport | | RemoteDesktop | Deferred: authorized input injection and EIS transport |
| Clipboard | Missing remote-session clipboard transfer | | Clipboard | Deferred with RemoteDesktop |
| InputCapture | Missing zones, pointer barriers and EIS transport | | InputCapture | Missing zones, pointer barriers and EIS transport |
| Lockdown | Native seven-property policy provider with persisted configuration and change signals | | Lockdown | Native seven-property policy provider with persisted configuration and change signals |
| Inhibit | GTK's GNOME-session/ScreenSaver integration does not provide complete Ferese session inhibition | | Inhibit | Native inhibitors and session monitors; UserSwitch recorded until a switch operation exists |
The frontend supplies additional APIs such as OpenURI, network monitoring, The frontend supplies additional APIs such as OpenURI, network monitoring,
document export and permission storage. Adding a similarly named backend service document export and permission storage. Adding a similarly named backend service
@@ -117,3 +123,13 @@ XML, verifies appearance changes and invalid-save retention, and checks that
sensitive methods reject callers outside the portal frontend. It does not open sensitive methods reject callers outside the portal frontend. It does not open
consent dialogs or capture the host desktop. Interactive consent, fractional-scale consent dialogs or capture the host desktop. Interactive consent, fractional-scale
capture, polkit and hotplug still require a live session test. capture, polkit and hotplug still require a live session test.
Check native inhibitor lifetimes, acknowledgement generations and session-ending
transitions against a mock logind in an isolated nested compositor:
```sh
FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py
```
The shell now requires version 4 of Ferese's shell protocol; rebuild the shell
and compositor together when upgrading.
+1
View File
@@ -8,3 +8,4 @@ org.freedesktop.impl.portal.Background=ferese;
org.freedesktop.impl.portal.Usb=ferese; org.freedesktop.impl.portal.Usb=ferese;
org.freedesktop.impl.portal.Lockdown=ferese; org.freedesktop.impl.portal.Lockdown=ferese;
org.freedesktop.impl.portal.GlobalShortcuts=ferese; org.freedesktop.impl.portal.GlobalShortcuts=ferese;
org.freedesktop.impl.portal.Inhibit=ferese;
+1 -1
View File
@@ -1,4 +1,4 @@
[portal] [portal]
DBusName=org.freedesktop.impl.portal.desktop.ferese DBusName=org.freedesktop.impl.portal.desktop.ferese
Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown;org.freedesktop.impl.portal.GlobalShortcuts; Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown;org.freedesktop.impl.portal.GlobalShortcuts;org.freedesktop.impl.portal.Inhibit;
UseIn=Ferese; UseIn=Ferese;
+1 -1
View File
@@ -31,7 +31,7 @@ memmap2 = "0.9"
pipewire = { version = "0.10.1", features = ["v0_3_65"] } pipewire = { version = "0.10.1", features = ["v0_3_65"] }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util", "net"] }
wayland-client.workspace = true wayland-client.workspace = true
wayland-protocols = { version = "0.32", features = ["client", "unstable"] } wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
wayland-protocols-wlr.workspace = true wayland-protocols-wlr.workspace = true
+5
View File
@@ -670,6 +670,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let background = crate::permissions::Background::new(requests.clone()); let background = crate::permissions::Background::new(requests.clone());
let lockdown = crate::lockdown::Lockdown::new(); let lockdown = crate::lockdown::Lockdown::new();
let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone()); let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone());
let inhibit = crate::inhibit::Inhibit::default();
let connection = zbus::connection::Builder::session()? let connection = zbus::connection::Builder::session()?
.name("org.freedesktop.impl.portal.desktop.ferese")? .name("org.freedesktop.impl.portal.desktop.ferese")?
.serve_at(PATH, backend.clone())? .serve_at(PATH, backend.clone())?
@@ -680,6 +681,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
.serve_at(PATH, background.clone())? .serve_at(PATH, background.clone())?
.serve_at(PATH, lockdown.clone())? .serve_at(PATH, lockdown.clone())?
.serve_at(PATH, shortcuts.clone())? .serve_at(PATH, shortcuts.clone())?
.serve_at(PATH, inhibit.clone())?
.serve_at( .serve_at(
"/org/ferese/ScreenRecorder", "/org/ferese/ScreenRecorder",
RecorderControl(backend.clone()), RecorderControl(backend.clone()),
@@ -689,6 +691,8 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
tokio::spawn(settings.watch(connection.clone())); tokio::spawn(settings.watch(connection.clone()));
tokio::spawn(background.watch(connection.clone())); tokio::spawn(background.watch(connection.clone()));
tokio::spawn(lockdown.watch(connection.clone())); tokio::spawn(lockdown.watch(connection.clone()));
tokio::spawn(inhibit.clone().watch(connection.clone()));
tokio::spawn(inhibit.clone().watch_logind());
// Frontend death must revoke every stream, even if Session.Close never arrives. // Frontend death must revoke every stream, even if Session.Close never arrives.
loop { loop {
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
@@ -702,6 +706,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
}; };
requests.revoke_stale(owner.as_deref()).await; requests.revoke_stale(owner.as_deref()).await;
shortcuts.revoke_stale(&connection, owner.as_deref()).await; shortcuts.revoke_stale(&connection, owner.as_deref()).await;
inhibit.revoke_stale(&connection, owner.as_deref()).await;
let stale: Vec<_> = backend let stale: Vec<_> = backend
.sessions .sessions
.lock() .lock()
+103
View File
@@ -0,0 +1,103 @@
use serde_json::Value as Json;
use std::{os::unix::net::UnixStream, sync::Arc, time::Duration};
#[derive(Clone)]
pub(crate) struct Bridge(Arc<BridgeInner>);
struct BridgeInner {
stream: std::sync::Mutex<UnixStream>,
shutdown: UnixStream,
}
impl Bridge {
pub(crate) fn connect() -> Result<Self, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.ok_or("Missing runtime directory")?
.join("ferese/control.sock");
let stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
let shutdown = stream.try_clone().map_err(|error| error.to_string())?;
Ok(Self(Arc::new(BridgeInner {
stream: std::sync::Mutex::new(stream),
shutdown,
})))
}
pub(crate) async fn call(&self, command: &'static str, args: Json) -> Result<Json, String> {
self.request(command, args, false).await
}
pub(crate) async fn wait(&self, since: u64) -> Result<Json, String> {
self.request("session-watch", serde_json::json!({"since": since}), true)
.await
}
async fn request(&self, command: &'static str, args: Json, wait: bool) -> Result<Json, String> {
let bridge = self.clone();
tokio::task::spawn_blocking(move || {
let mut stream = bridge
.0
.stream
.lock()
.map_err(|_| "Compositor connection failed")?;
stream
.set_read_timeout(if wait {
None
} else {
Some(Duration::from_secs(2))
})
.map_err(|error| error.to_string())?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
kind: "command".into(),
command: command.into(),
args,
};
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
response.result.ok_or("Missing compositor response".into())
})
.await
.map_err(|error| error.to_string())?
}
pub(crate) async fn closed(&self) {
use std::io::Read;
let Ok(reader) = self.0.shutdown.try_clone() else {
return;
};
if reader.set_nonblocking(true).is_err() {
return;
}
let Ok(reader) = tokio::io::unix::AsyncFd::new(reader) else {
return;
};
loop {
let Ok(mut ready) = reader.readable().await else {
return;
};
match ready.try_io(|reader| {
let mut socket = reader.get_ref();
socket.read(&mut [0_u8; 1])
}) {
Ok(_) => return,
Err(_) => continue,
}
}
}
pub(crate) fn close(&self) {
let _ = self.0.shutdown.shutdown(std::net::Shutdown::Both);
}
}
+13 -3
View File
@@ -22,10 +22,20 @@ const PATH: &str = "/org/freedesktop/portal/desktop";
const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024; const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024;
type Reply = (u32, Options); type Reply = (u32, Options);
#[derive(Clone, Default)] #[derive(Clone)]
pub(crate) struct Requests(Arc<Mutex<HashMap<String, (String, Arc<Cancel>)>>>); pub(crate) struct Requests(Arc<Mutex<HashMap<String, (String, Arc<Cancel>)>>>, usize);
impl Default for Requests {
fn default() -> Self {
Self::with_limit(8)
}
}
impl Requests { impl Requests {
pub(crate) fn with_limit(limit: usize) -> Self {
Self(Default::default(), limit)
}
pub(crate) async fn revoke_stale(&self, owner: Option<&str>) { pub(crate) async fn revoke_stale(&self, owner: Option<&str>) {
for (request_owner, cancel) in self.0.lock().await.values() { for (request_owner, cancel) in self.0.lock().await.values() {
if Some(request_owner.as_str()) != owner { if Some(request_owner.as_str()) != owner {
@@ -45,7 +55,7 @@ impl Requests {
return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into())); return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into()));
} }
let mut pending = self.0.lock().await; let mut pending = self.0.lock().await;
if pending.len() >= 8 || pending.contains_key(handle.as_str()) { if pending.len() >= self.1 || pending.contains_key(handle.as_str()) {
return Err(zbus::fdo::Error::LimitsExceeded( return Err(zbus::fdo::Error::LimitsExceeded(
"Request limit reached".into(), "Request limit reached".into(),
)); ));
+511
View File
@@ -0,0 +1,511 @@
use std::{
collections::HashMap,
sync::{Arc, atomic::Ordering},
time::Duration,
};
use tokio::sync::{Mutex, Notify};
use zbus::{
Connection,
message::Header,
object_server::SignalEmitter,
zvariant::{OwnedFd, OwnedObjectPath, Value},
};
use crate::{
backend::{Cancel, Options, authorize},
bridge::Bridge,
desktop::Requests,
};
const PATH: &str = "/org/freedesktop/portal/desktop";
struct Monitor {
owner: String,
cancel: Arc<Cancel>,
last: Mutex<Option<(bool, u32, u64, u32)>>,
bridge: Bridge,
native_owner: u64,
}
#[derive(Clone)]
pub(crate) struct Inhibit {
requests: Requests,
monitors: Arc<Mutex<HashMap<String, Arc<Monitor>>>>,
changed: Arc<Notify>,
}
impl Default for Inhibit {
fn default() -> Self {
Self {
requests: Requests::with_limit(24),
monitors: Default::default(),
changed: Default::default(),
}
}
}
impl Inhibit {
pub(crate) async fn revoke_stale(&self, connection: &Connection, owner: Option<&str>) {
self.requests.revoke_stale(owner).await;
let stale = self
.monitors
.lock()
.await
.iter()
.filter(|(_, monitor)| Some(monitor.owner.as_str()) != owner)
.map(|(path, _)| path.clone())
.collect::<Vec<_>>();
for path in stale {
self.end(connection, &path).await;
}
}
async fn end(&self, connection: &Connection, path: &str) {
if let Some(monitor) = self.monitors.lock().await.remove(path) {
let _state = monitor.last.lock().await;
monitor.cancel.stop();
monitor.bridge.close();
if let Ok(emitter) = SignalEmitter::new(connection, path) {
let _ = MonitorObject::closed(&emitter).await;
}
let _ = connection
.object_server()
.remove::<MonitorObject, _>(path)
.await;
self.changed.notify_one();
}
}
pub(crate) async fn watch(self, connection: Connection) {
let mut bridge: Option<Bridge> = None;
let mut since = None;
loop {
if self.monitors.lock().await.is_empty() {
if let Some(bridge) = bridge.take() {
bridge.close();
}
since = None;
self.changed.notified().await;
continue;
}
if bridge.is_none() {
bridge = Bridge::connect().ok();
}
let result = if let Some(active) = &bridge {
tokio::select! {
biased;
_ = self.changed.notified() => { active.close(); bridge = None; since = None; continue; }
state = async {
match since { Some(revision) => active.wait(revision).await, None => active.call("get-session-state", serde_json::json!({})).await }
} => state,
}
} else {
Err("Session monitor is unavailable".into())
};
let monitors = self
.monitors
.lock()
.await
.iter()
.map(|(path, monitor)| (path.clone(), monitor.clone()))
.collect::<Vec<_>>();
match result {
Ok(state) => {
since = state["revision"].as_u64();
for (path, monitor) in monitors {
if !state["monitor-owners"].as_array().is_some_and(|owners| {
owners
.iter()
.any(|owner| owner.as_u64() == Some(monitor.native_owner))
}) {
self.end(&connection, &path).await;
continue;
}
let mut last = monitor.last.lock().await;
if monitor.cancel.stopped.load(Ordering::SeqCst) {
continue;
}
let transitions = match *last {
None => vec![state.clone()],
Some((_, _, revision, _)) => {
let events =
state["transitions"].as_array().cloned().unwrap_or_default();
if events
.first()
.and_then(|event| event["revision"].as_u64())
.is_some_and(|first| first > revision.saturating_add(1))
{
drop(last);
self.end(&connection, &path).await;
continue;
}
events
.into_iter()
.filter(|event| {
event["revision"]
.as_u64()
.is_some_and(|next| next > revision)
})
.collect()
}
};
for event in transitions {
let locked = event["screensaver-active"].as_bool().unwrap_or(false);
let phase = event["session-state"].as_u64().unwrap_or(1) as u32;
let revision = event["revision"].as_u64().unwrap_or(0);
let token = event["query-token"].as_u64().unwrap_or(0) as u32;
let changed = last.as_ref().is_none_or(|old| {
old.0 != locked || old.1 != phase || (phase == 2 && old.3 != token)
});
if changed {
let values = HashMap::from([
(
"screensaver-active".into(),
Value::from(locked).try_into().unwrap(),
),
(
"session-state".into(),
Value::from(phase).try_into().unwrap(),
),
]);
if let Ok(emitter) = SignalEmitter::new(&connection, PATH) {
if Self::state_changed(
&emitter,
OwnedObjectPath::try_from(path.clone()).unwrap(),
values,
)
.await
.is_err()
{
break;
}
}
}
*last = Some((locked, phase, revision, token));
}
}
}
Err(error) => {
eprintln!("ferese-portal: {error}");
for (path, _) in monitors {
self.end(&connection, &path).await;
}
if let Some(bridge) = bridge.take() {
bridge.close();
}
since = None;
}
}
}
}
pub(crate) async fn watch_logind(self) {
use futures_util::StreamExt;
loop {
let result: Result<(), String> = async {
let connection = Connection::system()
.await
.map_err(|error| error.to_string())?;
let proxy = zbus::Proxy::new(
&connection,
"org.freedesktop.login1",
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
)
.await
.map_err(|error| error.to_string())?;
let mut signals = proxy
.receive_signal("PrepareForShutdown")
.await
.map_err(|error| error.to_string())?;
while let Some(message) = signals.next().await {
let (ending,): (bool,) = message
.body()
.deserialize()
.map_err(|error| error.to_string())?;
if let Ok(bridge) = Bridge::connect() {
let _ = bridge
.call(
"logind-session-ending",
serde_json::json!({"ending": ending}),
)
.await;
bridge.close();
}
}
Ok(())
}
.await;
if let Err(error) = result {
eprintln!("ferese-portal: logind monitor unavailable: {error}");
}
tokio::time::sleep(Duration::from_secs(10)).await;
}
}
}
fn flags_what(flags: u32) -> zbus::fdo::Result<String> {
if flags == 0 || flags & !15 != 0 {
return Err(zbus::fdo::Error::InvalidArgs(
"Invalid inhibition flags".into(),
));
}
let mut what = Vec::new();
if flags & 1 != 0 {
what.push("shutdown");
}
if flags & 4 != 0 {
what.push("sleep");
}
if flags & 8 != 0 {
what.push("idle");
}
Ok(what.join(":"))
}
async fn login_inhibitor(what: &str, app: &str, reason: &str) -> Result<Option<OwnedFd>, String> {
if what.is_empty() {
return Ok(None);
}
tokio::time::timeout(Duration::from_secs(5), async {
let connection = Connection::system()
.await
.map_err(|error| error.to_string())?;
let proxy = zbus::Proxy::new(
&connection,
"org.freedesktop.login1",
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
)
.await
.map_err(|error| error.to_string())?;
let fd: OwnedFd = proxy
.call("Inhibit", &(what, app, reason, "block"))
.await
.map_err(|error| error.to_string())?;
Ok(Some(fd))
})
.await
.map_err(|_| "Session inhibition timed out".to_owned())?
}
#[zbus::interface(name = "org.freedesktop.impl.portal.Inhibit")]
impl Inhibit {
async fn inhibit(
&self,
handle: OwnedObjectPath,
app_id: String,
_window: String,
flags: u32,
options: Options,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
authorize(connection, &header).await?;
let what = flags_what(flags)?;
let reason = options
.get("reason")
.map(|value| <&str>::try_from(value).map(str::to_owned))
.transpose()
.map_err(|_| zbus::fdo::Error::InvalidArgs("Invalid inhibition reason".into()))?
.unwrap_or_else(|| "Application requested session inhibition".into());
if app_id.len() > 512
|| reason.len() > 1024
|| app_id.chars().chain(reason.chars()).any(char::is_control)
{
return Err(zbus::fdo::Error::InvalidArgs(
"Invalid application or reason".into(),
));
}
let cancel = self.requests.begin(connection, &header, &handle).await?;
let bridge = match Bridge::connect() {
Ok(bridge) => bridge,
Err(error) => {
self.requests.end(connection, &handle).await;
return Err(zbus::fdo::Error::Failed(error));
}
};
let acquired = tokio::select! {
biased;
_ = cancel.wait() => Err("Inhibition was cancelled".to_owned()),
result = async {
let fd = login_inhibitor(&what, &app_id, &reason).await?;
bridge.call("portal-inhibit", serde_json::json!({"flags": flags, "app": app_id, "reason": reason})).await?;
Ok(fd)
} => result,
};
match acquired {
Ok(fd) if !cancel.stopped.load(Ordering::SeqCst) => {
let requests = self.requests.clone();
let connection = connection.clone();
tokio::spawn(async move {
tokio::select! { _ = cancel.wait() => {}, _ = bridge.closed() => {} }
cancel.stop();
bridge.close();
drop(fd);
requests.end(&connection, &handle).await;
});
Ok(())
}
result => {
bridge.close();
self.requests.end(connection, &handle).await;
Err(zbus::fdo::Error::Failed(
result
.err()
.unwrap_or_else(|| "Inhibition was cancelled".into()),
))
}
}
}
async fn create_monitor(
&self,
handle: OwnedObjectPath,
session_handle: OwnedObjectPath,
app_id: String,
_window: String,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<u32> {
let owner = authorize(connection, &header).await?;
if !session_handle
.as_str()
.starts_with(&format!("{PATH}/session/"))
|| session_handle.as_str().len() > 512
|| app_id.len() > 512
{
return Err(zbus::fdo::Error::InvalidArgs(
"Invalid session monitor".into(),
));
}
let cancel = self.requests.begin(connection, &header, &handle).await?;
let mut monitors = self.monitors.lock().await;
let result = if monitors.len() >= 8
|| monitors.contains_key(session_handle.as_str())
|| cancel.stopped.load(Ordering::SeqCst)
{
Ok(2)
} else {
let attempt = async {
let bridge = Bridge::connect().map_err(zbus::fdo::Error::Failed)?;
let result = tokio::select! {
biased;
_ = cancel.wait() => Err("Monitor creation was cancelled".into()),
result = bridge.call("portal-monitor-register", serde_json::json!({})) => result,
};
let native = match result { Ok(native) => native, Err(error) => { bridge.close(); return Err(zbus::fdo::Error::Failed(error)); } };
let Some(native_owner) = native["monitor-owner"].as_u64() else {
bridge.close();
return Err(zbus::fdo::Error::Failed("Missing monitor owner".into()));
};
let exported = connection.object_server().at(&session_handle, MonitorObject {
backend: self.clone(), path: session_handle.to_string(), owner: owner.clone(), cancel: cancel.clone(),
}).await;
match exported {
Ok(true) if !cancel.stopped.load(Ordering::SeqCst) => {
monitors.insert(session_handle.to_string(), Arc::new(Monitor {
owner, cancel: cancel.clone(), last: Mutex::new(None), bridge,
native_owner,
}));
self.changed.notify_one();
Ok(0)
}
result => {
bridge.close();
if matches!(result, Ok(true)) { let _ = connection.object_server().remove::<MonitorObject, _>(&session_handle).await; }
result.map(|_| if cancel.stopped.load(Ordering::SeqCst) { 1 } else { 2 }).map_err(Into::into)
}
}
}.await;
attempt
};
drop(monitors);
self.requests.end(connection, &handle).await;
result
}
async fn query_end_response(
&self,
session_handle: OwnedObjectPath,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
let owner = authorize(connection, &header).await?;
let monitor = self
.monitors
.lock()
.await
.get(session_handle.as_str())
.filter(|monitor| monitor.owner == owner)
.cloned()
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Not your session monitor".into()))?;
let last = monitor.last.lock().await;
if monitor.cancel.stopped.load(Ordering::SeqCst) {
return Err(zbus::fdo::Error::AccessDenied(
"Session monitor closed".into(),
));
}
if let Some((_, 2, _, token)) = *last {
monitor
.bridge
.call("portal-monitor-ack", serde_json::json!({"token": token}))
.await
.map_err(zbus::fdo::Error::Failed)?;
}
Ok(())
}
#[zbus(signal)]
async fn state_changed(
emitter: &SignalEmitter<'_>,
session_handle: OwnedObjectPath,
state: Options,
) -> zbus::Result<()>;
}
struct MonitorObject {
backend: Inhibit,
path: String,
owner: String,
cancel: Arc<Cancel>,
}
#[zbus::interface(name = "org.freedesktop.impl.portal.Session")]
impl MonitorObject {
#[zbus(property, name = "version")]
fn version(&self) -> u32 {
1
}
async fn close(
&self,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
if authorize(connection, &header).await? != self.owner {
return Err(zbus::fdo::Error::AccessDenied(
"Not your session monitor".into(),
));
}
self.cancel.stop();
self.backend.end(connection, &self.path).await;
Ok(())
}
#[zbus(signal)]
async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logind_flags_are_validated_and_mapped_independently() {
assert_eq!(flags_what(1 | 4 | 8).unwrap(), "shutdown:sleep:idle");
assert_eq!(flags_what(2).unwrap(), "");
assert!(flags_what(0).is_err());
assert!(flags_what(16).is_err());
}
}
+2
View File
@@ -1,7 +1,9 @@
mod backend; mod backend;
mod bridge;
mod capture; mod capture;
mod consent; mod consent;
mod desktop; mod desktop;
mod inhibit;
mod lockdown; mod lockdown;
mod parent; mod parent;
mod permissions; mod permissions;
+13 -65
View File
@@ -1,9 +1,6 @@
use std::{ use std::{collections::HashMap, io::Read, process::Stdio, sync::Arc, time::Duration};
collections::HashMap, io::Read, os::unix::net::UnixStream, process::Stdio, sync::Arc,
time::Duration,
};
use serde_json::{Value as Json, json}; use serde_json::json;
use tokio::{io::AsyncWriteExt, sync::Mutex}; use tokio::{io::AsyncWriteExt, sync::Mutex};
use zbus::{ use zbus::{
Connection, Connection,
@@ -14,6 +11,7 @@ use zbus::{
use crate::{ use crate::{
backend::{Cancel, Options, authorize}, backend::{Cancel, Options, authorize},
bridge::Bridge,
consent::{Prompt, ShortcutField}, consent::{Prompt, ShortcutField},
desktop::Requests, desktop::Requests,
}; };
@@ -21,66 +19,6 @@ use crate::{
const PATH: &str = "/org/freedesktop/portal/desktop"; const PATH: &str = "/org/freedesktop/portal/desktop";
type Shortcuts = Vec<(String, Options)>; type Shortcuts = Vec<(String, Options)>;
#[derive(Clone)]
struct Bridge(Arc<BridgeInner>);
struct BridgeInner {
stream: std::sync::Mutex<UnixStream>,
shutdown: UnixStream,
}
impl Bridge {
fn connect() -> Result<Self, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.ok_or("Missing runtime directory")?
.join("ferese/control.sock");
let stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
let shutdown = stream.try_clone().map_err(|error| error.to_string())?;
Ok(Self(Arc::new(BridgeInner {
stream: std::sync::Mutex::new(stream),
shutdown,
})))
}
async fn call(&self, command: &'static str, args: Json) -> Result<Json, String> {
let bridge = self.clone();
tokio::task::spawn_blocking(move || {
let mut stream = bridge
.0
.stream
.lock()
.map_err(|_| "Shortcut connection failed")?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
kind: "command".into(),
command: command.into(),
args,
};
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
response.result.ok_or("Missing shortcut response".into())
})
.await
.map_err(|error| error.to_string())?
}
fn close(&self) {
let _ = self.0.shutdown.shutdown(std::net::Shutdown::Both);
}
}
struct Session { struct Session {
owner: String, owner: String,
app: String, app: String,
@@ -177,6 +115,7 @@ impl GlobalShortcuts {
}; };
let mut fields = fields; let mut fields = fields;
let mut message = "Choose shortcuts for this application. Leave a shortcut empty to disable it. System shortcuts cannot be overridden.".to_owned(); let mut message = "Choose shortcuts for this application. Leave a shortcut empty to disable it. System shortcuts cannot be overridden.".to_owned();
loop { loop {
let selected = tokio::select! { let selected = tokio::select! {
biased; biased;
@@ -187,7 +126,9 @@ impl GlobalShortcuts {
let Some(selected) = selected else { let Some(selected) = selected else {
return Ok(None); return Ok(None);
}; };
validate_selection(&fields, &selected)?; validate_selection(&fields, &selected)?;
let registration = selected let registration = selected
.iter() .iter()
.filter(|field| !field.trigger.trim().is_empty()) .filter(|field| !field.trigger.trim().is_empty())
@@ -199,6 +140,7 @@ impl GlobalShortcuts {
{ {
Ok(_) => { Ok(_) => {
let mut state = session.state.lock().await; let mut state = session.state.lock().await;
if session if session
.cancel .cancel
.stopped .stopped
@@ -218,6 +160,7 @@ impl GlobalShortcuts {
.filter(|field| !field.trigger.trim().is_empty()) .filter(|field| !field.trigger.trim().is_empty())
.map(|field| json!({"id":field.id,"trigger":field.trigger})) .map(|field| json!({"id":field.id,"trigger":field.trigger}))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if bridge if bridge
.call("portal-shortcuts-register", json!(previous)) .call("portal-shortcuts-register", json!(previous))
.await .await
@@ -226,16 +169,20 @@ impl GlobalShortcuts {
bridge.close(); bridge.close();
} }
} }
return Ok(None); return Ok(None);
} }
state.fields = selected.clone(); state.fields = selected.clone();
state.bound = true; state.bound = true;
state.bridge = Some(bridge.clone()); state.bridge = Some(bridge.clone());
state.revision = state.revision.wrapping_add(1); state.revision = state.revision.wrapping_add(1);
drop(state); drop(state);
if let Err(error) = save(&session.app, &selected) { if let Err(error) = save(&session.app, &selected) {
eprintln!("ferese shortcuts: could not save preferences: {error}"); eprintln!("ferese shortcuts: could not save preferences: {error}");
} }
if first { if first {
let backend = self.clone(); let backend = self.clone();
let connection = connection.clone(); let connection = connection.clone();
@@ -244,6 +191,7 @@ impl GlobalShortcuts {
backend.watch(connection, path, session, bridge).await; backend.watch(connection, path, session, bridge).await;
}); });
} }
return Ok(Some(results(&selected))); return Ok(Some(results(&selected)));
} }
Err(error) => { Err(error) => {
+8 -2
View File
@@ -22,7 +22,7 @@
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
</copyright> </copyright>
<interface name="ferese_shell_manager_v1" version="3"> <interface name="ferese_shell_manager_v1" version="4">
<description summary="create a capability-scoped Ferese shell controller"> <description summary="create a capability-scoped Ferese shell controller">
This private global is visible only to clients granted the shell-control This private global is visible only to clients granted the shell-control
capability. It does not assign a Wayland surface role. capability. It does not assign a Wayland surface role.
@@ -35,7 +35,7 @@
<request name="destroy" type="destructor"/> <request name="destroy" type="destructor"/>
</interface> </interface>
<interface name="ferese_shell_v1" version="3"> <interface name="ferese_shell_v1" version="4">
<description summary="managed-window metadata and shell control"> <description summary="managed-window metadata and shell control">
Window, workspace, and output identifiers are unsigned 64-bit values Window, workspace, and output identifiers are unsigned 64-bit values
encoded as high and low 32-bit words. Each snapshot atomically replaces encoded as high and low 32-bit words. Each snapshot atomically replaces
@@ -156,5 +156,11 @@
<arg name="serial" type="uint"/> <arg name="serial" type="uint"/>
<arg name="output_name" type="string"/> <arg name="output_name" type="string"/>
</event> </event>
<request name="confirm_logout_with_inhibitors" since="4">
<arg name="serial" type="uint"/>
<arg name="inhibitor_revision" type="uint"/>
<arg name="query_token" type="uint"/>
<arg name="force" type="uint"/>
</request>
</interface> </interface>
</protocol> </protocol>
+234
View File
@@ -0,0 +1,234 @@
"""Exercise real inhibitor FD lifetimes and session handshake on an isolated bus.
FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py
Uses a nested compositor and mock logind; never suspends or ends the host session.
"""
import json
import os
from pathlib import Path
import signal
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
import unittest
REPO = Path(__file__).resolve().parents[2]
NAME = "org.freedesktop.impl.portal.desktop.ferese"
PATH = "/org/freedesktop/portal/desktop"
INTERFACE = "org.freedesktop.impl.portal.Inhibit"
@unittest.skipUnless(os.environ.get("FERESE_TEST_INHIBIT") == "1", "requires a Wayland host")
class InhibitIntegration(unittest.TestCase):
def test_private_bus_lifetimes_and_query_end(self):
result = subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--private"], capture_output=True, text=True, timeout=40)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class IPC:
def __init__(self, path):
self.socket = socket.socket(socket.AF_UNIX)
self.socket.settimeout(3)
self.socket.connect(str(path))
def call(self, command, args=None):
request = json.dumps({"version": 1, "id": 1, "type": "command", "command": command, "args": args or {}}).encode()
self.socket.sendall(struct.pack(">I", len(request)) + request)
def read(size):
result = b""
while len(result) < size:
chunk = self.socket.recv(size - len(result))
if not chunk:
raise EOFError("IPC connection closed")
result += chunk
return result
response = json.loads(read(struct.unpack(">I", read(4))[0]))
if response.get("error"):
raise ValueError(response["error"])
return response["result"]
def close(self):
self.socket.close()
def private_test():
from gi.repository import Gio, GLib
processes = []
pipes = []
pipe_lock = threading.Lock()
ready = threading.Event()
login_loop = None
login_connection = None
manager_xml = """<node><interface name="org.freedesktop.login1.Manager">
<method name="Inhibit"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="h" direction="out"/></method>
<method name="ListInhibitors"><arg type="a(ssssuu)" direction="out"/></method>
<signal name="PrepareForShutdown"><arg type="b"/></signal>
</interface></node>"""
def logind():
nonlocal login_loop, login_connection
context = GLib.MainContext.new()
context.push_thread_default()
login_connection = Gio.DBusConnection.new_for_address_sync(os.environ["DBUS_SESSION_BUS_ADDRESS"], Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
login_connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.login1", 0)), None, Gio.DBusCallFlags.NONE, 2000, None)
def method(connection, sender, object_path, interface, name, params, invocation):
if name == "Inhibit":
what, app, reason, mode = params.unpack()
reader, writer = os.pipe2(os.O_CLOEXEC | os.O_NONBLOCK)
descriptors = Gio.UnixFDList.new()
index = descriptors.append(writer)
with pipe_lock:
pipes.append((reader, what, app, reason, mode))
invocation.return_value_with_unix_fd_list(GLib.Variant("(h)", (index,)), descriptors)
os.close(writer)
else:
rows = []
with pipe_lock:
for reader, what, app, reason, mode in pipes:
try:
if os.read(reader, 1) == b"":
continue
except BlockingIOError:
pass
rows.append((what, app, reason, mode, os.getuid(), os.getpid()))
invocation.return_value(GLib.Variant("(a(ssssuu))", (rows,)))
login_connection.register_object("/org/freedesktop/login1", Gio.DBusNodeInfo.new_for_xml(manager_xml).interfaces[0], method, None, None)
login_loop = GLib.MainLoop.new(context, False)
ready.set()
login_loop.run()
context.pop_thread_default()
thread = threading.Thread(target=logind, daemon=True)
thread.start()
assert ready.wait(3), "Mock logind did not start"
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
def call(interface, method, params=None, signature=None, path=PATH):
return bus.call_sync(NAME, path, interface, method, params, GLib.VariantType.new(signature) if signature else None, Gio.DBusCallFlags.NO_AUTO_START, 5000, None).unpack()
def wait_for(check, message):
deadline = time.monotonic() + 4
while time.monotonic() < deadline:
while GLib.MainContext.default().pending():
GLib.MainContext.default().iteration(False)
if check():
return
time.sleep(0.01)
raise AssertionError(message)
def active_fd(index):
with pipe_lock:
try:
return os.read(pipes[index][0], 1) != b""
except BlockingIOError:
return True
with tempfile.TemporaryDirectory(prefix="ferese-inhibit-") as directory:
root = Path(directory)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese/config.kdl"
config.parent.mkdir(parents=True)
config.write_text("status { keybinding-guide #false; }\n")
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=str(display), DBUS_SYSTEM_BUS_ADDRESS=os.environ["DBUS_SESSION_BUS_ADDRESS"])
env.pop("WAYLAND_SOCKET", None)
log = (root / "test.log").open("w")
try:
compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(compositor)
deadline = time.monotonic() + 10
while not (runtime / "ferese/control.sock").exists():
assert compositor.poll() is None and time.monotonic() < deadline, (root / "test.log").read_text()
time.sleep(0.02)
backend = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese")], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(backend)
deadline = time.monotonic() + 10
while True:
try:
call("org.freedesktop.DBus.Introspectable", "Introspect", signature="(s)")
break
except GLib.Error:
assert backend.poll() is None and time.monotonic() < deadline, (root / "test.log").read_text()
time.sleep(0.02)
request = PATH + "/request/test/inhibit"
try:
call(INTERFACE, "Inhibit", GLib.Variant("(ossua{sv})", (request, "test.media", "", 8, {})))
raise AssertionError("Untrusted inhibition was accepted")
except GLib.Error as error:
assert "AccessDenied" in str(error)
bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), None, Gio.DBusCallFlags.NONE, 2000, None)
admin = IPC(runtime / "ferese/control.sock")
def inhibit(path, flags):
call(INTERFACE, "Inhibit", GLib.Variant("(ossua{sv})", (path, "test.media", "", flags, {"reason": GLib.Variant("s", "Playing media")})))
inhibit(request, 8)
assert active_fd(0)
assert admin.call("get-session-state")["inhibitors"][0]["flags"] == 8
call("org.freedesktop.impl.portal.Request", "Close", path=request)
wait_for(lambda: not active_fd(0) and not admin.call("get-session-state")["inhibitors"], "Close retained inhibition")
session = PATH + "/session/test/monitor"
changes = []
closed = []
bus.signal_subscribe(NAME, INTERFACE, "StateChanged", PATH, None, Gio.DBusSignalFlags.NONE, lambda *args: changes.append(args[-1].unpack()[1]))
bus.signal_subscribe(NAME, "org.freedesktop.impl.portal.Session", "Closed", session, None, Gio.DBusSignalFlags.NONE, lambda *args: closed.append(True))
assert call(INTERFACE, "CreateMonitor", GLib.Variant("(ooss)", (PATH + "/request/test/monitor", session, "test.media", "")), "(u)") == (0,)
wait_for(lambda: any(value["session-state"] == 1 for value in changes), "Missing initial Running")
assert call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.Session", "version")), "(v)", session) == (1,)
query = IPC(runtime / "ferese/control.sock")
state = query.call("begin-session-end")
assert state["query-ready"] is False
wait_for(lambda: any(value["session-state"] == 2 for value in changes), "Missing QueryEnd")
call(INTERFACE, "QueryEndResponse", GLib.Variant("(o)", (session,)))
assert admin.call("get-session-state")["query-ready"] is True
approved = state["inhibitor-revision"]
inhibit(request, 1)
try:
query.call("commit-session-end", {"token": state["query-token"], "inhibitor-revision": approved, "force": True})
raise AssertionError("Late inhibitor bypassed confirmation")
except ValueError:
pass
current = admin.call("get-session-state")
query.call("validate-session-end", {"token": state["query-token"], "inhibitor-revision": current["inhibitor-revision"], "force": True})
assert admin.call("get-session-state")["session-state"] == 2
query.call("commit-session-end", {"token": state["query-token"], "inhibitor-revision": current["inhibitor-revision"], "force": True})
wait_for(lambda: any(value["session-state"] == 3 for value in changes), "Missing Ending")
query.call("cancel-session-end", {"token": state["query-token"]})
wait_for(lambda: changes[-1]["session-state"] == 1, "Missing cancelled end state")
query.close()
call("org.freedesktop.impl.portal.Session", "Close", path=session)
wait_for(lambda: len(closed) == 1, "Missing Closed")
count = len(changes)
login_connection.emit_signal(None, "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "PrepareForShutdown", GLib.Variant("(b)", (True,)))
wait_for(lambda: admin.call("get-session-state")["session-state"] == 3, "Missing external shutdown state")
time.sleep(0.1)
while GLib.MainContext.default().pending():
GLib.MainContext.default().iteration(False)
assert len(changes) == count, "StateChanged appeared after Closed"
compositor.terminate()
compositor.wait(timeout=5)
wait_for(lambda: not active_fd(1), "Compositor death retained logind inhibitor FD")
admin.close()
print("Inhibit FD, monitor, ACK, late consent and disconnect checks passed")
finally:
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)
log.close()
login_loop.quit()
thread.join(timeout=2)
for pipe, *_ in pipes:
os.close(pipe)
if __name__ == "__main__":
if "--private" in sys.argv:
private_test()
else:
unittest.main()
+1 -1
View File
@@ -54,7 +54,7 @@ def private_bus_checks():
raise AssertionError((root / "backend.log").read_text()) raise AssertionError((root / "backend.log").read_text())
time.sleep(0.05) time.sleep(0.05)
node = ET.fromstring(xml) node = ET.fromstring(xml)
for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts"): for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts", "Inhibit"):
interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']") interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']")
assert interface is not None, name assert interface is not None, name
expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface") expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface")
+221
View File
@@ -0,0 +1,221 @@
use serde_json::{Value, json};
use std::{
os::unix::net::UnixStream,
path::PathBuf,
time::{Duration, Instant},
};
pub(super) fn call(command: &str) -> Result<Value, String> {
request(command, json!({}))
}
#[derive(Debug)]
struct SessionConnection {
stream: std::sync::Mutex<UnixStream>,
}
impl SessionConnection {
fn connect() -> Result<Self, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.ok_or("Missing runtime directory")?
.join("ferese/control.sock");
let stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
Ok(Self {
stream: std::sync::Mutex::new(stream),
})
}
fn request(&self, command: &str, args: Value) -> Result<Value, String> {
let mut stream = self
.stream
.lock()
.map_err(|_| "Compositor connection failed")?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
kind: "command".into(),
command: command.into(),
args,
};
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
response.result.ok_or("Missing compositor response".into())
}
}
fn request(command: &str, args: Value) -> Result<Value, String> {
SessionConnection::connect()?.request(command, args)
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub(super) enum Mode {
#[default]
Guide,
Logout,
Shutdown,
Suspend,
}
#[derive(Clone, Debug, Default)]
pub(super) struct Approval {
pub reasons: Vec<String>,
pub revision: u32,
pub token: u32,
mode: Mode,
external: Vec<String>,
connection: Option<std::sync::Arc<SessionConnection>>,
}
impl Approval {
pub fn force(&self) -> bool {
!self.reasons.is_empty()
}
pub fn cancel(&self) {
if self.mode == Mode::Shutdown {
let _ = request("cancel-session-end", json!({"token": self.token}));
}
}
pub fn prepare(&self) -> Result<(), String> {
if matches!(self.mode, Mode::Shutdown | Mode::Suspend)
&& login_inhibitors(self.mode)? != self.external
{
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
}
if self.mode == Mode::Shutdown {
self.connection.as_ref().ok_or("Missing session-ending lease")?.request(
"validate-session-end",
json!({"token": self.token, "inhibitor-revision": self.revision, "force": self.force()}),
)?;
} else if self.mode == Mode::Suspend {
let state = call("get-session-state")?;
if state["inhibitor-revision"].as_u64() != Some(u64::from(self.revision)) {
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
}
}
Ok(())
}
}
fn login_inhibitors(mode: Mode) -> Result<Vec<String>, String> {
if !matches!(mode, Mode::Shutdown | Mode::Suspend) {
return Ok(Vec::new());
}
let connection = zbus::blocking::connection::Builder::system()
.map_err(|error| error.to_string())?
.method_timeout(Duration::from_secs(2))
.build()
.map_err(|error| error.to_string())?;
let proxy = zbus::blocking::Proxy::new(
&connection,
"org.freedesktop.login1",
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
)
.map_err(|error| error.to_string())?;
let inhibitors: Vec<(String, String, String, String, u32, u32)> = proxy
.call("ListInhibitors", &())
.map_err(|error| error.to_string())?;
let what = if mode == Mode::Suspend {
"sleep"
} else {
"shutdown"
};
let mut reasons = inhibitors
.into_iter()
.filter(|(flags, _, _, inhibition, _, _)| {
inhibition == "block" && flags.split(':').any(|flag| flag == what)
})
.map(|(_, app, reason, _, _, _)| format!("{app}: {reason}"))
.collect::<Vec<_>>();
reasons.sort();
reasons.dedup();
Ok(reasons)
}
pub(super) fn inhibitors(mode: Mode) -> Result<Approval, String> {
let connection = if mode == Mode::Shutdown {
Some(std::sync::Arc::new(SessionConnection::connect()?))
} else {
None
};
let mut state = match &connection {
Some(connection) => connection.request("begin-session-end", json!({}))?,
None => call("get-session-state")?,
};
let token = state["query-token"]
.as_u64()
.and_then(|token| u32::try_from(token).ok())
.unwrap_or(0);
let result = (|| {
if matches!(mode, Mode::Logout | Mode::Shutdown) {
let deadline = Instant::now() + Duration::from_millis(1500);
while state["query-ready"] != true {
if state["session-state"] != 2
|| state["query-token"].as_u64() != Some(u64::from(token))
|| Instant::now() >= deadline
{
return Err("Session-ending request was cancelled or timed out".into());
}
std::thread::sleep(Duration::from_millis(50));
state = match &connection {
Some(connection) => connection.request("get-session-state", json!({}))?,
None => call("get-session-state")?,
};
}
}
let flags = if mode == Mode::Suspend { 4 } else { 1 };
let entries = state["inhibitors"]
.as_array()
.ok_or("Invalid inhibitor list")?;
let mut reasons = entries
.iter()
.filter(|entry| entry["flags"].as_u64().unwrap_or(0) & flags != 0)
.map(|entry| {
let app = entry["app"]
.as_str()
.filter(|app| !app.is_empty())
.unwrap_or("Application");
let reason = entry["reason"]
.as_str()
.unwrap_or("Requested session inhibition");
format!("{app}: {reason}")
})
.collect::<Vec<_>>();
let external = login_inhibitors(mode)?;
reasons.extend(external.iter().cloned());
reasons.sort();
reasons.dedup();
Ok(Approval {
reasons,
revision: state["inhibitor-revision"]
.as_u64()
.and_then(|revision| u32::try_from(revision).ok())
.ok_or("Missing inhibitor revision")?,
token,
mode,
external,
connection,
})
})();
if result.is_err() && mode == Mode::Shutdown {
let _ = request("cancel-session-end", json!({"token": token}));
}
result
}
+5 -6
View File
@@ -81,7 +81,7 @@ impl ShellControl {
let connection = control_connection()?; let connection = control_connection()?;
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?; let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
let qh = queue.handle(); let qh = queue.handle();
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 1..=3, ())?; let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 4..=4, ())?;
let shell = manager.get_shell(&qh, ()); let shell = manager.get_shell(&qh, ());
let (sender, updates) = mpsc::channel(); let (sender, updates) = mpsc::channel();
let mut state = ControlState::new(sender); let mut state = ControlState::new(sender);
@@ -150,11 +150,10 @@ impl ShellControl {
let _ = self.connection.flush(); let _ = self.connection.flush();
} }
pub(crate) fn confirm_logout(&self, serial: u32) { pub(crate) fn confirm_logout(&self, serial: u32, revision: u32, token: u32, force: bool) {
if self.shell.version() >= 3 { self.shell
self.shell.confirm_logout(serial); .confirm_logout_with_inhibitors(serial, revision, token, u32::from(force));
let _ = self.connection.flush(); let _ = self.connection.flush();
}
} }
pub(crate) fn cancel_logout(&self, serial: u32) { pub(crate) fn cancel_logout(&self, serial: u32) {
+3 -28
View File
@@ -1,5 +1,3 @@
use std::{os::unix::net::UnixStream, path::PathBuf, time::Duration};
#[derive(Clone, Debug, PartialEq, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, serde::Deserialize)]
pub(super) struct Entry { pub(super) struct Entry {
pub keys: String, pub keys: String,
@@ -7,32 +5,9 @@ pub(super) struct Entry {
} }
pub(super) fn load() -> Result<Vec<Entry>, String> { pub(super) fn load() -> Result<Vec<Entry>, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR") let entries: Vec<Entry> =
.map(PathBuf::from) serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?)
.ok_or("Missing runtime directory")? .map_err(|error| error.to_string())?;
.join("ferese/control.sock");
let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
kind: "command".into(),
command: "get-keybindings".into(),
args: serde_json::json!({}),
};
ferese_ipc::write_frame(&mut stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
let entries: Vec<Entry> = serde_json::from_value(response.result.ok_or("Missing keybindings")?)
.map_err(|error| error.to_string())?;
if entries.len() > 256 if entries.len() > 256
|| entries || entries
.iter() .iter()
+3
View File
@@ -1,3 +1,4 @@
mod compositor_ipc;
mod config; mod config;
mod control; mod control;
mod keybinding_guide; mod keybinding_guide;
@@ -256,6 +257,7 @@ enum Message {
Control(status::Action), Control(status::Action),
ShowGuide, ShowGuide,
GuideLoaded(Result<Vec<keybinding_guide::Entry>, String>), GuideLoaded(Result<Vec<keybinding_guide::Entry>, String>),
SystemInhibitors(window::Id, Result<compositor_ipc::Approval, String>),
ConfirmPower(status::Action), ConfirmPower(status::Action),
CancelPower, CancelPower,
ExecutePower, ExecutePower,
@@ -690,6 +692,7 @@ impl cosmic::Application for FereseShell {
} }
} }
} }
Message::SystemInhibitors(id, result) => self.set_system_inhibitors(id, result),
Message::ConfirmPower(action) => { Message::ConfirmPower(action) => {
if let Some(action) = system_modal::PowerAction::from_status(action) { if let Some(action) = system_modal::PowerAction::from_status(action) {
return self.open_system_modal(action, None); return self.open_system_modal(action, None);
+7 -2
View File
@@ -523,14 +523,19 @@ fn notifications(bus: &mut StatusBus) -> Option<Notifications> {
Some(Notifications { count, dnd }) Some(Notifications { count, dnd })
} }
pub(super) fn execute_power(action: Action) -> Result<(), String> { pub(super) fn execute_power(action: Action, force: bool) -> Result<(), String> {
let action = match action { let action = match action {
Action::Poweroff => "poweroff", Action::Poweroff => "poweroff",
Action::Reboot => "reboot", Action::Reboot => "reboot",
Action::Suspend => "suspend", Action::Suspend => "suspend",
_ => return Err("Invalid power action".to_owned()), _ => return Err("Invalid power action".to_owned()),
}; };
run_with_timeout("systemctl", &[action], "120s").map(|_| ()) let check = if force {
"--check-inhibitors=no"
} else {
"--check-inhibitors=yes"
};
run_with_timeout("systemctl", &[check, action], "120s").map(|_| ())
} }
fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> { fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
+120 -14
View File
@@ -65,6 +65,7 @@ pub(super) struct SystemModal {
surfaces: Vec<ModalSurface>, surfaces: Vec<ModalSurface>,
pub(super) motion: motion::PopupMotion, pub(super) motion: motion::PopupMotion,
error: Option<String>, error: Option<String>,
inhibitors: Option<compositor_ipc::Approval>,
} }
impl SystemModal { impl SystemModal {
@@ -183,15 +184,65 @@ impl FereseShell {
); );
tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
} }
let mode = match content {
Content::Power(PowerAction::Suspend) => Some(compositor_ipc::Mode::Suspend),
Content::Power(PowerAction::Logout(_)) => Some(compositor_ipc::Mode::Logout),
Content::Power(_) => Some(compositor_ipc::Mode::Shutdown),
Content::Guide(_) => None,
};
if let Some(mode) = mode {
let id = surfaces[0].id;
tasks.push(Task::perform(
async move {
tokio::task::spawn_blocking(move || compositor_ipc::inhibitors(mode))
.await
.map_err(|error| error.to_string())
.and_then(|result| result)
},
move |result| cosmic::Action::App(Message::SystemInhibitors(id, result)),
));
}
self.system_modal = Some(SystemModal { self.system_modal = Some(SystemModal {
content, content,
surfaces, surfaces,
motion: motion::PopupMotion::new(self.config.animations), motion: motion::PopupMotion::new(self.config.animations),
error: None, error: None,
inhibitors: mode.is_none().then(compositor_ipc::Approval::default),
}); });
Task::batch(tasks) Task::batch(tasks)
} }
pub(super) fn set_system_inhibitors(
&mut self,
id: window::Id,
result: Result<compositor_ipc::Approval, String>,
) -> Task<Message> {
if let Some(modal) = &mut self.system_modal
&& modal.contains(id)
&& matches!(modal.content, Content::Power(_))
&& !modal.motion.closing()
{
match result {
Ok(inhibitors) => modal.inhibitors = Some(inhibitors),
Err(error) => {
modal.error = Some(error);
}
}
} else if let Ok(approval) = result {
return Self::cancel_query(approval);
}
Task::none()
}
fn cancel_query(approval: compositor_ipc::Approval) -> Task<Message> {
Task::perform(
async move {
let _ = tokio::task::spawn_blocking(move || approval.cancel()).await;
},
|_| cosmic::Action::App(Message::AnimatePower),
)
}
pub(super) fn attach_power_material( pub(super) fn attach_power_material(
&mut self, &mut self,
id: window::Id, id: window::Id,
@@ -232,7 +283,13 @@ impl FereseShell {
{ {
control.cancel_logout(serial); control.cancel_logout(serial);
} }
let cancel = modal
.inhibitors
.take()
.map(Self::cancel_query)
.unwrap_or_else(Task::none);
modal.motion.retarget(0.0, Instant::now()); modal.motion.retarget(0.0, Instant::now());
return Task::batch([cancel, self.animate_system_modal()]);
} }
self.animate_system_modal() self.animate_system_modal()
} }
@@ -259,12 +316,15 @@ impl FereseShell {
{ {
control.cancel_logout(serial); control.cancel_logout(serial);
} }
Task::batch( let mut tasks = modal
modal .surfaces
.surfaces .into_iter()
.into_iter() .map(|surface| destroy_layer_surface(surface.id))
.map(|surface| destroy_layer_surface(surface.id)), .collect::<Vec<_>>();
) if cancel && let Some(approval) = modal.inhibitors {
tasks.push(Self::cancel_query(approval));
}
Task::batch(tasks)
} }
pub(super) fn cancel_logout_modal(&mut self, serial: u32) -> Task<Message> { pub(super) fn cancel_logout_modal(&mut self, serial: u32) -> Task<Message> {
@@ -283,16 +343,22 @@ impl FereseShell {
let Some(modal) = &mut self.system_modal else { let Some(modal) = &mut self.system_modal else {
return Task::none(); return Task::none();
}; };
if modal.motion.closing() || self.pending_power.is_some() { if modal.motion.closing() || self.pending_power.is_some() || modal.inhibitors.is_none() {
return Task::none(); return Task::none();
} }
let Content::Power(power) = modal.content else { let Content::Power(power) = modal.content else {
return self.close_system_modal(); return self.close_system_modal();
}; };
let approval = modal.inhibitors.as_ref().unwrap().clone();
let action = match power { let action = match power {
PowerAction::Logout(serial) => { PowerAction::Logout(serial) => {
if let Some(control) = &self.control { if let Some(control) = &self.control {
control.confirm_logout(serial); control.confirm_logout(
serial,
approval.revision,
approval.token,
approval.force(),
);
return self.destroy_system_modal(false); return self.destroy_system_modal(false);
} }
modal.error = Some("Ferese shell control is unavailable".to_owned()); modal.error = Some("Ferese shell control is unavailable".to_owned());
@@ -307,10 +373,18 @@ impl FereseShell {
let destroy = self.destroy_system_modal(false); let destroy = self.destroy_system_modal(false);
let execute = Task::perform( let execute = Task::perform(
async move { async move {
tokio::task::spawn_blocking(move || status::execute_power(action)) tokio::task::spawn_blocking(move || {
.await let result = approval
.map_err(|error| error.to_string()) .prepare()
.and_then(|result| result) .and_then(|()| status::execute_power(action, approval.force()));
if result.is_err() {
approval.cancel();
}
result
})
.await
.map_err(|error| error.to_string())
.and_then(|result| result)
}, },
move |result| cosmic::Action::App(Message::PowerCompleted(id, result)), move |result| cosmic::Action::App(Message::PowerCompleted(id, result)),
); );
@@ -369,6 +443,33 @@ impl FereseShell {
.class(theme::Text::Color(palette.muted)), .class(theme::Text::Color(palette.muted)),
] ]
.spacing(12); .spacing(12);
if let Some(inhibitors) = &modal.inhibitors
&& !inhibitors.reasons.is_empty()
{
rows = rows.push(
text("Applications requested that this action be prevented:")
.font(shell_font())
.size(13)
.class(theme::Text::Color(palette.muted)),
);
let items = inhibitors.reasons.iter().fold(
column::with_capacity(inhibitors.reasons.len()).spacing(6),
|rows, reason| rows.push(text(reason).font(shell_font()).size(13)),
);
rows = rows.push(
container(cosmic::widget::scrollable(items).height(Length::Shrink))
.max_height(160),
);
}
let label = if modal
.inhibitors
.as_ref()
.is_some_and(|approval| approval.force())
{
format!("{} anyway", action.label())
} else {
action.label().to_owned()
};
if let Some(error) = &modal.error { if let Some(error) = &modal.error {
rows = rows.push(text(error).size(13)); rows = rows.push(text(error).size(13));
} }
@@ -378,9 +479,14 @@ impl FereseShell {
button::text("Cancel") button::text("Cancel")
.class(ferese_theme::controls::button_style(palette, false)) .class(ferese_theme::controls::button_style(palette, false))
.on_press(cosmic::Action::App(Message::CancelPower)), .on_press(cosmic::Action::App(Message::CancelPower)),
button::text(action.label()) button::text(label)
.class(ferese_theme::controls::button_style(palette, true)) .class(ferese_theme::controls::button_style(palette, true))
.on_press(cosmic::Action::App(Message::ExecutePower)), .on_press_maybe(
modal
.inhibitors
.is_some()
.then_some(cosmic::Action::App(Message::ExecutePower))
),
] ]
.spacing(10), .spacing(10),
) )